From d22ce1ae01780c036a3cb99abce2e857152ead09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Fri, 21 Aug 2026 21:50:42 +0000 Subject: [PATCH 1/4] task: add message commenting backend plan --- ...-21-message-anchored-commenting-backend.md | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 tasks/active/2026-08-21-message-anchored-commenting-backend.md diff --git a/tasks/active/2026-08-21-message-anchored-commenting-backend.md b/tasks/active/2026-08-21-message-anchored-commenting-backend.md new file mode 100644 index 000000000..a9a964690 --- /dev/null +++ b/tasks/active/2026-08-21-message-anchored-commenting-backend.md @@ -0,0 +1,71 @@ +# Message-anchored commenting backend + +## Problem statement + +Build the backend/multiplayer foundation for SAM's message-anchored commenting MVP from idea `01M0JQB842XSJ3W172DYPB37HN`. + +This is a constituent PR for a coordinated multi-PR integration. It must provide the clean server-side contract and persistence layer for sibling UI/MCP tracks, without merging or deploying to staging from this branch. + +## Scope + +In scope: + +- Message-anchored comment threads on `ProjectData.chat_messages`. +- Replies, `open` / `sent` / `resolved` status, resolve/reopen/send status transitions. +- Bounded write validation, idempotency, and deterministic ordering for optimistic clients. +- ProjectData Durable Object migration, RPC/service methods, HTTP API routes, and WebSocket broadcast events. +- Project membership authorization at HTTP boundaries and ProjectData-side session/message/thread consistency checks. +- Tests for migration, CRUD, replies, status transitions, validation, authorization, idempotency, ordering, and events. + +Out of scope: + +- File comments, fuzzy file re-anchoring, mentions, reactions, notification inboxes, and unrelated refactors. +- Actual "send to agent" prompt enqueueing. This backend records and broadcasts `sent` status; sibling work can attach prompt delivery to the explicit API contract. +- Permissions beyond project membership. + +## Research findings + +- Idea `01M0JQB842XSJ3W172DYPB37HN` defines the MVP as message comments first. `chat_messages.id` is stable and immutable; file anchors are deferred because production-safe re-anchoring is a separate hard problem. +- The prototype branch `sam/really-get-feature-talked-5ckt0s` validates behavior only: one anchor-discriminated thread model, replies, status pill, resolve/reopen, and message-row comment markers. Prototype code is UI-only mock data and must not be promoted blindly. +- ProjectData DO migrations live in `apps/api/src/durable-objects/migrations.ts` and must be append-only. Current latest migration is `031-task-wait-replay-hardening`; new work must append after it and avoid table recreation/drop patterns per `.claude/rules/31-migration-safety.md`. +- ProjectData public RPC methods live on `apps/api/src/durable-objects/project-data/index.ts`, with domain logic split into sibling modules. New comment logic should follow the module pattern instead of growing unrelated files. +- `apps/api/src/services/project-data.ts` is the typed Worker-to-DO service boundary. Write calls that can duplicate user intent should use explicit idempotency rather than relying on retry behavior. +- `ProjectData.broadcastEvent()` already fans out to session-tagged and project-wide WebSocket listeners. New comment mutations should use this existing channel with server-authoritative full-thread payloads so clients converge without CRDT/OT. +- `routes/chat.ts` already mounts project-scoped session endpoints under `/api/projects/:projectId/sessions`, protects them with `requireAuth()` and `requireApproved()`, and uses `requireProjectAccess` / `requireProjectCapability` for tenant authorization. +- Existing chat routes sometimes enforce session creator for follow-up prompts, but the commenting MVP explicitly scopes permissions to project membership only. Comment writes should require project `task:write`, reads should require `task:read`, and should not require session creator. +- Storage-safety work warns that ProjectData is low-level, write-hot state with a 10 GB ceiling. Comment writes must bound body, quote, idempotency key, thread count, and reply count at the write boundary via env-backed defaults. +- Rule 50 requires list reads that parse rows to tolerate malformed rows. Comment list mapping must isolate per-row parsing and warn/skip bad rows instead of throwing a whole list response. +- Vertical-slice coverage is required because this crosses HTTP route → D1 membership → ProjectData DO → SQLite → WebSocket event contracts. + +## Implementation checklist + +- [ ] Add shared comment API/event types and env-backed default limits. +- [ ] Append a ProjectData DO SQLite migration for `comment_threads`, `comment_replies`, and status-idempotency rows. +- [ ] Add a `project-data/comments.ts` module with bounded validation, message-anchor verification, idempotent create/reply/status transitions, deterministic sequence ordering, and row parsing isolation. +- [ ] Add public ProjectData RPC delegates in `project-data/index.ts` that broadcast server-authoritative comment events on the existing WebSocket channel. +- [ ] Add typed service wrappers in `apps/api/src/services/project-data.ts`. +- [ ] Add Valibot schemas and HTTP routes under `/api/projects/:projectId/sessions/:sessionId/comments`. +- [ ] Enforce project membership/capability authorization at every HTTP route and DO-side rejection for missing sessions, missing/cross-session messages, missing threads, and idempotency conflicts. +- [ ] Add/refresh API documentation for the HTTP/RPC/event contract. +- [ ] Add focused tests for migrations, CRUD/replies/status transitions, validation/limits, authorization, idempotency, ordering, and WebSocket events. +- [ ] Run local quality checks and required specialist reviews. +- [ ] Create an open PR to `main`; do not deploy to staging and do not merge. + +## Acceptance criteria + +- A project member with read access can list message comment threads for a session; non-members cannot. +- A project member with write access can create a message-anchored thread only when the target message exists in that same ProjectData project/session. +- Cross-project or cross-session message/thread IDs are rejected as missing/not found instead of silently creating orphan anchors. +- Thread body, reply body, quote, idempotency key, list limits, per-session thread count, and per-thread reply count are bounded via env-backed defaults. +- Duplicate create/reply/status requests with the same idempotency key return the same authoritative result; reuse of a key with a different intent conflicts. +- Threads and replies have deterministic sequence ordering even when timestamps collide. +- Status transitions support `sent`, `resolved`, and reopening to `open` with server-side actor/timestamp metadata. +- Every mutation emits a ProjectData WebSocket event with enough authoritative state for multiple clients to converge. +- Tests cover the route/service/DO storage path and event payloads; staging is intentionally skipped by explicit instruction. + +## Contract assumptions + +- MVP anchors accepted by this backend are message anchors only: `{ kind: 'message', messageId, quote? }`. +- `sent` means "marked as sent for agent handling" in storage and events. This PR does not enqueue a prompt or wait for agent completion. +- Comment authors for HTTP writes are server-authoritative human actors derived from the authenticated session. Agent-authored comments can be added later through MCP/tool routes using the same ProjectData RPC shape. +- Comment authorization is project membership/capability based, not session-creator based. From 7eada5f38bb02293d483fd403be1778e6aa20fc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Fri, 21 Aug 2026 22:30:03 +0000 Subject: [PATCH 2/4] feat(api): add message anchored comments backend --- .agents/skills/api-reference/SKILL.md | 1 + .claude/skills/api-reference/SKILL.md | 8 + apps/api/.env.example | 7 + apps/api/src/durable-objects/migrations.ts | 91 +++ .../durable-objects/project-data/comments.ts | 754 ++++++++++++++++++ .../src/durable-objects/project-data/index.ts | 63 +- .../src/durable-objects/project-data/types.ts | 7 + apps/api/src/env.ts | 7 + apps/api/src/routes/chat-comments.ts | 259 ++++++ apps/api/src/routes/chat.ts | 3 + apps/api/src/schemas/comments.ts | 24 + apps/api/src/schemas/index.ts | 7 + apps/api/src/services/project-data.ts | 63 ++ .../unit/durable-objects/comments.test.ts | 394 +++++++++ .../project-data-comment-broadcast.test.ts | 192 +++++ .../tests/unit/routes/chat-comments.test.ts | 311 ++++++++ .../services/project-data-comments.test.ts | 133 +++ apps/api/wrangler.toml | 7 + .../docs/docs/reference/configuration.md | 7 + packages/shared/src/constants/defaults.ts | 25 + packages/shared/src/constants/index.ts | 7 + packages/shared/src/types/comments.ts | 81 ++ packages/shared/src/types/index.ts | 21 +- ...-21-message-anchored-commenting-backend.md | 45 +- 24 files changed, 2501 insertions(+), 16 deletions(-) create mode 100644 apps/api/src/durable-objects/project-data/comments.ts create mode 100644 apps/api/src/routes/chat-comments.ts create mode 100644 apps/api/src/schemas/comments.ts create mode 100644 apps/api/tests/unit/durable-objects/comments.test.ts create mode 100644 apps/api/tests/unit/durable-objects/project-data-comment-broadcast.test.ts create mode 100644 apps/api/tests/unit/routes/chat-comments.test.ts create mode 100644 apps/api/tests/unit/services/project-data-comments.test.ts create mode 100644 packages/shared/src/types/comments.ts diff --git a/.agents/skills/api-reference/SKILL.md b/.agents/skills/api-reference/SKILL.md index 443f921f8..d982cfdb0 100644 --- a/.agents/skills/api-reference/SKILL.md +++ b/.agents/skills/api-reference/SKILL.md @@ -16,6 +16,7 @@ The reference covers: - MCP orchestration (`wait_for_subtasks`, `dispatch_task`, task inspection) - MCP private incident backlog tools (`list_incident_queue`, `get_incident`, `claim_incident`, `resolve_incident`) - Agent Sessions (`/api/workspaces/:id/agent-sessions/*`) +- Message-anchored chat comments (`/api/projects/:projectId/sessions/:sessionId/comments*`) - Agent Settings (`/api/agent-settings/*`) - Notifications (`/api/notifications/*`) - Automation triggers (`/api/projects/:projectId/triggers/*`, `/api/webhooks/ingest`) diff --git a/.claude/skills/api-reference/SKILL.md b/.claude/skills/api-reference/SKILL.md index 40829f807..ef9c60bd8 100644 --- a/.claude/skills/api-reference/SKILL.md +++ b/.claude/skills/api-reference/SKILL.md @@ -43,11 +43,19 @@ user-invocable: false - `GET /api/projects/:projectId/sessions/:sessionId/state` — Get lightweight ACP activity state for a chat session - `GET /api/projects/:projectId/sessions/:sessionId/messages` — List persisted session messages (supports `roles`, `before`, `limit`, `compact`, `order=asc|desc`) - `GET /api/projects/:projectId/sessions/:sessionId/messages/:messageId/tool-content` — Lazy-load stored tool content for compact messages +- `GET /api/projects/:projectId/sessions/:sessionId/comments` — List message-anchored comment threads (supports `messageId`, `status=open|sent|resolved`, `afterSequence`, `limit`) +- `POST /api/projects/:projectId/sessions/:sessionId/comments` — Create a message-anchored comment thread (`{ messageId, body, quote?, clientMutationId? }`) +- `POST /api/projects/:projectId/sessions/:sessionId/comments/:threadId/replies` — Append a comment reply (`{ body, clientMutationId? }`) +- `POST /api/projects/:projectId/sessions/:sessionId/comments/:threadId/send` — Mark a thread `sent` (`{ clientMutationId? }`) +- `POST /api/projects/:projectId/sessions/:sessionId/comments/:threadId/resolve` — Mark a thread `resolved` (`{ clientMutationId? }`) +- `POST /api/projects/:projectId/sessions/:sessionId/comments/:threadId/reopen` — Reopen a thread to `open` (`{ clientMutationId? }`) - `POST /api/projects/:projectId/sessions/:sessionId/prompt` — Send a follow-up prompt to the active agent session - `POST /api/projects/:projectId/sessions/:sessionId/attention/:markerId/resolve` — Validate, forward, and record one structured human-input answer (`{ answer }`) - `POST /api/projects/:projectId/sessions/:sessionId/summarize` — Generate a session summary for conversation forking - `POST /api/projects/:projectId/sessions/:sessionId/stop` — Stop a chat session +Comment threads are scoped to the ProjectData Durable Object addressed by `projectId`; route authorization requires project `task:read` for list and `task:write` for mutations, and the DO rejects missing sessions, missing messages, and cross-session message anchors. Mutations return `{ thread, idempotent }` or `{ thread, reply, idempotent }`; successful first writes use HTTP 201 for create/reply and 200 for status transitions. Project session WebSocket listeners receive `{ type: "comment.thread.changed", payload: { sessionId, thread, reason } }` with `reason` in `thread_created | reply_created | marked_sent | resolved | reopened`. + ## Task Management (Project Scoped) - `POST /api/projects/:projectId/tasks` — Create task diff --git a/apps/api/.env.example b/apps/api/.env.example index 875f69833..c9e57972a 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -751,6 +751,13 @@ INFOMANIAK_IP_POLL_INTERVAL_MS=3000 # Project Data Durable Object limits # MAX_SESSIONS_PER_PROJECT=10000 # MAX_MESSAGES_PER_SESSION=100000 +# COMMENT_BODY_MAX_LENGTH=8000 # Max characters per message-anchored comment or reply body +# COMMENT_QUOTE_MAX_LENGTH=2000 # Max characters preserved from quoted message text +# COMMENT_IDEMPOTENCY_KEY_MAX_LENGTH=200 # Max clientMutationId length for comment writes +# COMMENT_LIST_LIMIT_DEFAULT=100 # Default page size for comment thread lists +# COMMENT_LIST_LIMIT_MAX=500 # Max page size for comment thread lists +# COMMENT_THREADS_PER_SESSION_MAX=1000 # Max comment threads per chat session +# COMMENT_REPLIES_PER_THREAD_MAX=200 # Max replies per comment thread # DOCUMENT_CARD_RAW_OUTPUT_MAX_BYTES=16384 # MESSAGE_SIZE_THRESHOLD=102400 # ACTIVITY_RETENTION_DAYS=90 diff --git a/apps/api/src/durable-objects/migrations.ts b/apps/api/src/durable-objects/migrations.ts index f928596dc..962ce177b 100644 --- a/apps/api/src/durable-objects/migrations.ts +++ b/apps/api/src/durable-objects/migrations.ts @@ -964,6 +964,97 @@ export const MIGRATIONS: Migration[] = [ `); }, }, + { + name: '032-message-comment-threads', + run: (sql) => { + sql.exec(` + CREATE TABLE comment_threads ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL REFERENCES chat_sessions(id) ON DELETE CASCADE, + anchor_kind TEXT NOT NULL DEFAULT 'message' CHECK (anchor_kind = 'message'), + message_id TEXT NOT NULL REFERENCES chat_messages(id) ON DELETE CASCADE, + quote TEXT, + body TEXT NOT NULL, + author_type TEXT NOT NULL CHECK (author_type IN ('human', 'agent')), + author_id TEXT NOT NULL, + author_name TEXT, + status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'sent', 'resolved')), + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + sequence INTEGER NOT NULL, + version INTEGER NOT NULL DEFAULT 1, + client_mutation_id TEXT, + client_mutation_fingerprint TEXT, + sent_at INTEGER, + sent_by_type TEXT CHECK (sent_by_type IS NULL OR sent_by_type IN ('human', 'agent')), + sent_by_id TEXT, + sent_by_name TEXT, + resolved_at INTEGER, + resolved_by_type TEXT CHECK (resolved_by_type IS NULL OR resolved_by_type IN ('human', 'agent')), + resolved_by_id TEXT, + resolved_by_name TEXT, + reopened_at INTEGER, + reopened_by_type TEXT CHECK (reopened_by_type IS NULL OR reopened_by_type IN ('human', 'agent')), + reopened_by_id TEXT, + reopened_by_name TEXT, + UNIQUE(session_id, client_mutation_id) + ) + `); + sql.exec(` + CREATE INDEX idx_comment_threads_session_sequence + ON comment_threads(session_id, sequence) + `); + sql.exec(` + CREATE INDEX idx_comment_threads_message + ON comment_threads(session_id, message_id, sequence) + `); + sql.exec(` + CREATE INDEX idx_comment_threads_status + ON comment_threads(session_id, status, sequence) + `); + + sql.exec(` + CREATE TABLE comment_replies ( + id TEXT PRIMARY KEY, + thread_id TEXT NOT NULL REFERENCES comment_threads(id) ON DELETE CASCADE, + session_id TEXT NOT NULL REFERENCES chat_sessions(id) ON DELETE CASCADE, + body TEXT NOT NULL, + author_type TEXT NOT NULL CHECK (author_type IN ('human', 'agent')), + author_id TEXT NOT NULL, + author_name TEXT, + created_at INTEGER NOT NULL, + sequence INTEGER NOT NULL, + client_mutation_id TEXT, + client_mutation_fingerprint TEXT, + UNIQUE(thread_id, client_mutation_id) + ) + `); + sql.exec(` + CREATE INDEX idx_comment_replies_thread_sequence + ON comment_replies(thread_id, sequence) + `); + sql.exec(` + CREATE INDEX idx_comment_replies_session + ON comment_replies(session_id, thread_id) + `); + + sql.exec(` + CREATE TABLE comment_status_mutations ( + thread_id TEXT NOT NULL REFERENCES comment_threads(id) ON DELETE CASCADE, + session_id TEXT NOT NULL REFERENCES chat_sessions(id) ON DELETE CASCADE, + client_mutation_id TEXT NOT NULL, + target_status TEXT NOT NULL CHECK (target_status IN ('open', 'sent', 'resolved')), + thread_version INTEGER NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (thread_id, client_mutation_id) + ) + `); + sql.exec(` + CREATE INDEX idx_comment_status_mutations_session + ON comment_status_mutations(session_id, created_at) + `); + }, + }, ]; /** diff --git a/apps/api/src/durable-objects/project-data/comments.ts b/apps/api/src/durable-objects/project-data/comments.ts new file mode 100644 index 000000000..0c8677df6 --- /dev/null +++ b/apps/api/src/durable-objects/project-data/comments.ts @@ -0,0 +1,754 @@ +import type { + CommentAuthor, + CommentStatus, + MessageCommentReply, + MessageCommentThread, +} from '@simple-agent-manager/shared'; +import { + COMMENT_STATUSES, + DEFAULT_COMMENT_BODY_MAX_LENGTH, + DEFAULT_COMMENT_IDEMPOTENCY_KEY_MAX_LENGTH, + DEFAULT_COMMENT_LIST_LIMIT_DEFAULT, + DEFAULT_COMMENT_LIST_LIMIT_MAX, + DEFAULT_COMMENT_QUOTE_MAX_LENGTH, + DEFAULT_COMMENT_REPLIES_PER_THREAD_MAX, + DEFAULT_COMMENT_THREADS_PER_SESSION_MAX, +} from '@simple-agent-manager/shared'; +import * as v from 'valibot'; + +import { createModuleLogger } from '../../lib/logger'; +import { parseRow } from './row-schemas'; +import type { Env } from './types'; +import { generateId } from './types'; + +const log = createModuleLogger('project_data.comments'); + +export type CommentActor = CommentAuthor; + +export type CreateCommentThreadInput = { + sessionId: string; + messageId: string; + body: string; + quote?: string | null; + clientMutationId?: string | null; + actor: CommentActor; +}; + +export type CreateCommentReplyInput = { + sessionId: string; + threadId: string; + body: string; + clientMutationId?: string | null; + actor: CommentActor; +}; + +export type ListCommentThreadsInput = { + sessionId: string; + messageId?: string | null; + status?: CommentStatus | null; + afterSequence?: number | null; + limit?: number | null; +}; + +export type UpdateCommentStatusInput = { + sessionId: string; + threadId: string; + status: CommentStatus; + clientMutationId?: string | null; + actor: CommentActor; +}; + +export type CommentThreadMutationResult = { + thread: MessageCommentThread; + idempotent: boolean; + changed: boolean; +}; + +export type CommentReplyMutationResult = CommentThreadMutationResult & { + reply: MessageCommentReply; +}; + +export type ListCommentThreadsResult = { + threads: MessageCommentThread[]; + hasMore: boolean; +}; + +export const COMMENT_NOT_FOUND = 'COMMENT_NOT_FOUND'; +export const COMMENT_VALIDATION = 'COMMENT_VALIDATION'; +export const COMMENT_IDEMPOTENCY_CONFLICT = 'COMMENT_IDEMPOTENCY_CONFLICT'; +export const COMMENT_LIMIT_EXCEEDED = 'COMMENT_LIMIT_EXCEEDED'; + +export class CommentNotFoundError extends Error { + readonly code = COMMENT_NOT_FOUND; + + constructor(readonly resource: 'Chat session' | 'Message' | 'Comment thread') { + super(`${resource} not found`); + this.name = 'CommentNotFoundError'; + } +} + +export class CommentValidationError extends Error { + readonly code = COMMENT_VALIDATION; + + constructor(message: string) { + super(message); + this.name = 'CommentValidationError'; + } +} + +export class CommentIdempotencyConflictError extends Error { + readonly code = COMMENT_IDEMPOTENCY_CONFLICT; + + constructor() { + super('clientMutationId already belongs to a different comment mutation'); + this.name = 'CommentIdempotencyConflictError'; + } +} + +export class CommentLimitExceededError extends Error { + readonly code = COMMENT_LIMIT_EXCEEDED; + + constructor(message: string) { + super(message); + this.name = 'CommentLimitExceededError'; + } +} + +type CommentLimits = { + bodyMaxLength: number; + quoteMaxLength: number; + idempotencyKeyMaxLength: number; + listDefaultLimit: number; + listMaxLimit: number; + threadsPerSessionMax: number; + repliesPerThreadMax: number; +}; + +const ThreadRowSchema = v.object({ + id: v.string(), + session_id: v.string(), + message_id: v.string(), + quote: v.nullable(v.string()), + body: v.string(), + author_type: v.picklist(['human', 'agent']), + author_id: v.string(), + author_name: v.nullable(v.string()), + status: v.picklist(COMMENT_STATUSES), + created_at: v.number(), + updated_at: v.number(), + sequence: v.number(), + version: v.number(), + client_mutation_id: v.nullable(v.string()), + sent_at: v.nullable(v.number()), + sent_by_type: v.nullable(v.picklist(['human', 'agent'])), + sent_by_id: v.nullable(v.string()), + sent_by_name: v.nullable(v.string()), + resolved_at: v.nullable(v.number()), + resolved_by_type: v.nullable(v.picklist(['human', 'agent'])), + resolved_by_id: v.nullable(v.string()), + resolved_by_name: v.nullable(v.string()), + reopened_at: v.nullable(v.number()), + reopened_by_type: v.nullable(v.picklist(['human', 'agent'])), + reopened_by_id: v.nullable(v.string()), + reopened_by_name: v.nullable(v.string()), +}); + +const ReplyRowSchema = v.object({ + id: v.string(), + thread_id: v.string(), + session_id: v.string(), + body: v.string(), + author_type: v.picklist(['human', 'agent']), + author_id: v.string(), + author_name: v.nullable(v.string()), + created_at: v.number(), + sequence: v.number(), + client_mutation_id: v.nullable(v.string()), +}); + +type ThreadRow = v.InferOutput; + +function positiveInteger(value: string | undefined, fallback: number): number { + const parsed = Number.parseInt(value || '', 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +export function resolveCommentLimits(env: Env): CommentLimits { + const listDefaultLimit = positiveInteger( + env.COMMENT_LIST_LIMIT_DEFAULT, + DEFAULT_COMMENT_LIST_LIMIT_DEFAULT + ); + const listMaxLimit = positiveInteger(env.COMMENT_LIST_LIMIT_MAX, DEFAULT_COMMENT_LIST_LIMIT_MAX); + return { + bodyMaxLength: positiveInteger(env.COMMENT_BODY_MAX_LENGTH, DEFAULT_COMMENT_BODY_MAX_LENGTH), + quoteMaxLength: positiveInteger(env.COMMENT_QUOTE_MAX_LENGTH, DEFAULT_COMMENT_QUOTE_MAX_LENGTH), + idempotencyKeyMaxLength: positiveInteger( + env.COMMENT_IDEMPOTENCY_KEY_MAX_LENGTH, + DEFAULT_COMMENT_IDEMPOTENCY_KEY_MAX_LENGTH + ), + listDefaultLimit, + listMaxLimit: Math.max(listDefaultLimit, listMaxLimit), + threadsPerSessionMax: positiveInteger( + env.COMMENT_THREADS_PER_SESSION_MAX, + DEFAULT_COMMENT_THREADS_PER_SESSION_MAX + ), + repliesPerThreadMax: positiveInteger( + env.COMMENT_REPLIES_PER_THREAD_MAX, + DEFAULT_COMMENT_REPLIES_PER_THREAD_MAX + ), + }; +} + +export function resolveCommentListLimit(env: Env, requested?: number | null): number { + const limits = resolveCommentLimits(env); + const limit = + typeof requested === 'number' && Number.isFinite(requested) && requested > 0 + ? Math.floor(requested) + : limits.listDefaultLimit; + return Math.min(limit, limits.listMaxLimit); +} + +function normalizeBody(body: string, limits: CommentLimits): string { + const normalized = body.trim(); + if (!normalized) throw new CommentValidationError('body is required'); + if (normalized.length > limits.bodyMaxLength) { + throw new CommentValidationError(`body must be ${limits.bodyMaxLength} characters or fewer`); + } + return normalized; +} + +function normalizeQuote(quote: string | null | undefined, limits: CommentLimits): string | null { + if (quote === null || quote === undefined) return null; + const normalized = quote.trim(); + if (!normalized) return null; + if (normalized.length > limits.quoteMaxLength) { + throw new CommentValidationError(`quote must be ${limits.quoteMaxLength} characters or fewer`); + } + return normalized; +} + +function normalizeClientMutationId( + clientMutationId: string | null | undefined, + limits: CommentLimits +): string | null { + if (clientMutationId === null || clientMutationId === undefined) return null; + const normalized = clientMutationId.trim(); + if (!normalized) return null; + if (normalized.length > limits.idempotencyKeyMaxLength) { + throw new CommentValidationError( + `clientMutationId must be ${limits.idempotencyKeyMaxLength} characters or fewer` + ); + } + return normalized; +} + +function normalizeActor(actor: CommentActor): CommentActor { + if (actor.kind !== 'human' && actor.kind !== 'agent') { + throw new CommentValidationError('actor kind must be human or agent'); + } + const id = actor.id.trim(); + if (!id) throw new CommentValidationError('actor id is required'); + const name = actor.name?.trim() || null; + return { kind: actor.kind, id, name }; +} + +function fingerprint(value: unknown): string { + return JSON.stringify(value); +} + +function ensureSession(sql: SqlStorage, sessionId: string): void { + const row = sql.exec('SELECT id FROM chat_sessions WHERE id = ? LIMIT 1', sessionId).toArray()[0]; + if (!row) throw new CommentNotFoundError('Chat session'); +} + +function ensureMessageAnchor(sql: SqlStorage, sessionId: string, messageId: string): void { + const row = sql + .exec( + 'SELECT id FROM chat_messages WHERE id = ? AND session_id = ? LIMIT 1', + messageId, + sessionId + ) + .toArray()[0]; + if (!row) throw new CommentNotFoundError('Message'); +} + +function nextThreadSequence(sql: SqlStorage, sessionId: string): number { + const row = sql + .exec( + 'SELECT COALESCE(MAX(sequence), 0) AS max_sequence FROM comment_threads WHERE session_id = ?', + sessionId + ) + .toArray()[0]; + return (typeof row?.max_sequence === 'number' ? row.max_sequence : 0) + 1; +} + +function nextReplySequence(sql: SqlStorage, threadId: string): number { + const row = sql + .exec( + 'SELECT COALESCE(MAX(sequence), 0) AS max_sequence FROM comment_replies WHERE thread_id = ?', + threadId + ) + .toArray()[0]; + return (typeof row?.max_sequence === 'number' ? row.max_sequence : 0) + 1; +} + +function actorFromColumns( + kind: 'human' | 'agent' | null, + id: string | null, + name: string | null +): CommentAuthor | null { + if (!kind || !id) return null; + return { kind, id, name }; +} + +function mapReply(row: unknown): MessageCommentReply { + const r = parseRow(ReplyRowSchema, row, 'comment_reply'); + return { + id: r.id, + threadId: r.thread_id, + sessionId: r.session_id, + author: { kind: r.author_type, id: r.author_id, name: r.author_name }, + body: r.body, + createdAt: r.created_at, + sequence: r.sequence, + clientMutationId: r.client_mutation_id, + }; +} + +function mapThread(row: unknown, replies: MessageCommentReply[]): MessageCommentThread { + const r = parseRow(ThreadRowSchema, row, 'comment_thread'); + return { + id: r.id, + sessionId: r.session_id, + anchor: { + kind: 'message', + messageId: r.message_id, + quote: r.quote, + }, + author: { kind: r.author_type, id: r.author_id, name: r.author_name }, + body: r.body, + status: r.status, + createdAt: r.created_at, + updatedAt: r.updated_at, + sequence: r.sequence, + version: r.version, + clientMutationId: r.client_mutation_id, + sentAt: r.sent_at, + sentBy: actorFromColumns(r.sent_by_type, r.sent_by_id, r.sent_by_name), + resolvedAt: r.resolved_at, + resolvedBy: actorFromColumns(r.resolved_by_type, r.resolved_by_id, r.resolved_by_name), + reopenedAt: r.reopened_at, + reopenedBy: actorFromColumns(r.reopened_by_type, r.reopened_by_id, r.reopened_by_name), + replies, + }; +} + +function readReplies(sql: SqlStorage, threadIds: string[]): Map { + const byThread = new Map(); + for (const threadId of threadIds) byThread.set(threadId, []); + if (threadIds.length === 0) return byThread; + + const placeholders = threadIds.map(() => '?').join(', '); + const rows = sql + .exec( + `SELECT id, thread_id, session_id, body, author_type, author_id, author_name, + created_at, sequence, client_mutation_id + FROM comment_replies + WHERE thread_id IN (${placeholders}) + ORDER BY thread_id ASC, sequence ASC`, + ...threadIds + ) + .toArray(); + + for (const row of rows) { + try { + const reply = mapReply(row); + byThread.get(reply.threadId)?.push(reply); + } catch (err) { + log.warn('comments.reply_row_skipped', { + rowId: typeof row.id === 'string' ? row.id : null, + threadId: typeof row.thread_id === 'string' ? row.thread_id : null, + error: String(err), + }); + } + } + return byThread; +} + +function readThreadRows(sql: SqlStorage, sessionId: string, threadIds: string[]): ThreadRow[] { + if (threadIds.length === 0) return []; + const placeholders = threadIds.map(() => '?').join(', '); + const rows = sql + .exec( + `SELECT id, session_id, message_id, quote, body, author_type, author_id, author_name, + status, created_at, updated_at, sequence, version, client_mutation_id, + sent_at, sent_by_type, sent_by_id, sent_by_name, + resolved_at, resolved_by_type, resolved_by_id, resolved_by_name, + reopened_at, reopened_by_type, reopened_by_id, reopened_by_name + FROM comment_threads + WHERE session_id = ? AND id IN (${placeholders})`, + sessionId, + ...threadIds + ) + .toArray(); + + const parsed: ThreadRow[] = []; + for (const row of rows) { + try { + parsed.push(parseRow(ThreadRowSchema, row, 'comment_thread')); + } catch (err) { + log.warn('comments.thread_row_skipped', { + rowId: typeof row.id === 'string' ? row.id : null, + sessionId, + error: String(err), + }); + } + } + return parsed; +} + +function hydrateThreads( + sql: SqlStorage, + sessionId: string, + rows: unknown[] +): MessageCommentThread[] { + const parsedRows: ThreadRow[] = []; + for (const row of rows) { + try { + parsedRows.push(parseRow(ThreadRowSchema, row, 'comment_thread')); + } catch (err) { + const record = row && typeof row === 'object' ? (row as Record) : {}; + log.warn('comments.thread_row_skipped', { + rowId: typeof record.id === 'string' ? record.id : null, + sessionId, + error: String(err), + }); + } + } + + const replies = readReplies( + sql, + parsedRows.map((row) => row.id) + ); + return parsedRows.map((row) => mapThread(row, replies.get(row.id) ?? [])); +} + +export function getCommentThread( + sql: SqlStorage, + sessionId: string, + threadId: string +): MessageCommentThread | null { + const rows = readThreadRows(sql, sessionId, [threadId]); + if (rows.length === 0) return null; + const replies = readReplies(sql, [threadId]); + return mapThread(rows[0], replies.get(threadId) ?? []); +} + +export function listCommentThreads( + sql: SqlStorage, + env: Env, + input: ListCommentThreadsInput +): ListCommentThreadsResult { + ensureSession(sql, input.sessionId); + const limit = resolveCommentListLimit(env, input.limit); + const conditions = ['session_id = ?']; + const params: Array = [input.sessionId]; + + if (input.messageId) { + ensureMessageAnchor(sql, input.sessionId, input.messageId); + conditions.push('message_id = ?'); + params.push(input.messageId); + } + if (input.status) { + conditions.push('status = ?'); + params.push(input.status); + } + if (input.afterSequence !== null && input.afterSequence !== undefined) { + conditions.push('sequence > ?'); + params.push(input.afterSequence); + } + + const rows = sql + .exec( + `SELECT id, session_id, message_id, quote, body, author_type, author_id, author_name, + status, created_at, updated_at, sequence, version, client_mutation_id, + sent_at, sent_by_type, sent_by_id, sent_by_name, + resolved_at, resolved_by_type, resolved_by_id, resolved_by_name, + reopened_at, reopened_by_type, reopened_by_id, reopened_by_name + FROM comment_threads + WHERE ${conditions.join(' AND ')} + ORDER BY sequence ASC + LIMIT ?`, + ...params, + limit + 1 + ) + .toArray(); + + const hasMore = rows.length > limit; + return { + threads: hydrateThreads(sql, input.sessionId, hasMore ? rows.slice(0, limit) : rows), + hasMore, + }; +} + +export function createCommentThread( + sql: SqlStorage, + env: Env, + input: CreateCommentThreadInput +): CommentThreadMutationResult { + const limits = resolveCommentLimits(env); + const actor = normalizeActor(input.actor); + const body = normalizeBody(input.body, limits); + const quote = normalizeQuote(input.quote, limits); + const clientMutationId = normalizeClientMutationId(input.clientMutationId, limits); + const requestFingerprint = fingerprint([ + 'thread', + input.messageId, + body, + quote, + actor.kind, + actor.id, + ]); + + ensureSession(sql, input.sessionId); + ensureMessageAnchor(sql, input.sessionId, input.messageId); + + if (clientMutationId) { + const existing = sql + .exec( + `SELECT id, client_mutation_fingerprint + FROM comment_threads + WHERE session_id = ? AND client_mutation_id = ? + LIMIT 1`, + input.sessionId, + clientMutationId + ) + .toArray()[0]; + if (existing) { + if (existing.client_mutation_fingerprint !== requestFingerprint) { + throw new CommentIdempotencyConflictError(); + } + const thread = getCommentThread(sql, input.sessionId, String(existing.id)); + if (!thread) throw new CommentNotFoundError('Comment thread'); + return { thread, idempotent: true, changed: false }; + } + } + + const countRow = sql + .exec('SELECT COUNT(*) AS count FROM comment_threads WHERE session_id = ?', input.sessionId) + .toArray()[0]; + if ((typeof countRow?.count === 'number' ? countRow.count : 0) >= limits.threadsPerSessionMax) { + throw new CommentLimitExceededError( + `session comment thread limit of ${limits.threadsPerSessionMax} reached` + ); + } + + const id = generateId(); + const now = Date.now(); + const sequence = nextThreadSequence(sql, input.sessionId); + sql.exec( + `INSERT INTO comment_threads + (id, session_id, anchor_kind, message_id, quote, body, author_type, author_id, author_name, + status, created_at, updated_at, sequence, version, client_mutation_id, + client_mutation_fingerprint) + VALUES (?, ?, 'message', ?, ?, ?, ?, ?, ?, 'open', ?, ?, ?, 1, ?, ?)`, + id, + input.sessionId, + input.messageId, + quote, + body, + actor.kind, + actor.id, + actor.name, + now, + now, + sequence, + clientMutationId, + clientMutationId ? requestFingerprint : null + ); + + const thread = getCommentThread(sql, input.sessionId, id); + if (!thread) throw new CommentNotFoundError('Comment thread'); + return { thread, idempotent: false, changed: true }; +} + +export function createCommentReply( + sql: SqlStorage, + env: Env, + input: CreateCommentReplyInput +): CommentReplyMutationResult { + const limits = resolveCommentLimits(env); + const actor = normalizeActor(input.actor); + const body = normalizeBody(input.body, limits); + const clientMutationId = normalizeClientMutationId(input.clientMutationId, limits); + const requestFingerprint = fingerprint(['reply', input.threadId, body, actor.kind, actor.id]); + + const thread = getCommentThread(sql, input.sessionId, input.threadId); + if (!thread) throw new CommentNotFoundError('Comment thread'); + + if (clientMutationId) { + const existing = sql + .exec( + `SELECT id, client_mutation_fingerprint + FROM comment_replies + WHERE thread_id = ? AND client_mutation_id = ? + LIMIT 1`, + input.threadId, + clientMutationId + ) + .toArray()[0]; + if (existing) { + if (existing.client_mutation_fingerprint !== requestFingerprint) { + throw new CommentIdempotencyConflictError(); + } + const authoritative = getCommentThread(sql, input.sessionId, input.threadId); + const reply = authoritative?.replies.find((candidate) => candidate.id === existing.id); + if (!authoritative || !reply) throw new CommentNotFoundError('Comment thread'); + return { thread: authoritative, reply, idempotent: true, changed: false }; + } + } + + const countRow = sql + .exec('SELECT COUNT(*) AS count FROM comment_replies WHERE thread_id = ?', input.threadId) + .toArray()[0]; + if ((typeof countRow?.count === 'number' ? countRow.count : 0) >= limits.repliesPerThreadMax) { + throw new CommentLimitExceededError( + `comment reply limit of ${limits.repliesPerThreadMax} reached` + ); + } + + const id = generateId(); + const now = Date.now(); + const sequence = nextReplySequence(sql, input.threadId); + sql.exec( + `INSERT INTO comment_replies + (id, thread_id, session_id, body, author_type, author_id, author_name, + created_at, sequence, client_mutation_id, client_mutation_fingerprint) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + id, + input.threadId, + input.sessionId, + body, + actor.kind, + actor.id, + actor.name, + now, + sequence, + clientMutationId, + clientMutationId ? requestFingerprint : null + ); + sql.exec( + `UPDATE comment_threads + SET updated_at = ?, version = version + 1 + WHERE id = ? AND session_id = ?`, + now, + input.threadId, + input.sessionId + ); + + const authoritative = getCommentThread(sql, input.sessionId, input.threadId); + const reply = authoritative?.replies.find((candidate) => candidate.id === id); + if (!authoritative || !reply) throw new CommentNotFoundError('Comment thread'); + return { thread: authoritative, reply, idempotent: false, changed: true }; +} + +export function updateCommentThreadStatus( + sql: SqlStorage, + env: Env, + input: UpdateCommentStatusInput +): CommentThreadMutationResult { + const limits = resolveCommentLimits(env); + const actor = normalizeActor(input.actor); + const clientMutationId = normalizeClientMutationId(input.clientMutationId, limits); + if (!COMMENT_STATUSES.includes(input.status)) { + throw new CommentValidationError('status must be open, sent, or resolved'); + } + + const current = getCommentThread(sql, input.sessionId, input.threadId); + if (!current) throw new CommentNotFoundError('Comment thread'); + + if (clientMutationId) { + const existing = sql + .exec( + `SELECT target_status + FROM comment_status_mutations + WHERE thread_id = ? AND client_mutation_id = ? + LIMIT 1`, + input.threadId, + clientMutationId + ) + .toArray()[0]; + if (existing) { + if (existing.target_status !== input.status) throw new CommentIdempotencyConflictError(); + return { thread: current, idempotent: true, changed: false }; + } + } + + const now = Date.now(); + let changed = false; + if (current.status !== input.status) { + changed = true; + if (input.status === 'sent') { + sql.exec( + `UPDATE comment_threads + SET status = 'sent', sent_at = ?, sent_by_type = ?, sent_by_id = ?, sent_by_name = ?, + updated_at = ?, version = version + 1 + WHERE id = ? AND session_id = ?`, + now, + actor.kind, + actor.id, + actor.name, + now, + input.threadId, + input.sessionId + ); + } else if (input.status === 'resolved') { + sql.exec( + `UPDATE comment_threads + SET status = 'resolved', resolved_at = ?, resolved_by_type = ?, resolved_by_id = ?, + resolved_by_name = ?, updated_at = ?, version = version + 1 + WHERE id = ? AND session_id = ?`, + now, + actor.kind, + actor.id, + actor.name, + now, + input.threadId, + input.sessionId + ); + } else { + sql.exec( + `UPDATE comment_threads + SET status = 'open', reopened_at = ?, reopened_by_type = ?, reopened_by_id = ?, + reopened_by_name = ?, updated_at = ?, version = version + 1 + WHERE id = ? AND session_id = ?`, + now, + actor.kind, + actor.id, + actor.name, + now, + input.threadId, + input.sessionId + ); + } + } + + const authoritative = getCommentThread(sql, input.sessionId, input.threadId); + if (!authoritative) throw new CommentNotFoundError('Comment thread'); + + if (clientMutationId) { + sql.exec( + `INSERT INTO comment_status_mutations + (thread_id, session_id, client_mutation_id, target_status, thread_version, created_at) + VALUES (?, ?, ?, ?, ?, ?)`, + input.threadId, + input.sessionId, + clientMutationId, + input.status, + authoritative.version, + now + ); + } + + return { thread: authoritative, idempotent: false, changed }; +} diff --git a/apps/api/src/durable-objects/project-data/index.ts b/apps/api/src/durable-objects/project-data/index.ts index f98776dee..22ceb009e 100644 --- a/apps/api/src/durable-objects/project-data/index.ts +++ b/apps/api/src/durable-objects/project-data/index.ts @@ -14,6 +14,8 @@ import { type CheckpointEpisodeTransitionInput, type CreateCheckpointEpisodeInput, MAILBOX_DEFAULTS, + type MessageCommentThread, + type MessageCommentThreadEventReason, type SessionActivityTerminalReason, } from '@simple-agent-manager/shared'; import { DurableObject } from 'cloudflare:workers'; @@ -28,6 +30,7 @@ import { computeProjectDataAlarmTime } from './alarm-schedule'; import * as attention from './attention'; import * as attentionExpiry from './attention-expiry'; import * as commands from './commands'; +import * as comments from './comments'; import { stopTimedOutConversationWorkspaces } from './conversation-timeout'; import * as durability from './durability-foundation'; import * as ideas from './ideas'; @@ -244,8 +247,7 @@ export class ProjectData extends DurableObject { ): Promise { sessionWakeProgress.publishSessionWakeProgress( { - broadcastEvent: (type, payload, sessionId) => - this.broadcastEvent(type, payload, sessionId), + broadcastEvent: (type, payload, sessionId) => this.broadcastEvent(type, payload, sessionId), }, input, Date.now() @@ -481,6 +483,40 @@ export class ProjectData extends DurableObject { return messages.searchMessages(this.sql, query, sessionId, roles, limit); } + listCommentThreads(input: comments.ListCommentThreadsInput): comments.ListCommentThreadsResult { + return comments.listCommentThreads(this.sql, this.env, input); + } + + createCommentThread(input: comments.CreateCommentThreadInput) { + const result = this.ctx.storage.transactionSync(() => + comments.createCommentThread(this.sql, this.env, input) + ); + if (result.changed) this.broadcastCommentThread(result.thread, 'thread_created'); + return { thread: result.thread, idempotent: result.idempotent }; + } + + createCommentReply(input: comments.CreateCommentReplyInput) { + const result = this.ctx.storage.transactionSync(() => + comments.createCommentReply(this.sql, this.env, input) + ); + if (result.changed) this.broadcastCommentThread(result.thread, 'reply_created'); + return { + thread: result.thread, + reply: result.reply, + idempotent: result.idempotent, + }; + } + + updateCommentThreadStatus(input: comments.UpdateCommentStatusInput) { + const result = this.ctx.storage.transactionSync(() => + comments.updateCommentThreadStatus(this.sql, this.env, input) + ); + if (result.changed) { + this.broadcastCommentThread(result.thread, this.commentStatusEventReason(input.status)); + } + return { thread: result.thread, idempotent: result.idempotent }; + } + materializeSession(sessionId: string): void { materialization.materializeSession(this.sql, sessionId); } @@ -1598,6 +1634,29 @@ export class ProjectData extends DurableObject { } } + private broadcastCommentThread( + thread: MessageCommentThread, + reason: MessageCommentThreadEventReason + ): void { + this.broadcastEvent( + 'comment.thread.changed', + { + sessionId: thread.sessionId, + thread, + reason, + }, + thread.sessionId + ); + } + + private commentStatusEventReason( + status: comments.UpdateCommentStatusInput['status'] + ): MessageCommentThreadEventReason { + if (status === 'sent') return 'marked_sent'; + if (status === 'resolved') return 'resolved'; + return 'reopened'; + } + private scheduleSummarySync(): void { const debounceMs = parseInt(this.env.DO_SUMMARY_SYNC_DEBOUNCE_MS || '5000', 10); if (this.summarySyncTimer !== null) clearTimeout(this.summarySyncTimer); diff --git a/apps/api/src/durable-objects/project-data/types.ts b/apps/api/src/durable-objects/project-data/types.ts index 091ece82f..8656baedf 100644 --- a/apps/api/src/durable-objects/project-data/types.ts +++ b/apps/api/src/durable-objects/project-data/types.ts @@ -19,6 +19,13 @@ export type Env = { SESSION_INDEX_MAX_ROWS?: string; MAX_SESSIONS_PER_PROJECT?: string; MAX_MESSAGES_PER_SESSION?: string; + COMMENT_BODY_MAX_LENGTH?: string; + COMMENT_QUOTE_MAX_LENGTH?: string; + COMMENT_IDEMPOTENCY_KEY_MAX_LENGTH?: string; + COMMENT_LIST_LIMIT_DEFAULT?: string; + COMMENT_LIST_LIMIT_MAX?: string; + COMMENT_THREADS_PER_SESSION_MAX?: string; + COMMENT_REPLIES_PER_THREAD_MAX?: string; DOCUMENT_CARD_RAW_OUTPUT_MAX_BYTES?: string; PROJECT_DATA_TOOL_METADATA_MAX_BYTES?: string; PROJECT_DATA_STORAGE_TELEMETRY_ENABLED?: string; diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index f148edcbf..94615867e 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -506,6 +506,13 @@ export interface Env extends WebhookTriggerEnv, TaskRecoveryEnv { CACHED_COMMANDS_MAX_DESC_LENGTH?: string; MAX_SESSIONS_PER_PROJECT?: string; MAX_MESSAGES_PER_SESSION?: string; + COMMENT_BODY_MAX_LENGTH?: string; // Max characters per message-anchored comment or reply body (default: 8000) + COMMENT_QUOTE_MAX_LENGTH?: string; // Max characters preserved from quoted message text (default: 2000) + COMMENT_IDEMPOTENCY_KEY_MAX_LENGTH?: string; // Max clientMutationId length for comment writes (default: 200) + COMMENT_LIST_LIMIT_DEFAULT?: string; // Default page size for comment thread lists (default: 100) + COMMENT_LIST_LIMIT_MAX?: string; // Max page size for comment thread lists (default: 500) + COMMENT_THREADS_PER_SESSION_MAX?: string; // Max comment threads per chat session (default: 1000) + COMMENT_REPLIES_PER_THREAD_MAX?: string; // Max replies per comment thread (default: 200) DOCUMENT_CARD_RAW_OUTPUT_MAX_BYTES?: string; // Max document-card rawOutput bytes preserved in compact message metadata (default: 16384) PROJECT_DATA_TOOL_METADATA_MAX_BYTES?: string; PROJECT_DATA_STORAGE_TELEMETRY_ENABLED?: string; diff --git a/apps/api/src/routes/chat-comments.ts b/apps/api/src/routes/chat-comments.ts new file mode 100644 index 000000000..a94fed753 --- /dev/null +++ b/apps/api/src/routes/chat-comments.ts @@ -0,0 +1,259 @@ +import type { CommentStatus } from '@simple-agent-manager/shared'; +import { COMMENT_STATUSES } from '@simple-agent-manager/shared'; +import { drizzle } from 'drizzle-orm/d1'; +import { type Context, Hono } from 'hono'; + +import * as schema from '../db/schema'; +import type { Env } from '../env'; +import { requireRouteParam } from '../lib/route-helpers'; +import { getAuth, getUserId } from '../middleware/auth'; +import { errors } from '../middleware/error'; +import { requireProjectCapability } from '../middleware/project-auth'; +import { + CommentStatusMutationSchema, + CreateCommentReplySchema, + CreateCommentThreadSchema, + jsonValidator, +} from '../schemas'; +import * as projectDataService from '../services/project-data'; + +export const chatCommentRoutes = new Hono<{ Bindings: Env }>(); + +function parseCommentStatus(rawStatus?: string): CommentStatus | null { + if (!rawStatus) return null; + const status = rawStatus.trim().toLowerCase(); + if (COMMENT_STATUSES.includes(status as CommentStatus)) return status as CommentStatus; + throw errors.badRequest('status must be open, sent, or resolved'); +} + +function parsePositiveIntegerQuery(name: string, rawValue?: string): number | null { + if (!rawValue) return null; + const parsed = Number.parseInt(rawValue, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw errors.badRequest(`${name} must be a positive integer`); + } + return parsed; +} + +function parseNonNegativeIntegerQuery(name: string, rawValue?: string): number | null { + if (!rawValue) return null; + const parsed = Number.parseInt(rawValue, 10); + if (!Number.isFinite(parsed) || parsed < 0) { + throw errors.badRequest(`${name} must be a non-negative integer`); + } + return parsed; +} + +function getCommentActor(c: Context<{ Bindings: Env }>) { + const auth = getAuth(c); + return { + kind: 'human' as const, + id: auth.user.id, + name: auth.user.name ?? auth.user.email ?? null, + }; +} + +function getCommentErrorName(err: unknown): string | null { + return err instanceof Error ? err.name : null; +} + +function getCommentErrorCode(err: unknown): string | null { + if (!err || typeof err !== 'object') return null; + const code = (err as { code?: unknown }).code; + return typeof code === 'string' ? code : null; +} + +function getCommentNotFoundResource(err: unknown): string { + if (err && typeof err === 'object') { + const resource = (err as { resource?: unknown }).resource; + if (typeof resource === 'string') return resource; + } + const message = err instanceof Error ? err.message : ''; + if (message.startsWith('Message ')) return 'Message'; + if (message.startsWith('Comment thread ')) return 'Comment thread'; + if (message.startsWith('Chat session ')) return 'Chat session'; + return 'Resource'; +} + +function rethrowCommentError(err: unknown): never { + const code = getCommentErrorCode(err); + const name = getCommentErrorName(err); + if ( + err instanceof projectDataService.CommentValidationError || + code === 'COMMENT_VALIDATION' || + name === 'CommentValidationError' + ) { + throw errors.badRequest(err instanceof Error ? err.message : 'Invalid comment request'); + } + if ( + err instanceof projectDataService.CommentNotFoundError || + code === 'COMMENT_NOT_FOUND' || + name === 'CommentNotFoundError' + ) { + throw errors.notFound(getCommentNotFoundResource(err)); + } + if ( + err instanceof projectDataService.CommentIdempotencyConflictError || + code === 'COMMENT_IDEMPOTENCY_CONFLICT' || + name === 'CommentIdempotencyConflictError' + ) { + throw errors.conflict( + err instanceof Error + ? err.message + : 'clientMutationId already belongs to a different comment mutation' + ); + } + if ( + err instanceof projectDataService.CommentLimitExceededError || + code === 'COMMENT_LIMIT_EXCEEDED' || + name === 'CommentLimitExceededError' + ) { + throw errors.unprocessable(err instanceof Error ? err.message : 'Comment limit exceeded'); + } + throw err; +} + +/** + * GET /api/projects/:projectId/sessions/:sessionId/comments + * List message-anchored comment threads for a chat session. + */ +chatCommentRoutes.get('/:sessionId/comments', async (c) => { + const userId = getUserId(c); + const projectId = requireRouteParam(c, 'projectId'); + const sessionId = requireRouteParam(c, 'sessionId'); + const db = drizzle(c.env.DATABASE, { schema }); + + await requireProjectCapability(db, projectId, userId, 'task:read'); + + try { + const result = await projectDataService.listCommentThreads(c.env, projectId, { + sessionId, + messageId: c.req.query('messageId') ?? null, + status: parseCommentStatus(c.req.query('status')), + afterSequence: parseNonNegativeIntegerQuery('afterSequence', c.req.query('afterSequence')), + limit: parsePositiveIntegerQuery('limit', c.req.query('limit')), + }); + return c.json(result); + } catch (err) { + rethrowCommentError(err); + } +}); + +/** + * POST /api/projects/:projectId/sessions/:sessionId/comments + * Create a message-anchored comment thread. + */ +chatCommentRoutes.post( + '/:sessionId/comments', + jsonValidator(CreateCommentThreadSchema), + async (c) => { + const userId = getUserId(c); + const projectId = requireRouteParam(c, 'projectId'); + const sessionId = requireRouteParam(c, 'sessionId'); + const db = drizzle(c.env.DATABASE, { schema }); + + await requireProjectCapability(db, projectId, userId, 'task:write'); + + const body = c.req.valid('json'); + try { + const result = await projectDataService.createCommentThread(c.env, projectId, { + sessionId, + messageId: body.messageId, + body: body.body, + quote: body.quote ?? null, + clientMutationId: body.clientMutationId ?? null, + actor: getCommentActor(c), + }); + return c.json(result, result.idempotent ? 200 : 201); + } catch (err) { + rethrowCommentError(err); + } + } +); + +/** + * POST /api/projects/:projectId/sessions/:sessionId/comments/:threadId/replies + * Append a reply to a message-anchored comment thread. + */ +chatCommentRoutes.post( + '/:sessionId/comments/:threadId/replies', + jsonValidator(CreateCommentReplySchema), + async (c) => { + const userId = getUserId(c); + const projectId = requireRouteParam(c, 'projectId'); + const sessionId = requireRouteParam(c, 'sessionId'); + const threadId = requireRouteParam(c, 'threadId'); + const db = drizzle(c.env.DATABASE, { schema }); + + await requireProjectCapability(db, projectId, userId, 'task:write'); + + const body = c.req.valid('json'); + try { + const result = await projectDataService.createCommentReply(c.env, projectId, { + sessionId, + threadId, + body: body.body, + clientMutationId: body.clientMutationId ?? null, + actor: getCommentActor(c), + }); + return c.json(result, result.idempotent ? 200 : 201); + } catch (err) { + rethrowCommentError(err); + } + } +); + +async function updateCommentStatus( + c: Context<{ Bindings: Env }>, + status: CommentStatus, + clientMutationId: string | null +) { + const userId = getUserId(c); + const projectId = requireRouteParam(c, 'projectId'); + const sessionId = requireRouteParam(c, 'sessionId'); + const threadId = requireRouteParam(c, 'threadId'); + const db = drizzle(c.env.DATABASE, { schema }); + + await requireProjectCapability(db, projectId, userId, 'task:write'); + + try { + return c.json( + await projectDataService.updateCommentThreadStatus(c.env, projectId, { + sessionId, + threadId, + status, + clientMutationId, + actor: getCommentActor(c), + }) + ); + } catch (err) { + rethrowCommentError(err); + } +} + +chatCommentRoutes.post( + '/:sessionId/comments/:threadId/send', + jsonValidator(CommentStatusMutationSchema), + (c) => { + const body = c.req.valid('json'); + return updateCommentStatus(c, 'sent', body.clientMutationId ?? null); + } +); + +chatCommentRoutes.post( + '/:sessionId/comments/:threadId/resolve', + jsonValidator(CommentStatusMutationSchema), + (c) => { + const body = c.req.valid('json'); + return updateCommentStatus(c, 'resolved', body.clientMutationId ?? null); + } +); + +chatCommentRoutes.post( + '/:sessionId/comments/:threadId/reopen', + jsonValidator(CommentStatusMutationSchema), + (c) => { + const body = c.req.valid('json'); + return updateCommentStatus(c, 'open', body.clientMutationId ?? null); + } +); diff --git a/apps/api/src/routes/chat.ts b/apps/api/src/routes/chat.ts index 81e0f6e6e..39b63ada8 100644 --- a/apps/api/src/routes/chat.ts +++ b/apps/api/src/routes/chat.ts @@ -41,6 +41,7 @@ import { isTaskStatus } from '../services/task-status'; import { attachWakeState } from './chat/wake-state'; import { resolveChatAgentState } from './chat-agent-state'; import { registerChatCancelRoute } from './chat-cancel'; +import { chatCommentRoutes } from './chat-comments'; import { chatForkRoutes } from './chat-fork'; import { recordChatSessionLoadFailure } from './chat-load-diagnostics'; import { preparePromptForLiveAgent, sendPreparedPromptToLiveAgent } from './chat-prompt-forward'; @@ -417,6 +418,8 @@ chatRoutes.get('/:sessionId/messages/:messageId/tool-content', async (c) => { return c.json({ content }); }); +chatRoutes.route('/', chatCommentRoutes); + registerChatStopRoute(chatRoutes); registerChatCancelRoute(chatRoutes); diff --git a/apps/api/src/schemas/comments.ts b/apps/api/src/schemas/comments.ts new file mode 100644 index 000000000..f2685f82a --- /dev/null +++ b/apps/api/src/schemas/comments.ts @@ -0,0 +1,24 @@ +import * as v from 'valibot'; + +/** + * Message-anchored comment request schemas. + * + * Length limits are environment-backed and enforced inside ProjectData so HTTP, + * RPC, MCP, and future callers share one authoritative boundary. + */ + +export const CreateCommentThreadSchema = v.object({ + messageId: v.pipe(v.string(), v.trim(), v.minLength(1)), + body: v.string(), + quote: v.optional(v.nullable(v.string())), + clientMutationId: v.optional(v.nullable(v.string())), +}); + +export const CreateCommentReplySchema = v.object({ + body: v.string(), + clientMutationId: v.optional(v.nullable(v.string())), +}); + +export const CommentStatusMutationSchema = v.object({ + clientMutationId: v.optional(v.nullable(v.string())), +}); diff --git a/apps/api/src/schemas/index.ts b/apps/api/src/schemas/index.ts index 131cfe521..5a66ae2dc 100644 --- a/apps/api/src/schemas/index.ts +++ b/apps/api/src/schemas/index.ts @@ -114,6 +114,13 @@ export { CreateAcpSessionSchema, } from './acp-sessions'; +// Message-anchored comment schemas +export { + CommentStatusMutationSchema, + CreateCommentReplySchema, + CreateCommentThreadSchema, +} from './comments'; + // Admin schemas export { AdminUserActionSchema, diff --git a/apps/api/src/services/project-data.ts b/apps/api/src/services/project-data.ts index acc485ca9..82dc804ec 100644 --- a/apps/api/src/services/project-data.ts +++ b/apps/api/src/services/project-data.ts @@ -11,14 +11,31 @@ import type { AgentMailboxMessage, CheckpointEpisode, CheckpointEpisodeTransitionInput, + CommentAuthor, + CommentStatus, CreateCheckpointEpisodeInput, DeliveryState, MessageClass, + MessageCommentListResponse, + MessageCommentMutationResponse, + MessageCommentReplyMutationResponse, SessionActivityTerminalReason, } from '@simple-agent-manager/shared'; import { resolveHandoffLimits, resolveMissionStateLimits } from '@simple-agent-manager/shared'; import type { ProjectData } from '../durable-objects/project-data'; +import type { + CreateCommentReplyInput, + CreateCommentThreadInput, + ListCommentThreadsInput, + UpdateCommentStatusInput, +} from '../durable-objects/project-data/comments'; +export { + CommentIdempotencyConflictError, + CommentLimitExceededError, + CommentNotFoundError, + CommentValidationError, +} from '../durable-objects/project-data/comments'; import type { AcceptedPromptDelivery, AcceptPromptDeliveryInput, @@ -392,6 +409,52 @@ export async function searchMessages( return stub.searchMessages(query, sessionId, roles, limit); } +// ========================================================================= +// Message-Anchored Comments +// ========================================================================= + +export type MessageCommentActor = CommentAuthor; + +export async function listCommentThreads( + env: Env, + projectId: string, + input: ListCommentThreadsInput +): Promise { + return callProjectDataWithRetry(env, projectId, 'listCommentThreads', (stub) => + stub.listCommentThreads(input) + ); +} + +export async function createCommentThread( + env: Env, + projectId: string, + input: CreateCommentThreadInput +): Promise { + return callProjectDataNoRetry(env, projectId, 'createCommentThread', (stub) => + stub.createCommentThread(input) + ); +} + +export async function createCommentReply( + env: Env, + projectId: string, + input: CreateCommentReplyInput +): Promise { + return callProjectDataNoRetry(env, projectId, 'createCommentReply', (stub) => + stub.createCommentReply(input) + ); +} + +export async function updateCommentThreadStatus( + env: Env, + projectId: string, + input: UpdateCommentStatusInput & { status: CommentStatus } +): Promise { + return callProjectDataNoRetry(env, projectId, 'updateCommentThreadStatus', (stub) => + stub.updateCommentThreadStatus(input) + ); +} + /** Materialize all stopped sessions that haven't been indexed yet. */ export async function materializeAllStopped( env: Env, diff --git a/apps/api/tests/unit/durable-objects/comments.test.ts b/apps/api/tests/unit/durable-objects/comments.test.ts new file mode 100644 index 000000000..e67a573cb --- /dev/null +++ b/apps/api/tests/unit/durable-objects/comments.test.ts @@ -0,0 +1,394 @@ +import Database from 'better-sqlite3'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { runMigrations } from '../../../src/durable-objects/migrations'; +import * as comments from '../../../src/durable-objects/project-data/comments'; +import type { Env } from '../../../src/durable-objects/project-data/types'; +import { createSqlStorage } from './sql-storage-test-utils'; + +const HUMAN = { kind: 'human' as const, id: 'user-1', name: 'Ada' }; + +describe('ProjectData message comments', () => { + let db: Database.Database; + let sql: SqlStorage; + let env: Env; + + beforeEach(() => { + db = new Database(':memory:'); + sql = createSqlStorage(db); + env = {} as Env; + runMigrations(sql); + }); + + function seedSession(sessionId: string): void { + sql.exec( + `INSERT INTO chat_sessions (id, topic, started_at) + VALUES (?, ?, ?)`, + sessionId, + `topic ${sessionId}`, + Date.now() + ); + } + + function seedMessage(sessionId: string, messageId: string, sequence: number): void { + sql.exec( + `INSERT INTO chat_messages + (id, session_id, role, content, tool_metadata, created_at, sequence) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + messageId, + sessionId, + 'assistant', + `message ${messageId}`, + null, + Date.now() + sequence, + sequence + ); + } + + it('adds append-only migration tables and indexes', () => { + const tables = sql + .exec( + `SELECT name FROM sqlite_master + WHERE type = 'table' AND name IN ( + 'comment_threads', + 'comment_replies', + 'comment_status_mutations' + ) + ORDER BY name ASC` + ) + .toArray() + .map((row) => row.name); + + expect(tables).toEqual(['comment_replies', 'comment_status_mutations', 'comment_threads']); + + const indexes = sql + .exec( + `SELECT name FROM sqlite_master + WHERE type = 'index' AND name LIKE 'idx_comment_%' + ORDER BY name ASC` + ) + .toArray() + .map((row) => row.name); + + expect(indexes).toEqual( + expect.arrayContaining([ + 'idx_comment_replies_session', + 'idx_comment_replies_thread_sequence', + 'idx_comment_status_mutations_session', + 'idx_comment_threads_message', + 'idx_comment_threads_session_sequence', + 'idx_comment_threads_status', + ]) + ); + }); + + it('creates, lists, and idempotently replays message-anchored threads', () => { + seedSession('session-a'); + seedMessage('session-a', 'message-a', 1); + + const created = comments.createCommentThread(sql, env, { + sessionId: 'session-a', + messageId: 'message-a', + body: ' Needs clarification ', + quote: ' selected quote ', + clientMutationId: 'thread-key-1', + actor: HUMAN, + }); + + expect(created.idempotent).toBe(false); + expect(created.changed).toBe(true); + expect(created.thread).toMatchObject({ + sessionId: 'session-a', + anchor: { kind: 'message', messageId: 'message-a', quote: 'selected quote' }, + author: HUMAN, + body: 'Needs clarification', + status: 'open', + sequence: 1, + version: 1, + clientMutationId: 'thread-key-1', + replies: [], + }); + + const replay = comments.createCommentThread(sql, env, { + sessionId: 'session-a', + messageId: 'message-a', + body: 'Needs clarification', + quote: 'selected quote', + clientMutationId: 'thread-key-1', + actor: HUMAN, + }); + expect(replay.idempotent).toBe(true); + expect(replay.changed).toBe(false); + expect(replay.thread.id).toBe(created.thread.id); + + expect(() => + comments.createCommentThread(sql, env, { + sessionId: 'session-a', + messageId: 'message-a', + body: 'Different body', + clientMutationId: 'thread-key-1', + actor: HUMAN, + }) + ).toThrow(comments.CommentIdempotencyConflictError); + + expect(comments.listCommentThreads(sql, env, { sessionId: 'session-a' })).toMatchObject({ + hasMore: false, + threads: [{ id: created.thread.id, sequence: 1 }], + }); + }); + + it('appends replies and status transitions with stable sequence and version semantics', () => { + seedSession('session-a'); + seedMessage('session-a', 'message-a', 1); + const created = comments.createCommentThread(sql, env, { + sessionId: 'session-a', + messageId: 'message-a', + body: 'Thread', + clientMutationId: 'thread-key', + actor: HUMAN, + }); + + const firstReply = comments.createCommentReply(sql, env, { + sessionId: 'session-a', + threadId: created.thread.id, + body: 'First reply', + clientMutationId: 'reply-key-1', + actor: HUMAN, + }); + const replay = comments.createCommentReply(sql, env, { + sessionId: 'session-a', + threadId: created.thread.id, + body: 'First reply', + clientMutationId: 'reply-key-1', + actor: HUMAN, + }); + const secondReply = comments.createCommentReply(sql, env, { + sessionId: 'session-a', + threadId: created.thread.id, + body: 'Second reply', + clientMutationId: 'reply-key-2', + actor: HUMAN, + }); + + expect(firstReply.idempotent).toBe(false); + expect(replay.idempotent).toBe(true); + expect(replay.reply.id).toBe(firstReply.reply.id); + expect(secondReply.thread.replies.map((reply) => reply.sequence)).toEqual([1, 2]); + expect(secondReply.thread.version).toBe(3); + + const sent = comments.updateCommentThreadStatus(sql, env, { + sessionId: 'session-a', + threadId: created.thread.id, + status: 'sent', + clientMutationId: 'status-key-1', + actor: HUMAN, + }); + expect(sent.thread.status).toBe('sent'); + expect(sent.thread.sentBy).toEqual(HUMAN); + expect(sent.thread.version).toBe(4); + + const sentReplay = comments.updateCommentThreadStatus(sql, env, { + sessionId: 'session-a', + threadId: created.thread.id, + status: 'sent', + clientMutationId: 'status-key-1', + actor: HUMAN, + }); + expect(sentReplay.idempotent).toBe(true); + expect(sentReplay.changed).toBe(false); + + expect(() => + comments.updateCommentThreadStatus(sql, env, { + sessionId: 'session-a', + threadId: created.thread.id, + status: 'resolved', + clientMutationId: 'status-key-1', + actor: HUMAN, + }) + ).toThrow(comments.CommentIdempotencyConflictError); + + const resolved = comments.updateCommentThreadStatus(sql, env, { + sessionId: 'session-a', + threadId: created.thread.id, + status: 'resolved', + clientMutationId: 'status-key-2', + actor: HUMAN, + }); + const reopened = comments.updateCommentThreadStatus(sql, env, { + sessionId: 'session-a', + threadId: created.thread.id, + status: 'open', + clientMutationId: 'status-key-3', + actor: HUMAN, + }); + + expect(resolved.thread.status).toBe('resolved'); + expect(reopened.thread.status).toBe('open'); + expect(reopened.thread.reopenedBy).toEqual(HUMAN); + expect(reopened.thread.version).toBe(6); + }); + + it('enforces message ownership, missing resources, validation, and configured limits', () => { + seedSession('session-a'); + seedSession('session-b'); + seedMessage('session-a', 'message-a', 1); + seedMessage('session-b', 'message-b', 1); + + expect(() => + comments.createCommentThread(sql, env, { + sessionId: 'session-a', + messageId: 'message-missing', + body: 'Thread', + actor: HUMAN, + }) + ).toThrow(comments.CommentNotFoundError); + + expect(() => + comments.createCommentThread(sql, env, { + sessionId: 'session-a', + messageId: 'message-b', + body: 'Thread', + actor: HUMAN, + }) + ).toThrow(comments.CommentNotFoundError); + expect(() => + comments.listCommentThreads(sql, env, { + sessionId: 'session-a', + messageId: 'message-b', + }) + ).toThrow(comments.CommentNotFoundError); + + env.COMMENT_BODY_MAX_LENGTH = '5'; + env.COMMENT_QUOTE_MAX_LENGTH = '4'; + env.COMMENT_IDEMPOTENCY_KEY_MAX_LENGTH = '3'; + + expect(() => + comments.createCommentThread(sql, env, { + sessionId: 'session-a', + messageId: 'message-a', + body: '', + actor: HUMAN, + }) + ).toThrow(comments.CommentValidationError); + expect(() => + comments.createCommentThread(sql, env, { + sessionId: 'session-a', + messageId: 'message-a', + body: 'too long', + actor: HUMAN, + }) + ).toThrow(comments.CommentValidationError); + expect(() => + comments.createCommentThread(sql, env, { + sessionId: 'session-a', + messageId: 'message-a', + body: 'ok', + quote: 'quote', + actor: HUMAN, + }) + ).toThrow(comments.CommentValidationError); + expect(() => + comments.createCommentThread(sql, env, { + sessionId: 'session-a', + messageId: 'message-a', + body: 'ok', + clientMutationId: 'long', + actor: HUMAN, + }) + ).toThrow(comments.CommentValidationError); + + env = { + ...env, + COMMENT_BODY_MAX_LENGTH: '100', + COMMENT_QUOTE_MAX_LENGTH: '100', + COMMENT_IDEMPOTENCY_KEY_MAX_LENGTH: '100', + COMMENT_THREADS_PER_SESSION_MAX: '1', + COMMENT_REPLIES_PER_THREAD_MAX: '1', + }; + const thread = comments.createCommentThread(sql, env, { + sessionId: 'session-a', + messageId: 'message-a', + body: 'Thread', + actor: HUMAN, + }).thread; + expect(() => + comments.createCommentThread(sql, env, { + sessionId: 'session-a', + messageId: 'message-a', + body: 'Another', + actor: HUMAN, + }) + ).toThrow(comments.CommentLimitExceededError); + + comments.createCommentReply(sql, env, { + sessionId: 'session-a', + threadId: thread.id, + body: 'Reply', + actor: HUMAN, + }); + expect(() => + comments.createCommentReply(sql, env, { + sessionId: 'session-a', + threadId: thread.id, + body: 'Another reply', + actor: HUMAN, + }) + ).toThrow(comments.CommentLimitExceededError); + }); + + it('lists with status, message, afterSequence, and limit filters in sequence order', () => { + seedSession('session-a'); + seedMessage('session-a', 'message-a', 1); + seedMessage('session-a', 'message-b', 2); + + const first = comments.createCommentThread(sql, env, { + sessionId: 'session-a', + messageId: 'message-a', + body: 'First', + actor: HUMAN, + }).thread; + const second = comments.createCommentThread(sql, env, { + sessionId: 'session-a', + messageId: 'message-a', + body: 'Second', + actor: HUMAN, + }).thread; + const third = comments.createCommentThread(sql, env, { + sessionId: 'session-a', + messageId: 'message-b', + body: 'Third', + actor: HUMAN, + }).thread; + + comments.updateCommentThreadStatus(sql, env, { + sessionId: 'session-a', + threadId: second.id, + status: 'resolved', + actor: HUMAN, + }); + comments.updateCommentThreadStatus(sql, env, { + sessionId: 'session-a', + threadId: third.id, + status: 'sent', + actor: HUMAN, + }); + + expect( + comments + .listCommentThreads(sql, env, { sessionId: 'session-a', messageId: 'message-a' }) + .threads.map((thread) => thread.id) + ).toEqual([first.id, second.id]); + expect( + comments.listCommentThreads(sql, env, { sessionId: 'session-a', status: 'sent' }).threads + ).toMatchObject([{ id: third.id, status: 'sent' }]); + expect( + comments + .listCommentThreads(sql, env, { sessionId: 'session-a', afterSequence: 1 }) + .threads.map((thread) => thread.id) + ).toEqual([second.id, third.id]); + + const firstPage = comments.listCommentThreads(sql, env, { sessionId: 'session-a', limit: 2 }); + expect(firstPage.hasMore).toBe(true); + expect(firstPage.threads.map((thread) => thread.id)).toEqual([first.id, second.id]); + }); +}); diff --git a/apps/api/tests/unit/durable-objects/project-data-comment-broadcast.test.ts b/apps/api/tests/unit/durable-objects/project-data-comment-broadcast.test.ts new file mode 100644 index 000000000..f152524ad --- /dev/null +++ b/apps/api/tests/unit/durable-objects/project-data-comment-broadcast.test.ts @@ -0,0 +1,192 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('drizzle-orm', () => ({ + sql: Object.assign((s: unknown) => s, { raw: (s: unknown) => s }), + eq: (a: unknown, b: unknown) => [a, b], + and: (...args: unknown[]) => args, + desc: (col: unknown) => ({ desc: true, col }), +})); + +vi.mock('cloudflare:workers', () => ({ + DurableObject: class { + ctx: any; + env: any; + constructor(ctx: any, env: any) { + this.ctx = ctx; + this.env = env; + } + }, +})); + +vi.mock('../../../src/durable-objects/migrations', () => ({ + runMigrations: vi.fn(), +})); + +vi.mock('@simple-agent-manager/shared', async (importOriginal) => ({ + ...(await importOriginal()), + ACP_SESSION_VALID_TRANSITIONS: {}, + ACP_SESSION_TERMINAL_STATUSES: new Set(), + ACP_SESSION_DEFAULTS: { + DETECTION_WINDOW_MS: 30000, + MAX_FORK_DEPTH: 5, + }, + DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES: 60, + DEFAULT_WORKSPACE_PROFILE: 'default', + PROVIDER_LOCATIONS: {}, +})); + +vi.mock('../../../src/durable-objects/project-data/comments', () => ({ + createCommentThread: vi.fn(), + createCommentReply: vi.fn(), + listCommentThreads: vi.fn(), + updateCommentThreadStatus: vi.fn(), +})); + +const commentStorage = await import('../../../src/durable-objects/project-data/comments'); +const { ProjectData } = await import('../../../src/durable-objects/project-data'); + +interface MockWebSocket { + send: ReturnType; + close: ReturnType; + tags: string[]; + _sent: string[]; +} + +function createMockWebSocket(tags: string[] = []): MockWebSocket { + const sent: string[] = []; + return { + send: vi.fn((data: string) => sent.push(data)), + close: vi.fn(), + tags, + _sent: sent, + }; +} + +function createMockCtx(websockets: MockWebSocket[] = []) { + return { + storage: { + sql: { + exec: vi.fn(() => ({ + toArray: () => [], + columnNames: [], + rowsRead: 0, + rowsWritten: 0, + })), + }, + transactionSync: vi.fn((fn: () => unknown) => fn()), + get: vi.fn(), + put: vi.fn(), + }, + id: { toString: () => 'test-do-id' }, + blockConcurrencyWhile: vi.fn(async (fn: () => Promise) => fn()), + getWebSockets: vi.fn((tag?: string) => { + if (tag) return websockets.filter((ws) => ws.tags.includes(tag)); + return [...websockets]; + }), + getTags: vi.fn((ws: MockWebSocket) => ws.tags), + acceptWebSocket: vi.fn(), + }; +} + +const thread = { + id: 'thread-1', + sessionId: 'session-a', + anchor: { kind: 'message' as const, messageId: 'message-1', quote: null }, + author: { kind: 'human' as const, id: 'user-1', name: 'Ada' }, + body: 'Needs clarification', + status: 'open' as const, + createdAt: 1, + updatedAt: 1, + sequence: 1, + version: 1, + clientMutationId: 'mutation-1', + sentAt: null, + sentBy: null, + resolvedAt: null, + resolvedBy: null, + reopenedAt: null, + reopenedBy: null, + replies: [], +}; + +describe('ProjectData DO — comment WebSocket broadcasting', () => { + let projectData: InstanceType; + let sessionASocket: MockWebSocket; + let sessionBSocket: MockWebSocket; + let projectSocket: MockWebSocket; + + beforeEach(() => { + vi.resetAllMocks(); + sessionASocket = createMockWebSocket(['session:session-a']); + sessionBSocket = createMockWebSocket(['session:session-b']); + projectSocket = createMockWebSocket([]); + projectData = new ProjectData( + createMockCtx([sessionASocket, sessionBSocket, projectSocket]) as any, + {} as any + ); + }); + + it('broadcasts changed comment threads to matching session and project sockets', () => { + vi.mocked(commentStorage.createCommentThread).mockReturnValue({ + thread, + idempotent: false, + changed: true, + }); + + const result = projectData.createCommentThread({} as never); + + expect(result).toEqual({ thread, idempotent: false }); + expect(sessionASocket.send).toHaveBeenCalledOnce(); + expect(projectSocket.send).toHaveBeenCalledOnce(); + expect(sessionBSocket.send).not.toHaveBeenCalled(); + + const event = JSON.parse(sessionASocket._sent[0]); + expect(event).toEqual({ + type: 'comment.thread.changed', + payload: { + sessionId: 'session-a', + thread, + reason: 'thread_created', + }, + }); + }); + + it('broadcasts reply and status reasons only when the authoritative state changes', () => { + vi.mocked(commentStorage.createCommentReply).mockReturnValue({ + thread, + reply: { + id: 'reply-1', + threadId: 'thread-1', + sessionId: 'session-a', + author: thread.author, + body: 'Reply', + createdAt: 2, + sequence: 1, + clientMutationId: 'reply-key', + }, + idempotent: false, + changed: true, + }); + vi.mocked(commentStorage.updateCommentThreadStatus).mockReturnValueOnce({ + thread: { ...thread, status: 'resolved', version: 2 }, + idempotent: false, + changed: true, + }); + vi.mocked(commentStorage.updateCommentThreadStatus).mockReturnValueOnce({ + thread: { ...thread, status: 'resolved', version: 2 }, + idempotent: true, + changed: false, + }); + + projectData.createCommentReply({} as never); + projectData.updateCommentThreadStatus({ status: 'resolved' } as never); + projectData.updateCommentThreadStatus({ status: 'resolved' } as never); + + const reasons = sessionASocket._sent.map( + (message) => JSON.parse(message).payload.reason as string + ); + expect(reasons).toEqual(['reply_created', 'resolved']); + expect(projectSocket._sent).toHaveLength(2); + expect(sessionBSocket._sent).toHaveLength(0); + }); +}); diff --git a/apps/api/tests/unit/routes/chat-comments.test.ts b/apps/api/tests/unit/routes/chat-comments.test.ts new file mode 100644 index 000000000..0195c418d --- /dev/null +++ b/apps/api/tests/unit/routes/chat-comments.test.ts @@ -0,0 +1,311 @@ +import { drizzle } from 'drizzle-orm/d1'; +import { Hono } from 'hono'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { Env } from '../../../src/env'; +import { AppError } from '../../../src/middleware/error'; + +const mocks = vi.hoisted(() => { + class CommentNotFoundError extends Error { + readonly code = 'COMMENT_NOT_FOUND'; + constructor(readonly resource: 'Chat session' | 'Message' | 'Comment thread') { + super(`${resource} not found`); + this.name = 'CommentNotFoundError'; + } + } + + class CommentValidationError extends Error { + readonly code = 'COMMENT_VALIDATION'; + constructor(message: string) { + super(message); + this.name = 'CommentValidationError'; + } + } + + class CommentIdempotencyConflictError extends Error { + readonly code = 'COMMENT_IDEMPOTENCY_CONFLICT'; + constructor() { + super('clientMutationId already belongs to a different comment mutation'); + this.name = 'CommentIdempotencyConflictError'; + } + } + + class CommentLimitExceededError extends Error { + readonly code = 'COMMENT_LIMIT_EXCEEDED'; + constructor(message: string) { + super(message); + this.name = 'CommentLimitExceededError'; + } + } + + return { + createCommentReply: vi.fn(), + createCommentThread: vi.fn(), + getMessageToolContent: vi.fn(), + getMessages: vi.fn(), + getSession: vi.fn(), + listCommentThreads: vi.fn(), + requireProjectAccess: vi.fn(), + requireProjectCapability: vi.fn(), + stopSession: vi.fn(), + updateCommentThreadStatus: vi.fn(), + CommentIdempotencyConflictError, + CommentLimitExceededError, + CommentNotFoundError, + CommentValidationError, + }; +}); + +vi.mock('drizzle-orm/d1'); +vi.mock('../../../src/middleware/auth', () => ({ + requireAuth: () => vi.fn((_c: unknown, next: () => Promise) => next()), + requireApproved: () => vi.fn((_c: unknown, next: () => Promise) => next()), + getUserId: () => 'user-1', + getAuth: () => ({ + user: { + id: 'user-1', + email: 'user@example.com', + name: 'Ada', + avatarUrl: null, + role: 'user', + status: 'active', + }, + session: { id: 'session-1', expiresAt: new Date('2030-01-01T00:00:00Z') }, + }), +})); +vi.mock('../../../src/middleware/project-auth', () => ({ + requireProjectAccess: mocks.requireProjectAccess, + requireProjectCapability: mocks.requireProjectCapability, +})); +vi.mock('../../../src/services/project-data', () => ({ + CommentIdempotencyConflictError: mocks.CommentIdempotencyConflictError, + CommentLimitExceededError: mocks.CommentLimitExceededError, + CommentNotFoundError: mocks.CommentNotFoundError, + CommentValidationError: mocks.CommentValidationError, + createCommentReply: mocks.createCommentReply, + createCommentThread: mocks.createCommentThread, + createSession: vi.fn(), + forwardWebSocket: vi.fn(), + getDurableExecutionSnapshot: vi.fn(), + getMessageToolContent: mocks.getMessageToolContent, + getMessages: mocks.getMessages, + getSession: mocks.getSession, + getSessionState: vi.fn(), + linkSessionIdea: vi.fn(), + listAcpSessions: vi.fn().mockResolvedValue({ sessions: [], total: 0 }), + listCommentThreads: mocks.listCommentThreads, + listSessionIdeas: vi.fn().mockResolvedValue({ ideas: [] }), + listSessions: vi.fn(), + prepareAttentionAnswer: vi.fn(), + completeAttentionAnswer: vi.fn(), + releaseAttentionAnswer: vi.fn(), + resetIdleCleanup: vi.fn(), + stopSession: mocks.stopSession, + unlinkSessionIdea: vi.fn(), + updateCommentThreadStatus: mocks.updateCommentThreadStatus, +})); +vi.mock('../../../src/services/workspace-cleanup', () => ({ + cleanupWorkspaceForDeletion: vi.fn(), +})); +vi.mock('../../../src/services/session-task-repair', () => ({ + ensureSessionTaskBacked: vi.fn(), +})); +vi.mock('../../../src/services/task-terminal-cleanup', () => ({ + cleanupTerminalTaskResources: vi.fn(), +})); + +import { chatRoutes } from '../../../src/routes/chat'; + +const thread = { + id: 'thread-1', + sessionId: 'session-1', + anchor: { kind: 'message' as const, messageId: 'message-1', quote: null }, + author: { kind: 'human' as const, id: 'user-1', name: 'Ada' }, + body: 'Needs clarification', + status: 'open' as const, + createdAt: 1, + updatedAt: 1, + sequence: 1, + version: 1, + clientMutationId: 'mutation-1', + sentAt: null, + sentBy: null, + resolvedAt: null, + resolvedBy: null, + reopenedAt: null, + reopenedBy: null, + replies: [], +}; + +function createApp() { + const app = new Hono<{ Bindings: Env }>(); + app.onError((err, c) => { + if (err instanceof AppError) { + return c.json(err.toJSON(), err.statusCode as never); + } + return c.json({ error: 'INTERNAL_ERROR', message: err.message }, 500); + }); + app.route('/api/projects/:projectId/sessions', chatRoutes); + return app; +} + +describe('chat comment routes', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(drizzle).mockReturnValue({} as never); + mocks.requireProjectCapability.mockResolvedValue({ id: 'project-1' }); + mocks.listCommentThreads.mockResolvedValue({ threads: [thread], hasMore: false }); + mocks.createCommentThread.mockResolvedValue({ thread, idempotent: false }); + mocks.createCommentReply.mockResolvedValue({ + thread: { ...thread, replies: [{ id: 'reply-1' }] }, + reply: { id: 'reply-1' }, + idempotent: true, + }); + mocks.updateCommentThreadStatus.mockResolvedValue({ + thread: { ...thread, status: 'resolved' }, + idempotent: false, + }); + }); + + it('lists comment threads with task:read project authorization and bounded query params', async () => { + const response = await createApp().request( + 'https://api.test/api/projects/project-1/sessions/session-1/comments?messageId=message-1&status=open&afterSequence=3&limit=10', + { method: 'GET' }, + { DATABASE: {} } as Env + ); + + expect(response.status).toBe(200); + expect(mocks.requireProjectCapability).toHaveBeenCalledWith( + expect.anything(), + 'project-1', + 'user-1', + 'task:read' + ); + expect(mocks.listCommentThreads).toHaveBeenCalledWith(expect.anything(), 'project-1', { + sessionId: 'session-1', + messageId: 'message-1', + status: 'open', + afterSequence: 3, + limit: 10, + }); + }); + + it('creates threads and replies with human actor and clientMutationId payloads', async () => { + const createResponse = await createApp().request( + 'https://api.test/api/projects/project-1/sessions/session-1/comments', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + messageId: 'message-1', + body: 'Needs clarification', + clientMutationId: 'thread-key', + }), + }, + { DATABASE: {} } as Env + ); + expect(createResponse.status).toBe(201); + expect(mocks.requireProjectCapability).toHaveBeenLastCalledWith( + expect.anything(), + 'project-1', + 'user-1', + 'task:write' + ); + expect(mocks.createCommentThread).toHaveBeenCalledWith(expect.anything(), 'project-1', { + sessionId: 'session-1', + messageId: 'message-1', + body: 'Needs clarification', + quote: null, + clientMutationId: 'thread-key', + actor: { kind: 'human', id: 'user-1', name: 'Ada' }, + }); + + const replyResponse = await createApp().request( + 'https://api.test/api/projects/project-1/sessions/session-1/comments/thread-1/replies', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ body: 'Reply', clientMutationId: 'reply-key' }), + }, + { DATABASE: {} } as Env + ); + expect(replyResponse.status).toBe(200); + expect(mocks.createCommentReply).toHaveBeenCalledWith(expect.anything(), 'project-1', { + sessionId: 'session-1', + threadId: 'thread-1', + body: 'Reply', + clientMutationId: 'reply-key', + actor: { kind: 'human', id: 'user-1', name: 'Ada' }, + }); + }); + + it('updates status through server-authoritative transition endpoints', async () => { + const response = await createApp().request( + 'https://api.test/api/projects/project-1/sessions/session-1/comments/thread-1/resolve', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ clientMutationId: 'resolve-key' }), + }, + { DATABASE: {} } as Env + ); + + expect(response.status).toBe(200); + expect(mocks.updateCommentThreadStatus).toHaveBeenCalledWith(expect.anything(), 'project-1', { + sessionId: 'session-1', + threadId: 'thread-1', + status: 'resolved', + clientMutationId: 'resolve-key', + actor: { kind: 'human', id: 'user-1', name: 'Ada' }, + }); + }); + + it('does not reach ProjectData when project authorization rejects membership', async () => { + mocks.requireProjectCapability.mockRejectedValueOnce( + new AppError(404, 'NOT_FOUND', 'Project not found') + ); + + const response = await createApp().request( + 'https://api.test/api/projects/project-2/sessions/session-1/comments', + { method: 'GET' }, + { DATABASE: {} } as Env + ); + + expect(response.status).toBe(404); + expect(mocks.listCommentThreads).not.toHaveBeenCalled(); + }); + + it('maps ProjectData validation, missing-message, and idempotency errors to API errors', async () => { + const invalidStatus = await createApp().request( + 'https://api.test/api/projects/project-1/sessions/session-1/comments?status=closed', + { method: 'GET' }, + { DATABASE: {} } as Env + ); + expect(invalidStatus.status).toBe(400); + + mocks.createCommentThread.mockRejectedValueOnce(new mocks.CommentNotFoundError('Message')); + const missingMessage = await createApp().request( + 'https://api.test/api/projects/project-1/sessions/session-1/comments', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ messageId: 'missing-message', body: 'Thread' }), + }, + { DATABASE: {} } as Env + ); + expect(missingMessage.status).toBe(404); + await expect(missingMessage.json()).resolves.toMatchObject({ message: 'Message not found' }); + + mocks.createCommentReply.mockRejectedValueOnce(new mocks.CommentIdempotencyConflictError()); + const conflict = await createApp().request( + 'https://api.test/api/projects/project-1/sessions/session-1/comments/thread-1/replies', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ body: 'Reply', clientMutationId: 'used-key' }), + }, + { DATABASE: {} } as Env + ); + expect(conflict.status).toBe(409); + }); +}); diff --git a/apps/api/tests/unit/services/project-data-comments.test.ts b/apps/api/tests/unit/services/project-data-comments.test.ts new file mode 100644 index 000000000..f1975c915 --- /dev/null +++ b/apps/api/tests/unit/services/project-data-comments.test.ts @@ -0,0 +1,133 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { Env } from '../../../src/env'; +import * as svc from '../../../src/services/project-data'; +import { resetProjectDataEnsureMemo } from '../../../src/services/project-data-ensure-memo'; + +const actor = { kind: 'human' as const, id: 'user-1', name: 'Ada' }; +const thread = { + id: 'thread-1', + sessionId: 'session-1', + anchor: { kind: 'message' as const, messageId: 'message-1', quote: null }, + author: actor, + body: 'Needs clarification', + status: 'open' as const, + createdAt: 1, + updatedAt: 1, + sequence: 1, + version: 1, + clientMutationId: 'thread-key', + sentAt: null, + sentBy: null, + resolvedAt: null, + resolvedBy: null, + reopenedAt: null, + reopenedBy: null, + replies: [], +}; + +function makeEnv(stub: Record): Env { + return { + DO_RETRY_MAX_ATTEMPTS: '2', + DO_RETRY_BASE_DELAY_MS: '1', + PROJECT_DATA: { + idFromName: vi.fn((name: string) => ({ toString: () => `doid-${name}` })), + get: vi.fn(() => stub), + }, + } as unknown as Env; +} + +beforeEach(() => { + resetProjectDataEnsureMemo(); +}); + +describe('project-data service comment RPC wrappers', () => { + it('forwards list, create, reply, and status inputs to the ProjectData stub', async () => { + const stub = { + ensureProjectId: vi.fn().mockResolvedValue(undefined), + listCommentThreads: vi.fn().mockResolvedValue({ threads: [thread], hasMore: false }), + createCommentThread: vi.fn().mockResolvedValue({ thread, idempotent: false }), + createCommentReply: vi.fn().mockResolvedValue({ + thread: { ...thread, replies: [{ id: 'reply-1' }] }, + reply: { id: 'reply-1' }, + idempotent: false, + }), + updateCommentThreadStatus: vi.fn().mockResolvedValue({ + thread: { ...thread, status: 'resolved' }, + idempotent: false, + }), + }; + const env = makeEnv(stub); + + await expect( + svc.listCommentThreads(env, 'project-1', { + sessionId: 'session-1', + messageId: 'message-1', + status: 'open', + afterSequence: 2, + limit: 10, + }) + ).resolves.toMatchObject({ threads: [thread], hasMore: false }); + + await expect( + svc.createCommentThread(env, 'project-1', { + sessionId: 'session-1', + messageId: 'message-1', + body: 'Needs clarification', + quote: null, + clientMutationId: 'thread-key', + actor, + }) + ).resolves.toMatchObject({ thread, idempotent: false }); + + await expect( + svc.createCommentReply(env, 'project-1', { + sessionId: 'session-1', + threadId: 'thread-1', + body: 'Reply', + clientMutationId: 'reply-key', + actor, + }) + ).resolves.toMatchObject({ reply: { id: 'reply-1' }, idempotent: false }); + + await expect( + svc.updateCommentThreadStatus(env, 'project-1', { + sessionId: 'session-1', + threadId: 'thread-1', + status: 'resolved', + clientMutationId: 'resolve-key', + actor, + }) + ).resolves.toMatchObject({ thread: { status: 'resolved' }, idempotent: false }); + + expect(stub.listCommentThreads).toHaveBeenCalledWith({ + sessionId: 'session-1', + messageId: 'message-1', + status: 'open', + afterSequence: 2, + limit: 10, + }); + expect(stub.createCommentThread).toHaveBeenCalledWith({ + sessionId: 'session-1', + messageId: 'message-1', + body: 'Needs clarification', + quote: null, + clientMutationId: 'thread-key', + actor, + }); + expect(stub.createCommentReply).toHaveBeenCalledWith({ + sessionId: 'session-1', + threadId: 'thread-1', + body: 'Reply', + clientMutationId: 'reply-key', + actor, + }); + expect(stub.updateCommentThreadStatus).toHaveBeenCalledWith({ + sessionId: 'session-1', + threadId: 'thread-1', + status: 'resolved', + clientMutationId: 'resolve-key', + actor, + }); + }); +}); diff --git a/apps/api/wrangler.toml b/apps/api/wrangler.toml index d6062d69a..a1ba0820f 100644 --- a/apps/api/wrangler.toml +++ b/apps/api/wrangler.toml @@ -50,6 +50,13 @@ NODE_HEARTBEAT_STALE_SECONDS = "180" MAX_PROJECTS_PER_USER = "100" MAX_SESSIONS_PER_PROJECT = "10000" MAX_MESSAGES_PER_SESSION = "100000" +COMMENT_BODY_MAX_LENGTH = "8000" +COMMENT_QUOTE_MAX_LENGTH = "2000" +COMMENT_IDEMPOTENCY_KEY_MAX_LENGTH = "200" +COMMENT_LIST_LIMIT_DEFAULT = "100" +COMMENT_LIST_LIMIT_MAX = "500" +COMMENT_THREADS_PER_SESSION_MAX = "1000" +COMMENT_REPLIES_PER_THREAD_MAX = "200" PROJECT_DATA_TOOL_METADATA_MAX_BYTES = "131072" PROJECT_DATA_STORAGE_TELEMETRY_ENABLED = "true" PROJECT_DATA_STORAGE_LIMIT_BYTES = "10000000000" diff --git a/apps/www/src/content/docs/docs/reference/configuration.md b/apps/www/src/content/docs/docs/reference/configuration.md index 5ebe8146c..f0208fa7c 100644 --- a/apps/www/src/content/docs/docs/reference/configuration.md +++ b/apps/www/src/content/docs/docs/reference/configuration.md @@ -769,6 +769,13 @@ ProjectData stores a single prompt-delivery queue and checkpoint episodes keyed | --------------------------------------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `MAX_SESSIONS_PER_PROJECT` | `10000` | Max chat sessions per project | | `MAX_MESSAGES_PER_SESSION` | `100000` | Max messages per chat session | +| `COMMENT_BODY_MAX_LENGTH` | `8000` | Max characters per message-anchored comment or reply body | +| `COMMENT_QUOTE_MAX_LENGTH` | `2000` | Max characters preserved from quoted message text | +| `COMMENT_IDEMPOTENCY_KEY_MAX_LENGTH` | `200` | Max `clientMutationId` length for message-anchored comment writes | +| `COMMENT_LIST_LIMIT_DEFAULT` | `100` | Default page size for comment thread lists | +| `COMMENT_LIST_LIMIT_MAX` | `500` | Max page size for comment thread lists | +| `COMMENT_THREADS_PER_SESSION_MAX` | `1000` | Max message-anchored comment threads per chat session | +| `COMMENT_REPLIES_PER_THREAD_MAX` | `200` | Max replies per message-anchored comment thread | | `DOCUMENT_CARD_RAW_OUTPUT_MAX_BYTES` | `16384` | Max compact metadata bytes preserved for library document cards | | `PROJECT_DATA_TOOL_METADATA_MAX_BYTES` | `131072` | Max stored `tool_metadata` bytes per message before oversized tool content is stripped into bounded metadata | | `PROJECT_DATA_STORAGE_TELEMETRY_ENABLED` | `true` | Enables ProjectData `databaseSize` alarm measurement and D1 telemetry writes | diff --git a/packages/shared/src/constants/defaults.ts b/packages/shared/src/constants/defaults.ts index d2bc1c3ea..898c8c949 100644 --- a/packages/shared/src/constants/defaults.ts +++ b/packages/shared/src/constants/defaults.ts @@ -93,6 +93,31 @@ export const DEFAULT_CHAT_SESSION_MESSAGE_MAX = 50000; */ export const DEFAULT_CHAT_SESSION_DELTA_MESSAGE_LIMIT = 5000; +// ============================================================================= +// Message-anchored comments +// ============================================================================= + +/** Max comment thread/reply body length. Override via COMMENT_BODY_MAX_LENGTH. */ +export const DEFAULT_COMMENT_BODY_MAX_LENGTH = 8_000; + +/** Max selected quote snapshot length stored on message anchors. Override via COMMENT_QUOTE_MAX_LENGTH. */ +export const DEFAULT_COMMENT_QUOTE_MAX_LENGTH = 2_000; + +/** Max idempotency key length accepted for optimistic comment mutations. Override via COMMENT_IDEMPOTENCY_KEY_MAX_LENGTH. */ +export const DEFAULT_COMMENT_IDEMPOTENCY_KEY_MAX_LENGTH = 200; + +/** Default page size for session comment list reads. Override via COMMENT_LIST_LIMIT_DEFAULT. */ +export const DEFAULT_COMMENT_LIST_LIMIT_DEFAULT = 100; + +/** Max page size for session comment list reads. Override via COMMENT_LIST_LIMIT_MAX. */ +export const DEFAULT_COMMENT_LIST_LIMIT_MAX = 500; + +/** Max message-anchored comment threads per chat session. Override via COMMENT_THREADS_PER_SESSION_MAX. */ +export const DEFAULT_COMMENT_THREADS_PER_SESSION_MAX = 1_000; + +/** Max replies per comment thread. Override via COMMENT_REPLIES_PER_THREAD_MAX. */ +export const DEFAULT_COMMENT_REPLIES_PER_THREAD_MAX = 200; + /** * Safety bound on how many older pages the chat client will fetch while chasing a * timeline jump target that predates the loaded window (the rare oversized-session diff --git a/packages/shared/src/constants/index.ts b/packages/shared/src/constants/index.ts index 2ebf9da6f..7001337c3 100644 --- a/packages/shared/src/constants/index.ts +++ b/packages/shared/src/constants/index.ts @@ -38,6 +38,13 @@ export { DEFAULT_CHAT_SESSION_MESSAGE_LIMIT, DEFAULT_CHAT_SESSION_MESSAGE_MAX, DEFAULT_CHAT_TIMELINE_MAX_PAGES, + DEFAULT_COMMENT_BODY_MAX_LENGTH, + DEFAULT_COMMENT_IDEMPOTENCY_KEY_MAX_LENGTH, + DEFAULT_COMMENT_LIST_LIMIT_DEFAULT, + DEFAULT_COMMENT_LIST_LIMIT_MAX, + DEFAULT_COMMENT_QUOTE_MAX_LENGTH, + DEFAULT_COMMENT_REPLIES_PER_THREAD_MAX, + DEFAULT_COMMENT_THREADS_PER_SESSION_MAX, DEFAULT_DASHBOARD_INACTIVE_THRESHOLD_MS, DEFAULT_DASHBOARD_POLL_INTERVAL_MS, DEFAULT_MAX_AGENT_SESSIONS_PER_WORKSPACE, diff --git a/packages/shared/src/types/comments.ts b/packages/shared/src/types/comments.ts new file mode 100644 index 000000000..935337a0e --- /dev/null +++ b/packages/shared/src/types/comments.ts @@ -0,0 +1,81 @@ +export const COMMENT_STATUSES = ['open', 'sent', 'resolved'] as const; +export type CommentStatus = (typeof COMMENT_STATUSES)[number]; + +export const COMMENT_AUTHOR_KINDS = ['human', 'agent'] as const; +export type CommentAuthorKind = (typeof COMMENT_AUTHOR_KINDS)[number]; + +export type MessageCommentAnchor = { + kind: 'message'; + messageId: string; + quote: string | null; +}; + +export type CommentAuthor = { + kind: CommentAuthorKind; + id: string; + name: string | null; +}; + +export type MessageCommentReply = { + id: string; + threadId: string; + sessionId: string; + author: CommentAuthor; + body: string; + createdAt: number; + sequence: number; + clientMutationId: string | null; +}; + +export type MessageCommentThread = { + id: string; + sessionId: string; + anchor: MessageCommentAnchor; + author: CommentAuthor; + body: string; + status: CommentStatus; + createdAt: number; + updatedAt: number; + sequence: number; + version: number; + clientMutationId: string | null; + sentAt: number | null; + sentBy: CommentAuthor | null; + resolvedAt: number | null; + resolvedBy: CommentAuthor | null; + reopenedAt: number | null; + reopenedBy: CommentAuthor | null; + replies: MessageCommentReply[]; +}; + +export type MessageCommentListResponse = { + threads: MessageCommentThread[]; + hasMore: boolean; +}; + +export type MessageCommentMutationResponse = { + thread: MessageCommentThread; + idempotent: boolean; +}; + +export type MessageCommentReplyMutationResponse = { + thread: MessageCommentThread; + reply: MessageCommentReply; + idempotent: boolean; +}; + +export type MessageCommentThreadEventReason = + | 'thread_created' + | 'reply_created' + | 'marked_sent' + | 'resolved' + | 'reopened'; + +export type MessageCommentThreadEvent = { + type: 'comment.thread.changed'; + payload: { + sessionId: string; + thread: MessageCommentThread; + reason: MessageCommentThreadEventReason; + }; +}; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 1856ba02a..2adc5fe3d 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -218,6 +218,22 @@ export type { UpdateTaskRequest, UpdateTaskStatusRequest, } from './task'; + +// Message-anchored comments +export type { + CommentAuthor, + CommentAuthorKind, + CommentStatus, + MessageCommentAnchor, + MessageCommentListResponse, + MessageCommentMutationResponse, + MessageCommentReply, + MessageCommentReplyMutationResponse, + MessageCommentThread, + MessageCommentThreadEvent, + MessageCommentThreadEventReason, +} from './comments'; +export { COMMENT_AUTHOR_KINDS, COMMENT_STATUSES } from './comments'; export { ATTACHMENT_DEFAULTS, COMPLETION_EVIDENCE_VERIFICATION_KINDS, @@ -546,10 +562,7 @@ export type { CheckpointProgressEnvelope, CreateCheckpointEpisodeInput, } from './checkpoint'; -export { - CHECKPOINT_EPISODE_STATES, - CHECKPOINT_EPISODE_TRANSITIONS, -} from './checkpoint'; +export { CHECKPOINT_EPISODE_STATES, CHECKPOINT_EPISODE_TRANSITIONS } from './checkpoint'; // Mission (Phase 2: Orchestration Primitives) export type { diff --git a/tasks/active/2026-08-21-message-anchored-commenting-backend.md b/tasks/active/2026-08-21-message-anchored-commenting-backend.md index a9a964690..6811b2fd9 100644 --- a/tasks/active/2026-08-21-message-anchored-commenting-backend.md +++ b/tasks/active/2026-08-21-message-anchored-commenting-backend.md @@ -39,16 +39,16 @@ Out of scope: ## Implementation checklist -- [ ] Add shared comment API/event types and env-backed default limits. -- [ ] Append a ProjectData DO SQLite migration for `comment_threads`, `comment_replies`, and status-idempotency rows. -- [ ] Add a `project-data/comments.ts` module with bounded validation, message-anchor verification, idempotent create/reply/status transitions, deterministic sequence ordering, and row parsing isolation. -- [ ] Add public ProjectData RPC delegates in `project-data/index.ts` that broadcast server-authoritative comment events on the existing WebSocket channel. -- [ ] Add typed service wrappers in `apps/api/src/services/project-data.ts`. -- [ ] Add Valibot schemas and HTTP routes under `/api/projects/:projectId/sessions/:sessionId/comments`. -- [ ] Enforce project membership/capability authorization at every HTTP route and DO-side rejection for missing sessions, missing/cross-session messages, missing threads, and idempotency conflicts. -- [ ] Add/refresh API documentation for the HTTP/RPC/event contract. -- [ ] Add focused tests for migrations, CRUD/replies/status transitions, validation/limits, authorization, idempotency, ordering, and WebSocket events. -- [ ] Run local quality checks and required specialist reviews. +- [x] Add shared comment API/event types and env-backed default limits. +- [x] Append a ProjectData DO SQLite migration for `comment_threads`, `comment_replies`, and status-idempotency rows. +- [x] Add a `project-data/comments.ts` module with bounded validation, message-anchor verification, idempotent create/reply/status transitions, deterministic sequence ordering, and row parsing isolation. +- [x] Add public ProjectData RPC delegates in `project-data/index.ts` that broadcast server-authoritative comment events on the existing WebSocket channel. +- [x] Add typed service wrappers in `apps/api/src/services/project-data.ts`. +- [x] Add Valibot schemas and HTTP routes under `/api/projects/:projectId/sessions/:sessionId/comments`. +- [x] Enforce project membership/capability authorization at every HTTP route and DO-side rejection for missing sessions, missing/cross-session messages, missing threads, and idempotency conflicts. +- [x] Add/refresh API documentation for the HTTP/RPC/event contract. +- [x] Add focused tests for migrations, CRUD/replies/status transitions, validation/limits, authorization, idempotency, ordering, and WebSocket events. +- [x] Run local quality checks and required specialist reviews. - [ ] Create an open PR to `main`; do not deploy to staging and do not merge. ## Acceptance criteria @@ -69,3 +69,28 @@ Out of scope: - `sent` means "marked as sent for agent handling" in storage and events. This PR does not enqueue a prompt or wait for agent completion. - Comment authors for HTTP writes are server-authoritative human actors derived from the authenticated session. Agent-authored comments can be added later through MCP/tool routes using the same ProjectData RPC shape. - Comment authorization is project membership/capability based, not session-creator based. + +## Validation evidence + +- `pnpm --filter @simple-agent-manager/shared build` +- `pnpm --filter @simple-agent-manager/api typecheck` +- `pnpm --filter @simple-agent-manager/api test -- tests/unit/durable-objects/comments.test.ts tests/unit/durable-objects/project-data-comment-broadcast.test.ts tests/unit/routes/chat-comments.test.ts tests/unit/services/project-data-comments.test.ts` +- `pnpm --filter @simple-agent-manager/api exec eslint src/durable-objects/project-data/comments.ts src/durable-objects/project-data/index.ts src/durable-objects/project-data/types.ts src/env.ts src/routes/chat.ts src/routes/chat-comments.ts src/schemas/comments.ts src/schemas/index.ts src/services/project-data.ts tests/unit/durable-objects/comments.test.ts tests/unit/durable-objects/project-data-comment-broadcast.test.ts tests/unit/routes/chat-comments.test.ts tests/unit/services/project-data-comments.test.ts` +- `pnpm exec prettier --check .agents/skills/api-reference/SKILL.md .claude/skills/api-reference/SKILL.md apps/api/src/durable-objects/project-data/comments.ts apps/api/src/durable-objects/project-data/index.ts apps/api/src/durable-objects/project-data/types.ts apps/api/src/env.ts apps/api/src/routes/chat.ts apps/api/src/routes/chat-comments.ts apps/api/src/schemas/comments.ts apps/api/src/schemas/index.ts apps/api/src/services/project-data.ts apps/api/tests/unit/durable-objects/comments.test.ts apps/api/tests/unit/durable-objects/project-data-comment-broadcast.test.ts apps/api/tests/unit/routes/chat-comments.test.ts apps/api/tests/unit/services/project-data-comments.test.ts apps/www/src/content/docs/docs/reference/configuration.md packages/shared/src/constants/defaults.ts packages/shared/src/constants/index.ts packages/shared/src/types/comments.ts packages/shared/src/types/index.ts tasks/active/2026-08-21-message-anchored-commenting-backend.md` +- `pnpm quality:do-migration-safety` +- `pnpm quality:source-contract-tests` +- `pnpm quality:wrangler-bindings` +- `pnpm quality:type-boundaries` +- `git diff --check` + +## Specialist review evidence + +| Reviewer | Status | Outcome | +| ------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| constitution-validator | PASS | New limits are env-backed shared defaults with Worker/DO Env, Wrangler, `.env.example`, and docs entries; no hardcoded URLs/timeouts/identifiers added. | +| cloudflare-specialist | PASS | ProjectData migration is append-only/non-destructive and passes `quality:do-migration-safety`; write RPCs use synchronous DO transactions and existing WebSocket fan-out. | +| security-auditor | PASS | HTTP routes require project `task:read`/`task:write`; server derives human actor from auth; ProjectData rejects missing/cross-session message anchors and missing threads. | +| test-engineer | PASS | Added storage, route/auth/error, service-wrapper, and DO WebSocket event tests covering migration, CRUD, replies, status, limits, idempotency, ordering, and event payloads. | +| doc-sync-validator | PASS | API reference, shared types, env examples, Wrangler defaults, and configuration docs reflect the new HTTP/RPC/event contract and env knobs. | +| env-validator | PASS | `COMMENT_*` env vars are consistently named and present in Worker Env, ProjectData Env, Wrangler, `.env.example`, docs, and shared defaults. | +| task-completion-validator | PASS | Acceptance criteria checked against the diff and passing tests; staging and merge intentionally skipped by explicit task constraint. | From 3a66dadde801f68dbc3ce2a531caf3ab578e0fd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Fri, 21 Aug 2026 22:41:29 +0000 Subject: [PATCH 3/4] fix: satisfy comment listing SQL quality check --- apps/api/src/durable-objects/project-data/comments.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/api/src/durable-objects/project-data/comments.ts b/apps/api/src/durable-objects/project-data/comments.ts index 0c8677df6..8fc9598e9 100644 --- a/apps/api/src/durable-objects/project-data/comments.ts +++ b/apps/api/src/durable-objects/project-data/comments.ts @@ -468,6 +468,7 @@ export function listCommentThreads( params.push(input.afterSequence); } + const whereClause = conditions.join(' AND '); const rows = sql .exec( `SELECT id, session_id, message_id, quote, body, author_type, author_id, author_name, @@ -476,7 +477,7 @@ export function listCommentThreads( resolved_at, resolved_by_type, resolved_by_id, resolved_by_name, reopened_at, reopened_by_type, reopened_by_id, reopened_by_name FROM comment_threads - WHERE ${conditions.join(' AND ')} + WHERE ${whereClause} ORDER BY sequence ASC LIMIT ?`, ...params, From 2c980b159128de0e2a02bb639ba348e3735c7be7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Fri, 21 Aug 2026 23:15:42 +0000 Subject: [PATCH 4/4] test: isolate comment contracts from route mocks --- .../project-data/comment-contracts.ts | 97 +++++++++++++ .../durable-objects/project-data/comments.ts | 133 +++++------------- apps/api/src/routes/chat-comments.ts | 4 +- apps/api/src/services/project-data.ts | 4 +- .../unit/durable-objects/migrations.test.ts | 3 +- .../unit/routes/chat-prompt-cancel.test.ts | 1 + .../routes/chat-session-agent-routing.test.ts | 1 + 7 files changed, 143 insertions(+), 100 deletions(-) create mode 100644 apps/api/src/durable-objects/project-data/comment-contracts.ts diff --git a/apps/api/src/durable-objects/project-data/comment-contracts.ts b/apps/api/src/durable-objects/project-data/comment-contracts.ts new file mode 100644 index 000000000..ea073acda --- /dev/null +++ b/apps/api/src/durable-objects/project-data/comment-contracts.ts @@ -0,0 +1,97 @@ +import type { + CommentAuthor, + CommentStatus, + MessageCommentReply, + MessageCommentThread, +} from '@simple-agent-manager/shared'; + +export type CommentActor = CommentAuthor; + +export type CreateCommentThreadInput = { + sessionId: string; + messageId: string; + body: string; + quote?: string | null; + clientMutationId?: string | null; + actor: CommentActor; +}; + +export type CreateCommentReplyInput = { + sessionId: string; + threadId: string; + body: string; + clientMutationId?: string | null; + actor: CommentActor; +}; + +export type ListCommentThreadsInput = { + sessionId: string; + messageId?: string | null; + status?: CommentStatus | null; + afterSequence?: number | null; + limit?: number | null; +}; + +export type UpdateCommentStatusInput = { + sessionId: string; + threadId: string; + status: CommentStatus; + clientMutationId?: string | null; + actor: CommentActor; +}; + +export type CommentThreadMutationResult = { + thread: MessageCommentThread; + idempotent: boolean; + changed: boolean; +}; + +export type CommentReplyMutationResult = CommentThreadMutationResult & { + reply: MessageCommentReply; +}; + +export type ListCommentThreadsResult = { + threads: MessageCommentThread[]; + hasMore: boolean; +}; + +export const COMMENT_NOT_FOUND = 'COMMENT_NOT_FOUND'; +export const COMMENT_VALIDATION = 'COMMENT_VALIDATION'; +export const COMMENT_IDEMPOTENCY_CONFLICT = 'COMMENT_IDEMPOTENCY_CONFLICT'; +export const COMMENT_LIMIT_EXCEEDED = 'COMMENT_LIMIT_EXCEEDED'; + +export class CommentNotFoundError extends Error { + readonly code = COMMENT_NOT_FOUND; + + constructor(readonly resource: 'Chat session' | 'Message' | 'Comment thread') { + super(`${resource} not found`); + this.name = 'CommentNotFoundError'; + } +} + +export class CommentValidationError extends Error { + readonly code = COMMENT_VALIDATION; + + constructor(message: string) { + super(message); + this.name = 'CommentValidationError'; + } +} + +export class CommentIdempotencyConflictError extends Error { + readonly code = COMMENT_IDEMPOTENCY_CONFLICT; + + constructor() { + super('clientMutationId already belongs to a different comment mutation'); + this.name = 'CommentIdempotencyConflictError'; + } +} + +export class CommentLimitExceededError extends Error { + readonly code = COMMENT_LIMIT_EXCEEDED; + + constructor(message: string) { + super(message); + this.name = 'CommentLimitExceededError'; + } +} diff --git a/apps/api/src/durable-objects/project-data/comments.ts b/apps/api/src/durable-objects/project-data/comments.ts index 8fc9598e9..509ac2265 100644 --- a/apps/api/src/durable-objects/project-data/comments.ts +++ b/apps/api/src/durable-objects/project-data/comments.ts @@ -1,9 +1,4 @@ -import type { - CommentAuthor, - CommentStatus, - MessageCommentReply, - MessageCommentThread, -} from '@simple-agent-manager/shared'; +import type { MessageCommentReply, MessageCommentThread } from '@simple-agent-manager/shared'; import { COMMENT_STATUSES, DEFAULT_COMMENT_BODY_MAX_LENGTH, @@ -17,103 +12,51 @@ import { import * as v from 'valibot'; import { createModuleLogger } from '../../lib/logger'; +import { + COMMENT_IDEMPOTENCY_CONFLICT, + COMMENT_LIMIT_EXCEEDED, + COMMENT_NOT_FOUND, + COMMENT_VALIDATION, + type CommentActor, + CommentIdempotencyConflictError, + CommentLimitExceededError, + CommentNotFoundError, + type CommentReplyMutationResult, + type CommentThreadMutationResult, + CommentValidationError, + type CreateCommentReplyInput, + type CreateCommentThreadInput, + type ListCommentThreadsInput, + type ListCommentThreadsResult, + type UpdateCommentStatusInput, +} from './comment-contracts'; import { parseRow } from './row-schemas'; import type { Env } from './types'; import { generateId } from './types'; const log = createModuleLogger('project_data.comments'); -export type CommentActor = CommentAuthor; - -export type CreateCommentThreadInput = { - sessionId: string; - messageId: string; - body: string; - quote?: string | null; - clientMutationId?: string | null; - actor: CommentActor; -}; - -export type CreateCommentReplyInput = { - sessionId: string; - threadId: string; - body: string; - clientMutationId?: string | null; - actor: CommentActor; -}; - -export type ListCommentThreadsInput = { - sessionId: string; - messageId?: string | null; - status?: CommentStatus | null; - afterSequence?: number | null; - limit?: number | null; -}; - -export type UpdateCommentStatusInput = { - sessionId: string; - threadId: string; - status: CommentStatus; - clientMutationId?: string | null; - actor: CommentActor; -}; - -export type CommentThreadMutationResult = { - thread: MessageCommentThread; - idempotent: boolean; - changed: boolean; -}; - -export type CommentReplyMutationResult = CommentThreadMutationResult & { - reply: MessageCommentReply; +export { + COMMENT_IDEMPOTENCY_CONFLICT, + COMMENT_LIMIT_EXCEEDED, + COMMENT_NOT_FOUND, + COMMENT_VALIDATION, + CommentIdempotencyConflictError, + CommentLimitExceededError, + CommentNotFoundError, + CommentValidationError, }; - -export type ListCommentThreadsResult = { - threads: MessageCommentThread[]; - hasMore: boolean; +export type { + CommentActor, + CommentReplyMutationResult, + CommentThreadMutationResult, + CreateCommentReplyInput, + CreateCommentThreadInput, + ListCommentThreadsInput, + ListCommentThreadsResult, + UpdateCommentStatusInput, }; -export const COMMENT_NOT_FOUND = 'COMMENT_NOT_FOUND'; -export const COMMENT_VALIDATION = 'COMMENT_VALIDATION'; -export const COMMENT_IDEMPOTENCY_CONFLICT = 'COMMENT_IDEMPOTENCY_CONFLICT'; -export const COMMENT_LIMIT_EXCEEDED = 'COMMENT_LIMIT_EXCEEDED'; - -export class CommentNotFoundError extends Error { - readonly code = COMMENT_NOT_FOUND; - - constructor(readonly resource: 'Chat session' | 'Message' | 'Comment thread') { - super(`${resource} not found`); - this.name = 'CommentNotFoundError'; - } -} - -export class CommentValidationError extends Error { - readonly code = COMMENT_VALIDATION; - - constructor(message: string) { - super(message); - this.name = 'CommentValidationError'; - } -} - -export class CommentIdempotencyConflictError extends Error { - readonly code = COMMENT_IDEMPOTENCY_CONFLICT; - - constructor() { - super('clientMutationId already belongs to a different comment mutation'); - this.name = 'CommentIdempotencyConflictError'; - } -} - -export class CommentLimitExceededError extends Error { - readonly code = COMMENT_LIMIT_EXCEEDED; - - constructor(message: string) { - super(message); - this.name = 'CommentLimitExceededError'; - } -} - type CommentLimits = { bodyMaxLength: number; quoteMaxLength: number; @@ -296,7 +239,7 @@ function actorFromColumns( kind: 'human' | 'agent' | null, id: string | null, name: string | null -): CommentAuthor | null { +): CommentActor | null { if (!kind || !id) return null; return { kind, id, name }; } diff --git a/apps/api/src/routes/chat-comments.ts b/apps/api/src/routes/chat-comments.ts index a94fed753..b85d03287 100644 --- a/apps/api/src/routes/chat-comments.ts +++ b/apps/api/src/routes/chat-comments.ts @@ -9,12 +9,12 @@ import { requireRouteParam } from '../lib/route-helpers'; import { getAuth, getUserId } from '../middleware/auth'; import { errors } from '../middleware/error'; import { requireProjectCapability } from '../middleware/project-auth'; +import { jsonValidator } from '../schemas/_validator'; import { CommentStatusMutationSchema, CreateCommentReplySchema, CreateCommentThreadSchema, - jsonValidator, -} from '../schemas'; +} from '../schemas/comments'; import * as projectDataService from '../services/project-data'; export const chatCommentRoutes = new Hono<{ Bindings: Env }>(); diff --git a/apps/api/src/services/project-data.ts b/apps/api/src/services/project-data.ts index 82dc804ec..dd7e7b347 100644 --- a/apps/api/src/services/project-data.ts +++ b/apps/api/src/services/project-data.ts @@ -29,13 +29,13 @@ import type { CreateCommentThreadInput, ListCommentThreadsInput, UpdateCommentStatusInput, -} from '../durable-objects/project-data/comments'; +} from '../durable-objects/project-data/comment-contracts'; export { CommentIdempotencyConflictError, CommentLimitExceededError, CommentNotFoundError, CommentValidationError, -} from '../durable-objects/project-data/comments'; +} from '../durable-objects/project-data/comment-contracts'; import type { AcceptedPromptDelivery, AcceptPromptDeliveryInput, diff --git a/apps/api/tests/unit/durable-objects/migrations.test.ts b/apps/api/tests/unit/durable-objects/migrations.test.ts index 490ad52a6..fa6b166a9 100644 --- a/apps/api/tests/unit/durable-objects/migrations.test.ts +++ b/apps/api/tests/unit/durable-objects/migrations.test.ts @@ -399,7 +399,8 @@ describe('DO Migrations', () => { // durable task waits: 2 (due + child) from migration 030; the // active-parent (030) and idempotency (031) indexes are CREATE UNIQUE // INDEX and are counted separately - expect(indexes.length).toBe(51); + // message-anchored comments: 6 from migration 032 + expect(indexes.length).toBe(57); }); }); }); diff --git a/apps/api/tests/unit/routes/chat-prompt-cancel.test.ts b/apps/api/tests/unit/routes/chat-prompt-cancel.test.ts index 4932fdf84..26e6e0ac6 100644 --- a/apps/api/tests/unit/routes/chat-prompt-cancel.test.ts +++ b/apps/api/tests/unit/routes/chat-prompt-cancel.test.ts @@ -23,6 +23,7 @@ vi.mock('drizzle-orm/d1', () => ({ })); vi.mock('@simple-agent-manager/shared', () => ({ + COMMENT_STATUSES: ['open', 'sent', 'resolved'], DEFAULT_CHAT_SESSION_MESSAGE_LIMIT: 500, DEFAULT_CHAT_SESSION_MESSAGE_MAX: 50000, DEFAULT_CHAT_COMPACT_MODE: true, diff --git a/apps/api/tests/unit/routes/chat-session-agent-routing.test.ts b/apps/api/tests/unit/routes/chat-session-agent-routing.test.ts index 4f758039c..537bb4077 100644 --- a/apps/api/tests/unit/routes/chat-session-agent-routing.test.ts +++ b/apps/api/tests/unit/routes/chat-session-agent-routing.test.ts @@ -67,6 +67,7 @@ vi.mock('drizzle-orm/d1', () => ({ })); vi.mock('@simple-agent-manager/shared', () => ({ + COMMENT_STATUSES: ['open', 'sent', 'resolved'], DEFAULT_CHAT_SESSION_DELTA_MESSAGE_LIMIT: 5000, DEFAULT_CHAT_SESSION_MESSAGE_LIMIT: 500, DEFAULT_CHAT_SESSION_MESSAGE_MAX: 50000,