Compare commits

..

No commits in common. "ff01a5f09789cbf55c8e7b3e6f017c732cc4af46" and "4a1bea95c562a314e4e7f32c4713b1ca83cfd936" have entirely different histories.

135 changed files with 265 additions and 42106 deletions

View file

@ -1,117 +0,0 @@
---
name: ai-agent-studio
classification: feature
success_metric: The existing AI rail destination provides accessible Chat and Agents modes, versioned agent foundations, and a provider-agnostic service that streams validated agent draft proposals.
skip: []
status: in-progress
created: 2026-09-25
model_policy:
roles:
role-conductor: claude-planning
role-architect: claude-planning
role-implementer: qwen-coder
role-reviewer: qwen-coder
escalate_to: claude-planning
---
# AI agent studio and marketplace
## Why
The existing AI rail destination is already the natural home for AI interaction, but it currently provides only one stateless full-page chat. Users should be able to chat with AI or switch modes and use AI itself to create, review, install, and manage reusable agents. This should feel like one coherent AI workspace rather than separate Chat, Settings, and marketplace products.
Canonical backlog: `plans/Plan-workspace-communications/Epic-agent-participation/Epic-agent-participation.md`.
## Scope
- Brief 1: refactor `/<workspaceSlug>/ai` into URL-backed Chat and Agents tabs with one shared composer shell.
- Brief 2: add first-class, versioned agent persistence and shared validation/permission contracts.
- Brief 3: turn Agents-mode prompts into validated clarification and structured draft-patch events; it does not implement the editor or persistence.
- Follow-on backlog tasks add the builder editor/private-draft persistence, marketplace library UI, permission review/sandbox/activation, durable jobs, direct saved-agent chat, channel membership, harness events, and chat delivery.
- Preserve the existing Sparkles `AI` rail entry; do not add a separate Agents rail item.
- Reuse model routing, harness routing, template UI patterns, and workspace authorization without conflating those records with agent identity.
Out of scope: channel participation/jobs, commercial marketplace billing/ratings, arbitrary public publishing, and autonomous channel access. Those follow after the agent registry exists.
## Role activity and kickoff — 2026-10-22
- User approved beginning the integrated chat/agent direction, not merge or release.
- This session performed conductor reconciliation and architect access/registry/dependency review. Existing IA/UX/architecture sections are design guidance, not evidence of historical role completion; independent IA, UX and reviewer completion is not asserted.
- Brief 1 and its canonical task are `in_progress`: an independent implementer owns the AI shell app changes. This planning session changes no app code. Brief 2 is ready; Brief 3 waits for its dependencies.
- `in-progress` is local README bookkeeping for the active brief. No DB registration, task claim, event logging or MCP transition occurred; those tools are unavailable here.
## Full product versus this foundation
These three briefs provide a shell, schema/contracts, and inert builder service—not a working marketplace, install API, contextual chat or agent execution. The [registry API task](../plans/Plan-workspace-communications/Epic-agent-participation/Task-agent-registry-api.md) and [install/lifecycle task](../plans/Plan-workspace-communications/Epic-agent-participation/Task-agent-install-lifecycle.md) now explicitly own the missing server boundaries. Library/editor/review tasks consume them rather than creating duplicate registries or lifecycle endpoints.
AI Chat, Agents creation/library, channel mentions and durable jobs use **one workspace agent registry**. Installation/activation does not grant conversation access. Follow the [plan's context and mention-to-add contracts](../plans/Plan-workspace-communications/Plan-workspace-communications.md): channel addition or expanded access needs authorized explicit confirmation, reviewed version/history/tool scope, idempotency, and revocation checks; workspace role alone cannot grant private-object access.
## Todos
- [x] User approved kickoff of the AI Chat/Agents direction on 2026-10-22; merge/release gates remain pending.
- [ ] Implement the Chat/Agents shell without regressing existing AI chat.
- [ ] Implement versioned agent persistence and shared registry contracts.
- [ ] Implement provider-agnostic conversational clarification and validated structured draft-patch streaming.
- [ ] Reviewer validates tenant isolation, permission safety, provider agnosticism, accessibility, and spec alignment.
- [ ] Human performs merge and release gates.
## IA
- Keep `/<workspaceSlug>/ai` as the canonical route and Sparkles rail destination.
- Use URL-backed top-level tabs: `Chat` and `Agents`; Chat remains the default for old links.
- The shared composer stays centered and visually stable across modes.
- Chat preserves current conversation behavior; saved-agent selection arrives in the direct-agent-chat follow-on, not the shell brief.
- Agents renders scaffolded library regions under/around the composer; the follow-on marketplace task populates Featured/Templates, Installed, Created by your workspace, and Drafts.
- This convoy provides placeholders for future agent cards/detail actions and focuses `Create new` on the builder composer.
- Brief 3 exposes testable builder service events; the follow-on editor wires them into Agents mode and turns them into editable, persisted drafts.
## UX
- Switching tabs changes intent, placeholder, suggestions, submit label, and result content—not the fundamental composer size or interaction language.
- Agents-mode examples include outcome-oriented prompts rather than raw configuration fields.
- Validated builder events expose what the proposed agent could read, requested tools, invocation policy, budget, and requested model role/harness route; the follow-on editor presents the full review experience.
- Draft proposal generation never persists, activates, publishes, grants tools, or joins channels automatically.
- Marketplace lifecycle/provenance visuals are owned by the follow-on library task; this convoy reserves compatible states in the shell.
- Keyboard focus survives tab switches where sensible; tabs, cards, dialogs, draft fields, and permission review are accessible.
## Architecture
### Product boundaries
- An agent definition is a stable service identity and versioned policy bundle.
- A role is a requested capability used by model/harness routing; it is not an agent identity.
- A model provider or OmniRoute profile performs/routes inference; it is not an agent.
- A harness executes a run; it is not an agent.
- An `agent_run` is execution history and references the pinned agent version where applicable.
### Builder flow
1. The Agents tab submits a workspace-authorized builder request through a dedicated typed streaming endpoint, not the normal chat endpoint.
2. The builder resolves a model role through existing routing and returns validated clarification or structured draft-patch events.
3. This convoy stops at the typed event stream; it does not edit or persist drafts.
4. A follow-on editor consumes those events and saves private draft versions owned by the current member.
5. A later review flow handles permission approval, sandbox tests, install, activation, and workspace publication.
### Marketplace model
- Schemas/contracts support global curated templates that are readable but not tenant-owned; catalog seeding and browse APIs are follow-on work.
- Workspace agents/installations, drafts, permissions, and versions are always scoped by `workspace_id`.
- Installation contracts require workspace-owned versioned configuration and prohibit copying secrets.
- First release supports curated templates and workspace sharing, not open public submissions, ratings, or payments.
### Security
- Generated output is untrusted and must pass zod validation plus server-side policy checks.
- Tool grants, channel access, budgets, and activation are never inferred as silent side effects.
- Provider credentials remain referenced through existing provider configuration and are never embedded in an agent definition or prompt transcript.
- Agent handles are unique per workspace; immutable IDs/version IDs drive references and audit history.
## Briefs
| Brief | Backlog task | Depends on |
| --- | --- | --- |
| 1 | `Task-ai-workspace-tabs.md` | none |
| 2 | `Task-agent-registry-marketplace.md` | none; may proceed in parallel with brief 1 |
| 3 | `Task-ai-assisted-agent-builder.md` | briefs 1 and 2 |
Follow-on scheduling: registry API → install/lifecycle → library and editor/review UI; durable jobs → direct-chat and reviewed channel membership; normalized harness → sandbox execution and run delivery. UI-only review scaffolding can precede the harness, but sandbox execution cannot. Contextual channel integration additionally waits for a verified object access policy. These are canonical backlog dependencies, not extra deliverables of the three foundation briefs.

View file

@ -1,50 +0,0 @@
---
brief: 1
convoy: ai-agent-studio
role: role-implementer
title: AI workspace Chat and Agents tabs
status: in_progress
priority: P1
slice_dependencies: []
recommended_model: auto/coding
model_tier: standard
---
# Brief 1 — `Task-ai-workspace-tabs.md`
## Execution note — 2026-10-22
User approved kickoff. The code slice and `/api/chat` role-boundary hardening are implemented; standards/spec reviews are clean, web tests pass (34 tests), and web type-check/lint pass with pre-existing warnings only. Browser verification remains required for native back/forward behavior, Radix keyboard focus, streaming scroll restoration, responsive layout, and screen-reader announcements, so the brief remains in progress. No merge/release or DB transition is claimed. Agents remains an explicit placeholder/adapter stub: the shell must not claim working creation, installation, mention-to-add, saved-agent execution, or private-context access.
## Goal
Refactor the existing workspace AI page into accessible, deep-linkable Chat and Agents modes that share one visual composer shell without regressing current streaming chat.
## Files
- `apps/web/app/(app)/[workspaceSlug]/ai/page.tsx` — preserve the canonical route; split orchestration from presentation.
- `apps/web/components/ai/` — add a workspace frame, URL-backed tab switcher, shared composer shell, Chat mode, and Agents placeholder/library frame.
- Existing `/api/chat` integration — preserve for Chat mode only.
- Adjacent component tests.
## Required design
- Keep the existing `AI` Sparkles item in `apps/web/components/layout/icon-rail.tsx`; do not add an Agents rail item.
- `Chat` is the default for `/<workspaceSlug>/ai`; tab state is URL-backed and browser-navigation safe.
- The composer keeps the same dimensions, shape, attachment/action affordances, and keyboard conventions in both modes.
- Mode-specific behavior is explicit: Chat submits conversation messages; Agents emits typed builder requests through a separate adapter stub until Brief 3.
- Tab changes preserve each mode's independent draft input and relevant scroll state.
- Reuse existing UI primitives and responsive shell patterns.
## Acceptance
- Old AI links still open functional Chat mode.
- Users can switch/deep-link/refresh/back-forward between Chat and Agents.
- Chat streaming, error, stop, and focus behavior remains functional and workspace-scoped.
- Agents shows the shared composer plus library loading/empty/error scaffolding without submitting to `/api/chat`.
- Tabs and composer pass keyboard/screen-reader checks.
- `pnpm lint && pnpm type-check && pnpm test` pass.
## Prompt
Read the current AI page, `/api/chat` route, icon rail, tRPC provider, and UI primitives before editing. Separate shared presentation from mode-specific state; do not introduce the registry schema or persist generated agents in this brief.

View file

@ -1,51 +0,0 @@
---
brief: 2
convoy: ai-agent-studio
role: role-implementer
title: Versioned agent registry schema and contracts
status: ready
priority: P1
slice_dependencies: []
recommended_model: auto/coding
model_tier: standard
---
# Brief 2 — `Task-agent-registry-marketplace.md`
## Goal
Add durable workspace agent definitions and shared validation/permission contracts that later draft, marketplace, and builder APIs can consume.
## Files
Expected scope; confirm names against current exports.
- `packages/database/src/schema/` and a generated migration — template, agent, version, and installation/provenance records.
- `packages/shared/src/` — zod contracts for identity, configuration, policies, versions, permissions, and marketplace filters.
- Adjacent schema/contract/permission-policy tests.
## Required design
- Agents are not `users`, role IDs, provider aliases, harness aliases, or `agent_runs`. This is the single identity/version registry for AI Chat, Agents builder/library, mentions, installations and jobs; never add a second channel-agent registry.
- Separate lifecycle/tool approval roles from context delegation and per-conversation access. Installation/activation grants no channel/private-object access. Follow the parent plan's permission matrix; schemas/contracts must support reviewed, version-bound scoped grants without inventing project ACL tables.
- Workspace agents have immutable IDs, unique workspace handles, versioned configuration, lifecycle status, provenance, and creator/audit metadata.
- Configuration references model role and harness route aliases but contains no provider secrets.
- Schemas support global curated-template provenance and workspace-owned installations without storing provider secrets.
- Reusable policy contracts encode: members manage their own private drafts/use active agents; `admin`/`owner` manage template installation, shared lifecycle, and ordinary allowlisted grants; only `owner` approves mutating sandbox policy or sensitive workspace-wide tools.
- Contracts expose lifecycle, visibility, provenance, permissions, and update state for later APIs and UI.
## Follow-on boundaries
[Registry API](../../plans/Plan-workspace-communications/Epic-agent-participation/Task-agent-registry-api.md) owns catalog/private-draft procedures. [Install lifecycle](../../plans/Plan-workspace-communications/Epic-agent-participation/Task-agent-install-lifecycle.md) owns reviewed lifecycle transitions. These are not hidden deliverables of this schema brief; UI tasks consume them.
## Acceptance
- Tenant ownership, handle uniqueness, and supporting indexes/foreign keys are enforced at the schema/contract layer; workspace-scoped query enforcement is required in the follow-on CRUD APIs.
- Version/updated-at precondition contracts prevent silent last-write-wins behavior in later APIs.
- Contracts cover capabilities, requested/granted tools, context/invocation policy, model role, harness route, budget, provenance, permissions, and version changes without weakening private-draft boundaries.
- No schema or contract treats existing workflow role IDs as agent identities.
- `pnpm db:generate`, `pnpm lint`, `pnpm type-check`, and relevant tests pass.
## Prompt
Read existing model/harness registry schemas and settings UI, template CRUD/editor patterns, workspace authorization helpers, and schema exports. Keep this brief focused on registry/marketplace; do not implement channel participation or builder inference.

View file

@ -1,49 +0,0 @@
---
brief: 3
convoy: ai-agent-studio
role: role-implementer
title: Conversational AI agent builder
status: blocked
priority: P1
slice_dependencies:
- brief-1-ai-workspace-tabs
- brief-2-agent-registry-marketplace
recommended_model: auto/coding
model_tier: standard
---
# Brief 3 — `Task-ai-assisted-agent-builder.md` (service slice)
## Goal
Implement the provider-agnostic builder service and streaming contracts that transform an Agents-mode request into validated clarification or structured draft-patch events. UI editing and private-draft persistence remain a follow-on slice.
## Files
Expected scope; confirm routing conventions before implementation.
- `packages/shared/src/` — versioned builder request/event/draft-patch schemas.
- `packages/ai/src/prompts/` — agent-builder prompts and structured-output instructions.
- `apps/web/app/api/` or a suitable streaming tRPC boundary — workspace-authorized builder stream separate from `/api/chat`.
- Adjacent service tests for validation, workspace authorization, provider abstraction, stream event ordering, and malformed output.
## Required design
- The builder requests an existing model role/capability and uses provider resolution; no direct OmniRoute/provider branch exists in UI or route code.
- Model output is untrusted. Only versioned zod-validated clarification and draft-patch events may affect client state.
- The service emits deterministic base revision IDs and structured patches so a follow-on UI can maintain conflict-safe draft history.
- Generated requested tools, channel access, activation, and workspace publication remain inert, unapproved fields in validated output.
- The endpoint does not persist, publish, install, activate, or execute an agent.
## Acceptance
- Outcome-oriented prompts create valid drafts or targeted clarification questions.
- The service supports initial requests and follow-up refinements through validated clarification/draft-patch events.
- Invalid or policy-violating model output becomes a typed error event and is never persisted.
- Requested tools and access remain marked unapproved and cannot be exercised by this endpoint.
- No plaintext provider secret appears in events, logs, or client payloads.
- `pnpm lint && pnpm type-check && pnpm test` pass; `pnpm build` passes for changed server/AI exports.
## Prompt
Read model routing, `packages/ai` provider adaptation, prompt conventions, registry contracts from Brief 2, and workspace guards. Use the existing AI SDK only as a normalized model API; keep the builder compatible with OmniRoute and other providers through the resolved model contract. Do not build the editor or draft CRUD in this brief.

View file

@ -1,47 +0,0 @@
---
name: repo-discovery-onboarding
classification: feature
success_metric: Operators can authorize GitHub or configure Forgejo/Gitea, discover their repositories, bulk-connect selected repos, import supported repository context, and receive usable agent onboarding instructions.
skip: []
status: open
created: 2026-09-25
model_policy:
roles:
role-conductor: claude-planning
role-architect: claude-planning
role-implementer: qwen-coder
role-reviewer: qwen-coder
escalate_to: claude-planning
---
# Repo discovery and onboarding
## Why
Echodo currently requires workspace admins to paste one remote URL at a time and reference a server-side token environment variable. That is adequate for worker execution but too cumbersome for onboarding a fleet of existing repositories.
The target flow is account/instance connection, repository discovery, explicit selection, context import, and agent setup. `plans/` remains the canonical product backlog; this convoy defines the implementation slices and gates.
## Scope and security decisions
- GitHub discovery uses the current user's linked Auth.js GitHub account and its server-side access token. The browser never receives the token.
- GitHub OAuth requests the minimum scopes needed for repository discovery. Existing accounts must explicitly re-authorize when their stored scope is insufficient.
- Forgejo/Gitea v1 uses an instance URL plus a **token environment-variable reference**. A PAT value is never stored in Echodo or sent back to the browser.
- Every tRPC procedure is workspace-scoped. Mutations require workspace owner/admin; discovery additionally verifies that the linked forge identity belongs to the current user.
- A connected repository remains a workspace resource. Importing repositories does not automatically create new workspaces.
- Repository context import is idempotent and records source repo/path/revision metadata. The canonical remote file is not silently overwritten by Echodo.
## Delivery order
1. **Forge discovery API** — shared schemas, credential resolution, paginated GitHub and Forgejo/Gitea discovery, and `connectedRepos.listAvailable`.
2. **Repo picker and batch connect** — searchable selection UI and transactional/idempotent bulk connection.
3. **Repository context ingestion** — import supported docs and existing convoy manifests with provenance and retryable status.
4. **Agent onboarding** — client-specific, copyable MCP setup based on the actual Echodo deployment model.
Briefs map 1:1 to the matching `plans/Plan-automation-foundation/Epic-repo-discovery-onboarding/Task-*.md` files so the worker can claim and complete backlog work.
## Gates
- Gate 1 must confirm the credential boundary above before Brief 1 is dispatched.
- Gate 2 reviews auth handling, tenant scoping, pagination, and redaction before merge.
- Live GitHub/Forgejo verification uses operator-provided test accounts; automated tests must use mocked HTTP responses and contain no tokens.

View file

@ -1,50 +0,0 @@
---
brief: 1
convoy: repo-discovery-onboarding
role: role-implementer
title: Forge account repository discovery API
status: ready
priority: P1
slice_dependencies: []
recommended_model: qwen-coder
model_tier: local
workspace_id: global
---
# Brief 1 — Forge account repository discovery API
## Goal
A workspace admin can request a normalized, paginated list of repositories visible to either their linked GitHub identity or a configured Forgejo/Gitea instance. Already-connected repositories are identified without exposing credentials to the client.
Backlog task: `plans/Plan-automation-foundation/Epic-repo-discovery-onboarding/Task-forge-discovery-api.md`.
## Security and product contract
- Do not accept or return raw access tokens in tRPC input/output.
- GitHub credentials come from the current user's `accounts` row for `provider='github'`; never use another workspace member's account.
- Update the existing GitHub provider authorization parameters to request repository discovery scopes. Return a typed `reauthorization_required` state when the stored account lacks the required scope instead of treating it as a forge API failure.
- Forgejo/Gitea discovery uses a workspace-admin-supplied instance descriptor and `tokenEnvVar`; resolve the token only on the server with the existing `readToken` pattern.
- Only workspace owner/admin may call discovery. All connected-repo comparisons filter by `workspace_id`.
- Keep the existing paste-URL flow working.
## Files
- `packages/shared/src/connected-repos/schemas.ts` — add zod schemas/types for discovery source, normalized remote repository summary, pagination cursor/page, and typed connection state; export through `packages/shared/src/connected-repos/index.ts`.
- `apps/web/lib/auth.ts` — configure GitHub authorization scopes while preserving the existing sign-in and account-linking flow.
- `apps/web/server/lib/forge/adapters.ts` — add a discovery context separate from repo-specific `ForgeAdapterContext`; implement paginated GitHub `/user/repos` and Forgejo/Gitea `/user/repos` mapping. Preserve `fetchRepoMetadata` and `createPullRequest` behavior.
- `apps/web/server/routers/connected-repos.ts` — add admin-only `listAvailable`; resolve credentials server-side, call the selected adapter, and mark an item connected using the unique tuple `(workspace_id, api_base_url, owner, repo_name)`.
- Add focused tests for response validation, pagination, missing credentials/scopes, private repository mapping, and already-connected matching. If web tests require missing infrastructure, add the smallest Vitest setup consistent with existing package configs.
## Acceptance
- GitHub and Forgejo/Gitea payloads map to one zod-validated result containing stable remote ID, name/full name, owner, HTTPS clone URL, web URL, default branch, privacy flag, description, and updated time.
- Pagination is explicit; the implementation does not silently truncate accounts with more than 100 repositories.
- A credentials or scope problem returns a typed connection state suitable for UI remediation and does not leak a token or provider response body.
- Existing connected repos are marked accurately within the current workspace only.
- Existing manual connect/test/update/disconnect procedures continue to work.
- `pnpm --filter @tasks/shared test`, `pnpm --filter @tasks/web lint`, and `pnpm --filter @tasks/web type-check` pass.
## Prompt for implementer
Implement only Brief 1. Read `AGENTS.md`, the matching backlog task, `apps/web/lib/auth.ts`, `apps/web/server/lib/forge/adapters.ts`, and `apps/web/server/routers/connected-repos.ts` before editing. Use TDD for adapter normalization and credential-state behavior. Do not build the picker UI, batch connection, document ingestion, or agent configuration in this brief. Do not persist plaintext PATs.

View file

@ -1,40 +0,0 @@
---
brief: 2
convoy: repo-discovery-onboarding
role: role-implementer
title: Repository picker and batch connection
status: ready
priority: P1
slice_dependencies: [1]
recommended_model: qwen-coder
model_tier: local
workspace_id: global
---
# Brief 2 — Repository picker and batch connection
## Goal
Workspace admins can search discovered repositories, select multiple entries, and connect them to the current workspace in one idempotent operation while retaining the existing paste-URL workflow.
Backlog task: `plans/Plan-automation-foundation/Epic-repo-discovery-onboarding/Task-repo-picker-modal.md`.
## Files
- `apps/web/server/routers/connected-repos.ts` — add zod-validated, admin-only `batchConnect`. Re-resolve or validate selected remote IDs server-side; do not trust client-supplied owner, URLs, forge type, or token references. Insert with workspace scoping and conflict-safe behavior against `connected_repos_workspace_repo_unique`.
- `apps/web/app/(app)/[workspaceSlug]/settings/repos/page.tsx` — retain manual connection and add a discovery entry point with source/connection-state handling.
- `apps/web/components/settings/repo-picker-dialog.tsx` (new only if extraction improves the existing page) — search, pagination/load-more, multi-select, connected/disabled states, loading/error/empty states, and import progress using existing UI primitives.
## Acceptance
- The picker supports GitHub and Forgejo/Gitea connection states returned by Brief 1 and gives actionable reauthorization/configuration messages.
- Search matches full name and description without discarding selection when paging.
- Already-connected repos cannot be selected.
- `batchConnect` is atomic for valid selections, idempotent for duplicates, tenant-scoped, and returns per-repo connected/already-connected results.
- A stale or forged client selection cannot connect an arbitrary URL.
- Successful import invalidates `connectedRepos.list` and `listAvailable` and leaves the manual paste-URL dialog usable.
- Lint and type-check pass; cover server-side validation and selection-state helpers with tests where feasible.
## Prompt for implementer
Implement only Brief 2 after Brief 1 is complete. Read the matching backlog task and existing repo settings page before editing. Reuse Radix and `components/ui`; do not implement repository document ingestion or agent setup. Never send forge credentials to a client component.

View file

@ -1,51 +0,0 @@
---
brief: 3
convoy: repo-discovery-onboarding
role: role-implementer
title: Idempotent repository context ingestion
status: ready
priority: P1
slice_dependencies: [1, 2]
recommended_model: qwen-coder
model_tier: local
workspace_id: global
---
# Brief 3 — Idempotent repository context ingestion
## Goal
After repositories are connected, an explicit retryable ingestion action imports supported repository documentation and existing convoy manifests with provenance, without making remote API latency part of the batch-connect transaction.
Backlog task: `plans/Plan-automation-foundation/Epic-repo-discovery-onboarding/Task-repo-doc-ingestion.md`.
## Architecture constraints
- Use the canonical object type `document`, not `doc`.
- Do not import an unbounded repository tree. V1 paths are root `README.md`, `AGENTS.md`, `CONTRIBUTING.md`, and `.convoys/*.md`; recursive `docs/**` and `plans/**` are deferred until limits and conflict semantics are designed.
- Import is idempotent by `(workspace, connectedRepoId, sourcePath)`, and records source forge, repo, path, revision/sha, and last import result in metadata.
- Keep remote content read-only. Re-import updates the matching imported object only when the remote revision changes.
- Share convoy parsing/reconciliation logic from a neutral package; do not import `apps/mcp-server` source into `apps/web`.
- A missing optional file is not an error. Authentication, rate-limit, parse, and persistence failures are visible per repo and retryable.
## Files
- Shared connected-repo schemas/services — add bounded tree/list-file and fetch-text-file contracts with size/type validation.
- Forge adapters — implement GitHub and Forgejo/Gitea directory/file reads against the connected repo's default branch.
- Extract reusable convoy markdown parsing from `apps/mcp-server/src/lib/convoy-files.ts` into a package usable by web and MCP, preserving local-file behavior.
- Database schema/migration if needed — add durable repository-source identity and ingestion status. Every new tenant row includes `workspace_id` and an index.
- Web tRPC/service — add admin-only `startImport`/`importStatus` or an equivalent bounded background execution path. Do not perform all remote reads inside the `batchConnect` transaction.
## Acceptance
- Importing a repo creates or updates `document` objects for supported files with source provenance.
- Re-running an unchanged import creates no duplicates.
- Existing `.convoys/*.md` manifests reconcile to workspace-scoped convoy projects. Brief files are imported only when their schema is valid and can be linked safely.
- One bad file does not roll back successful files; the result reports imported, unchanged, skipped, and failed counts.
- File count and byte limits prevent accidental large imports.
- Unit/integration tests cover idempotency, missing files, malformed convoy frontmatter, tenant isolation, and partial failure.
- Generate and commit a new migration for schema changes; lint, type-check, test, and relevant build checks pass.
## Prompt for implementer
Implement only Brief 3 after Briefs 1 and 2. Read `config/CursorSync.md` before changing sync behavior. Preserve the local-first `.convoys/` contract and never mutate remote files. Keep ingestion bounded and retryable; do not silently invent conflict resolution for `plans/**`.

View file

@ -1,48 +0,0 @@
---
brief: 4
convoy: repo-discovery-onboarding
role: role-implementer
title: Agent onboarding and repository bootstrap
status: ready
priority: P2
slice_dependencies: [2, 3]
recommended_model: qwen-coder
model_tier: local
workspace_id: global
---
# Brief 4 — Agent onboarding and repository bootstrap
## Goal
After connection/import, the operator receives accurate, client-specific instructions for connecting an agent to Echodo and can optionally open a bootstrap PR for repository-side convoy guidance.
Backlog task: `plans/Plan-automation-foundation/Epic-repo-discovery-onboarding/Task-agent-mcp-starter.md`.
## Product and security contract
- Do not generate snippets containing `DATABASE_URL` or other infrastructure secrets.
- Do not assume an `ECHODO_WORKSPACE_ID` environment variable is consumed unless the MCP transport/config actually supports it.
- Support the deployment's real MCP transport first. If this remains local stdio, show the command plus non-secret workspace slug used in tool arguments. If remote authenticated MCP is added separately, link to that setup rather than fabricating it here.
- Client formats differ. Render templates from typed server data for explicitly supported clients (OpenCode and Cursor first); label unsupported clients instead of presenting one generic JSON object as universal.
- Any repository bootstrap change is a commit on a branch followed by `createPullRequest`; `createPullRequest` alone cannot create files.
## Files
- Add a typed onboarding-config procedure returning only non-secret deployment and workspace/repo identifiers.
- Add an Agent setup panel to connected-repo settings with client tabs, copy controls, prerequisites, and verification steps.
- Add bootstrap service only if the forge adapter has branch/file/commit primitives; otherwise scope this brief to guidance and create a follow-up task rather than simulating a PR.
- Bootstrap content should reference the canonical `.convoys/README.md` contract and avoid overwriting an existing file without explicit confirmation.
## Acceptance
- OpenCode and Cursor examples are syntactically valid for their actual configuration formats and contain no database URL, PAT, access token, or API-key value.
- Instructions identify the selected workspace and repository and include a smoke test such as `query_manifest_status`.
- Copy controls provide success/error feedback and work over secure browser contexts.
- Bootstrap is idempotent: existing `.convoys/README.md` is detected, and any remote change is shown to the user through a PR URL.
- Authorization is workspace owner/admin for remote writes; read-only members may view non-secret setup guidance if allowed by workspace policy.
- Lint, type-check, and relevant UI/service tests pass.
## Prompt for implementer
Implement only Brief 4 after Briefs 2 and 3. First verify the MCP server's supported transport and environment contract in code. Never place infrastructure credentials in browser-rendered snippets. Do not claim a bootstrap PR is possible unless branch/file commit support is implemented and tested.

View file

@ -1,124 +0,0 @@
---
name: workspace-chat-foundation
classification: feature
success_metric: Two workspace members can exchange durable channel messages, reconnect without loss, and open Chat from the left rail with accurate unread state.
skip: []
status: open
created: 2026-09-25
model_policy:
roles:
role-conductor: claude-planning
role-architect: claude-planning
role-implementer: qwen-coder
role-reviewer: qwen-coder
escalate_to: claude-planning
---
# Workspace chat foundation
## Why
Echodo has collaborative documents, convoy operator notes, and AI chat surfaces, but no durable workspace conversation layer. The first slice must establish trustworthy persistence and realtime delivery before notifications or agent participation depend on it.
Canonical backlog: `plans/Plan-workspace-communications/Epic-workspace-chat/Epic-workspace-chat.md`.
## Scope
- Brief 1: tenant-scoped chat schema, channel lifecycle, message/outbox transaction, and workspace-authorized tRPC API.
- Brief 2: outbox publication, authenticated WebSockets, Redis fan-out, and reconnect catch-up.
- Brief 3: workspace Chat routes, channel/message UI, and left-rail unread/mention affordance.
- Postgres is authoritative; Redis and WebSockets accelerate delivery.
- Yjs/Hocuspocus may support presence/typing/drafts, but does not own message history.
Out of scope for this convoy: unified non-chat inbox, agent execution, external delivery, search, and federation.
## Role activity and kickoff — 2026-10-22
- User approved beginning the integrated contextual-chat/AI-agent direction; this does not authorize merge or release.
- This session performed conductor reconciliation and architect schema/access/dependency review. IA, UX, implementation, review and release work are not claimed complete by the existing section headings.
- At kickoff, Brief 1 was the next implementable slice. Superseded by the integration checkpoint below; the independent AI shell implementer remains tracked in the AI convoy.
- Files are local planning artifacts. No convoy DB registration, task claim, event logging, or MCP gate transition was performed; those tools are unavailable here. `open` is the local README status, not a server lifecycle claim.
## Go-live reconciliation — 2026-10-29
- Operator reports Chat deployed on CT 116 and 14/14 checks passed, including two-member messaging, reply, reconnect, unread, edit/delete recovery and revocation. Canonical endpoint: `wss://chat.stillwell.cloud/chat`.
- CT 102 ledger reported at 24 entries through `0023`; Chat schema verified and table ownership corrected to `echodo`. No migrations rerun here.
- Deployment evidence is now present in `docs/Current Progress.md`; local channel projection/plain-JSON redaction fixes preserved with regression coverage. Ticket origin handling is hardened locally to configured auth URL rather than forwarded headers; requires rebuild and post-deploy retest.
- Direct/PVE SSH trust in this harness remains unavailable, but is not a production rollout blocker. Operator production acceptance and local automated checks are separate evidence.
- Shared credentials password removal needs an operator decision. Source-built web rebuild, full checks and final review remain open; no lifecycle transition or release gate completion is inferred from the deployment report.
## Earlier integration checkpoint — 2026-10-29 (superseded by go-live report)
- Local implementation now includes persistence/edit/tombstone transactions, mutation catch-up, signed-ticket subscriptions, Redis outbox publication, and workspace Chat UI with scoped recovery. Cursors are in-memory, not persisted across browser reloads.
- Deployment wiring now extends `collab` with `CHAT_PORT` (default 1235), a shared runtime signing secret, dual-listener liveness checks, standalone publication, and a public TLS proxy example. The web URL is a build argument and Turbo build input.
- Configuration regression tests cover the wiring; actual Docker Compose/image validation still requires a Docker host. See [current progress](../docs/Current%20Progress.md) and the [deployment gate](../docs/deployment-production.md#workspace-chat-integration-gate).
- Production migrations `0022`/`0023` remain unverified. CT 116 SSH was blocked by a missing trusted host key; no remote changes were made. Disposable-Postgres, Redis/WebSocket, browser/revocation, accessibility and final review gates remain open.
- Earlier commit/push completion claims were incorrect: the implementation was still uncommitted at inspection. No merge, release, or server lifecycle transition is claimed by this checkpoint.
## Full product versus this foundation
The approved product includes workspace, project and supported-object chat plus mention-to-add agents. This convoy implements **workspace-context human chat only**, with an explicit fail-closed context policy seam. Object contexts must be rejected until the canonical follow-on [context authorization task](../plans/Plan-workspace-communications/Epic-workspace-chat/Task-contextual-chat-authorization.md) lands; do not treat a same-workspace object lookup as an ACL. Mention-to-add belongs to [agent channel membership](../plans/Plan-workspace-communications/Epic-agent-participation/Task-agent-channel-membership.md), not these three briefs.
All briefs follow the [plan's context and agent permission contracts](../plans/Plan-workspace-communications/Plan-workspace-communications.md). Projects are generic `objects` rows; the inspected schema has no private-object authorization model. This gap is a blocker for contextual rollout, not a reason to silently widen access.
## Todos
- [x] User approved kickoff of the integrated direction on 2026-10-22; merge/release gates remain pending.
- [ ] Implement brief 1 and validate tenant isolation and idempotency.
- [ ] Implement brief 2 and validate outage/reconnect behavior.
- [ ] Implement brief 3 and validate accessibility and multi-device unread state.
- [ ] Reviewer checks standards and the three linked backlog tasks.
- [ ] Human performs merge and release gates.
## IA
- Add `Chat` as a first-class left-rail destination within the current workspace.
- `/[workspaceSlug]/chat` selects or redirects to a channel.
- `/[workspaceSlug]/chat/[channelSlug]` renders channel navigation and conversation.
- Threads open in the existing right-panel pattern where practical, preserving channel context.
- The global notification inbox is a later epic; Chat still exposes its own durable unread and mention counts.
## UX
- Ordinary unread activity uses a dot; mentions/direct messages use a numeric badge.
- Badges include accessible labels and never rely on color alone.
- Optimistic messages show pending, failed, and retry states.
- Reconnect and catch-up are silent when successful and explicit when recovery fails.
- Keyboard navigation covers rail entry, channel switching, message history, composer, and thread opening.
## Architecture
### Data and command path
1. A workspace-authorized tRPC mutation validates the command and channel membership.
2. Brief 1 owns one Postgres transaction that inserts the message and its versioned outbox event.
3. Brief 2 owns an outbox worker that claims and publishes committed events through Redis.
4. Realtime instances fan events out to authenticated local WebSocket subscribers.
5. Reconnecting clients query Postgres after their last durable sequence cursor.
### Boundaries
- `packages/database`: tenant-scoped chat and outbox schema plus migration.
- `packages/shared`: zod contracts and versioned realtime event types when shared across processes.
- `apps/web`: workspace-authorized tRPC router, routes, React Query state, and shell navigation.
- `apps/collab-server` or a narrowly separated realtime entry point: authenticated subscriptions and Redis fan-out.
- Existing Hocuspocus/Yjs infrastructure remains available for ephemeral presence, typing, and collaborative drafts.
### Security and scale
- Every query enforces workspace AND context AND channel access. Foundation accepts workspace context only; public workspace channels require active workspace membership, private/direct also require channel membership, and archived channels are read-only. Channel owners or workspace admins/owners administer only channels they may access; role alone grants no private/direct read or self-join bypass. Object contexts require a separate verified policy.
- Brief 1 defines the versioned outbox contract and owns the atomic message/outbox transaction; Brief 2 owns claim, publication, retry, and consumer delivery.
- Message commands accept client idempotency keys and generate a per-channel monotonic sequence; reconnect cursors are `(channel_id, sequence)` pairs.
- Realtime events carry identifiers and cursors, not authorization truth; subscription and catch-up endpoints authorize independently.
- Redis failure cannot roll back a committed message. Outbox retries and Postgres catch-up restore delivery.
- Use bounded pagination and backpressure; do not broadcast entire message histories.
- This convoy supports one-level threads in the persistence/API contract; full thread-panel polish may remain minimal in Brief 3.
- Read state is the highest message sequence the member has intentionally viewed. In this convoy, mention counts are derived from validated persisted message-mention rows above that cursor; the notification epic later projects those mentions into the unified inbox. Mark-read emits an invalidation event for other devices.
## Briefs
| Brief | Backlog task | Depends on |
| --- | --- | --- |
| 1 | `Task-chat-persistence.md` | none |
| 2 | `Task-chat-realtime-delivery.md` | brief 1 |
| 3 | `Task-chat-ui-and-navigation.md` | briefs 1 and 2 |

View file

@ -1,75 +0,0 @@
---
brief: 1
convoy: workspace-chat-foundation
role: role-implementer
title: Durable chat persistence and workspace API
status: in_progress
priority: P1
slice_dependencies: []
recommended_model: auto/coding
model_tier: standard
---
# Brief 1 — `Task-chat-persistence.md`
## Execution checkpoint — 2026-10-22
- Shared command/context validation and the fail-closed authorization policy are implemented and accepted after standards/spec review; `@tasks/shared` has 65 passing tests.
- The tenant-scoped chat schema and generated migration are implemented and accepted after standards/spec/migration review; database tests pass. A separate generated `0018` repairs pre-existing omitted budget tables, and chat is isolated in generated migration `0019`.
- The concrete Drizzle human send/reply transaction, mention-aware idempotency, atomic sequence allocation, validated `chat.message.created.v1` outbox event, and workspace-authorized `chat.send` tRPC procedure are implemented and review-clean. Acceptance remains blocked on the live PostgreSQL integration gate below; channel lifecycle, history, edit/tombstone, mention counts, and read-state operations are pending.
- **Blocking integration gate:** against disposable PostgreSQL with migrations through `0019`, verify advisory-lock blocking under simultaneous same-key sends; exactly one message, mention set, logical outbox event, and sequence increment; full rollback of the sequence/message/mentions/outbox after an injected post-allocation failure; and Drizzle's runtime `bigint` shape for `UPDATE … RETURNING next_sequence - 1`. Unit fakes verify orchestration only and must not be cited as transaction/concurrency proof.
- IA/UX review completed; private-object authorization remains blocked, and agent approval semantics and AI side-panel convergence require resolution before their dependent integrations ship.
## Goal
Implement the durable, tenant-scoped channel/message foundation and workspace-authorized tRPC API described by `plans/Plan-workspace-communications/Epic-workspace-chat/Task-chat-persistence.md`.
## Files
Expected scope; confirm exact module names against current package exports before editing.
- `packages/database/src/schema/` — add chat channel, membership, message, read-state, and the outbox contract schemas and export them.
- `packages/database/migrations/` — generate a new migration and metadata; never edit an existing migration.
- `packages/shared/src/` — add reusable zod schemas/types for chat commands, author/message states, and the versioned outbox event where cross-process sharing is needed.
- `apps/web/server/routers/` — add a `workspaceProcedure` chat router for channel list/create/archive, membership management, history/send/reply/edit/delete, and mark-read.
- Adjacent `*.test.ts` files — authorization, provisioning, pagination, idempotency, thread, and cross-workspace isolation coverage.
## Required design
- Every tenant-owned table has non-null indexed `workspace_id`.
- Channel types are `public`, `private`, and `direct`; visibility is separate from context. This brief accepts only an explicit workspace context (including a documented workspace default for existing callers); reject object references/unknown context keys rather than dropping them. Add a shared authorization seam with workspace AND context AND channel checks; do not implement a project ACL in this brief.
- Public workspace channels are readable by active workspace members; private/direct channels additionally require active channel membership. Every list/title/preview/history/thread/mention/count/read-state query uses the same access predicate. Archived channels permit authorized reads only.
- Active members create workspace public/private channels and become channel owner. An authorized channel owner or workspace `admin`/`owner` can manage that channel only with channel access; role alone is not a private/direct read bypass or self-join mechanism. All membership targets must themselves be authorized. Direct-channel participant sets are immutable; changes resolve a different exact-participant channel. Object/private-project access remains blocked until an authoritative context policy exists.
- Message authors may edit their own non-deleted messages. They may tombstone their own messages; channel owners and workspace `admin`/`owner` may tombstone messages for moderation. System/agent edits require the owning service path, not an impersonated user mutation.
- Channel lifecycle includes public/private creation, rename/archive, private membership management, and idempotent direct-channel creation for an exact sorted participant set.
- Message authors use a discriminated user/agent/system representation without fake user rows.
- A client-generated idempotency key is unique within the workspace, channel, and author scope.
- Every message receives a transactionally allocated, per-channel monotonic sequence. History and reconnect cursors are `(channel_id, sequence)`; message ID breaks ties only if allocation semantics ever permit one.
- Sending a message or thread reply inserts its versioned outbox event in the same transaction. Brief 1 owns this boundary; Brief 2 consumes the outbox.
- Threads are one level. Channel history returns root messages only plus reply counts/latest-reply metadata; thread history returns direct replies ordered by the same channel sequence; replies cannot themselves receive replies. Every root reference must resolve inside the same workspace and channel.
- Deletes are tombstones where thread/history integrity requires them.
- Read state stores the highest intentionally viewed message sequence per member/channel. `markChannelRead` may only advance it and inserts a read-state invalidation event for other devices.
- Mention parsing creates validated, workspace-scoped persisted message-mention rows in the message transaction. This convoy derives unread mention counts from those rows above the recipient's read cursor; the notifications epic later creates inbox projections from the same source events.
- All reads and mutations authorize through `ctx.workspace` plus the channel-type and role rules above.
## Scope checkpoint and acceptance
Next implementable chat brief: this one. Start with failing policy tests and schema contracts, then migration and workspace-only router/outbox. Existing `objects` includes projects but has no private-object ACL: do not invent project memberships or authorize by `workspace_id` alone. See [context follow-on](../../plans/Plan-workspace-communications/Epic-workspace-chat/Task-contextual-chat-authorization.md).
- Same-workspace unsupported object contexts fail closed; no fallback public channel is created.
- Nonmember workspace admins cannot read/self-add to private/direct channels via administrative endpoints; counts/previews cannot reveal them.
- Access removal is respected by subsequent queries/writes; stale membership cannot override context denial.
- Foundation mention parsing cannot install/add/invoke agents; the agent-channel-membership task owns reviewed addition.
- Duplicate send retries result in one persisted message.
- Cross-workspace and unauthorized private-channel requests return non-disclosing errors.
- Stable cursor pagination works for forward catch-up and backward history.
- Channel creation, rename/archive, private membership changes, immutable exact-participant direct channels, and message edit/moderation enforce the documented ownership and workspace-role rules.
- One-level thread roots/replies, persisted mention rows/counts, and durable per-member read state have working API coverage.
- `markChannelRead` only advances the per-channel sequence and atomically emits its invalidation event.
- Message and thread-reply commits atomically include the corresponding outbox event.
- `pnpm db:generate`, `pnpm lint`, `pnpm type-check`, and relevant tests pass.
## Prompt
Read `AGENTS.md`, `docs/Glossary.md`, database schema exports, `apps/web/server/trpc.ts`, and `apps/web/server/lib/resolve-workspace.ts` before writing. Use TDD for authorization and idempotency. Keep this brief limited to durable persistence/API and atomic outbox writes; do not implement outbox publication, WebSockets, or UI.

View file

@ -1,55 +0,0 @@
---
brief: 2
convoy: workspace-chat-foundation
role: role-implementer
title: Transactional outbox and realtime chat delivery
status: blocked
priority: P1
slice_dependencies:
- brief-1-chat-persistence
recommended_model: auto/coding
model_tier: standard
---
# Brief 2 — `Task-chat-realtime-delivery.md`
## Goal
Deliver committed chat events to authenticated subscribers across instances while preserving Postgres replay as the recovery path.
## Files
Expected scope; refine after brief 1 lands.
- `packages/database/src/schema/` — consume the outbox and per-channel sequence contracts from brief 1; add a generated migration only if claim/retry state genuinely requires a schema extension.
- `packages/shared/src/` — consume or refine the versioned chat event envelopes and `(channel_id, sequence)` cursor schemas from brief 1 without creating a second contract.
- `apps/collab-server/src/index.ts` and focused modules — authenticated WebSocket subscriptions and Redis fan-out.
- `apps/worker/src/` or the established background-job boundary — claim/publish/retry outbox records.
- `apps/web/` realtime client integration — authenticated connection and catch-up trigger only; visual chat UI belongs to brief 3.
- Adjacent tests for authorization, retries, ordering, reconnect, and Redis outage behavior.
## Required design
- Commands remain HTTP/tRPC; do not introduce browser gRPC.
- Brief 1 already commits message/read-state rows and outbox events atomically; this brief must preserve that boundary and must not publish before commit.
- Publishers are at-least-once; consumers and clients handle duplicates by outbox event ID and durable entity ID.
- Redis carries cross-instance fan-out, not canonical history.
- Subscriptions and replay use Brief 1's workspace AND context AND channel authorization, never workspace membership alone. Public is relative to the authorized context; private/direct also require membership. Archived channels allow authorized read/catch-up only. Recheck on delivery and permission changes, close revoked subscriptions and stop fan-out; a Redis topic or cached subscription is not authority. Object contexts remain rejected until the context-policy follow-on.
- Each subscribed channel tracks its own `(channel_id, sequence)` cursor. Reconnect catch-up queries Postgres independently for each authorized channel; no workspace-global ordering is implied.
- Message, thread-reply, tombstone, channel lifecycle, membership, and read-state events use one versioned envelope. `markChannelRead` invalidations update badges on the user's other devices.
- Reuse Hocuspocus/Yjs only for ephemeral presence, typing, or drafts.
## Acceptance
- A committed message remains queryable and is eventually delivered after Redis recovers.
- Two realtime instances fan the same event to their local authorized subscribers.
- Duplicate outbox publishes do not duplicate rendered durable messages or read-state changes.
- Multi-channel reconnect catches each channel up from its own sequence without assuming cross-channel ordering.
- Public, private, direct, and archived subscription rules match Brief 1 and unauthorized attempts disclose no data.
- A read-state event clears or recalculates unread state on another connected device.
- Heartbeat, bounded buffers/backpressure, graceful shutdown, and reconnect behavior are covered.
- `pnpm lint && pnpm type-check && pnpm test && pnpm build` pass.
## Prompt
Read `config/CursorSync.md` for collab-server lifecycle, the current Hocuspocus/Redis configuration, and brief 1's resulting schema/API. Keep durable messages out of Yjs. Document any deploy-impacting port or process changes explicitly.

View file

@ -1,55 +0,0 @@
---
brief: 3
convoy: workspace-chat-foundation
role: role-implementer
title: Chat workspace UI and left-rail navigation
status: blocked
priority: P1
slice_dependencies:
- brief-1-chat-persistence
- brief-2-chat-realtime
recommended_model: auto/coding
model_tier: standard
---
# Brief 3 — `Task-chat-ui-and-navigation.md`
## Goal
Expose workspace chat through the existing application shell with accessible channel navigation, durable history/read state, and realtime updates.
## Files
Expected scope; confirm conventions against nearby workspace routes and UI components.
- `apps/web/app/(app)/[workspaceSlug]/chat/page.tsx` — channel selection/redirect or empty state.
- `apps/web/app/(app)/[workspaceSlug]/chat/[channelSlug]/page.tsx` — channel conversation route.
- `apps/web/components/layout/icon-rail.tsx` — Chat destination and accessible unread/mention badge.
- `apps/web/components/chat/` — channel list, paginated message feed, composer, thread affordance, connection state, and retry UI.
- `apps/web/components/providers/` or focused hooks — realtime event reconciliation with tRPC/React Query caches.
- Adjacent component/unit tests.
## Required design
- Server state is authoritative; realtime events invalidate or reconcile durable query data. Foundation UI supports workspace-context channels only; project/object entry points wait for the context authorization task. Never expose unauthorized titles/previews, mention candidates or counts; purge inaccessible cached conversations on denial/revocation. Mention UI must not promise agent addition/execution before its reviewed membership flow exists.
- Ordinary unread activity uses a dot. In this convoy, the numeric badge is computed from persisted message-mention rows above each channel read cursor plus unread direct-channel activity. The later notifications epic adds unified inbox projections without changing the rail contract.
- Unread means a channel's highest message sequence is greater than the member's last-read sequence. Opening a channel does not mark unseen content read until the client intentionally advances the visible boundary.
- Direct-channel unread activity contributes to the numeric Chat badge; ordinary public/private channel activity contributes only the unread dot unless the user is mentioned.
- Optimistic messages and thread replies have deterministic pending, committed, failed, and retry states keyed by the idempotency key.
- History and thread history use per-channel sequence cursor pagination and preserve scroll position while loading older messages.
- Realtime read-state invalidations reconcile rail/channel badges across the user's devices.
- Keyboard and screen-reader behavior cover navigation, unread indicators, composer, threads, and failures.
- Use existing Radix/Tailwind primitives and the current right-panel pattern for a minimally complete thread list/reply surface.
## Acceptance
- A user can enter Chat from the left rail, switch channels, send/retry messages and thread replies, intentionally advance the read boundary, and reload without losing state.
- Realtime messages and replies appear once and per-channel reconnect catch-up converges to server truth.
- Rail/channel unread, direct-message, and mention indicators update across devices after durable mark-read operations.
- Loading, empty, unauthorized, archived, offline, and failed-send states are handled.
- Responsive layout and keyboard navigation work without inaccessible color-only signals.
- `pnpm lint && pnpm type-check && pnpm test` pass.
## Prompt
Read the workspace layout, icon rail, right panel, UI primitives, tRPC provider, and the APIs/events produced by briefs 1–2. Do not add a separate design system or raw internal fetch calls. Keep notification inbox and agent participation out of this slice.

View file

@ -171,13 +171,4 @@ POSTGRES_DB=echodo
POSTGRES_PORT=5432 POSTGRES_PORT=5432
REDIS_PORT=6379 REDIS_PORT=6379
WEB_PORT=3000 WEB_PORT=3000
# ----- Chat (durable channel messaging) -----
# Shared secret for signing short-lived subscription tickets. Must match on web and collab.
# Generate at least 32 random bytes, for example: openssl rand -hex 32
CHAT_TICKET_SECRET="CHANGE_ME_USE_A_RANDOM_32_BYTE_SECRET"
# Internal collab chat listener port (distinct from Hocuspocus/Yjs on 1234)
CHAT_PORT=1235
# Public WSS endpoint, baked into the web bundle at build time. Rebuild web after changing.
NEXT_PUBLIC_CHAT_SERVER_URL="wss://chat.stillwell.cloud"
COLLAB_PORT=1234 COLLAB_PORT=1234

View file

@ -6,8 +6,7 @@
"dev": "tsx watch src/index.ts", "dev": "tsx watch src/index.ts",
"build": "tsup", "build": "tsup",
"start": "node dist/index.mjs", "start": "node dist/index.mjs",
"type-check": "tsc --noEmit", "type-check": "tsc --noEmit"
"test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@hocuspocus/common": "^2.15.0", "@hocuspocus/common": "^2.15.0",
@ -17,15 +16,12 @@
"@tasks/database": "workspace:*", "@tasks/database": "workspace:*",
"drizzle-orm": "^0.38.0", "drizzle-orm": "^0.38.0",
"ioredis": "^5.4.2", "ioredis": "^5.4.2",
"ws": "^8.20.0",
"yjs": "^13.6.22", "yjs": "^13.6.22",
"zod": "^3.24.0" "zod": "^3.24.0"
}, },
"devDependencies": { "devDependencies": {
"@types/ws": "^8.18.2",
"tsup": "^8.3.5", "tsup": "^8.3.5",
"tsx": "^4.19.2", "tsx": "^4.19.2",
"typescript": "^5.7.0", "typescript": "^5.7.0"
"vitest": "^2.1.9"
} }
} }

View file

@ -1,86 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { createHmac } from "node:crypto";
import { authorizeChannel, parseTopic, verifyTicket } from "./chat.js";
const workspaceId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const channelId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
const userId = "cccccccc-cccc-4ccc-8ccc-cccccccccccc";
const secret = "a".repeat(40);
const claims = {
iss: "echodo-web",
aud: "echodo-chat-v1",
scope: "chat:subscribe",
sub: userId,
workspaceId,
exp: Math.floor(Date.now() / 1000) + 30,
};
function ticket(payload: object, key = secret) {
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
return `${body}.${createHmac("sha256", key).update(body).digest("base64url")}`;
}
describe("chat ticket", () => {
it("rejects unsigned, altered, expired and wrong-scope tickets", () => {
expect(verifyTicket(ticket(claims), secret)).toEqual(claims);
expect(verifyTicket(ticket(claims).replace(/.$/, "x"), secret)).toBeNull();
expect(verifyTicket(ticket({ ...claims, exp: 1 }), secret)).toBeNull();
expect(
verifyTicket(ticket({ ...claims, scope: "yjs" }), secret),
).toBeNull();
expect(
verifyTicket(ticket({ ...claims, aud: "other" }), secret),
).toBeNull();
expect(verifyTicket(ticket(claims, "b".repeat(40)), secret)).toBeNull();
});
});
describe("topic and authorization", () => {
it("accepts only exact workspace and channel UUID topics", () => {
expect(
parseTopic(`chat:v1:workspace:${workspaceId}:channel:${channelId}`),
).toEqual({ workspaceId, channelId });
expect(
parseTopic(`chat:v1:workspace:${workspaceId}:channel:${channelId}:evil`),
).toBeNull();
});
it("requires workspace membership, and channel membership for private/direct (no admin bypass)", async () => {
const lookup = vi
.fn()
.mockResolvedValueOnce({
kind: "public",
workspaceMember: true,
channelMember: false,
})
.mockResolvedValueOnce({
kind: "private",
workspaceMember: true,
channelMember: false,
})
.mockResolvedValueOnce({
kind: "direct",
workspaceMember: true,
channelMember: true,
})
.mockResolvedValueOnce({
kind: "public",
workspaceMember: false,
channelMember: true,
})
.mockRejectedValueOnce(new Error("db down"));
expect(await authorizeChannel(lookup, workspaceId, channelId, userId)).toBe(
true,
);
expect(await authorizeChannel(lookup, workspaceId, channelId, userId)).toBe(
false,
);
expect(await authorizeChannel(lookup, workspaceId, channelId, userId)).toBe(
true,
);
expect(await authorizeChannel(lookup, workspaceId, channelId, userId)).toBe(
false,
);
expect(await authorizeChannel(lookup, workspaceId, channelId, userId)).toBe(
false,
);
});
});

View file

@ -1,400 +0,0 @@
import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";
import { and, eq } from "drizzle-orm";
import IoRedis from "ioredis";
import { WebSocketServer, type WebSocket } from "ws";
import { z } from "zod";
import { db } from "@tasks/database/client";
import {
chatChannelMembers,
chatChannels,
workspaceMembers,
} from "@tasks/database/schema";
const uuid = z.string().uuid();
const ticketClaims = z
.object({
iss: z.literal("echodo-web"),
aud: z.literal("echodo-chat-v1"),
scope: z.literal("chat:subscribe"),
sub: uuid,
workspaceId: uuid,
exp: z.number().int().positive(),
})
.strict();
export type ChatTicket = z.infer<typeof ticketClaims>;
const topicPattern =
/^chat:v1:workspace:([0-9a-f-]{36}):channel:([0-9a-f-]{36})$/;
const subscribe = z
.object({ type: z.literal("subscribe"), channelId: uuid })
.strict();
const unsubscribe = z
.object({ type: z.literal("unsubscribe"), channelId: uuid })
.strict();
const clientMessage = z.discriminatedUnion("type", [subscribe, unsubscribe]);
const MAX_CONNECTIONS = 2000;
const MAX_CHANNELS = 64;
const MAX_MESSAGE_BYTES = 512;
const MAX_BUFFERED_BYTES = 64 * 1024;
const HEARTBEAT_MS = 15_000;
const AUTH_TIMEOUT_MS = 5_000;
export function verifyTicket(value: string, secret: string): ChatTicket | null {
if (secret.length < 32 || value.length > 2048) return null;
const parts = value.split(".");
if (
parts.length !== 2 ||
!parts[0] ||
!/^[A-Za-z0-9_-]+$/.test(parts[0]) ||
!/^[A-Za-z0-9_-]+$/.test(parts[1])
)
return null;
const expected = createHmac("sha256", secret).update(parts[0]).digest();
const supplied = Buffer.from(parts[1], "base64url");
if (
supplied.length !== expected.length ||
!timingSafeEqual(supplied, expected)
)
return null;
try {
const parsed = ticketClaims.safeParse(
JSON.parse(Buffer.from(parts[0], "base64url").toString("utf8")),
);
if (
!parsed.success ||
parsed.data.exp <= Math.floor(Date.now() / 1000) ||
parsed.data.exp > Math.floor(Date.now() / 1000) + 60
)
return null;
return parsed.data;
} catch {
return null;
}
}
export function parseTopic(
topic: string,
): { workspaceId: string; channelId: string } | null {
const match = topicPattern.exec(topic);
if (
!match ||
!uuid.safeParse(match[1]).success ||
!uuid.safeParse(match[2]).success
)
return null;
return { workspaceId: match[1], channelId: match[2] };
}
type Lookup = (
workspaceId: string,
channelId: string,
userId: string,
) => Promise<{
kind: string;
workspaceMember: boolean;
channelMember: boolean;
} | null>;
export async function authorizeChannel(
lookup: Lookup,
workspaceId: string,
channelId: string,
userId: string,
): Promise<boolean> {
try {
let timeout: ReturnType<typeof setTimeout> | undefined;
const row = await Promise.race([
lookup(workspaceId, channelId, userId),
new Promise<null>((resolve) => {
timeout = setTimeout(() => resolve(null), AUTH_TIMEOUT_MS);
}),
]).finally(() => clearTimeout(timeout));
return (
!!row &&
row.workspaceMember &&
(row.kind === "public" ||
((row.kind === "private" || row.kind === "direct") &&
row.channelMember))
);
} catch {
return false;
}
}
// Always query current membership; never cache authorization across notifications.
const lookupChannel: Lookup = async (workspaceId, channelId, userId) => {
const [row] = await db
.select({
kind: chatChannels.kind,
workspaceMember: workspaceMembers.userId,
channelMember: chatChannelMembers.userId,
})
.from(chatChannels)
.leftJoin(
workspaceMembers,
and(
eq(workspaceMembers.workspaceId, chatChannels.workspaceId),
eq(workspaceMembers.userId, userId),
),
)
.leftJoin(
chatChannelMembers,
and(
eq(chatChannelMembers.workspaceId, chatChannels.workspaceId),
eq(chatChannelMembers.channelId, chatChannels.id),
eq(chatChannelMembers.userId, userId),
),
)
.where(
and(
eq(chatChannels.workspaceId, workspaceId),
eq(chatChannels.id, channelId),
),
)
.limit(1);
return row
? {
kind: row.kind,
workspaceMember: !!row.workspaceMember,
channelMember: !!row.channelMember,
}
: null;
};
type Peer = {
socket: WebSocket;
ticket: ChatTicket;
channels: Set<string>;
busy: boolean;
};
/** Separate port: Hocuspocus owns its existing HTTP/WebSocket upgrade handler. */
export async function startChatServer(): Promise<void> {
const secret = process.env.CHAT_TICKET_SECRET?.trim();
const redisUrl = process.env.REDIS_URL?.trim();
if (!secret || secret.length < 32 || !redisUrl) {
console.warn(
"[chat] disabled: CHAT_TICKET_SECRET (>=32 characters) and REDIS_URL required",
);
return;
}
const port = Number(process.env.CHAT_PORT ?? 1235);
if (!Number.isInteger(port) || port < 1 || port > 65535)
throw new Error("Invalid CHAT_PORT");
const subscriber = new IoRedis(redisUrl, { maxRetriesPerRequest: 1 });
await subscriber.ping(); // Fail closed: never accept subscribers without Redis.
const http = createServer((_req, res) => {
res.writeHead(404).end();
});
const wss = new WebSocketServer({
noServer: true,
maxPayload: MAX_MESSAGE_BYTES,
perMessageDeflate: false,
});
const peers = new Set<Peer>();
const topicPeers = new Map<string, Set<Peer>>();
const send = (peer: Peer, value: object) => {
if (peer.socket.readyState !== 1) return;
if (peer.socket.bufferedAmount > MAX_BUFFERED_BYTES) {
peer.socket.close(1013);
return;
}
peer.socket.send(JSON.stringify(value));
};
const remove = (peer: Peer, topic: string) => {
peer.channels.delete(topic);
const set = topicPeers.get(topic);
set?.delete(peer);
if (set?.size === 0) {
topicPeers.delete(topic);
void subscriber.unsubscribe(topic).catch(() => {});
}
};
const revoke = (peer: Peer, topic: string, channelId: string) => {
remove(peer, topic);
send(peer, { type: "revoked", channelId });
};
const deliver = async (topic: string) => {
const ids = parseTopic(topic);
if (!ids) return;
for (const peer of [...(topicPeers.get(topic) ?? [])]) {
if (!peer.channels.has(topic)) continue;
if (
peer.ticket.exp <= Date.now() / 1000 ||
!(await authorizeChannel(
lookupChannel,
ids.workspaceId,
ids.channelId,
peer.ticket.sub,
))
) {
revoke(peer, topic, ids.channelId);
peer.socket.close(1008);
continue;
}
// Deliberately ignore Redis envelope including message bodies and keys.
if (peer.channels.has(topic))
send(peer, { type: "invalidate", channelId: ids.channelId });
}
};
subscriber.on("message", (topic) => {
void deliver(topic).catch(() => {});
});
subscriber.on("error", () => {
for (const peer of peers) peer.socket.close(1011);
});
subscriber.on("end", () => {
for (const peer of peers) peer.socket.close(1011);
});
http.on("upgrade", (req, socket, head) => {
if (
peers.size >= MAX_CONNECTIONS ||
req.url?.length === undefined ||
req.url.length > 2300
) {
socket.destroy();
return;
}
let token: string | null;
try {
const url = new URL(req.url, "http://localhost");
token = url.pathname === "/chat" ? url.searchParams.get("ticket") : null;
} catch {
token = null;
}
const ticket = token ? verifyTicket(token, secret) : null;
if (!ticket) {
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) =>
wss.emit("connection", ws, ticket),
);
});
wss.on("connection", (socket: WebSocket, ticket: ChatTicket) => {
const peer: Peer = { socket, ticket, channels: new Set(), busy: false };
peers.add(peer);
const expiry = setTimeout(
() => socket.close(1008),
Math.max(0, ticket.exp * 1000 - Date.now()),
);
expiry.unref();
socket.on("close", () => {
clearTimeout(expiry);
peers.delete(peer);
for (const topic of [...peer.channels]) remove(peer, topic);
});
socket.on("message", (bytes) => {
if (peer.busy) {
socket.close(1008);
return;
}
peer.busy = true;
void (async () => {
if (ticket.exp <= Date.now() / 1000) {
socket.close(1008);
return;
}
let data: unknown;
try {
data = JSON.parse(bytes.toString());
} catch {
socket.close(1008);
return;
}
const parsed = clientMessage.safeParse(data);
if (!parsed.success) {
socket.close(1008);
return;
}
const { channelId } = parsed.data;
const topic = `chat:v1:workspace:${ticket.workspaceId}:channel:${channelId}`;
if (parsed.data.type === "unsubscribe") {
remove(peer, topic);
return;
}
if (peer.channels.size >= MAX_CHANNELS && !peer.channels.has(topic)) {
socket.close(1008);
return;
}
if (
!(await authorizeChannel(
lookupChannel,
ticket.workspaceId,
channelId,
ticket.sub,
))
) {
socket.close(1008);
return;
}
if (peer.channels.has(topic)) return;
if (!topicPeers.has(topic)) {
try {
await subscriber.subscribe(topic);
} catch {
socket.close(1011);
return;
}
if (!topicPeers.has(topic)) topicPeers.set(topic, new Set());
}
// Recheck after the Redis subscribe await: access may have been revoked meanwhile.
if (
!(await authorizeChannel(
lookupChannel,
ticket.workspaceId,
channelId,
ticket.sub,
))
) {
socket.close(1008);
return;
}
if (socket.readyState !== 1) return;
topicPeers.get(topic)?.add(peer);
peer.channels.add(topic);
send(peer, { type: "subscribed", channelId });
})()
.catch(() => socket.close(1011))
.finally(() => {
peer.busy = false;
});
});
});
setInterval(() => {
for (const peer of peers) {
if (peer.busy) continue;
peer.busy = true;
void (async () => {
if (peer.ticket.exp <= Date.now() / 1000) {
peer.socket.close(1008);
return;
}
for (const topic of [...peer.channels]) {
const ids = parseTopic(topic);
if (
!ids ||
!(await authorizeChannel(
lookupChannel,
ids.workspaceId,
ids.channelId,
peer.ticket.sub,
))
) {
revoke(peer, topic, ids?.channelId ?? "");
peer.socket.close(1008);
return;
}
}
})()
.catch(() => peer.socket.close(1011))
.finally(() => {
peer.busy = false;
});
}
}, HEARTBEAT_MS).unref();
await new Promise<void>((resolve, reject) => {
http.once("error", reject);
http.listen(port, "0.0.0.0", resolve);
});
console.log(`[chat] listening on port ${port}`);
}

View file

@ -8,7 +8,6 @@ import { objects } from "@tasks/database/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import IoRedis from "ioredis"; import IoRedis from "ioredis";
import { z } from "zod"; import { z } from "zod";
import { startChatServer } from "./chat.js";
import { import {
base64ToYUpdate, base64ToYUpdate,
extractObjectId, extractObjectId,
@ -187,8 +186,6 @@ function colorFromName(name: string): string {
async function main() { async function main() {
const port = Number(process.env.PORT) || 1234; const port = Number(process.env.PORT) || 1234;
await startChatServer();
const extensions: Extension[] = [buildDatabaseExtension()]; const extensions: Extension[] = [buildDatabaseExtension()];
const redis = await tryCreateRedisExtension(); const redis = await tryCreateRedisExtension();
if (redis) { if (redis) {

View file

@ -1,23 +1,212 @@
import { Suspense } from "react"; "use client";
import { AIWorkspace } from "@/components/ai/ai-workspace"; import { useEffect, useRef } from "react";
import { useParams } from "next/navigation";
import { useChat } from "@ai-sdk/react";
import { AlertTriangle, Bot, Loader2, Send, Sparkles, User } from "lucide-react";
export default async function AIPage({ import { Button } from "@/components/ui/button";
params, import { cn } from "@/lib/utils";
}: {
params: Promise<{ workspaceSlug: string }>; export default function AIPage() {
}) { const params = useParams();
const { workspaceSlug } = await params; const workspaceSlug =
typeof params?.workspaceSlug === "string" ? params.workspaceSlug : "";
const scrollRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const {
messages,
input,
handleInputChange,
handleSubmit,
status,
error,
setInput,
} = useChat({
api: "/api/chat",
body: { workspace: workspaceSlug },
});
const isBusy = status === "submitted" || status === "streaming";
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages, status]);
return ( return (
<Suspense <div className="flex h-full min-h-0 flex-col">
fallback={ <div className="flex shrink-0 items-center gap-3 border-b px-6 py-4">
<p role="status" className="p-6"> <div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
Loading AI workspace… <Sparkles className="size-5 text-primary" />
</div>
<div>
<h1 className="text-lg font-semibold">AI Assistant</h1>
<p className="text-xs text-muted-foreground">
Ask me anything about your workspace
</p> </p>
} </div>
</div>
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto">
{messages.length === 0 ? (
<div className="flex h-full min-h-[12rem] flex-col items-center justify-center gap-4 text-muted-foreground">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/10">
<Bot className="size-8 text-primary" />
</div>
<div className="text-center">
<p className="text-base font-medium">How can I help you today?</p>
<p className="mt-1 text-sm">
Ask me to break down a task, draft an outline, or summarize what
you have open.
</p>
</div>
<div className="mt-2 flex flex-wrap justify-center gap-2">
{[
"Break this project into subtasks",
"Summarize my open tasks",
"Draft a sprint plan for this week",
].map((suggestion) => (
<button
key={suggestion}
type="button"
onClick={() => setInput(suggestion)}
className="rounded-full border px-3 py-1.5 text-xs transition-colors hover:bg-muted"
> >
<AIWorkspace key={workspaceSlug} workspaceSlug={workspaceSlug} /> {suggestion}
</Suspense> </button>
))}
</div>
</div>
) : (
<div className="mx-auto max-w-3xl space-y-4 px-4 py-6">
{messages.map((msg) => (
<div
key={msg.id}
className={cn(
"flex gap-3",
msg.role === "user" && "flex-row-reverse",
)}
>
<div
className={cn(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
msg.role === "user"
? "bg-primary text-primary-foreground"
: "bg-muted",
)}
>
{msg.role === "user" ? (
<User className="size-4" />
) : (
<Bot className="size-4" />
)}
</div>
<div
className={cn(
"max-w-[80%] rounded-xl px-4 py-2.5 text-sm whitespace-pre-wrap",
msg.role === "user"
? "bg-primary text-primary-foreground"
: "bg-muted",
)}
>
{msg.content}
</div>
</div>
))}
{status === "submitted" && (
<div className="flex gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted">
<Bot className="size-4" />
</div>
<div className="rounded-xl bg-muted px-4 py-2.5">
<Loader2 className="size-4 animate-spin" />
</div>
</div>
)}
</div>
)}
{error ? <ChatErrorBanner error={error} /> : null}
</div>
<div className="shrink-0 border-t bg-background p-4">
<form
onSubmit={handleSubmit}
className="mx-auto flex max-w-3xl gap-2"
>
<textarea
ref={inputRef}
value={input}
onChange={handleInputChange}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (input.trim() && !isBusy) {
// Cast: handleSubmit accepts FormEvent | KeyboardEvent; the
// useChat helper unwraps either.
handleSubmit(
e as unknown as React.FormEvent<HTMLFormElement>,
); );
} }
}
}}
placeholder="Ask anything..."
className="flex-1 resize-none rounded-lg border bg-background px-4 py-2.5 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
rows={1}
/>
<Button
type="submit"
size="icon"
disabled={!input.trim() || isBusy || !workspaceSlug}
>
<Send className="size-4" />
</Button>
</form>
</div>
</div>
);
}
function ChatErrorBanner({ error }: { error: Error }) {
const message = parseErrorMessage(error);
const isUnavailable = /AI is unavailable/i.test(message);
return (
<div
className={cn(
"mx-auto mb-4 mt-2 flex max-w-3xl items-start gap-3 rounded-lg border px-4 py-3 text-sm",
isUnavailable
? "border-amber-500/40 bg-amber-500/10 text-amber-900 dark:text-amber-200"
: "border-destructive/40 bg-destructive/10 text-destructive",
)}
role="alert"
>
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
<div className="min-w-0">
<p className="font-medium">
{isUnavailable ? "AI is unavailable" : "Something went wrong"}
</p>
<p className="mt-1 break-words text-xs opacity-90">{message}</p>
</div>
</div>
);
}
/**
* `useChat` wraps fetch errors in a generic Error; the route handler returns
* a JSON body with `{ error: string }`. Try to surface that to the user; fall
* back to whatever message the SDK gave us.
*/
function parseErrorMessage(error: Error): string {
const raw = error.message || "";
try {
const parsed = JSON.parse(raw) as { error?: string };
if (parsed?.error) return parsed.error;
} catch {
// not JSON; fall through
}
return raw || "Unknown error";
}

View file

@ -1,10 +0,0 @@
import { ChatWorkspace } from "@/components/chat/chat-workspace";
export default async function ChannelPage({
params,
}: {
params: Promise<{ workspaceSlug: string; channelId: string }>;
}) {
const { workspaceSlug, channelId } = await params;
return <ChatWorkspace workspace={workspaceSlug} channelId={channelId} />;
}

View file

@ -1,10 +0,0 @@
import { ChatWorkspace } from "@/components/chat/chat-workspace";
export default async function ChatPage({
params,
}: {
params: Promise<{ workspaceSlug: string }>;
}) {
const { workspaceSlug } = await params;
return <ChatWorkspace workspace={workspaceSlug} />;
}

View file

@ -1,54 +0,0 @@
import { describe, expect, it } from "vitest";
import { chatRequestSchema } from "./schema";
describe("chat request boundary", () => {
const parseMessages = (messages: unknown[]) =>
chatRequestSchema.safeParse({ workspace: "acme", messages });
it.each(["system", "developer", "tool"])(
"rejects client-authored %s messages",
(role) => {
expect(parseMessages([{ role, content: "override instructions" }]).success).toBe(
false,
);
},
);
it("accepts user messages and assistant history from useChat", () => {
const result = parseMessages([
{
id: "user-message",
role: "user",
content: "Help me plan this task",
createdAt: "2026-10-23T12:00:00.000Z",
parts: [{ type: "text", text: "Help me plan this task" }],
},
{
id: "assistant-message",
role: "assistant",
content: "What outcome do you need?",
},
]);
expect(result).toMatchObject({
success: true,
data: {
workspace: "acme",
messages: [
{ role: "user", content: "Help me plan this task" },
{ role: "assistant", content: "What outcome do you need?" },
],
},
});
});
it.each([
{ role: "user" },
{ role: "user", content: [{ type: "text", text: "hello" }] },
{ role: "assistant", content: 42 },
{ role: "unknown", content: "hello" },
])("rejects malformed message %#", (message) => {
expect(parseMessages([message]).success).toBe(false);
});
});

View file

@ -5,13 +5,22 @@ import { z } from "zod";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { resolveWorkspace } from "@/server/lib/resolve-workspace"; import { resolveWorkspace } from "@/server/lib/resolve-workspace";
import { chatRequestSchema } from "./schema";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
const BASE_SYSTEM = `You are a helpful AI assistant embedded in a project management and collaboration app. Users organize work in workspaces with objects such as projects, tasks, documents, and groups. You help them plan work, clarify requirements, break down tasks, summarize content, and suggest next steps. Be concise, actionable, and friendly. Use markdown when it improves readability (bold, lists, short code snippets).`; const BASE_SYSTEM = `You are a helpful AI assistant embedded in a project management and collaboration app. Users organize work in workspaces with objects such as projects, tasks, documents, and groups. You help them plan work, clarify requirements, break down tasks, summarize content, and suggest next steps. Be concise, actionable, and friendly. Use markdown when it improves readability (bold, lists, short code snippets).`;
const messageSchema = z.object({
role: z.enum(["user", "assistant", "system"]),
content: z.string(),
});
const bodySchema = z.object({
workspace: z.string().min(1),
messages: z.array(messageSchema).min(1),
});
function errorResponse(status: number, message: string): Response { function errorResponse(status: number, message: string): Response {
return new Response(JSON.stringify({ error: message }), { return new Response(JSON.stringify({ error: message }), {
status, status,
@ -25,9 +34,9 @@ export async function POST(req: Request): Promise<Response> {
return errorResponse(401, "Sign in to use the assistant"); return errorResponse(401, "Sign in to use the assistant");
} }
let body: z.infer<typeof chatRequestSchema>; let body: z.infer<typeof bodySchema>;
try { try {
body = chatRequestSchema.parse(await req.json()); body = bodySchema.parse(await req.json());
} catch (e) { } catch (e) {
if (e instanceof z.ZodError) { if (e instanceof z.ZodError) {
return errorResponse(400, e.issues[0]?.message ?? "Invalid request"); return errorResponse(400, e.issues[0]?.message ?? "Invalid request");

View file

@ -1,28 +0,0 @@
import { z } from "zod";
const clientMessageSchema = z
.object({
role: z.enum(["user", "assistant"]),
content: z.string(),
id: z.string().optional(),
createdAt: z.coerce.date().optional(),
parts: z
.array(
z
.object({
type: z.literal("text"),
text: z.string(),
})
.strict(),
)
.optional(),
})
.strict()
.transform(({ role, content }) => ({ role, content }));
export const chatRequestSchema = z
.object({
workspace: z.string().min(1),
messages: z.array(clientMessageSchema).min(1),
})
.strict();

View file

@ -1,87 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { mockAuth } = vi.hoisted(() => ({
mockAuth: vi.fn<() => Promise<{ user: { id: string } } | null>>(),
}));
vi.mock("@/lib/auth", () => ({ auth: mockAuth }));
vi.mock("@/server/lib/resolve-workspace", () => ({
resolveWorkspace: vi.fn(),
}));
import { auth } from "@/lib/auth";
import { resolveWorkspace } from "@/server/lib/resolve-workspace";
import { POST } from "./route";
const publicOrigin = "https://echodo.stillwell.cloud";
function request(
origin: string | null,
forwardedHost = "echodo.stillwell.cloud",
) {
const headers = new Headers({
"Content-Type": "application/json",
"x-forwarded-host": forwardedHost,
"x-forwarded-proto": "https",
});
if (origin !== null) headers.set("origin", origin);
return new Request("http://0.0.0.0:3000/api/chat/ticket", {
method: "POST",
headers,
body: JSON.stringify({ workspace: "example" }),
});
}
beforeEach(() => {
vi.stubEnv("NODE_ENV", "production");
vi.stubEnv("NEXTAUTH_URL", publicOrigin);
vi.stubEnv("AUTH_URL", "");
vi.stubEnv("CHAT_TICKET_SECRET", "x".repeat(64));
mockAuth.mockResolvedValue({
user: { id: "10000000-0000-4000-8000-000000000001" },
});
vi.mocked(resolveWorkspace).mockResolvedValue({
id: "20000000-0000-4000-8000-000000000001",
} as Awaited<ReturnType<typeof resolveWorkspace>>);
});
afterEach(() => {
vi.unstubAllEnvs();
vi.clearAllMocks();
});
describe("Chat ticket origin boundary", () => {
it("accepts the configured public origin behind an internal Docker URL", async () => {
const response = await POST(request(publicOrigin));
expect(response.status).toBe(200);
expect(response.headers.get("cache-control")).toBe("no-store");
expect(resolveWorkspace).toHaveBeenCalled();
});
it.each([
null,
"null",
"https://evil.example",
`${publicOrigin}.evil.example`,
`${publicOrigin}/path`,
])("rejects origin %s before session lookup", async (origin) => {
expect((await POST(request(origin, "evil.example"))).status).toBe(403);
expect(auth).not.toHaveBeenCalled();
});
it("does not trust forged forwarded host and protocol", async () => {
expect(
(await POST(request("https://evil.example", "evil.example"))).status,
).toBe(403);
});
it("fails closed without a configured production origin", async () => {
vi.stubEnv("NEXTAUTH_URL", "");
expect((await POST(request("http://0.0.0.0:3000"))).status).toBe(403);
});
it("fails closed with an invalid configured origin", async () => {
vi.stubEnv("NEXTAUTH_URL", "not a URL");
expect((await POST(request(publicOrigin))).status).toBe(403);
});
it("still requires an authenticated session", async () => {
mockAuth.mockResolvedValue(null);
expect((await POST(request(publicOrigin))).status).toBe(401);
});
it("does not mint a ticket when workspace authorization fails", async () => {
vi.mocked(resolveWorkspace).mockRejectedValue(new Error("denied"));
expect((await POST(request(publicOrigin))).status).toBe(404);
});
});

View file

@ -1,77 +0,0 @@
import { createHmac } from "node:crypto";
import { auth } from "@/lib/auth";
import { resolveWorkspace } from "@/server/lib/resolve-workspace";
import { z } from "zod";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const input = z.object({ workspace: z.string().min(1).max(200) }).strict();
const noStore = { "Cache-Control": "no-store" };
function trustedOrigin(request: Request): string | null {
// Docker's request URL may be internal. Pin production to deployment configuration,
// not Host/forwarded headers that a caller could supply outside the trusted proxy.
const configured = process.env.AUTH_URL || process.env.NEXTAUTH_URL;
if (!configured && process.env.NODE_ENV === "production") return null;
const parsed = z
.string()
.url()
.safeParse(configured || request.url);
if (!parsed.success) return null;
const url = new URL(parsed.data);
if (
!["https:", "http:"].includes(url.protocol) ||
url.username ||
url.password
)
return null;
return url.origin;
}
export async function POST(request: Request): Promise<Response> {
// A browser session cookie must never be usable by a foreign origin to mint tickets.
const origin = request.headers.get("origin");
if (!origin || origin !== trustedOrigin(request))
return new Response(null, { status: 403, headers: noStore });
const session = await auth();
if (!session?.user?.id)
return new Response(null, { status: 401, headers: noStore });
const secret = process.env.CHAT_TICKET_SECRET?.trim();
if (!secret || secret.length < 32)
return new Response(null, { status: 503, headers: noStore });
let body: unknown;
try {
body = await request.json();
} catch {
return new Response(null, { status: 400, headers: noStore });
}
const parsed = input.safeParse(body);
if (!parsed.success)
return new Response(null, { status: 400, headers: noStore });
try {
const workspace = await resolveWorkspace({
handle: parsed.data.workspace,
userId: session.user.id,
});
const payload = Buffer.from(
JSON.stringify({
iss: "echodo-web",
aud: "echodo-chat-v1",
scope: "chat:subscribe",
sub: session.user.id,
workspaceId: workspace.id,
exp: Math.floor(Date.now() / 1000) + 60,
}),
).toString("base64url");
const signature = createHmac("sha256", secret)
.update(payload)
.digest("base64url");
return Response.json(
{ ticket: `${payload}.${signature}` },
{ headers: noStore },
);
} catch {
// Do not distinguish nonexistent workspaces from denied or failed membership checks.
return new Response(null, { status: 404, headers: noStore });
}
}

View file

@ -1,20 +0,0 @@
export interface AgentBuilderRequest {
workspaceSlug: string;
prompt: string;
}
export interface AgentBuilderResult {
status: "unavailable";
message: string;
}
export type AgentBuilderAdapter = (
request: AgentBuilderRequest,
) => Promise<AgentBuilderResult>;
// This boundary deliberately has no Chat transport or persistence side effects.
export const agentBuilderStub: AgentBuilderAdapter = async () => ({
status: "unavailable",
message:
"Builder is not connected yet. Your draft is kept here; no agent was created.",
});

View file

@ -1,103 +0,0 @@
"use client";
import { useState } from "react";
import { AlertTriangle, Bot, Loader2 } from "lucide-react";
import { agentBuilderStub, type AgentBuilderAdapter } from "./agent-builder";
import { WorkspaceComposer } from "./workspace-composer";
export type AgentLibraryStatus =
| { status: "loading" }
| { status: "empty" }
| { status: "error"; message: string };
export function AgentLibraryState({ state }: { state: AgentLibraryStatus }) {
if (state.status === "loading") {
return (
<p role="status" className="flex items-center gap-2">
<Loader2 className="size-4 animate-spin" aria-hidden="true" />
Loading agents…
</p>
);
}
if (state.status === "error") {
return (
<p role="alert" className="flex items-center gap-2 text-destructive">
<AlertTriangle className="size-4" aria-hidden="true" />
{state.message}
</p>
);
}
return (
<div className="space-y-2 text-center">
<Bot className="mx-auto size-8 text-primary" aria-hidden="true" />
<h3 className="font-medium">No reusable agents yet</h3>
<p className="text-sm text-muted-foreground">
Agent creation and the workspace library are coming soon. You can draft
instructions below; nothing will be created or sent to Chat.
</p>
</div>
);
}
export function AgentsMode({
workspaceSlug,
active,
builder = agentBuilderStub,
libraryState = { status: "empty" },
}: {
workspaceSlug: string;
active: boolean;
builder?: AgentBuilderAdapter;
libraryState?: AgentLibraryStatus;
}) {
const [input, setInput] = useState("");
const [notice, setNotice] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function submitBuilderRequest() {
setBusy(true);
try {
const result = await builder({ workspaceSlug, prompt: input.trim() });
setNotice(result.message);
} catch {
setNotice(
"Builder unavailable. Your draft has been kept; please try again later.",
);
} finally {
setBusy(false);
}
}
return (
<div className="flex h-full min-h-0 flex-col">
<div className="min-h-0 flex-1 overflow-y-auto">
<section
aria-label="Agent library"
className="mx-auto max-w-3xl space-y-6 px-4 py-6"
>
<h2 className="text-lg font-semibold">Agent library</h2>
<AgentLibraryState state={libraryState} />
{notice && (
<p
role="status"
className="rounded-lg border p-3 text-sm text-muted-foreground"
>
{notice}
</p>
)}
</section>
</div>
<WorkspaceComposer
mode="agents"
active={active}
input={input}
onInputChange={setInput}
busy={busy}
onSubmit={() => {
void submitBuilderRequest();
}}
/>
</div>
);
}

View file

@ -1,155 +0,0 @@
import { renderToStaticMarkup } from "react-dom/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { AIWorkspace } from "./ai-workspace";
import { AgentLibraryState } from "./agents-mode";
import { agentBuilderStub } from "./agent-builder";
import { aiModeHref, parseAIMode } from "./workspace-mode";
import { WorkspaceComposer, shouldSubmitOnEnter } from "./workspace-composer";
const mocks = vi.hoisted(() => ({
search: "",
chat: vi.fn(),
}));
vi.mock("next/navigation", () => ({
usePathname: () => "/acme/ai",
useSearchParams: () => new URLSearchParams(mocks.search),
}));
vi.mock("@ai-sdk/react", () => ({ useChat: mocks.chat }));
beforeEach(() => {
mocks.search = "";
mocks.chat.mockReset().mockReturnValue({
messages: [],
input: "Chat draft",
handleSubmit: vi.fn(),
status: "ready",
setInput: vi.fn(),
stop: vi.fn(),
});
});
describe("AI mode URLs", () => {
it.each([null, "", "unknown", "chat"])("defaults %s to Chat", (mode) => {
expect(parseAIMode(mode)).toBe("chat");
});
it("recognizes an Agents deep link", () => {
expect(parseAIMode("agents")).toBe("agents");
});
it("preserves unrelated search parameters and the fragment", () => {
expect(
aiModeHref("/acme/ai", "filter=mine&tab=chat", "agents", "#library"),
).toBe("/acme/ai?filter=mine&tab=agents#library");
expect(aiModeHref("/acme/ai", "tab=agents", "chat")).toBe(
"/acme/ai?tab=chat",
);
expect(
aiModeHref("/acme/ai", "filter=mine&tab=agents", "chat", "#library"),
).toBe("/acme/ai?filter=mine&tab=chat#library");
});
});
describe("workspace shell", () => {
it("keeps legacy URLs in Chat with both mode trees mounted and labelled tabs", () => {
const html = renderToStaticMarkup(<AIWorkspace workspaceSlug="acme" />);
expect(html).toContain('role="tablist"');
expect(html).toMatch(/aria-selected="true"[^>]*>Chat/);
expect(html).toMatch(/role="tabpanel"[^>]*hidden=""/);
expect(html).toContain("Chat draft");
expect(html).toContain("Agent library");
expect(html).toContain('aria-label="Chat message"');
expect(html).toContain('aria-label="Agent instructions"');
});
it("renders the URL-selected mode and only one workspace-scoped Chat adapter", () => {
mocks.search = "tab=agents";
const html = renderToStaticMarkup(<AIWorkspace workspaceSlug="acme" />);
expect(html).toMatch(/aria-selected="true"[^>]*>Agents/);
expect(mocks.chat).toHaveBeenCalledTimes(1);
expect(mocks.chat).toHaveBeenCalledWith({
api: "/api/chat",
body: { workspace: "acme" },
});
expect(html).toContain("Builder is not connected yet");
});
it("retains streaming messages, stop control, and errors", () => {
mocks.chat.mockReturnValue({
messages: [{ id: "m1", role: "assistant", content: "Partial answer" }],
input: "Next question",
handleSubmit: vi.fn(),
setInput: vi.fn(),
stop: vi.fn(),
status: "streaming",
error: new Error("Provider unavailable"),
});
const html = renderToStaticMarkup(<AIWorkspace workspaceSlug="acme" />);
expect(html).toContain("Partial answer");
expect(html).toContain('aria-label="Stop generating"');
expect(html).toContain("Provider unavailable");
expect(html).toContain('role="alert"');
});
});
describe("shared composer", () => {
it.each([
["Enter", false, false, true],
["Enter", true, false, false],
["Enter", false, true, false],
["a", false, false, false],
])(
"handles key %s, shift %s, composing %s",
(key, shiftKey, isComposing, expected) => {
expect(shouldSubmitOnEnter({ key, shiftKey, isComposing })).toBe(
expected,
);
},
);
it("disables blank submissions and exposes the input and keyboard hint", () => {
const html = renderToStaticMarkup(
<WorkspaceComposer
mode="agents"
input=" "
onInputChange={vi.fn()}
onSubmit={vi.fn()}
/>,
);
expect(html).toContain('aria-label="Agent instructions"');
expect(html).toMatch(/<button[^>]*disabled=""/);
expect(html).toContain("Shift+Enter");
});
});
describe("Agents scaffold", () => {
it("returns an explicit unavailable result without making a network request", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch");
try {
const result = await agentBuilderStub({
workspaceSlug: "acme",
prompt: "Triage tasks",
});
expect(result.status).toBe("unavailable");
expect(result.message).toContain("not connected");
expect(fetchSpy).not.toHaveBeenCalled();
} finally {
fetchSpy.mockRestore();
}
});
it.each([
[{ status: "loading" } as const, "Loading agents"],
[{ status: "empty" } as const, "No reusable agents yet"],
[
{ status: "error", message: "Library unavailable" } as const,
"Library unavailable",
],
])("renders the library state %j", (state, text) => {
expect(renderToStaticMarkup(<AgentLibraryState state={state} />)).toContain(
text,
);
});
});

View file

@ -1,98 +0,0 @@
"use client";
import * as Tabs from "@radix-ui/react-tabs";
import { useLayoutEffect, useRef } from "react";
import { useSearchParams } from "next/navigation";
import { Sparkles } from "lucide-react";
import { AgentsMode } from "./agents-mode";
import { ChatMode } from "./chat-mode";
import { changeAIMode, parseAIMode } from "./workspace-mode";
import { createComposerFocusMemory } from "./workspace-interaction";
export function AIWorkspace({ workspaceSlug }: { workspaceSlug: string }) {
const searchParams = useSearchParams();
const mode = parseAIMode(searchParams.get("tab"));
const rootRef = useRef<HTMLDivElement>(null);
const focusMemory = useRef(createComposerFocusMemory());
useLayoutEffect(() => {
const root = rootRef.current;
const tabHasFocus = !!root
?.querySelector('[role="tablist"]')
?.contains(document.activeElement);
if (focusMemory.current.activate(mode, tabHasFocus)) {
root
?.querySelector<HTMLTextAreaElement>(`[data-composer-mode="${mode}"]`)
?.focus({ preventScroll: true });
}
}, [mode]);
return (
<Tabs.Root
key={workspaceSlug}
value={mode}
activationMode="manual"
ref={rootRef}
onFocusCapture={(event) => {
const target = event.target;
// Tabs retain keyboard focus; only focus within a panel changes its memory.
if (
!(target instanceof HTMLElement) ||
!target.closest('[role="tabpanel"]')
)
return;
focusMemory.current.record(mode, target.dataset.composerMode === mode);
}}
onValueChange={(value) => {
// Next integrates native history with useSearchParams, including popstate.
changeAIMode(window.location, parseAIMode(value), (href) => {
window.history.pushState(null, "", href);
});
}}
className="flex h-full min-h-0 flex-col"
>
<header className="shrink-0 border-b px-4 py-4 sm:px-6">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
<Sparkles className="size-5 text-primary" aria-hidden="true" />
</div>
<div>
<h1 className="text-lg font-semibold">AI Assistant</h1>
<p className="text-xs text-muted-foreground">
Chat about your workspace or draft reusable agents
</p>
</div>
</div>
<Tabs.List aria-label="AI workspace mode" className="mt-4 flex gap-2">
{(["chat", "agents"] as const).map((value) => (
<Tabs.Trigger
key={value}
value={value}
className="rounded-md px-4 py-2 text-sm font-medium text-muted-foreground hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 data-[state=active]:bg-primary/10 data-[state=active]:text-primary"
>
{value === "chat" ? "Chat" : "Agents"}
</Tabs.Trigger>
))}
</Tabs.List>
</header>
{/* Keeping both trees mounted retains drafts, Chat transport, and scroll containers. */}
<Tabs.Content
value="chat"
forceMount
hidden={mode !== "chat"}
className="min-h-0 flex-1 data-[state=inactive]:hidden"
>
<ChatMode workspaceSlug={workspaceSlug} active={mode === "chat"} />
</Tabs.Content>
<Tabs.Content
value="agents"
forceMount
hidden={mode !== "agents"}
className="min-h-0 flex-1 data-[state=inactive]:hidden"
>
<AgentsMode workspaceSlug={workspaceSlug} active={mode === "agents"} />
</Tabs.Content>
</Tabs.Root>
);
}

View file

@ -1,166 +0,0 @@
"use client";
import { useLayoutEffect, useRef } from "react";
import { useChat } from "@ai-sdk/react";
import { AlertTriangle, Bot, Loader2, User } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { WorkspaceComposer } from "./workspace-composer";
import { createChatScrollMemory } from "./workspace-interaction";
export function ChatMode({
workspaceSlug,
active,
}: {
workspaceSlug: string;
active: boolean;
}) {
const scrollRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const scrollMemory = useRef(createChatScrollMemory());
const { messages, input, handleSubmit, status, error, setInput, stop } =
useChat({
api: "/api/chat",
body: { workspace: workspaceSlug },
});
const isBusy = status === "submitted" || status === "streaming";
useLayoutEffect(() => {
const container = scrollRef.current;
if (container) scrollMemory.current.update(container, active);
}, [messages, status, active]);
return (
<div className="flex h-full min-h-0 flex-col">
<div
ref={scrollRef}
onScroll={(event) => {
scrollMemory.current.record(event.currentTarget, active);
}}
className="min-h-0 flex-1 overflow-y-auto"
>
{messages.length === 0 ? (
<div className="flex h-full min-h-[12rem] flex-col items-center justify-center gap-4 px-4 text-muted-foreground">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/10">
<Bot className="size-8 text-primary" aria-hidden="true" />
</div>
<div className="text-center">
<p className="text-base font-medium">How can I help you today?</p>
<p className="mt-1 text-sm">
Ask me to break down a task, draft an outline, or summarize what
you have open.
</p>
</div>
<div className="mt-2 flex flex-wrap justify-center gap-2">
{[
"Break this project into subtasks",
"Summarize my open tasks",
"Draft a sprint plan for this week",
].map((suggestion) => (
<Button
key={suggestion}
type="button"
variant="outline"
size="sm"
disabled={isBusy}
onClick={() => {
setInput(suggestion);
inputRef.current?.focus();
}}
className="h-auto rounded-full px-3 py-1.5 text-xs"
>
{suggestion}
</Button>
))}
</div>
</div>
) : (
<div
role="log"
aria-label="Chat messages"
aria-live="polite"
className="mx-auto max-w-3xl space-y-4 px-4 py-6"
>
{messages.map((msg) => (
<div
key={msg.id}
className={cn(
"flex gap-3",
msg.role === "user" && "flex-row-reverse",
)}
>
<div
className={cn(
"flex h-7 w-7 shrink-0 items-center justify-center rounded-full",
msg.role === "user"
? "bg-primary text-primary-foreground"
: "bg-muted",
)}
>
{msg.role === "user" ? (
<User className="size-3.5" aria-hidden="true" />
) : (
<Bot className="size-3.5" aria-hidden="true" />
)}
</div>
<div
className={cn(
"max-w-[85%] rounded-xl px-4 py-3 text-sm leading-relaxed",
msg.role === "user"
? "bg-primary text-primary-foreground"
: "bg-muted",
)}
>
<span className="sr-only">
{msg.role === "user" ? "You" : "Assistant"}:{" "}
</span>
<p className="whitespace-pre-wrap break-words">
{msg.content}
</p>
</div>
</div>
))}
{isBusy && messages[messages.length - 1]?.role === "user" && (
<div role="status" className="flex gap-3">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-muted">
<Bot className="size-3.5" aria-hidden="true" />
</div>
<div className="rounded-xl bg-muted px-4 py-3">
<Loader2
className="size-4 animate-spin text-muted-foreground"
aria-hidden="true"
/>
<span className="sr-only">Generating response…</span>
</div>
</div>
)}
</div>
)}
{error && (
<div role="alert" className="mx-auto max-w-3xl px-4 pb-4">
<div className="flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
<AlertTriangle
className="mt-0.5 size-4 shrink-0"
aria-hidden="true"
/>
<p>
{error.message || "Something went wrong. Please try again."}
</p>
</div>
</div>
)}
</div>
<WorkspaceComposer
mode="chat"
active={active}
input={input}
onInputChange={setInput}
onSubmit={handleSubmit}
busy={isBusy}
onStop={stop}
inputRef={inputRef}
/>
</div>
);
}

View file

@ -1,12 +0,0 @@
export function submitComposer<Event extends { preventDefault(): void }>(
event: Event,
input: string,
busy: boolean,
submit: (event: Event) => void,
focus: () => void,
): void {
event.preventDefault();
if (busy || !input.trim()) return;
submit(event);
focus();
}

View file

@ -1,121 +0,0 @@
"use client";
import { useEffect, useId, useRef } from "react";
import type { FormEvent, RefObject } from "react";
import { Send, Square } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { AIMode } from "./workspace-mode";
import { submitComposer } from "./composer-events";
export function shouldSubmitOnEnter(event: {
key: string;
shiftKey: boolean;
isComposing: boolean;
}): boolean {
return event.key === "Enter" && !event.shiftKey && !event.isComposing;
}
interface WorkspaceComposerProps {
mode: AIMode;
input: string;
onInputChange: (value: string) => void;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
busy?: boolean;
onStop?: () => void;
inputRef?: RefObject<HTMLTextAreaElement | null>;
active?: boolean;
}
export function WorkspaceComposer({
mode,
input,
onInputChange,
onSubmit,
busy = false,
onStop,
inputRef,
active = true,
}: WorkspaceComposerProps) {
const localRef = useRef<HTMLTextAreaElement>(null);
const ref = inputRef ?? localRef;
const hintId = useId();
const isChat = mode === "chat";
useEffect(() => {
if (!active || !ref.current) return;
ref.current.style.height = "auto";
ref.current.style.height = `${Math.min(ref.current.scrollHeight, 200)}px`;
}, [input, active, ref]);
return (
<div className="shrink-0 border-t bg-background px-4 py-4">
<form
aria-label={isChat ? "Chat composer" : "Agent builder composer"}
onSubmit={(event) => {
submitComposer(event, input, busy, onSubmit, () =>
ref.current?.focus(),
);
}}
className="mx-auto flex max-w-3xl items-end gap-3"
>
<textarea
ref={ref}
data-composer-mode={mode}
value={input}
onChange={(event) => onInputChange(event.target.value)}
onKeyDown={(event) => {
if (
shouldSubmitOnEnter({
key: event.key,
shiftKey: event.shiftKey,
isComposing: event.nativeEvent.isComposing,
})
) {
event.preventDefault();
event.currentTarget.form?.requestSubmit();
}
}}
placeholder={isChat ? "Ask anything…" : "Describe a reusable agent…"}
aria-label={isChat ? "Chat message" : "Agent instructions"}
aria-describedby={hintId}
rows={1}
readOnly={busy}
className="max-h-[200px] min-h-[44px] flex-1 resize-none rounded-xl border bg-muted/30 px-4 py-3 text-sm outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring read-only:opacity-60"
/>
{busy && onStop ? (
<Button
type="button"
size="icon"
variant="outline"
className="h-11 w-11 shrink-0 rounded-xl"
aria-label="Stop generating"
onClick={() => {
onStop();
ref.current?.focus();
}}
>
<Square className="size-4" aria-hidden="true" />
</Button>
) : (
<Button
type="submit"
size="icon"
className="h-11 w-11 shrink-0 rounded-xl"
disabled={busy || !input.trim()}
aria-label={isChat ? "Send message" : "Check builder availability"}
>
<Send className="size-4" aria-hidden="true" />
</Button>
)}
</form>
<p
id={hintId}
className="mx-auto mt-2 max-w-3xl text-center text-[10px] text-muted-foreground"
>
{isChat ? "AI can make mistakes. " : "Builder is not connected yet. "}
Enter to send · Shift+Enter for a new line.
</p>
</div>
);
}

View file

@ -1,125 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import {
createComposerFocusMemory,
createChatScrollMemory,
} from "./workspace-interaction";
import { changeAIMode, parseAIMode } from "./workspace-mode";
import { submitComposer } from "./composer-events";
import { agentBuilderStub } from "./agent-builder";
describe("mode navigation events", () => {
it("pushes canonical URLs, preserves hash/query, and reads history navigation", () => {
const history = ["https://example.test/acme/ai?filter=mine#library"];
let index = 0;
const navigate = (mode: "chat" | "agents") => {
const url = new URL(history[index]);
changeAIMode(url, mode, (href) => {
history.splice(++index);
history.push(new URL(href, url).href);
});
};
navigate("chat");
expect(history).toHaveLength(1);
navigate("agents");
expect(history[index]).toBe(
"https://example.test/acme/ai?filter=mine&tab=agents#library",
);
navigate("agents");
expect(history).toHaveLength(2);
navigate("chat");
expect(history[index]).toBe(
"https://example.test/acme/ai?filter=mine&tab=chat#library",
);
index--;
expect(parseAIMode(new URL(history[index]).searchParams.get("tab"))).toBe(
"agents",
);
index++;
expect(parseAIMode(new URL(history[index]).searchParams.get("tab"))).toBe(
"chat",
);
});
});
describe("composer focus memory", () => {
it("restores the previously focused composer on history return, not initial entry", () => {
const memory = createComposerFocusMemory();
expect(memory.activate("chat", false)).toBe(false);
memory.record("chat", true);
expect(memory.activate("agents", false)).toBe(false);
expect(memory.activate("chat", false)).toBe(true);
});
it("never steals keyboard tab focus and forgets focus moved elsewhere in a panel", () => {
const memory = createComposerFocusMemory();
memory.record("chat", true);
expect(memory.activate("agents", true)).toBe(false);
expect(memory.activate("chat", true)).toBe(false);
memory.record("chat", false);
memory.activate("agents", false);
expect(memory.activate("chat", false)).toBe(false);
});
});
describe("Chat scroll memory", () => {
it("follows streamed content only when already near the bottom", () => {
const memory = createChatScrollMemory();
const container = { scrollTop: 0, scrollHeight: 1000, clientHeight: 200 };
memory.update(container, true);
container.scrollTop = 400;
memory.record(container, true);
container.scrollHeight = 1200;
memory.update(container, true);
expect(container.scrollTop).toBe(400);
container.scrollTop = 980;
memory.record(container, true);
container.scrollHeight = 1400;
memory.update(container, true);
expect(container.scrollTop).toBe(1200);
});
it("ignores hidden scroll events and restores saved position before following new updates", () => {
const memory = createChatScrollMemory();
const container = { scrollTop: 0, scrollHeight: 1000, clientHeight: 200 };
memory.update(container, true);
container.scrollTop = 300;
memory.record(container, true);
memory.update(container, false);
container.scrollTop = 0;
memory.record(container, false);
container.scrollHeight = 1500;
memory.update(container, false);
memory.update(container, true);
expect(container.scrollTop).toBe(300);
});
});
describe("composer submission events", () => {
it("submits only through the supplied mode adapter and restores focus", async () => {
const chat = vi.fn();
const focus = vi.fn();
const fetchSpy = vi.spyOn(globalThis, "fetch");
const agents = vi.fn(() =>
agentBuilderStub({ workspaceSlug: "acme", prompt: "Triage tasks" }),
);
const event = { preventDefault: vi.fn() };
try {
submitComposer(event, "Triage tasks", false, agents, focus);
expect((await agents.mock.results[0].value).status).toBe("unavailable");
expect(chat).not.toHaveBeenCalled();
expect(fetchSpy).not.toHaveBeenCalled();
expect(focus).toHaveBeenCalledOnce();
expect(event.preventDefault).toHaveBeenCalledOnce();
} finally {
fetchSpy.mockRestore();
}
});
it.each([
[" ", false],
["Draft", true],
])("blocks blank/busy submissions", (input, busy) => {
const send = vi.fn();
const focus = vi.fn();
submitComposer({ preventDefault: vi.fn() }, input, busy, send, focus);
expect(send).not.toHaveBeenCalled();
expect(focus).not.toHaveBeenCalled();
});
});

View file

@ -1,50 +0,0 @@
import type { AIMode } from "./workspace-mode";
export function createComposerFocusMemory() {
const focused: Record<AIMode, boolean> = { chat: false, agents: false };
let previous: AIMode | undefined;
return {
record(mode: AIMode, isComposer: boolean) {
focused[mode] = isComposer;
},
activate(mode: AIMode, tabHasFocus: boolean) {
const changed = previous !== mode;
previous = mode;
return changed && !tabHasFocus && focused[mode];
},
};
}
interface ScrollContainer {
scrollTop: number;
scrollHeight: number;
clientHeight: number;
}
export function createChatScrollMemory() {
let savedTop = 0;
let following = true;
let wasActive = false;
return {
record(container: ScrollContainer, active: boolean) {
if (!active) return;
savedTop = container.scrollTop;
following =
container.scrollHeight - container.clientHeight - savedTop <= 48;
},
update(container: ScrollContainer, active: boolean) {
if (active) {
if (!wasActive) container.scrollTop = savedTop;
else if (following)
container.scrollTop = Math.max(
0,
container.scrollHeight - container.clientHeight,
);
savedTop = container.scrollTop;
following =
container.scrollHeight - container.clientHeight - savedTop <= 48;
}
wasActive = active;
},
};
}

View file

@ -1,27 +0,0 @@
export type AIMode = "chat" | "agents";
export function changeAIMode(
location: { pathname: string; search: string; hash: string },
mode: AIMode,
push: (href: string) => void,
): void {
if (parseAIMode(new URLSearchParams(location.search).get("tab")) === mode)
return;
push(aiModeHref(location.pathname, location.search, mode, location.hash));
}
export function parseAIMode(value: string | null): AIMode {
return value === "agents" ? "agents" : "chat";
}
export function aiModeHref(
pathname: string,
search: string,
mode: AIMode,
hash = "",
): string {
const params = new URLSearchParams(search);
params.set("tab", mode);
const query = params.toString();
return `${pathname}${query ? `?${query}` : ""}${hash}`;
}

View file

@ -1,61 +0,0 @@
"use client";
import { useId, useState, type FormEvent, type KeyboardEvent } from "react";
import { Send } from "lucide-react";
import { Button } from "@/components/ui/button";
export function ChatComposer({
onSend,
disabled = false,
label = "Message",
}: {
onSend: (body: string) => void;
disabled?: boolean;
label?: string;
}) {
const [body, setBody] = useState("");
const id = useId();
const submit = (event?: FormEvent) => {
event?.preventDefault();
const text = body.trim();
if (!text || disabled) return;
onSend(text);
setBody("");
};
const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
if (
event.key === "Enter" &&
!event.shiftKey &&
!event.nativeEvent.isComposing
) {
event.preventDefault();
submit();
}
};
return (
<form onSubmit={submit} className="flex items-end gap-2 border-t p-3">
<label className="sr-only" htmlFor={id}>
{label}
</label>
<textarea
id={id}
value={body}
onChange={(event) => setBody(event.target.value)}
onKeyDown={onKeyDown}
disabled={disabled}
maxLength={10000}
rows={2}
placeholder={`${label} (Enter to send, Shift+Enter for a new line)`}
className="min-h-12 max-h-36 flex-1 resize-y rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
/>
<Button
type="submit"
disabled={disabled || !body.trim()}
aria-label="Send message"
>
<Send aria-hidden="true" /> Send
</Button>
</form>
);
}

View file

@ -1,110 +0,0 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import { ChatMessageList } from "./chat-message-list";
const message = {
id: "server",
sequence: "2",
body: "Confirmed",
authorUserId: "user",
createdAt: "2026-01-01",
};
describe("rendered message states", () => {
it("shows a visible sending state and a retry only on failure", () => {
const pending = {
key: "same-key",
body: "Hello",
failed: false,
retry: vi.fn(),
};
const sending = renderToStaticMarkup(
<ChatMessageList messages={[]} pending={[pending]} />,
);
expect(sending).toContain("Sending…");
expect(sending).not.toContain("Retry");
const failed = renderToStaticMarkup(
<ChatMessageList
messages={[]}
pending={[
{
...pending,
failed: true,
error: "Could not send message. Please retry.",
},
]}
/>,
);
expect(failed).toContain('role="alert"');
expect(failed).toContain("Retry");
});
it("renders only the messages supplied by the parent", () => {
const html = renderToStaticMarkup(<ChatMessageList messages={[message]} />);
expect(html).toContain("Confirmed");
expect(html).not.toContain("Sending…");
expect(html).not.toContain("Retry");
const empty = renderToStaticMarkup(<ChatMessageList messages={[]} />);
expect(empty).not.toContain("Confirmed");
});
it("shows an edited indicator for edited messages", () => {
const html = renderToStaticMarkup(
<ChatMessageList messages={[{ ...message, editedAt: "2026-01-02" }]} />,
);
expect(html).toContain("(edited)");
});
it("only offers owner edit, owner or moderator delete, and never archived or deleted controls", () => {
const own = renderToStaticMarkup(
<ChatMessageList
messages={[message]}
currentUserId="user"
onEdit={vi.fn()}
onDelete={vi.fn()}
/>,
);
expect(own).toContain("Edit message server");
expect(own).toContain("Delete message server");
const moderator = renderToStaticMarkup(
<ChatMessageList
messages={[message]}
currentUserId="moderator"
canModerate
onEdit={vi.fn()}
onDelete={vi.fn()}
/>,
);
expect(moderator).not.toContain("Edit message server");
expect(moderator).toContain("Delete message server");
const archived = renderToStaticMarkup(
<ChatMessageList
messages={[message]}
currentUserId="user"
readOnly
onEdit={vi.fn()}
onDelete={vi.fn()}
/>,
);
expect(archived).not.toContain("Edit message server");
const deleted = renderToStaticMarkup(
<ChatMessageList
messages={[{ ...message, status: "deleted" }]}
currentUserId="user"
onEdit={vi.fn()}
onDelete={vi.fn()}
/>,
);
expect(deleted).not.toContain("Delete message server");
});
it("renders deleted messages as a placeholder instead of their body", () => {
const html = renderToStaticMarkup(
<ChatMessageList messages={[{ ...message, deletedAt: "2026-01-02" }]} />,
);
expect(html).toContain("Message deleted");
expect(html).not.toContain("Confirmed");
});
});

View file

@ -1,179 +0,0 @@
"use client";
import { useEffect, useRef } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export type ChatMessageView = {
id: string;
sequence: string;
body: string;
authorUserId: string | null;
createdAt: Date | string;
editedAt?: Date | string | null;
deletedAt?: Date | string | null;
status?: string;
revision?: number;
replyCount?: number;
parentMessageId?: string | null;
};
export function ChatMessageList({
messages,
onViewed,
onThread,
onEdit,
onDelete,
canModerate = false,
currentUserId,
readOnly = false,
pending = [],
}: {
messages: ChatMessageView[];
onViewed?: (sequence: string) => void;
onThread?: (id: string) => void;
onEdit?: (message: ChatMessageView) => void;
onDelete?: (message: ChatMessageView) => void;
canModerate?: boolean;
currentUserId?: string;
readOnly?: boolean;
pending?: {
key: string;
body: string;
failed: boolean;
error?: string;
retry: () => void;
}[];
}) {
const viewed = useRef(onViewed);
useEffect(() => {
viewed.current = onViewed;
}, [onViewed]);
const list = useRef<HTMLOListElement>(null);
useEffect(() => {
if (
!viewed.current ||
!list.current ||
typeof IntersectionObserver === "undefined"
)
return;
const observer = new IntersectionObserver(
(entries) => {
if (document.visibilityState !== "visible") return;
const visible = entries
.filter((entry) => entry.isIntersecting)
.map((entry) => (entry.target as HTMLElement).dataset.sequence)
.filter((sequence): sequence is string => !!sequence);
const highest = visible.reduce<string | null>(
(max, sequence) =>
max === null || BigInt(sequence) > BigInt(max) ? sequence : max,
null,
);
if (highest) viewed.current?.(highest);
},
{ threshold: 0.5 },
);
list.current
.querySelectorAll("[data-sequence]")
.forEach((element) => observer.observe(element));
return () => observer.disconnect();
}, [messages, pending]);
return (
<ol ref={list} aria-label="Messages" className="space-y-3 p-4">
{messages.map((message) => (
<li
key={message.id}
data-sequence={message.sequence}
className="rounded-md border p-3 text-sm"
>
<div className="flex flex-wrap items-baseline gap-2 text-xs text-muted-foreground">
<span className="font-semibold text-foreground">
{message.authorUserId}
</span>
<time dateTime={new Date(message.createdAt).toISOString()}>
{new Date(message.createdAt).toLocaleString()}
</time>
{message.editedAt && (
<span className="ml-1 text-xs text-muted-foreground">
(edited)
</span>
)}
</div>
<p className="mt-1 whitespace-pre-wrap break-words">
{message.deletedAt || message.status === "deleted"
? "Message deleted"
: message.body}
</p>
{!readOnly && !message.deletedAt && message.status !== "deleted" && (
<div className="flex gap-2">
{onEdit && message.authorUserId === currentUserId && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onEdit(message)}
aria-label={`Edit message ${message.id}`}
>
Edit
</Button>
)}
{onDelete &&
(message.authorUserId === currentUserId || canModerate) && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onDelete(message)}
aria-label={`Delete message ${message.id}`}
>
Delete
</Button>
)}
</div>
)}
{onThread && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onThread(message.id)}
aria-label={`Open replies to message by ${message.authorUserId}`}
>
{message.replyCount ? `${message.replyCount} replies` : "Reply"}
</Button>
)}
</li>
))}
{pending.map((message) => (
<li
key={message.key}
className={cn(
"rounded-md border p-3 text-sm",
message.failed && "border-destructive",
)}
>
<p className="whitespace-pre-wrap break-words">{message.body}</p>
<p
role={message.failed ? "alert" : "status"}
className="text-xs text-muted-foreground"
>
{message.failed ? message.error : "Sending…"}
</p>
{message.failed && (
<Button
size="sm"
variant="outline"
type="button"
onClick={message.retry}
>
Retry send
</Button>
)}
</li>
))}
</ol>
);
}

View file

@ -1,79 +0,0 @@
import { describe, expect, it } from "vitest";
import { createChatScope } from "./chat-utils";
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: Error) => void;
const promise = new Promise<T>((yes, no) => {
resolve = yes;
reject = no;
});
return { promise, resolve, reject };
}
describe("chat async scope", () => {
it("ignores a send resolved after a channel switch", async () => {
const scope = createChatScope("acme", "first");
const request = deferred<string>();
const result: string[] = [];
const valid = scope.capture();
const completion = request.promise.then((message) => {
if (valid()) result.push(message);
});
scope.select("acme", "second");
request.resolve("first-channel message");
await completion;
expect(result).toEqual([]);
});
it("ignores a late failed send, including a permission error", async () => {
const scope = createChatScope("acme", "first");
const request = deferred<string>();
const result: string[] = [];
const valid = scope.capture();
const completion = request.promise.catch((error: Error) => {
if (valid()) result.push(error.message);
});
scope.select("acme", "second");
request.reject(new Error("FORBIDDEN"));
await completion;
expect(result).toEqual([]);
});
it("ignores a stale mutation result and its subsequent refresh", async () => {
const scope = createChatScope("acme", "first");
const mutation = deferred<string>();
const refresh = deferred<string>();
const result: string[] = [];
const valid = scope.capture();
const completion = mutation.promise.then(async (value) => {
if (!valid()) return;
result.push(value);
const page = await refresh.promise;
if (valid()) result.push(page);
});
mutation.resolve("edited");
await Promise.resolve();
scope.select("acme", "second");
refresh.resolve("old page");
await completion;
expect(result).toEqual(["edited"]);
});
it("invalidates pending work on purge before redirect and cannot remove a new channel's cache", async () => {
const scope = createChatScope("acme", "first");
const cancellation = deferred<void>();
const result: string[] = [];
const valid = scope.capture();
scope.revoke();
result.push("redirect");
const cleanup = cancellation.promise.then(() => {
if (valid()) result.push("remove queries");
});
scope.select("acme", "second");
cancellation.resolve();
await cleanup;
expect(result).toEqual(["redirect"]);
expect(scope.capture()()).toBe(true);
});
});

View file

@ -1,101 +0,0 @@
import { describe, expect, it } from "vitest";
import {
advanceCatchUpWindow,
chatBadge,
mergeChatMessages,
messageError,
rootReadIsProven,
} from "./chat-utils";
describe("chat client state", () => {
it("retains through across pages, then starts a new interval after exhaustion", () => {
const first = advanceCatchUpWindow(
{ after: "5" },
{ next: "8", through: "10", hasMore: true },
);
expect(first).toEqual({ after: "8", through: "10" });
const second = advanceCatchUpWindow(first, {
next: "9",
through: "10",
hasMore: true,
});
expect(second).toEqual({ after: "9", through: "10" });
expect(
advanceCatchUpWindow(second, {
next: "9",
through: "10",
hasMore: false,
}),
).toEqual({ after: "10" });
});
it("does not lose the cursor on a stalled page or fold concurrent sends into the old interval", () => {
const window = { after: "8", through: "10" };
expect(
advanceCatchUpWindow(window, { next: "8", through: "10", hasMore: true }),
).toBe(window);
expect(
advanceCatchUpWindow(window, {
next: "10",
through: "10",
hasMore: false,
}),
).toEqual({ after: "10" });
expect(
advanceCatchUpWindow(
{ after: "10" },
{ next: "11", through: "12", hasMore: true },
),
).toEqual({ after: "11", through: "12" });
});
it("never replaces a newer edit or tombstone with an older page", () => {
const original = {
id: "m",
sequence: "1",
revision: 1,
body: "old",
status: "active",
};
const edited = { ...original, revision: 2, body: "new" };
const deleted = { ...edited, revision: 3, body: "", status: "deleted" };
expect(mergeChatMessages([edited], [original])).toEqual([edited]);
expect(mergeChatMessages([deleted], [edited])).toEqual([deleted]);
});
it("requires a channel-wide scan and does not claim unseen replies as read", () => {
expect(rootReadIsProven("20", null, null, "0")).toBe(false);
expect(rootReadIsProven("20", "19", null, "0")).toBe(false);
expect(rootReadIsProven("20", "25", "18", "0")).toBe(false);
expect(rootReadIsProven("20", "25", "22", "0")).toBe(true);
});
it("deduplicates history and catch-up in sequence order", () => {
expect(
mergeChatMessages(
[
{ id: "b", sequence: "9" },
{ id: "a", sequence: "2" },
],
[
{ id: "b", sequence: "9" },
{ id: "c", sequence: "10" },
],
).map((m) => m.id),
).toEqual(["a", "b", "c"]);
});
it("distinguishes unread dots from numeric direct and mention activity", () => {
expect(
chatBadge({ unreadCount: 3, mentionCount: 0, directUnreadCount: 0 }),
).toEqual({ dot: true, count: 0 });
expect(
chatBadge({ unreadCount: 3, mentionCount: 2, directUnreadCount: 1 }),
).toEqual({ dot: true, count: 3 });
});
it("maps failed sends to actionable messages", () => {
expect(messageError("NOT_FOUND")).toMatch(/access/i);
expect(messageError("CONFLICT")).toMatch(/different message/i);
expect(messageError("INTERNAL_SERVER_ERROR")).toMatch(/retry/i);
});
});

View file

@ -1,103 +0,0 @@
export function mergeChatMessages<
T extends { id: string; sequence: string; revision?: number },
>(current: T[], incoming: T[]): T[] {
const byId = new Map(current.map((message) => [message.id, message]));
for (const message of incoming) {
const previous = byId.get(message.id);
if (!previous || (message.revision ?? 1) >= (previous.revision ?? 1))
byId.set(message.id, message);
}
return [...byId.values()].sort((a, b) => {
const left = BigInt(a.sequence);
const right = BigInt(b.sequence);
return left < right ? -1 : left > right ? 1 : 0;
});
}
// Each async continuation retains the generation it started in. A return to the
// same channel is still a new generation, so old completions cannot re-enter it.
export function createChatScope(workspace: string, channelId?: string) {
let key = JSON.stringify([workspace, channelId ?? ""]);
let generation = 0;
let revoked = false;
return {
select(nextWorkspace: string, nextChannelId?: string) {
const next = JSON.stringify([nextWorkspace, nextChannelId ?? ""]);
if (next === key) return;
key = next;
generation++;
revoked = false;
},
revoke() {
if (!revoked) {
revoked = true;
generation++;
}
},
dispose() {
revoked = true;
generation++;
},
capture() {
const captured = generation;
return () => !revoked && captured === generation;
},
captureCleanup() {
const captured = generation;
return () => revoked && captured === generation;
},
isRevoked() {
return revoked;
},
};
}
export type CatchUpWindow = { after: string; through?: string };
// Keep the first response's high-water fixed until every page of that interval is read.
export function advanceCatchUpWindow(
window: CatchUpWindow,
page: { next: string; through: string; hasMore: boolean },
): CatchUpWindow {
if (page.hasMore) {
if (BigInt(page.next) <= BigInt(window.after)) return window;
return { after: page.next, through: window.through ?? page.through };
}
return { after: page.through };
}
export function rootReadIsProven(
sequence: string,
scanned: string | null,
unseenReply: string | null,
readBoundary: string,
): boolean {
return (
scanned !== null &&
BigInt(scanned) >= BigInt(sequence) &&
(unseenReply === null ||
BigInt(unseenReply) > BigInt(sequence) ||
BigInt(unseenReply) <= BigInt(readBoundary))
);
}
export function chatBadge(summary: {
unreadCount: number;
mentionCount: number;
directUnreadCount: number;
}) {
return {
dot: summary.unreadCount > 0,
count: summary.mentionCount + summary.directUnreadCount,
};
}
export function messageError(code?: string): string {
if (code === "NOT_FOUND" || code === "FORBIDDEN")
return "You no longer have access to this channel.";
if (code === "CONFLICT")
return "This request was already used for a different message. Please compose a new message.";
if (code === "BAD_REQUEST")
return "This message could not be sent. Check the text and retry.";
return "Could not send message. Please retry.";
}

View file

@ -1,229 +0,0 @@
import { renderToStaticMarkup } from "react-dom/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ChatWorkspace, ReadFailureAlert } from "./chat-workspace";
const mock = vi.hoisted(() => ({
channel: {
id: "channel",
kind: "public",
name: "General",
archivedAt: null as string | null,
},
channels: {
data: [] as unknown[],
isLoading: false,
isError: false,
error: null as unknown,
refetch: vi.fn(),
},
resolved: {
data: { id: "workspace" },
isLoading: false,
isError: false,
refetch: vi.fn(),
},
history: {
data: { messages: [] as unknown[], hasMore: false },
isLoading: false,
isError: false,
error: null as unknown,
refetch: vi.fn(),
},
thread: { data: null, isError: false, error: null, refetch: vi.fn() },
catchUp: { data: null, isError: false, error: null, refetch: vi.fn() },
summary: { data: { channels: [] as unknown[] }, error: null as unknown },
catchUpQuery: vi.fn(),
replace: vi.fn(),
recovery: { messages: [] as unknown[], error: null as unknown, refresh: vi.fn() },
}));
vi.mock("./use-chat-mutation-recovery", () => ({ useChatMutationRecovery: () => mock.recovery }));
vi.mock("next-auth/react", () => ({
useSession: () => ({ data: { user: { id: "user" } } }),
}));
vi.mock("@/lib/use-chat-subscription", () => ({
useChatSubscription: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ replace: mock.replace }),
}));
vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({ cancelQueries: vi.fn(), removeQueries: vi.fn() }),
}));
vi.mock("@/lib/trpc", () => ({
api: {
useUtils: () => ({ chat: {} }),
workspaces: { resolve: { useQuery: () => mock.resolved } },
chat: {
viewer: { useQuery: () => ({ data: { role: "member" } }) },
editMessage: {
useMutation: () => ({ mutateAsync: vi.fn(), isPending: false }),
},
deleteMessage: {
useMutation: () => ({ mutateAsync: vi.fn(), isPending: false }),
},
listChannels: { useQuery: () => mock.channels },
unreadSummary: { useQuery: () => mock.summary },
history: { useQuery: () => mock.history },
threadHistory: { useQuery: () => mock.thread },
catchUp: {
useQuery: (...args: unknown[]) => {
mock.catchUpQuery(...args);
return mock.catchUp;
},
},
send: { useMutation: () => ({ mutateAsync: vi.fn() }) },
markChannelRead: { useMutation: () => ({ mutate: vi.fn() }) },
},
},
}));
beforeEach(() => {
mock.recovery.messages = [];
mock.recovery.error = null;
mock.channel.archivedAt = null;
mock.channels.data = [mock.channel];
mock.channels.isLoading = false;
mock.channels.isError = false;
mock.resolved.isLoading = false;
mock.resolved.isError = false;
mock.history.data = { messages: [], hasMore: false };
mock.history.isLoading = false;
mock.history.isError = false;
mock.history.error = null;
mock.summary.data = { channels: [] };
mock.catchUp.error = null;
mock.catchUp.isError = false;
mock.catchUpQuery.mockClear();
});
const render = () =>
renderToStaticMarkup(<ChatWorkspace workspace="acme" channelId="channel" />);
describe("rendered chat workspace", () => {
it("exposes a retryable read failure without claiming it advanced", () => {
const html = renderToStaticMarkup(<ReadFailureAlert onRetry={vi.fn()} />);
expect(html).toContain('role="alert"');
expect(html).toContain("Unread status has not advanced");
expect(html).toContain("Retry");
});
it("renders an ordinary unread dot and numeric mentions/direct activity", () => {
mock.channels.data = [
mock.channel,
{ id: "dm", kind: "direct", name: null, archivedAt: null },
];
mock.summary.data = {
channels: [
{ channelId: "channel", unreadCount: 5, mentionCount: 0 },
{ channelId: "dm", unreadCount: 3, mentionCount: 0 },
],
};
const html = render();
expect(html).toContain('aria-label="Unread messages"');
expect(html).toContain('aria-label="3 mentions or direct unread messages"');
expect(html).not.toContain(">5</span>");
});
it("separates loading, transient failure and unavailable channel", () => {
mock.channels.isLoading = true;
expect(render()).toContain("Loading channel");
mock.channels.isLoading = false;
mock.channels.isError = true;
expect(render()).toContain("Could not load channel");
expect(render()).not.toContain("Channel unavailable");
mock.channels.isError = false;
mock.channels.data = [];
expect(render()).toContain("Channel unavailable");
});
it("shows history loading rather than a misleading empty state", () => {
mock.history.isLoading = true;
expect(render()).toContain("Loading messages");
expect(render()).not.toContain("No messages yet");
});
it("overlays a recovered tombstone on stale history without adding unloaded rows", () => {
const original = { id: "m", sequence: "1", revision: 1, body: "secret original", authorUserId: "user", createdAt: "2026-01-01", status: "active" };
mock.history.data.messages = [original];
mock.recovery.messages = [{ ...original, revision: 3, body: "", status: "deleted", deletedAt: "2026-01-02" }, { ...original, id: "unloaded", sequence: "2", body: "unloaded edit" }];
const html = render();
expect(html).not.toContain("secret original");
expect(html).not.toContain("unloaded edit");
expect(html).toContain("deleted");
});
it("hides history when mutation recovery discovers revoked access", () => {
mock.recovery.error = { data: { code: "NOT_FOUND" } };
expect(render()).toContain("Channel unavailable or access removed");
});
it("hides revoked content rather than showing a transient retry", () => {
mock.history.error = { data: { code: "NOT_FOUND" } };
mock.history.isError = true;
const html = render();
expect(html).toContain("Channel unavailable or access removed");
expect(html).not.toContain("Could not load messages");
});
it("shows transient history failure with retry", () => {
mock.history.isError = true;
const html = render();
expect(html).toContain("Could not load messages");
expect(html).toContain("Retry");
});
it("keeps archived channels readable without a channel composer", () => {
mock.channel.archivedAt = "2026-01-01";
const html = render();
expect(html).toContain('aria-label="Conversation: General"');
expect(html).not.toContain('aria-label="Send message"');
});
it("bounds initial catch-up to the oldest root predecessor when complete root history has no replies", () => {
mock.history.data = {
messages: [
{
id: "a",
sequence: "12",
replyCount: 0,
body: "First",
authorUserId: "user",
createdAt: "2026-01-01",
},
{
id: "b",
sequence: "30",
replyCount: 0,
body: "Second",
authorUserId: "user",
createdAt: "2026-01-01",
},
],
hasMore: false,
};
render();
expect(mock.catchUpQuery.mock.calls.at(-1)?.[0]).toMatchObject({
after: "11",
});
});
it("does not skip replies before the initial root page", () => {
mock.history.data = {
messages: [
{
id: "a",
sequence: "30",
replyCount: 1,
body: "root",
authorUserId: "user",
createdAt: "2026-01-01",
},
],
hasMore: false,
};
render();
expect(mock.catchUpQuery.mock.calls.at(-1)?.[0]).toMatchObject({
after: "0",
});
});
});

File diff suppressed because it is too large Load diff

View file

@ -1,47 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { createMutationRecovery } from "./mutation-recovery";
const page = (next: string, through: string, hasMore = false) => ({
messages: [{ id: "m", sequence: "1", revision: Number(next) }], next, through, hasMore,
});
describe("mutation recovery", () => {
it("drains a fixed high water, then resumes independently of creation sequences", async () => {
const fetch = vi.fn().mockResolvedValueOnce(page("2", "4", true)).mockResolvedValueOnce(page("4", "4")).mockResolvedValueOnce(page("5", "5"));
const apply = vi.fn();
const recovery = createMutationRecovery(fetch, apply);
await recovery.run();
await recovery.run();
expect(fetch.mock.calls.map(([cursor]) => cursor)).toEqual([{ after: "0" }, { after: "2", through: "4" }, { after: "4" }]);
expect(apply).toHaveBeenCalledTimes(3);
});
it("retries the same cursor after projection application fails", async () => {
const fetch = vi.fn().mockResolvedValue(page("1", "1"));
const apply = vi.fn().mockImplementationOnce(() => { throw new Error("merge failed"); });
const recovery = createMutationRecovery(fetch, apply);
await expect(recovery.run()).rejects.toThrow("merge failed");
await recovery.run();
expect(fetch.mock.calls.map(([cursor]) => cursor)).toEqual([{ after: "0" }, { after: "0" }]);
});
it("ignores a response after scope disposal", async () => {
let resolve!: (value: ReturnType<typeof page>) => void;
const fetch = vi.fn(() => new Promise<ReturnType<typeof page>>((done) => { resolve = done; }));
const apply = vi.fn();
const recovery = createMutationRecovery(fetch, apply);
const pending = recovery.run();
recovery.stop();
resolve(page("1", "1"));
await pending;
expect(apply).not.toHaveBeenCalled();
});
it("rejects stalled pages instead of spinning", async () => {
const recovery = createMutationRecovery(async () => page("0", "2", true), vi.fn());
await expect(recovery.run()).rejects.toThrow();
});
it("advances empty completed windows without looping", async () => {
const fetch = vi.fn().mockResolvedValue({ ...page("0", "0"), messages: [] });
const recovery = createMutationRecovery(fetch, vi.fn());
await recovery.run();
expect(fetch).toHaveBeenCalledTimes(1);
});
});

View file

@ -1,45 +0,0 @@
import type { CatchUpWindow } from "./chat-utils";
type Page<T> = { messages: T[]; next: string; through: string; hasMore: boolean };
export function createMutationRecovery<T>(
fetchPage: (cursor: CatchUpWindow) => Promise<Page<T>>,
apply: (messages: T[]) => void,
initialCursor: CatchUpWindow = { after: "0" },
onCursor?: (cursor: CatchUpWindow) => void,
) {
let cursor: CatchUpWindow = { ...initialCursor };
let stopped = false;
let active: Promise<void> | undefined;
let requested = false;
async function drain() {
do {
requested = false;
let more: boolean;
do {
const page = await fetchPage({ ...cursor });
if (stopped) return;
if (
BigInt(page.through) < BigInt(cursor.after) ||
BigInt(page.next) < BigInt(cursor.after) ||
BigInt(page.next) > BigInt(page.through) ||
(cursor.through !== undefined && cursor.through !== page.through) ||
(page.hasMore && BigInt(page.next) <= BigInt(cursor.after))
) throw new Error("Invalid mutation recovery window");
apply(page.messages);
cursor = page.hasMore ? { after: page.next, through: page.through } : { after: page.through };
onCursor?.({ ...cursor });
more = page.hasMore;
} while (more && !stopped);
} while (requested && !stopped);
}
return {
run(): Promise<void> {
if (stopped) return Promise.resolve();
if (active) { requested = true; return active; }
active = drain().finally(() => { active = undefined; });
return active;
},
stop() { stopped = true; },
};
}

View file

@ -1,64 +0,0 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "@/lib/trpc";
import type { ChatMessageView } from "./chat-message-list";
import { mergeChatMessages } from "./chat-utils";
import { createMutationRecovery } from "./mutation-recovery";
export function useChatMutationRecovery({ workspace, channelId, enabled }: {
workspace: string;
channelId?: string;
enabled: boolean;
}) {
const utils = api.useUtils();
const identity = JSON.stringify([workspace, channelId, enabled]);
const scope = useRef({ identity });
if (scope.current.identity !== identity) scope.current = { identity };
const token = scope.current;
const [state, setState] = useState<{ token: typeof token; messages: ChatMessageView[]; error: unknown }>({ token, messages: [], error: null });
const cursors = useRef(new Map<string, { after: string; through?: string }>());
const run = useRef<(() => void) | undefined>(undefined);
const cursorKey = JSON.stringify([workspace, channelId ?? ""]);
const refresh = useCallback(() => { run.current?.(); }, []);
useEffect(() => {
if (!enabled || !channelId) return;
let stopped = false;
const valid = () => !stopped && scope.current === token;
const cursorKey = JSON.stringify([workspace, channelId]);
const recovery = createMutationRecovery(
(cursor) => utils.chat.mutationCatchUp.fetch({ workspace, channelId, ...cursor, limit: 100 }),
(messages) => {
if (!valid()) return;
setState((previous) => ({ token, messages: mergeChatMessages(previous.token === token ? previous.messages : [], messages), error: null }));
},
cursors.current.get(cursorKey),
(cursor) => cursors.current.set(cursorKey, cursor),
);
const recover = () => {
void recovery.run().catch((error: unknown) => {
if (valid()) setState((previous) => ({ token, messages: previous.token === token ? previous.messages : [], error }));
});
};
run.current = recover;
recover();
const timer = setInterval(recover, 15000);
window.addEventListener("online", recover);
window.addEventListener("focus", recover);
return () => {
stopped = true;
recovery.stop();
clearInterval(timer);
window.removeEventListener("online", recover);
window.removeEventListener("focus", recover);
if (run.current === recover) run.current = undefined;
};
}, [workspace, channelId, enabled, token, utils]);
return {
messages: state.token === token && enabled ? state.messages : [],
error: state.token === token && enabled ? state.error : null,
refresh,
};
}

View file

@ -3,7 +3,6 @@
import type { ComponentType } from "react"; import type { ComponentType } from "react";
import { import {
CalendarDays, CalendarDays,
MessageCircle,
ClipboardList, ClipboardList,
FileText, FileText,
Home, Home,
@ -17,15 +16,9 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useParams, usePathname, useRouter } from "next/navigation"; import { useParams, usePathname, useRouter } from "next/navigation";
import { import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useWorkspaceStore } from "@/lib/stores/workspace-store"; import { useWorkspaceStore } from "@/lib/stores/workspace-store";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { api } from "@/lib/trpc";
import { chatBadge } from "@/components/chat/chat-utils";
export interface IconRailProps { export interface IconRailProps {
className?: string; className?: string;
@ -42,22 +35,11 @@ const NAV_ITEMS: NavItem[] = [
{ id: "home", label: "Home", segment: "", icon: Home }, { id: "home", label: "Home", segment: "", icon: Home },
{ id: "spaces", label: "Spaces", segment: "", icon: LayoutGrid }, { id: "spaces", label: "Spaces", segment: "", icon: LayoutGrid },
{ id: "planner", label: "Planner", segment: "planner", icon: CalendarDays }, { id: "planner", label: "Planner", segment: "planner", icon: CalendarDays },
{ { id: "mission-control", label: "Mission control", segment: "mission-control", icon: Radar },
id: "mission-control",
label: "Mission control",
segment: "mission-control",
icon: Radar,
},
{ id: "ai", label: "AI", segment: "ai", icon: Sparkles }, { id: "ai", label: "AI", segment: "ai", icon: Sparkles },
{ id: "chat", label: "Chat", segment: "chat", icon: MessageCircle },
{ id: "teams", label: "Teams", segment: "teams", icon: Users }, { id: "teams", label: "Teams", segment: "teams", icon: Users },
{ id: "docs", label: "Docs", segment: "docs", icon: FileText }, { id: "docs", label: "Docs", segment: "docs", icon: FileText },
{ { id: "whiteboards", label: "Whiteboards", segment: "whiteboards", icon: Presentation },
id: "whiteboards",
label: "Whiteboards",
segment: "whiteboards",
icon: Presentation,
},
{ id: "forms", label: "Forms", segment: "forms", icon: ClipboardList }, { id: "forms", label: "Forms", segment: "forms", icon: ClipboardList },
]; ];
@ -69,7 +51,6 @@ const RESERVED_ROOT_SEGMENTS = new Set([
"planner", "planner",
"mission-control", "mission-control",
"ai", "ai",
"chat",
"teams", "teams",
"docs", "docs",
"whiteboards", "whiteboards",
@ -93,8 +74,7 @@ function isNavItemActive(
if (!pathname.startsWith(prefix)) return false; if (!pathname.startsWith(prefix)) return false;
if (item.segment) { if (item.segment) {
const next = const next = pathname[prefix.length] === "/" ? pathname.slice(prefix.length + 1) : "";
pathname[prefix.length] === "/" ? pathname.slice(prefix.length + 1) : "";
const first = next.split("/")[0] ?? ""; const first = next.split("/")[0] ?? "";
return first === item.segment; return first === item.segment;
} }
@ -119,39 +99,11 @@ export function IconRail({ className }: IconRailProps) {
const slugParam = const slugParam =
typeof params?.workspaceSlug === "string" ? params.workspaceSlug : ""; typeof params?.workspaceSlug === "string" ? params.workspaceSlug : "";
const workspaceSegment = const workspaceSegment =
currentWorkspace?.slug || currentWorkspace?.id || slugParam || undefined; currentWorkspace?.slug ||
currentWorkspace?.id ||
slugParam ||
undefined;
const workspaceName = currentWorkspace?.name ?? ""; const workspaceName = currentWorkspace?.name ?? "";
const unread = api.chat.unreadSummary.useQuery(
{ workspace: workspaceSegment ?? "" },
{ enabled: !!workspaceSegment, refetchInterval: 15000 },
);
const visibleChannels = api.chat.listChannels.useQuery(
{ workspace: workspaceSegment ?? "" },
{ enabled: !!workspaceSegment, refetchInterval: 15000 },
);
const directIds = new Set(
visibleChannels.data
?.filter((channel) => channel.kind === "direct")
.map((channel) => channel.id),
);
const badge = chatBadge({
unreadCount:
unread.data?.channels.reduce(
(total, channel) => total + channel.unreadCount,
0,
) ?? 0,
mentionCount:
unread.data?.channels.reduce(
(total, channel) => total + channel.mentionCount,
0,
) ?? 0,
directUnreadCount:
unread.data?.channels.reduce(
(total, channel) =>
total + (directIds.has(channel.channelId) ? channel.unreadCount : 0),
0,
) ?? 0,
});
const initials = workspaceName.trim().slice(0, 2).toUpperCase() || "—"; const initials = workspaceName.trim().slice(0, 2).toUpperCase() || "—";
@ -169,6 +121,8 @@ export function IconRail({ className }: IconRailProps) {
className, className,
)} )}
> >
<nav <nav
className="flex min-h-0 flex-1 flex-col items-stretch overflow-y-auto overflow-x-hidden px-1 py-2" className="flex min-h-0 flex-1 flex-col items-stretch overflow-y-auto overflow-x-hidden px-1 py-2"
aria-label="Main" aria-label="Main"
@ -182,11 +136,6 @@ export function IconRail({ className }: IconRailProps) {
<button <button
type="button" type="button"
disabled={!workspaceSegment} disabled={!workspaceSegment}
aria-label={
item.id === "chat" && badge.dot
? `Chat, ${badge.count ? `${badge.count} mentions and unread direct messages` : "unread messages"}`
: item.label
}
onClick={() => push(item.segment)} onClick={() => push(item.segment)}
className={cn( className={cn(
"w-full cursor-pointer rounded-lg px-1 py-2 transition-colors", "w-full cursor-pointer rounded-lg px-1 py-2 transition-colors",
@ -196,23 +145,7 @@ export function IconRail({ className }: IconRailProps) {
: "text-white/70 hover:bg-white/10", : "text-white/70 hover:bg-white/10",
)} )}
> >
<span className="relative">
<Icon className="h-5 w-5 shrink-0" aria-hidden /> <Icon className="h-5 w-5 shrink-0" aria-hidden />
{item.id === "chat" && badge.dot && (
<span
className="absolute -right-1 -top-1 size-2 rounded-full bg-rose-400"
aria-hidden
/>
)}
</span>
{item.id === "chat" && badge.count > 0 && (
<span
className="rounded-full bg-rose-500 px-1 text-[10px] text-white"
aria-label={`${badge.count} mentions and unread direct messages`}
>
{badge.count > 99 ? "99+" : badge.count}
</span>
)}
<span <span
className={cn( className={cn(
"max-w-[52px] truncate text-center text-[10px] font-medium leading-tight", "max-w-[52px] truncate text-center text-[10px] font-medium leading-tight",

View file

@ -1,123 +0,0 @@
"use client";
import { useEffect, useRef } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { getCollabUrl } from "@/lib/yjs-provider";
/** Invalidation-only transport. Durable chat history must be fetched through the authenticated API. */
export function useChatSubscription(
workspace: string,
channelIds: readonly string[],
onHint?: () => void,
): void {
const hint = useRef(onHint);
hint.current = onHint;
const queryClient = useQueryClient();
const channels = [...new Set(channelIds)].sort().join(",");
useEffect(() => {
if (!workspace || !channels) return;
let socket: WebSocket | undefined;
let timer: ReturnType<typeof setTimeout> | undefined;
let stopped = false;
let attempts = 0;
let revoked = false;
const invalidate = () => {
if (stopped) return;
hint.current?.();
void queryClient.invalidateQueries({ predicate: (query) => {
const [path, options] = query.queryKey;
return Array.isArray(path) && path[0] === "chat" &&
!!options && typeof options === "object" && "input" in options &&
!!options.input && typeof options.input === "object" &&
"workspace" in options.input && options.input.workspace === workspace;
} });
};
async function connect() {
revoked = false;
try {
const response = await fetch("/api/chat/ticket", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ workspace }),
cache: "no-store",
});
if (!response.ok) {
if (
response.status === 401 ||
response.status === 403 ||
response.status === 404
)
return;
throw new Error("Chat ticket unavailable");
}
const data: unknown = await response.json();
if (
!data ||
typeof data !== "object" ||
!("ticket" in data) ||
typeof data.ticket !== "string"
)
throw new Error("Invalid chat ticket");
if (stopped) return;
const url = new URL(
process.env.NEXT_PUBLIC_CHAT_SERVER_URL || getCollabUrl(),
);
if (!process.env.NEXT_PUBLIC_CHAT_SERVER_URL) url.port = "1235";
url.pathname = "/chat";
url.search = new URLSearchParams({ ticket: data.ticket }).toString();
socket = new WebSocket(url);
socket.onopen = () => {
attempts = 0;
invalidate(); // catch up after missed Redis notifications
for (const channelId of channels.split(","))
socket?.send(JSON.stringify({ type: "subscribe", channelId }));
};
socket.onmessage = (event) => {
try {
const message: unknown = JSON.parse(String(event.data));
if (
message &&
typeof message === "object" &&
"type" in message &&
(message.type === "invalidate" ||
message.type === "revoked" ||
message.type === "subscribed")
) {
if (message.type === "revoked") revoked = true;
invalidate();
}
} catch {
/* ignore malformed hints */
}
};
socket.onclose = (event) => {
if (event.code === 1008 && revoked) {
invalidate();
}
if (!stopped)
timer = setTimeout(
() => {
void connect();
},
Math.min(30_000, 1000 * 2 ** Math.min(attempts++, 5)),
);
};
} catch {
if (!stopped)
timer = setTimeout(
() => {
void connect();
},
Math.min(30_000, 1000 * 2 ** Math.min(attempts++, 5)),
);
}
}
void connect();
return () => {
stopped = true;
clearTimeout(timer);
socket?.close();
};
}, [workspace, channels, queryClient]);
}

View file

@ -7,9 +7,7 @@
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint .", "lint": "eslint .",
"type-check": "tsc --noEmit", "type-check": "tsc --noEmit"
"test": "vitest run",
"test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/openai": "^1", "@ai-sdk/openai": "^1",

View file

@ -20,11 +20,9 @@ import { convoysRouter } from "@/server/routers/convoys";
import { copilotRouter, opsCopilotRouter } from "@/server/routers/copilot"; import { copilotRouter, opsCopilotRouter } from "@/server/routers/copilot";
import { harnessesRouter } from "@/server/routers/harnesses"; import { harnessesRouter } from "@/server/routers/harnesses";
import { budgetsRouter } from "@/server/routers/budgets"; import { budgetsRouter } from "@/server/routers/budgets";
import { chatRouter } from "@/server/routers/chat";
export const appRouter = router({ export const appRouter = router({
health: healthRouter, health: healthRouter,
chat: chatRouter,
workspaces: workspacesRouter, workspaces: workspacesRouter,
types: typesRouter, types: typesRouter,
objects: objectsRouter, objects: objectsRouter,

View file

@ -1,116 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { db } from "@tasks/database";
import type { Session } from "next-auth";
import {
ChatMutationError,
mutateChatMessage,
createMessageMutationRunner,
chatMutationCatchUp,
} from "@tasks/database/chat";
import { resolveWorkspace } from "@/server/lib/resolve-workspace";
import { createCallerFactory } from "@/server/trpc";
import { chatRouter } from "./chat";
vi.mock("@tasks/database/chat", async (importOriginal) => ({
...(await importOriginal<typeof import("@tasks/database/chat")>()),
mutateChatMessage: vi.fn(),
chatMutationCatchUp: vi.fn(),
createMessageMutationRunner: vi.fn(() => ({ transaction: vi.fn() })),
}));
vi.mock("@/server/lib/resolve-workspace", () => ({
resolveWorkspace: vi.fn(),
}));
vi.mock("@/lib/auth", () => ({ auth: vi.fn() }));
const workspaceId = "11111111-1111-4111-8111-111111111111";
const channelId = "33333333-3333-4333-8333-333333333333";
const userId = "44444444-4444-4444-8444-444444444444";
const messageId = "77777777-7777-4777-8777-777777777777";
const clientMutationId = "66666666-6666-4666-8666-666666666666";
const caller = () =>
createCallerFactory(chatRouter)({
db,
session: { user: { id: userId } } as Session,
});
const command = {
type: "edit_message" as const,
workspaceId,
channelId,
context: { type: "workspace" as const, workspaceId },
messageId,
body: "corrected",
expectedRevision: 2,
clientMutationId,
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(resolveWorkspace).mockResolvedValue({
id: workspaceId,
slug: "acme",
name: "Acme",
role: "member",
ownerUserId: userId,
});
});
describe("chat message mutation procedures", () => {
it("delegates validated edit and delete with actor and exact command", async () => {
const result = {
changed: true,
result: { messageId, revision: 3, status: "edited" as const },
};
vi.mocked(mutateChatMessage).mockResolvedValue(result);
await expect(caller().editMessage({ workspace: workspaceId, command })).resolves.toEqual(result);
expect(mutateChatMessage).toHaveBeenCalledWith(expect.anything(), {
actorUserId: userId,
command,
});
});
});
describe("mutation recovery procedure", () => {
const input = { workspace: "acme", channelId, after: "0", limit: 2 };
const result = {
mutations: [{ messageId, mutationSequence: "1", revision: 3, kind: "edited" as const }],
next: "1",
through: "3",
hasMore: true,
};
it("passes the resolved workspace and session actor, and preserves the fixed high water", async () => {
vi.mocked(chatMutationCatchUp).mockResolvedValue(result as Awaited<ReturnType<typeof chatMutationCatchUp>>);
await expect(caller().mutationCatchUp(input)).resolves.toEqual(result);
expect(chatMutationCatchUp).toHaveBeenCalledWith(db, {
workspaceId,
actorUserId: userId,
channelId,
after: "0",
through: undefined,
limit: 2,
});
await caller().mutationCatchUp({ ...input, after: "1", through: "3" });
expect(chatMutationCatchUp).toHaveBeenLastCalledWith(db, {
workspaceId,
actorUserId: userId,
channelId,
after: "1",
through: "3",
limit: 2,
});
});
it("rejects malformed cursors and maps read denial without exposing a channel", async () => {
await expect(caller().mutationCatchUp({ ...input, after: "01" })).rejects.toMatchObject({ code: "BAD_REQUEST" });
await expect(caller().mutationCatchUp({ ...input, through: "0", after: "1" })).rejects.toMatchObject({ code: "BAD_REQUEST" });
expect(chatMutationCatchUp).not.toHaveBeenCalled();
vi.mocked(chatMutationCatchUp).mockRejectedValueOnce(new (await import("@tasks/database/chat")).ChatReadError());
await expect(caller().mutationCatchUp(input)).rejects.toMatchObject({ code: "NOT_FOUND" });
});
it("requires an authenticated workspace member", async () => {
vi.mocked(resolveWorkspace).mockRejectedValueOnce(new Error("not a member"));
await expect(caller().mutationCatchUp(input)).rejects.toThrow();
expect(chatMutationCatchUp).not.toHaveBeenCalled();
});
});

View file

@ -1,426 +0,0 @@
import { TRPCError } from "@trpc/server";
import {
chatChannelNameSchema,
chatClientMutationIdSchema,
chatCommandSchema,
chatCreateChannelInputSchema,
chatCreateDirectInputSchema,
type ChatCommand,
} from "@tasks/shared";
import {
addPrivateMember,
archiveChannel,
ChannelPersistenceError,
ChatPersistenceError,
ChatMutationError,
ChatReadError,
chatHistory,
chatThreadHistory,
chatCatchUp,
chatMutationCatchUp,
chatMarkChannelRead,
chatUnreadSummary,
chatSequenceSchema,
chatCursorSchema,
chatPageSizeSchema,
chatCatchUpSizeSchema,
createChannel,
createChannelTransactionRunner,
createChatTransactionRunner,
createDirectChannel,
listVisibleChannels,
createMessageMutationRunner,
mutateChatMessage,
removePrivateMember,
renameChannel,
sendChatMessage,
} from "@tasks/database/chat";
import { z } from "zod";
import { router, workspaceProcedure } from "@/server/trpc";
const sendCommandSchema = chatCommandSchema.refine(
(command) => command.type === "send_message",
{ message: "Expected a send_message command" },
);
const mutationCommandSchema = chatCommandSchema.refine(
(command) =>
command.type === "edit_message" || command.type === "delete_message",
{ message: "Expected a message mutation" },
);
const mutationInputSchema = z
.object({
workspace: z.string().min(1),
command: mutationCommandSchema,
})
.strict();
const inputSchema = z
.object({
workspace: z.string().min(1),
command: sendCommandSchema,
mentionedUserIds: z.array(z.string().uuid()).max(100).optional(),
})
.strict();
function mapPersistenceError(error: unknown): never {
if (error instanceof ChatPersistenceError) {
if (error.code === "CHAT_FORBIDDEN") {
throw new TRPCError({
code: "NOT_FOUND",
message: "Chat channel not found",
});
}
if (error.code === "CHAT_IDEMPOTENCY_CONFLICT") {
throw new TRPCError({
code: "CONFLICT",
message: "Message request conflicts with an existing request",
});
}
throw new TRPCError({
code: "BAD_REQUEST",
message: "Invalid chat message request",
});
}
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Unable to send chat message",
});
}
const workspaceInput = z.object({ workspace: z.string().min(1) }).strict();
const channelInput = workspaceInput
.extend({
channelId: z.string().uuid(),
clientMutationId: chatClientMutationIdSchema,
})
.strict();
const runChannels = (
db: Parameters<typeof createChannelTransactionRunner>[0],
) => createChannelTransactionRunner(db);
async function channelCall<T>(operation: () => Promise<T>): Promise<T> {
try {
return await operation();
} catch (error) {
if (error instanceof ChannelPersistenceError) {
if (error.code === "NOT_FOUND")
throw new TRPCError({
code: "NOT_FOUND",
message: "Chat channel not found",
});
if (error.code === "CONFLICT")
throw new TRPCError({
code: "CONFLICT",
message: "Channel conflicts with existing state",
});
throw new TRPCError({
code: "BAD_REQUEST",
message:
error.code === "READ_ONLY"
? "Channel is archived"
: "Invalid channel request",
});
}
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Unable to update chat channel",
});
}
}
const readScope = workspaceInput
.extend({ channelId: z.string().uuid() })
.strict();
const catchUpInput = readScope
.extend({
after: chatCursorSchema,
through: chatCursorSchema.optional(),
limit: chatCatchUpSizeSchema.default(100),
})
.strict()
.refine(
(input) =>
input.through === undefined ||
BigInt(input.after) <= BigInt(input.through),
{ path: ["through"], message: "After exceeds through" },
);
const pageInput = readScope
.extend({
before: chatSequenceSchema.optional(),
limit: chatPageSizeSchema.default(50),
})
.strict();
async function readCall<T>(operation: () => Promise<T>): Promise<T> {
try {
return await operation();
} catch (error) {
if (error instanceof ChatReadError)
throw new TRPCError({
code: "NOT_FOUND",
message: "Chat channel not found",
});
throw error;
}
}
async function mutationCall(
db: Parameters<typeof createMessageMutationRunner>[0],
actorUserId: string,
workspaceId: string,
command: Extract<ChatCommand, { type: "edit_message" | "delete_message" }>,
) {
if (
command.workspaceId !== workspaceId ||
command.context.workspaceId !== workspaceId
)
throw new TRPCError({ code: "BAD_REQUEST", message: "Workspace mismatch" });
try {
return await mutateChatMessage(createMessageMutationRunner(db), {
actorUserId,
command,
});
} catch (error) {
if (error instanceof ChatMutationError) {
if (error.code === "CHAT_FORBIDDEN" || error.code === "CHAT_NOT_FOUND")
throw new TRPCError({
code: "NOT_FOUND",
message: "Chat message not found",
});
if (error.code === "CHAT_REVISION_CONFLICT")
throw new TRPCError({
code: "CONFLICT",
message: "Message changed. Refresh before trying again.",
});
throw new TRPCError({
code: "CONFLICT",
message: "Message request conflicts with an existing request",
});
}
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Unable to update chat message",
});
}
}
export const chatRouter = router({
viewer: workspaceProcedure
.input(workspaceInput)
.query(({ ctx }) => ({ role: ctx.workspace.role })),
// History and reconnect endpoints use one workspace-resolved, membership-checked read seam.
history: workspaceProcedure.input(pageInput).query(({ ctx, input }) =>
readCall(() =>
chatHistory(ctx.db, {
workspaceId: ctx.workspace.id,
actorUserId: ctx.session.user.id,
channelId: input.channelId,
before: input.before,
limit: input.limit,
}),
),
),
threadHistory: workspaceProcedure
.input(pageInput.extend({ rootMessageId: z.string().uuid() }).strict())
.query(({ ctx, input }) =>
readCall(() =>
chatThreadHistory(ctx.db, {
workspaceId: ctx.workspace.id,
actorUserId: ctx.session.user.id,
channelId: input.channelId,
rootMessageId: input.rootMessageId,
before: input.before,
limit: input.limit,
}),
),
),
catchUp: workspaceProcedure
.input(catchUpInput)
.query(({ ctx, input }) =>
readCall(() =>
chatCatchUp(ctx.db, {
workspaceId: ctx.workspace.id,
actorUserId: ctx.session.user.id,
channelId: input.channelId,
after: input.after,
through: input.through,
limit: input.limit,
}),
),
),
mutationCatchUp: workspaceProcedure
.input(catchUpInput)
.query(({ ctx, input }) =>
readCall(() =>
chatMutationCatchUp(ctx.db, {
workspaceId: ctx.workspace.id,
actorUserId: ctx.session.user.id,
channelId: input.channelId,
after: input.after,
through: input.through,
limit: input.limit,
}),
),
),
markChannelRead: workspaceProcedure
.input(readScope.extend({ sequence: chatSequenceSchema }).strict())
.mutation(({ ctx, input }) =>
readCall(() =>
chatMarkChannelRead(ctx.db, {
workspaceId: ctx.workspace.id,
actorUserId: ctx.session.user.id,
channelId: input.channelId,
sequence: input.sequence,
}),
),
),
unreadSummary: workspaceProcedure.input(workspaceInput).query(({ ctx }) =>
readCall(() =>
chatUnreadSummary(ctx.db, {
workspaceId: ctx.workspace.id,
actorUserId: ctx.session.user.id,
}),
),
),
listChannels: workspaceProcedure.input(workspaceInput).query(({ ctx }) =>
channelCall(() =>
listVisibleChannels(runChannels(ctx.db), {
workspaceId: ctx.workspace.id,
actorUserId: ctx.session.user.id,
}),
),
),
createChannel: workspaceProcedure
.input(chatCreateChannelInputSchema)
.mutation(({ ctx, input }) =>
channelCall(() =>
createChannel(runChannels(ctx.db), {
workspaceId: ctx.workspace.id,
actorUserId: ctx.session.user.id,
kind: input.kind,
name: input.name,
clientMutationId: input.clientMutationId,
}),
),
),
renameChannel: workspaceProcedure
.input(channelInput.extend({ name: chatChannelNameSchema }).strict())
.mutation(({ ctx, input }) =>
channelCall(() =>
renameChannel(runChannels(ctx.db), {
workspaceId: ctx.workspace.id,
actorUserId: ctx.session.user.id,
channelId: input.channelId,
name: input.name,
clientMutationId: input.clientMutationId,
}),
),
),
archiveChannel: workspaceProcedure
.input(channelInput)
.mutation(({ ctx, input }) =>
channelCall(() =>
archiveChannel(runChannels(ctx.db), {
workspaceId: ctx.workspace.id,
actorUserId: ctx.session.user.id,
channelId: input.channelId,
clientMutationId: input.clientMutationId,
}),
),
),
addPrivateMember: workspaceProcedure
.input(channelInput.extend({ userId: z.string().uuid() }).strict())
.mutation(({ ctx, input }) =>
channelCall(() =>
addPrivateMember(runChannels(ctx.db), {
workspaceId: ctx.workspace.id,
actorUserId: ctx.session.user.id,
channelId: input.channelId,
userId: input.userId,
clientMutationId: input.clientMutationId,
}),
),
),
removePrivateMember: workspaceProcedure
.input(channelInput.extend({ userId: z.string().uuid() }).strict())
.mutation(({ ctx, input }) =>
channelCall(() =>
removePrivateMember(runChannels(ctx.db), {
workspaceId: ctx.workspace.id,
actorUserId: ctx.session.user.id,
channelId: input.channelId,
userId: input.userId,
clientMutationId: input.clientMutationId,
}),
),
),
createDirect: workspaceProcedure
.input(chatCreateDirectInputSchema)
.mutation(({ ctx, input }) =>
channelCall(() =>
createDirectChannel(runChannels(ctx.db), {
workspaceId: ctx.workspace.id,
actorUserId: ctx.session.user.id,
participantUserIds: input.participantUserIds,
clientMutationId: input.clientMutationId,
}),
),
),
editMessage: workspaceProcedure
.input(mutationInputSchema)
.mutation(({ ctx, input }) => {
if (input.command.type !== "edit_message")
throw new TRPCError({
code: "BAD_REQUEST",
message: "Expected edit_message command",
});
return mutationCall(
ctx.db,
ctx.session.user.id,
ctx.workspace.id,
input.command,
);
}),
deleteMessage: workspaceProcedure
.input(mutationInputSchema)
.mutation(({ ctx, input }) => {
if (input.command.type !== "delete_message")
throw new TRPCError({
code: "BAD_REQUEST",
message: "Expected delete_message command",
});
return mutationCall(
ctx.db,
ctx.session.user.id,
ctx.workspace.id,
input.command,
);
}),
send: workspaceProcedure
.input(inputSchema)
.mutation(async ({ ctx, input }) => {
const command = {
...input.command,
workspaceId: ctx.workspace.id,
context: { type: "workspace" as const, workspaceId: ctx.workspace.id },
};
try {
const result = await sendChatMessage(
createChatTransactionRunner(ctx.db),
{
actorUserId: ctx.session.user.id,
command,
mentionedUserIds: input.mentionedUserIds ?? [],
},
);
return {
created: result.created,
message: {
...result.message,
sequence: result.message.sequence.toString(),
},
};
} catch (error) {
mapPersistenceError(error);
}
}),
});

View file

@ -1,15 +0,0 @@
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";
export default defineConfig({
resolve: {
alias: { "@": fileURLToPath(new URL("./", import.meta.url)) },
},
esbuild: { jsx: "automatic" },
test: {
environment: "node",
include: ["**/*.test.{ts,tsx}"],
exclude: ["node_modules/**", ".next/**"],
passWithNoTests: false,
},
});

View file

@ -1,268 +0,0 @@
import { PgDialect } from "drizzle-orm/pg-core";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
chatOutboxChannel,
createChatOutboxClaim,
publishChatOutboxBatch,
runChatOutboxPublisher,
type ChatOutboxRow,
type ClaimBatch,
} from "./outbox.js";
const workspaceId = "00000000-0000-4000-8000-000000000001";
const channelId = "00000000-0000-4000-8000-000000000002";
const row: ChatOutboxRow = {
id: "00000000-0000-4000-8000-000000000003",
workspaceId,
channelId,
eventType: "chat.read_state.changed.v1",
eventVersion: 1,
dedupeKey: "read:1",
payload: {
eventType: "chat.read_state.changed.v1",
eventVersion: 1,
workspaceId,
channelId,
userId: workspaceId,
sequence: "1",
},
};
type Mark = (
id: string,
status: "published" | "failed",
error?: string,
) => Promise<void>;
function harness(rows: ChatOutboxRow[]) {
const changes: Array<{
id: string;
status: "published" | "failed";
error?: string;
}> = [];
const claim: ClaimBatch = vi.fn(async (run) =>
run(
rows,
async (id: string, status: "published" | "failed", error?: string) => {
changes.push({ id, status, error });
},
),
);
const publish = vi.fn(async (_channel: string, _payload: string) => 1);
return { claim, publish, changes };
}
const flush = async () => {
await Promise.resolve();
await Promise.resolve();
};
afterEach(() => vi.useRealTimers());
describe("chat outbox publisher", () => {
it("claims a batch and publishes the committed envelope on a tenant/channel scoped topic", async () => {
const h = harness([row]);
expect(await publishChatOutboxBatch(h.claim, h.publish)).toEqual({
published: 1,
failed: 0,
});
expect(h.publish).toHaveBeenCalledWith(
chatOutboxChannel(workspaceId, channelId),
JSON.stringify({
...row.payload,
id: row.id,
dedupeKey: row.dedupeKey,
workspaceId,
channelId,
eventType: row.eventType,
eventVersion: row.eventVersion,
}),
);
expect(h.changes).toEqual([
{ id: row.id, status: "published", error: undefined },
]);
});
it("records a failed publish and continues processing other rows", async () => {
const second = { ...row, id: "00000000-0000-4000-8000-000000000004" };
const h = harness([row, second]);
h.publish.mockRejectedValueOnce(new Error("redis down"));
expect(await publishChatOutboxBatch(h.claim, h.publish)).toEqual({
published: 1,
failed: 1,
});
expect(h.changes).toEqual([
{ id: row.id, status: "failed", error: "redis down" },
{ id: second.id, status: "published", error: undefined },
]);
});
it("does not publish when claim fails", async () => {
const h = harness([]);
vi.mocked(h.claim).mockRejectedValueOnce(new Error("postgres down"));
await expect(publishChatOutboxBatch(h.claim, h.publish)).rejects.toThrow(
"postgres down",
);
expect(h.publish).not.toHaveBeenCalled();
});
it("times out a hung publish without marking published even if Redis later acknowledges", async () => {
vi.useFakeTimers();
let acknowledge!: (count: number) => void;
const publish = vi.fn(
() =>
new Promise<number>((resolve) => {
acknowledge = resolve;
}),
);
const h = harness([row]);
const batch = publishChatOutboxBatch(h.claim, publish, {
publishTimeoutMs: 100,
});
await flush();
await vi.advanceTimersByTimeAsync(100);
expect(await batch).toEqual({ published: 0, failed: 1 });
acknowledge(1);
await flush();
expect(h.changes).toEqual([
{
id: row.id,
status: "failed",
error: expect.stringContaining("timed out"),
},
]);
});
it("stops starting new publishes on abort and leaves the pending row retryable", async () => {
const controller = new AbortController();
const h = harness([
row,
{ ...row, id: "00000000-0000-4000-8000-000000000004" },
]);
const publish = vi.fn(() => new Promise<number>(() => {}));
const batch = publishChatOutboxBatch(h.claim, publish, {
signal: controller.signal,
});
await flush();
controller.abort();
expect(await batch).toEqual({ published: 0, failed: 1 });
expect(publish).toHaveBeenCalledTimes(1);
expect(h.changes[0]).toMatchObject({ id: row.id, status: "failed" });
});
it("leaves a published notification pending after transaction rollback and redelivers with the same dedupe id", async () => {
const pending = new Set([row.id]);
let rollback = true;
const claim: ClaimBatch = async (run) => {
const staged = new Set<string>();
await run(pending.has(row.id) ? [row] : [], async (id, status) => {
if (status === "published") staged.add(id);
});
if (rollback) throw new Error("commit failed");
for (const id of staged) pending.delete(id);
};
const publish = vi.fn(async (_channel: string, _payload: string) => 1);
await expect(publishChatOutboxBatch(claim, publish)).rejects.toThrow(
"commit failed",
);
rollback = false;
expect(await publishChatOutboxBatch(claim, publish)).toEqual({
published: 1,
failed: 0,
});
expect(publish).toHaveBeenCalledTimes(2);
expect(JSON.parse(publish.mock.calls[0]![1]!)).toMatchObject({
id: row.id,
dedupeKey: row.dedupeKey,
});
expect(publish.mock.calls[1]).toEqual(publish.mock.calls[0]);
});
it("builds a skip-locked ordered claim and tenant/channel scoped update without a live DB", async () => {
const dialect = new PgDialect();
const queries: string[] = [];
const tx = {
select: vi.fn(() => ({
from: () => ({
where: () => ({
orderBy: (...columns: unknown[]) => {
queries.push(`order:${columns.length}`);
return {
limit: (size: number) => ({
for: (_mode: string, _options: unknown) => {
queries.push(
`limit:${size}:${_mode}:${JSON.stringify(_options)}`,
);
return Promise.resolve([row]);
},
}),
};
},
}),
}),
})),
update: vi.fn(() => ({
set: () => ({
where: (condition: unknown) => {
queries.push(
dialect.sqlToQuery(
condition as Parameters<typeof dialect.sqlToQuery>[0],
).sql,
);
return Promise.resolve();
},
}),
})),
};
type FakeTx = typeof tx;
const db = {
transaction: async (run: (transaction: FakeTx) => Promise<void>) =>
run(tx),
};
await createChatOutboxClaim(db as never)(async (rows, mark) => {
expect(rows).toEqual([row]);
await mark(row.id, "published");
});
expect(queries.slice(0, 2)).toEqual([
"order:2",
'limit:50:update:{"skipLocked":true}',
]);
expect(queries[2]).toContain("workspace_id");
expect(queries[2]).toContain("channel_id");
expect(queries[2]).toContain("published_at");
await expect(
createChatOutboxClaim(db as never)(async (_rows, mark) => {
await mark("unclaimed", "published");
}),
).rejects.toThrow("unclaimed");
});
it("paces retries after failures and exits promptly on abort during the delay", async () => {
vi.useFakeTimers();
const controller = new AbortController();
const claim = vi.fn(
async (run: (rows: ChatOutboxRow[], mark: Mark) => Promise<void>) => {
await run([row], async () => {});
},
);
const redis = {
publish: vi.fn(async () => {
throw new Error("redis down");
}),
};
const running = runChatOutboxPublisher(
{} as Parameters<typeof runChatOutboxPublisher>[0],
redis as never,
controller.signal,
claim,
);
await vi.advanceTimersByTimeAsync(900);
expect(redis.publish).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(100);
expect(redis.publish).toHaveBeenCalledTimes(2);
await vi.advanceTimersByTimeAsync(1900);
expect(redis.publish).toHaveBeenCalledTimes(2);
controller.abort();
await running;
expect(redis.publish).toHaveBeenCalledTimes(2);
});
});

View file

@ -1,218 +0,0 @@
import { and, asc, eq, isNull, sql } from "drizzle-orm";
import type IoRedis from "ioredis";
import type { db as dbInstance } from "@tasks/database";
import { chatOutbox } from "@tasks/database/schema";
/** Redis is a notification transport, not a durable history. Consumers replay from Postgres. */
export function chatOutboxChannel(
workspaceId: string,
channelId: string,
): string {
return `chat:v1:workspace:${workspaceId}:channel:${channelId}`;
}
export type ChatOutboxRow = Pick<
typeof chatOutbox.$inferSelect,
| "id"
| "workspaceId"
| "channelId"
| "eventType"
| "eventVersion"
| "dedupeKey"
| "payload"
>;
type Mark = (
id: string,
status: "published" | "failed",
error?: string,
) => Promise<void>;
export type ClaimBatch = (
run: (rows: ChatOutboxRow[], mark: Mark) => Promise<void>,
) => Promise<void>;
type Publish = (channel: string, payload: string) => Promise<number>;
const PUBLISH_TIMEOUT_MS = 5_000;
const RETRY_MIN_MS = 1_000;
const RETRY_MAX_MS = 30_000;
function abortError(): Error {
return new Error("Chat outbox publish aborted");
}
/** Do not disconnect the shared Redis client: it also handles orchestrator jobs. */
async function boundedPublish(
publish: Publish,
channel: string,
payload: string,
timeoutMs: number,
signal?: AbortSignal,
): Promise<void> {
if (signal?.aborted) throw abortError();
let timer: ReturnType<typeof setTimeout> | undefined;
let onAbort: (() => void) | undefined;
const deadline = new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new Error("Chat outbox publish timed out")),
timeoutMs,
);
if (signal) {
onAbort = () => reject(abortError());
signal.addEventListener("abort", onAbort, { once: true });
if (signal.aborted) onAbort();
}
});
try {
// A late Redis acknowledgement may mean duplicate delivery on retry. Keep the
// outbox row pending and let consumers deduplicate by id/dedupeKey.
await Promise.race([
Promise.resolve().then(() => publish(channel, payload)),
deadline,
]);
} finally {
clearTimeout(timer);
if (onAbort) signal?.removeEventListener("abort", onAbort);
}
}
function pause(ms: number, signal: AbortSignal): Promise<void> {
if (signal.aborted) return Promise.resolve();
return new Promise((resolve) => {
const finish = () => {
clearTimeout(timer);
signal.removeEventListener("abort", finish);
resolve();
};
const timer = setTimeout(finish, ms);
signal.addEventListener("abort", finish, { once: true });
if (signal.aborted) finish();
});
}
/**
* Keep the row locks until Redis acknowledges the publish, so concurrent workers
* cannot send the same pending row. A crash between publish and commit can still
* redeliver it: consumers must deduplicate by id and replay by channel cursor.
*/
export async function publishChatOutboxBatch(
claim: ClaimBatch,
publish: Publish,
options: { publishTimeoutMs?: number; signal?: AbortSignal } = {},
): Promise<{ published: number; failed: number }> {
const result = { published: 0, failed: 0 };
await claim(async (rows, mark) => {
for (const row of rows) {
if (options.signal?.aborted) break;
let failure: unknown;
try {
await boundedPublish(
publish,
chatOutboxChannel(row.workspaceId, row.channelId),
JSON.stringify({
...row.payload,
id: row.id,
dedupeKey: row.dedupeKey,
workspaceId: row.workspaceId,
channelId: row.channelId,
eventType: row.eventType,
eventVersion: row.eventVersion,
}),
options.publishTimeoutMs ?? PUBLISH_TIMEOUT_MS,
options.signal,
);
} catch (error) {
failure = error;
}
if (failure !== undefined) {
const message =
failure instanceof Error ? failure.message : String(failure);
// A failed status is still pending; it will be retried on the next tick.
await mark(row.id, "failed", message.slice(0, 2000));
result.failed++;
} else {
await mark(row.id, "published");
result.published++;
}
}
});
return result;
}
export function createChatOutboxClaim(
db: typeof dbInstance,
batchSize = 50,
): ClaimBatch {
return async (run) => {
await db.transaction(async (tx) => {
const rows = await tx
.select({
id: chatOutbox.id,
workspaceId: chatOutbox.workspaceId,
channelId: chatOutbox.channelId,
eventType: chatOutbox.eventType,
eventVersion: chatOutbox.eventVersion,
dedupeKey: chatOutbox.dedupeKey,
payload: chatOutbox.payload,
})
.from(chatOutbox)
.where(isNull(chatOutbox.publishedAt))
.orderBy(asc(chatOutbox.createdAt), asc(chatOutbox.id))
.limit(batchSize)
.for("update", { skipLocked: true });
await run(rows, async (id, status, error) => {
const row = rows.find((candidate) => candidate.id === id);
if (!row) throw new Error("Cannot mark an unclaimed chat outbox row");
await tx
.update(chatOutbox)
.set({
attempts: sql`${chatOutbox.attempts} + 1`,
publishedAt: status === "published" ? new Date() : null,
lastError:
status === "failed" ? (error ?? "Unknown publish failure") : null,
})
.where(
and(
eq(chatOutbox.id, id),
eq(chatOutbox.workspaceId, row.workspaceId),
eq(chatOutbox.channelId, row.channelId),
isNull(chatOutbox.publishedAt),
),
);
});
});
};
}
export async function runChatOutboxPublisher(
db: typeof dbInstance,
redis: Pick<IoRedis, "publish">,
signal: AbortSignal,
claim: ClaimBatch = createChatOutboxClaim(db),
): Promise<void> {
let retryMs = RETRY_MIN_MS;
while (!signal.aborted) {
try {
const result = await publishChatOutboxBatch(
claim,
(channel, payload) => redis.publish(channel, payload),
{ signal },
);
if (signal.aborted) break;
if (result.failed > 0) {
console.error(
`[worker] chat outbox: ${result.failed} publish failures`,
);
await pause(retryMs, signal);
retryMs = Math.min(retryMs * 2, RETRY_MAX_MS);
} else {
retryMs = RETRY_MIN_MS;
await pause(result.published === 50 ? 0 : RETRY_MIN_MS, signal);
}
} catch (error) {
console.error("[worker] chat outbox tick failed:", error);
await pause(retryMs, signal);
retryMs = Math.min(retryMs * 2, RETRY_MAX_MS);
}
}
}

View file

@ -1,7 +1,6 @@
import { db } from "@tasks/database"; import { db } from "@tasks/database";
import IoRedis from "ioredis"; import IoRedis from "ioredis";
import { runChatOutboxPublisher } from "./chat/outbox.js";
import { loadEnv } from "./env.js"; import { loadEnv } from "./env.js";
import { handleOrchestratorJob } from "./handle-job.js"; import { handleOrchestratorJob } from "./handle-job.js";
import { dequeueJob } from "./queue/dequeue.js"; import { dequeueJob } from "./queue/dequeue.js";
@ -22,15 +21,10 @@ async function main(): Promise<void> {
} }
console.info(`[worker] consuming ${env.WORKER_BLPOP_TIMEOUT_S}s BLPOP on orchestrator queue`); console.info(`[worker] consuming ${env.WORKER_BLPOP_TIMEOUT_S}s BLPOP on orchestrator queue`);
const shutdown = new AbortController();
process.once("SIGTERM", () => shutdown.abort());
process.once("SIGINT", () => shutdown.abort());
const publisher = runChatOutboxPublisher(db, redis, shutdown.signal);
while (!shutdown.signal.aborted) { for (;;) {
try { try {
const job = await dequeueJob(redis, env.WORKER_BLPOP_TIMEOUT_S); const job = await dequeueJob(redis, env.WORKER_BLPOP_TIMEOUT_S);
if (shutdown.signal.aborted) break;
if (!job) continue; if (!job) continue;
console.info(`[worker] dequeued job=${job.id} role=${job.roleId} convoy=${job.convoyId}`); console.info(`[worker] dequeued job=${job.id} role=${job.roleId} convoy=${job.convoyId}`);
await handleOrchestratorJob(db, redis, env, job); await handleOrchestratorJob(db, redis, env, job);
@ -38,8 +32,6 @@ async function main(): Promise<void> {
console.error("[worker] loop error:", error); console.error("[worker] loop error:", error);
} }
} }
await publisher;
await redis.quit();
} }
main().catch((error) => { main().catch((error) => {

View file

@ -132,14 +132,12 @@ RUN rm -f apps/web/.env apps/web/.env.local apps/web/.env.production apps/web/.e
# NEXT_PUBLIC_* must be present at build time — Next.js inlines them into the client bundle. # NEXT_PUBLIC_* must be present at build time — Next.js inlines them into the client bundle.
ARG NEXT_PUBLIC_COLLAB_SERVER_URL=ws://localhost:1234 ARG NEXT_PUBLIC_COLLAB_SERVER_URL=ws://localhost:1234
ARG NEXT_PUBLIC_CHAT_SERVER_URL=ws://localhost:1235
ARG NEXT_PUBLIC_UMAMI_SCRIPT= ARG NEXT_PUBLIC_UMAMI_SCRIPT=
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID= ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID=
ARG NEXT_PUBLIC_LIBREDESK_URL= ARG NEXT_PUBLIC_LIBREDESK_URL=
ARG NEXT_PUBLIC_LIBREDESK_WIDGET_ID= ARG NEXT_PUBLIC_LIBREDESK_WIDGET_ID=
ARG NEXT_PUBLIC_DIRECTUS_URL= ARG NEXT_PUBLIC_DIRECTUS_URL=
ENV NEXT_PUBLIC_COLLAB_SERVER_URL=${NEXT_PUBLIC_COLLAB_SERVER_URL} ENV NEXT_PUBLIC_COLLAB_SERVER_URL=${NEXT_PUBLIC_COLLAB_SERVER_URL}
ENV NEXT_PUBLIC_CHAT_SERVER_URL=${NEXT_PUBLIC_CHAT_SERVER_URL}
ENV NEXT_PUBLIC_UMAMI_SCRIPT=${NEXT_PUBLIC_UMAMI_SCRIPT} ENV NEXT_PUBLIC_UMAMI_SCRIPT=${NEXT_PUBLIC_UMAMI_SCRIPT}
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=${NEXT_PUBLIC_UMAMI_WEBSITE_ID} ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=${NEXT_PUBLIC_UMAMI_WEBSITE_ID}
ENV NEXT_PUBLIC_LIBREDESK_URL=${NEXT_PUBLIC_LIBREDESK_URL} ENV NEXT_PUBLIC_LIBREDESK_URL=${NEXT_PUBLIC_LIBREDESK_URL}
@ -178,7 +176,7 @@ CMD ["node", "apps/web/server.js"]
# ============================================================================ # ============================================================================
# collab — runtime image (Hocuspocus / Yjs and workspace Chat listeners) # collab — runtime image (Hocuspocus / Yjs websocket)
# ============================================================================ # ============================================================================
FROM node:${NODE_VERSION}-alpine AS collab FROM node:${NODE_VERSION}-alpine AS collab
RUN addgroup --system --gid 1001 nodejs \ RUN addgroup --system --gid 1001 nodejs \
@ -197,11 +195,10 @@ COPY --from=collab-build --chown=collab:nodejs /app/apps/collab-server/node_modu
USER collab USER collab
EXPOSE 1234 1235 EXPOSE 1234
# Listener liveness only; authenticated delivery is a separate acceptance gate.
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD node -e "let remaining=2;for(const p of [+(process.env.PORT||1234),+(process.env.CHAT_PORT||1235)]){const s=require('net').createConnection(p,'127.0.0.1');s.setTimeout(3000,()=>process.exit(1));s.on('connect',()=>{s.destroy();if(!--remaining)process.exit(0)}).on('error',()=>process.exit(1))}" CMD node -e "const p=+(process.env.PORT||1234);require('net').createConnection(p,'127.0.0.1').on('connect',()=>process.exit(0)).on('error',()=>process.exit(1))"
WORKDIR /app/apps/collab-server WORKDIR /app/apps/collab-server
CMD ["node", "dist/index.mjs"] CMD ["node", "dist/index.mjs"]

View file

@ -12,4 +12,3 @@ services:
collab: collab:
ports: ports:
- "1234:1234" - "1234:1234"
- "${CHAT_PORT:-1235}:${CHAT_PORT:-1235}"

View file

@ -12,7 +12,6 @@
# Domains (Dokploy UI or CT 100 Traefik): # Domains (Dokploy UI or CT 100 Traefik):
# web → echodo.stillwell.cloud (port 3000) # web → echodo.stillwell.cloud (port 3000)
# collab → collab.echodo.stillwell.cloud (port 1234) # collab → collab.echodo.stillwell.cloud (port 1234)
# chat → chat.stillwell.cloud (collab service, CHAT_PORT default 1235)
# opencode (optional) → opencode.stillwell.cloud (port 4096) # opencode (optional) → opencode.stillwell.cloud (port 4096)
# #
# Build (required on ≤8 GB hosts — parallel export OOMs with exit 255): # Build (required on ≤8 GB hosts — parallel export OOMs with exit 255):
@ -35,7 +34,6 @@ services:
target: web target: web
args: args:
NEXT_PUBLIC_COLLAB_SERVER_URL: ${NEXT_PUBLIC_COLLAB_SERVER_URL:-ws://localhost:1234} NEXT_PUBLIC_COLLAB_SERVER_URL: ${NEXT_PUBLIC_COLLAB_SERVER_URL:-ws://localhost:1234}
NEXT_PUBLIC_CHAT_SERVER_URL: ${NEXT_PUBLIC_CHAT_SERVER_URL:-ws://localhost:1235}
NEXT_PUBLIC_UMAMI_SCRIPT: ${NEXT_PUBLIC_UMAMI_SCRIPT:-} NEXT_PUBLIC_UMAMI_SCRIPT: ${NEXT_PUBLIC_UMAMI_SCRIPT:-}
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${NEXT_PUBLIC_UMAMI_WEBSITE_ID:-} NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${NEXT_PUBLIC_UMAMI_WEBSITE_ID:-}
NEXT_PUBLIC_LIBREDESK_URL: ${NEXT_PUBLIC_LIBREDESK_URL:-} NEXT_PUBLIC_LIBREDESK_URL: ${NEXT_PUBLIC_LIBREDESK_URL:-}
@ -50,7 +48,6 @@ services:
environment: environment:
- DATABASE_URL=${DATABASE_URL} - DATABASE_URL=${DATABASE_URL}
- REDIS_URL=${REDIS_URL} - REDIS_URL=${REDIS_URL}
- CHAT_TICKET_SECRET=${CHAT_TICKET_SECRET:?Set a shared Chat signing secret of at least 32 characters}
- NEXTAUTH_URL=${NEXTAUTH_URL} - NEXTAUTH_URL=${NEXTAUTH_URL}
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET} - NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
- AUTH_SECRET=${AUTH_SECRET} - AUTH_SECRET=${AUTH_SECRET}
@ -103,16 +100,13 @@ services:
- DATABASE_URL=${DATABASE_URL} - DATABASE_URL=${DATABASE_URL}
- REDIS_URL=${REDIS_URL} - REDIS_URL=${REDIS_URL}
- PORT=1234 - PORT=1234
- CHAT_PORT=${CHAT_PORT:-1235}
- CHAT_TICKET_SECRET=${CHAT_TICKET_SECRET:?Set a shared Chat signing secret of at least 32 characters}
- NODE_ENV=production - NODE_ENV=production
expose: expose:
- "1234" - "1234"
- "${CHAT_PORT:-1235}"
healthcheck: healthcheck:
test: test:
- CMD-SHELL - CMD-SHELL
- 'node -e "let remaining=2;for(const p of [+(process.env.PORT||1234),+(process.env.CHAT_PORT||1235)]){const s=require(''net'').createConnection(p,''127.0.0.1'');s.setTimeout(3000,()=>process.exit(1));s.on(''connect'',()=>{s.destroy();if(!--remaining)process.exit(0)}).on(''error'',()=>process.exit(1))}"' - 'node -e "const p=+(process.env.PORT||1234);require(''net'').createConnection(p,''127.0.0.1'').on(''connect'',()=>process.exit(0)).on(''error'',()=>process.exit(1))"'
interval: 30s interval: 30s
timeout: 5s timeout: 5s
retries: 3 retries: 3

View file

@ -1,65 +0,0 @@
# Workspace Chat — Current Progress
Updated: 2026-10-29 (homelab go-live + acceptance).
## Status
Chat runtime is live on CT 116; operator reports **14/14 acceptance checks passed**. Operator-confirmed CT 102 `echodo` ledger: **24 entries through `0023`**, with required columns/table verified and all `chat_*` tables owned by `echodo`. An earlier copy of this document said `0022–0024`; no `0024` migration exists in this checkout, so that statement is not treated as verified.
The deployment evidence below was supplied by the operator and is present in this workspace copy. It was not independently rerun in this reconciliation session; read-only access through PVE stopped at unknown SSH host-key verification.
## Deployed
- `CHAT_TICKET_SECRET` generated on CT 116 (≥32), shared by web + collab (sha256 match). Not logged.
- `CHAT_PORT=1235`; collab dual listeners 1234 (Hocuspocus) + 1235 (Chat).
- Traefik `echodo-chat` → `192.168.68.116:1235` for `chat.echodo.stillwell.cloud` and `chat.stillwell.cloud`.
- Public WSS URL baked into web: `wss://chat.stillwell.cloud` (nested `*.echodo.stillwell.cloud` Universal SSL fails publicly).
- Compose validated; web/collab/worker rebuilt/recreated; opencode-v2 preserved.
## Runtime fixes applied during go-live
1. Ticket Origin check used `request.url` origin (`https://0.0.0.0:3000`) behind Docker — now prefers `x-forwarded-host` / `host` + `x-forwarded-proto`.
2. `channel-drizzle-adapter` `row()` spread full DB columns into strict lifecycle receipt schema — project ChannelRecord fields only.
3. `redactCreatedOutbox` `jsonb_build_object(...)` params caused `could not determine data type of parameter $1` — set plain JSON payload instead.
4. Empty `AUTH_DEV_PASSWORD` blocked credentials acceptance — set on CT 116 (not logged).
Source under `/opt/submatter` (+ opencode workspace copy) updated for (1–3). Image chunks patched in place for immediate runtime; rebuild web on next deploy to bake permanently.
## Source reconciliation (2026-10-29)
- Channel projection and plain-JSON redaction fixes were already present locally; preserved and covered with adapter regression tests.
- Local ticket handling now pins the expected origin to configured `AUTH_URL` (or `NEXTAUTH_URL`), not caller-supplied forwarded headers. Production fails closed without a valid configured URL. This is a hardened replacement for the deployed forwarded-header hot patch, not a byte-for-byte copy. Deploy and retest ticket issuance before considering this replacement live.
- Canonical browser endpoint is `wss://chat.stillwell.cloud` (socket path `/chat`). The Traefik example retains both hostname routes.
- Next deploy must rebuild web from reconciled source; image hot patches are not durable. Re-run ticket issuance, cross-origin rejection and the acceptance suite afterward. No production changes, rebuild, commit or push were performed during this reconciliation.
- **Security follow-up:** `AUTH_DEV_PASSWORD` is a shared credentials password for existing user emails, not a per-user password. Remove it after acceptance unless the operator explicitly intends this login mechanism; verify SSO access first and recreate web when removing the environment value. Awaiting operator decision; not changed remotely.
- Remaining release gates: full local checks, disposable-Postgres coverage, final review and convoy lifecycle reconciliation. The reported live suite does not prove multi-instance fan-out or Redis outage behavior.
## Main deployment preparation
The operator approved sending the reconciled branch to `main` for real-build testing. The branch includes the earlier foundation commit and its AI-shell/backlog changes. The main push triggers `.forgejo/workflows/deploy-homelab.yml` when the runner and deployment secrets are configured; a push alone does not prove deployment success. The deploy script no longer removes orphan containers, preserving separately managed services such as `opencode-v2`. Six static deployment checks cover the configuration and this safety guard.
## Local validation after reconciliation
- `pnpm lint`: passed with existing warnings.
- `pnpm type-check --concurrency=1`: passed.
- `pnpm test`: ticket-origin regressions (11), channel adapter tests (10) and outbox adapter test (1) passed. Full suite remains blocked by the worker remote-AG-UI pending-response polling test timing out after 5000 ms. PostgreSQL-gated tests remain skipped without a disposable test database.
- `pnpm build --concurrency=1`: passed with a longer command allowance after the first attempt timed out during build tracing. This is a local build, not a Docker image rebuild or deployment.
- `git diff --check`: passed.
- Security review of this slice: ticket origin is pinned to server configuration; forwarded-header spoofing, missing/malformed origins and workspace denial are covered. Redaction remains workspace/channel/message/create-event scoped. Production shared-password sign-in remains an operational security concern until explicitly resolved. No secrets were read or printed.
## Acceptance evidence (2026-10-29)
Two signed-in members (`me@randallstillwell.com` + `chat-acceptance-b@stillwell.local`) against workspace `randall-stillwell`:
| Gate | Result |
|------|--------|
| Secret shared web/collab | Pass |
| Compose validate + ports | Pass |
| Public WSS reject without ticket | Pass (connection fails) |
| Dual WS subscribe via `wss://chat.stillwell.cloud/chat` | Pass |
| Send + Redis invalidate hint | Pass |
| Reply | Pass |
| Reconnect catch-up | Pass |
| Unread mark + summary | Pass |
| Edit / delete + mutation catch-up | Pass (after redact fix) |
| Membership revocation (hint + history denied) | Pass |

View file

@ -34,7 +34,7 @@ External: Postgres/Redis on **CT 102**, TLS on **CT 100**, OmniRoute on **CT 108
| File | Use | | File | Use |
|------|-----| |------|-----|
| `docker/docker-compose.prod.yml` | Core stack | | `docker/docker-compose.prod.yml` | Core stack |
| `docker/docker-compose.prod.standalone.yml` | Publish `:3000` / `:1234` / `:1235` for CT 100 Traefik | | `docker/docker-compose.prod.standalone.yml` | Publish `:3000` / `:1234` for CT 100 Traefik |
| `scripts/build-prod.sh` | Serialized image build | | `scripts/build-prod.sh` | Serialized image build |
| `scripts/deploy-prod.sh` | Build + up + health wait | | `scripts/deploy-prod.sh` | Build + up + health wait |
@ -65,9 +65,6 @@ docker compose -f docker/docker-compose.prod.yml \
| `AUTH_AUTHENTIK_*` | | Authentik OIDC app `echodo` | | `AUTH_AUTHENTIK_*` | | Authentik OIDC app `echodo` |
| `COLLAB_SERVER_URL` | `wss://collab.echodo.stillwell.cloud` | Server-side | | `COLLAB_SERVER_URL` | `wss://collab.echodo.stillwell.cloud` | Server-side |
| `NEXT_PUBLIC_COLLAB_SERVER_URL` | `wss://collab.echodo.stillwell.cloud` | **Build arg** — requires image rebuild | | `NEXT_PUBLIC_COLLAB_SERVER_URL` | `wss://collab.echodo.stillwell.cloud` | **Build arg** — requires image rebuild |
| `NEXT_PUBLIC_CHAT_SERVER_URL` | `wss://chat.stillwell.cloud` | **Build arg** — requires web image rebuild; provision DNS/TLS first |
| `CHAT_TICKET_SECRET` | Generate with `openssl rand -base64 32` | Same runtime secret for web and collab; at least 32 characters; never a build arg |
| `CHAT_PORT` | `1235` | Second listener inside collab; cannot equal Hocuspocus port `1234` |
| `OMNIROUTE_BASE_URL` | `https://omniroute.stillwell.cloud/v1` | Web + worker | | `OMNIROUTE_BASE_URL` | `https://omniroute.stillwell.cloud/v1` | Web + worker |
| `OMNIROUTE_API_KEY` | (your key) | + register in Settings → AI → Providers | | `OMNIROUTE_API_KEY` | (your key) | + register in Settings → AI → Providers |
| `FORGEJO_TOKEN` / `GITHUB_TOKEN` | PAT values | Referenced by name in Connected repos settings | | `FORGEJO_TOKEN` / `GITHUB_TOKEN` | PAT values | Referenced by name in Connected repos settings |
@ -77,33 +74,6 @@ Changing any `NEXT_PUBLIC_*` value requires `bash scripts/deploy-prod.sh build`,
--- ---
## Workspace Chat integration gate
Chat runs inside the existing `collab` service on a **separate listener** from Hocuspocus. The worker publishes metadata-only Redis hints; Postgres history and mutation catch-up remain authoritative. Team Chat does not invoke AI agents, and object contexts are not enabled by this deployment.
1. Set the Chat variables above in `/opt/submatter/.env`. Keep the signing secret server-side; Compose requires a nonempty value, and the application requires at least 32 characters. A missing/short secret disables the listener and makes the dual-listener health check fail.
2. Provision `chat.stillwell.cloud` DNS and TLS on CT 100 (canonical browser endpoint). The proxy example also retains `chat.echodo.stillwell.cloud`; a single-level wildcard certificate does not cover that nested hostname. Apply the `echodo-chat` router/service from [`traefik-ct100.example.yml`](../proxmox/ct116/traefik-ct100.example.yml). Traefik supports WebSocket upgrades without a separate service process. If `CHAT_PORT` changes, update the proxy backend port too. Restrict host backend ports to the trusted proxy network.
3. Before rolling out, verify the database target is CT 102 **`echodo`** and inspect `drizzle.__drizzle_migrations`. The operator reports 24 ledger entries through `0023`, verified Chat schema, and ownership of all `chat_*` tables transferred to `echodo`. Do not reapply these migrations; inspect the ledger for any future pending entries. Compare the ledger with `packages/database/migrations/meta/_journal.json`; apply only pending journaled migrations using `pnpm --filter @tasks/database db:migrate` in a checkout with the real `DATABASE_URL` exported securely. Do not print the URL, use `db:push`, or replay historical migrations manually. Take a backup first.
4. Verify `0022` added `chat_messages.send_intent_fingerprint` and positive `revision`; verify `0023` added `chat_message_mutations`, its tenant/channel/message foreign keys and indexes, and `chat_channels.next_mutation_sequence`. Do not infer applied schema from TypeScript files. The historical `0017` correction is already local; do not replay it on an initialized database.
5. Validate Compose on the deployment host without printing resolved secrets:
```bash
docker compose --project-directory /opt/submatter --env-file /opt/submatter/.env \
-f docker/docker-compose.prod.yml -f docker/docker-compose.prod.standalone.yml config --quiet
bash scripts/deploy-prod.sh build
```
6. Confirm both collab listeners and worker health. The TCP health check verifies listener liveness only, **not** Redis delivery, database authorization, or end-to-end readiness. An ordinary HTTP request to the Chat listener returns 404; use an authenticated browser WebSocket session for acceptance.
7. Test two signed-in workspace members: send/reply/edit/delete, unread acknowledgement, reconnect catch-up, offline edits/deletes, and duplicate retries. Revoke private/direct membership while connected and verify subsequent hints/history are denied and cached content is cleared; admins must not bypass membership. Verify another workspace cannot obtain history or subscribe. Restart collab/worker and test Redis outage recovery without message loss.
Run PostgreSQL integration tests only against a disposable test database, not production. DNS, TLS, migrations, Redis/WebSocket delivery, and two-browser acceptance are independent release gates; passing unit tests does not satisfy them. See [Current Progress](Current%20Progress.md) for the current evidence and blockers.
### Post-go-live reconciliation
The operator reports Chat live with 14/14 acceptance checks; see [Current Progress](Current%20Progress.md) for provenance and scope. Rebuild web on the next deploy to replace hot-patched image chunks with source-built code. The reconciled ticket route uses `AUTH_URL` or `NEXTAUTH_URL` as its trusted public origin; set it to the HTTPS **web app** origin, not the WSS endpoint. It ignores forwarded headers for this decision and fails closed without valid production configuration. Restrict direct backend access to the proxy network regardless.
`AUTH_DEV_PASSWORD` was enabled for acceptance. This shared password can authenticate existing email identities; it is not individual-user password authentication. Confirm SSO works, then remove it and recreate web if it was temporary. Do not leave this enabled solely because acceptance needed it. Re-run ticket origin/authentication and live Chat acceptance after the source-built deployment.
## Auto-deploy on push ## Auto-deploy on push
**CI** (every PR): `.github/workflows/ci.yml` — lint, type-check, test on GitHub-hosted or PR runner. **CI** (every PR): `.github/workflows/ci.yml` — lint, type-check, test on GitHub-hosted or PR runner.

View file

@ -6,7 +6,7 @@
"dev": "turbo dev", "dev": "turbo dev",
"lint": "turbo lint", "lint": "turbo lint",
"type-check": "turbo type-check", "type-check": "turbo type-check",
"test": "node --test scripts/chat-deployment.test.mjs && turbo test", "test": "turbo test",
"db:generate": "turbo db:generate --filter=@tasks/database", "db:generate": "turbo db:generate --filter=@tasks/database",
"db:migrate": "turbo db:migrate --filter=@tasks/database", "db:migrate": "turbo db:migrate --filter=@tasks/database",
"db:push": "turbo db:push --filter=@tasks/database", "db:push": "turbo db:push --filter=@tasks/database",

View file

@ -1,3 +1,16 @@
CREATE TABLE "convoy_operator_messages" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"workspace_id" uuid NOT NULL,
"convoy_id" uuid NOT NULL,
"brief_id" uuid,
"agent_run_id" uuid,
"delivery" varchar(20) NOT NULL,
"body" text NOT NULL,
"author_user_id" uuid NOT NULL,
"delivered_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "completion_artifacts" ( CREATE TABLE "completion_artifacts" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"workspace_id" uuid NOT NULL, "workspace_id" uuid NOT NULL,
@ -8,9 +21,15 @@ CREATE TABLE "completion_artifacts" (
"created_at" timestamp with time zone DEFAULT now() NOT NULL "created_at" timestamp with time zone DEFAULT now() NOT NULL
); );
--> statement-breakpoint --> statement-breakpoint
ALTER TABLE "convoy_operator_messages" ADD CONSTRAINT "convoy_operator_messages_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "convoy_operator_messages" ADD CONSTRAINT "convoy_operator_messages_convoy_id_objects_id_fk" FOREIGN KEY ("convoy_id") REFERENCES "public"."objects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "convoy_operator_messages" ADD CONSTRAINT "convoy_operator_messages_brief_id_objects_id_fk" FOREIGN KEY ("brief_id") REFERENCES "public"."objects"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "convoy_operator_messages" ADD CONSTRAINT "convoy_operator_messages_agent_run_id_agent_runs_id_fk" FOREIGN KEY ("agent_run_id") REFERENCES "public"."agent_runs"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "convoy_operator_messages" ADD CONSTRAINT "convoy_operator_messages_author_user_id_users_id_fk" FOREIGN KEY ("author_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "completion_artifacts" ADD CONSTRAINT "completion_artifacts_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "completion_artifacts" ADD CONSTRAINT "completion_artifacts_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "completion_artifacts" ADD CONSTRAINT "completion_artifacts_convoy_id_objects_id_fk" FOREIGN KEY ("convoy_id") REFERENCES "public"."objects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "completion_artifacts" ADD CONSTRAINT "completion_artifacts_convoy_id_objects_id_fk" FOREIGN KEY ("convoy_id") REFERENCES "public"."objects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "completion_artifacts" ADD CONSTRAINT "completion_artifacts_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint ALTER TABLE "completion_artifacts" ADD CONSTRAINT "completion_artifacts_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "convoy_operator_messages_workspace_id_convoy_id_delivered_at_idx" ON "convoy_operator_messages" USING btree ("workspace_id","convoy_id","delivered_at");--> statement-breakpoint
CREATE INDEX "completion_artifacts_workspace_id_idx" ON "completion_artifacts" USING btree ("workspace_id");--> statement-breakpoint CREATE INDEX "completion_artifacts_workspace_id_idx" ON "completion_artifacts" USING btree ("workspace_id");--> statement-breakpoint
CREATE INDEX "completion_artifacts_convoy_id_idx" ON "completion_artifacts" USING btree ("convoy_id");--> statement-breakpoint CREATE INDEX "completion_artifacts_convoy_id_idx" ON "completion_artifacts" USING btree ("convoy_id");--> statement-breakpoint
CREATE INDEX "completion_artifacts_created_at_idx" ON "completion_artifacts" USING btree ("created_at"); CREATE INDEX "completion_artifacts_created_at_idx" ON "completion_artifacts" USING btree ("created_at");

View file

@ -1,26 +0,0 @@
CREATE TABLE "budget_caps" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"workspace_id" uuid NOT NULL,
"cap_usd" numeric(12, 2) NOT NULL,
"period" varchar(20) DEFAULT 'monthly' NOT NULL,
"enabled" boolean DEFAULT true NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "spend_ledger" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"workspace_id" uuid NOT NULL,
"model_alias" varchar(120) NOT NULL,
"provider_type" varchar(40) NOT NULL,
"model_tier" varchar(20),
"estimated_cost_usd" numeric(12, 6) NOT NULL,
"occurred_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "budget_caps" ADD CONSTRAINT "budget_caps_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "spend_ledger" ADD CONSTRAINT "spend_ledger_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "budget_caps_workspace_id_idx" ON "budget_caps" USING btree ("workspace_id");--> statement-breakpoint
CREATE UNIQUE INDEX "budget_caps_workspace_id_period_unique" ON "budget_caps" USING btree ("workspace_id","period");--> statement-breakpoint
CREATE INDEX "spend_ledger_workspace_id_occurred_at_idx" ON "spend_ledger" USING btree ("workspace_id","occurred_at" DESC NULLS LAST);--> statement-breakpoint
CREATE INDEX "spend_ledger_model_alias_occurred_at_idx" ON "spend_ledger" USING btree ("model_alias","occurred_at" DESC NULLS LAST);

View file

@ -1,109 +0,0 @@
CREATE TABLE "chat_channel_members" (
"workspace_id" uuid NOT NULL,
"channel_id" uuid NOT NULL,
"user_id" uuid NOT NULL,
"joined_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "chat_channel_members_workspace_id_channel_id_user_id_pk" PRIMARY KEY("workspace_id","channel_id","user_id")
);
--> statement-breakpoint
CREATE TABLE "chat_channels" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"workspace_id" uuid NOT NULL,
"kind" varchar(16) NOT NULL,
"name" varchar(200),
"owner_user_id" uuid NOT NULL,
"direct_key" text,
"next_sequence" bigint DEFAULT 1 NOT NULL,
"archived_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "chat_channels_workspace_id_id_unique" UNIQUE("workspace_id","id"),
CONSTRAINT "chat_channels_kind_check" CHECK ("chat_channels"."kind" in ('public', 'private', 'direct')),
CONSTRAINT "chat_channels_direct_key_check" CHECK (("chat_channels"."kind" = 'direct' and "chat_channels"."direct_key" is not null and "chat_channels"."name" is null) or ("chat_channels"."kind" <> 'direct' and "chat_channels"."direct_key" is null)),
CONSTRAINT "chat_channels_next_sequence_check" CHECK ("chat_channels"."next_sequence" > 0)
);
--> statement-breakpoint
CREATE TABLE "chat_message_mentions" (
"workspace_id" uuid NOT NULL,
"message_id" uuid NOT NULL,
"user_id" uuid NOT NULL,
CONSTRAINT "chat_message_mentions_workspace_id_message_id_user_id_pk" PRIMARY KEY("workspace_id","message_id","user_id")
);
--> statement-breakpoint
CREATE TABLE "chat_messages" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"workspace_id" uuid NOT NULL,
"channel_id" uuid NOT NULL,
"sequence" bigint NOT NULL,
"parent_message_id" uuid,
"author_type" varchar(16) NOT NULL,
"author_user_id" uuid,
"author_agent_id" uuid,
"body" text NOT NULL,
"status" varchar(16) DEFAULT 'sent' NOT NULL,
"client_idempotency_key" varchar(200) NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"edited_at" timestamp with time zone,
"deleted_at" timestamp with time zone,
CONSTRAINT "chat_messages_workspace_id_id_unique" UNIQUE("workspace_id","id"),
CONSTRAINT "chat_messages_workspace_channel_id_unique" UNIQUE("workspace_id","channel_id","id"),
CONSTRAINT "chat_messages_workspace_channel_sequence_unique" UNIQUE("workspace_id","channel_id","sequence"),
CONSTRAINT "chat_messages_workspace_channel_client_key_unique" UNIQUE("workspace_id","channel_id","client_idempotency_key"),
CONSTRAINT "chat_messages_author_check" CHECK (("chat_messages"."author_type" = 'human' and "chat_messages"."author_user_id" is not null and "chat_messages"."author_agent_id" is null) or ("chat_messages"."author_type" = 'agent' and "chat_messages"."author_user_id" is null and "chat_messages"."author_agent_id" is not null) or ("chat_messages"."author_type" = 'system' and "chat_messages"."author_user_id" is null and "chat_messages"."author_agent_id" is null)),
CONSTRAINT "chat_messages_status_check" CHECK ("chat_messages"."status" in ('sent', 'edited', 'deleted')),
CONSTRAINT "chat_messages_sequence_check" CHECK ("chat_messages"."sequence" >= 0)
);
--> statement-breakpoint
CREATE TABLE "chat_outbox" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"workspace_id" uuid NOT NULL,
"message_id" uuid NOT NULL,
"event_version" integer DEFAULT 1 NOT NULL,
"event_type" varchar(100) NOT NULL,
"dedupe_key" varchar(250) NOT NULL,
"payload" jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"published_at" timestamp with time zone,
"attempts" integer DEFAULT 0 NOT NULL,
"last_error" text,
CONSTRAINT "chat_outbox_workspace_id_dedupe_key_unique" UNIQUE("workspace_id","dedupe_key"),
CONSTRAINT "chat_outbox_event_version_check" CHECK ("chat_outbox"."event_version" > 0),
CONSTRAINT "chat_outbox_attempts_check" CHECK ("chat_outbox"."attempts" >= 0)
);
--> statement-breakpoint
CREATE TABLE "chat_read_cursors" (
"workspace_id" uuid NOT NULL,
"channel_id" uuid NOT NULL,
"user_id" uuid NOT NULL,
"sequence" bigint DEFAULT 0 NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "chat_read_cursors_workspace_id_channel_id_user_id_pk" PRIMARY KEY("workspace_id","channel_id","user_id"),
CONSTRAINT "chat_read_cursors_sequence_check" CHECK ("chat_read_cursors"."sequence" >= 0)
);
--> statement-breakpoint
ALTER TABLE "chat_channel_members" ADD CONSTRAINT "chat_channel_members_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_channel_members" ADD CONSTRAINT "chat_channel_members_channel_fk" FOREIGN KEY ("workspace_id","channel_id") REFERENCES "public"."chat_channels"("workspace_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_channel_members" ADD CONSTRAINT "chat_channel_members_workspace_member_fk" FOREIGN KEY ("workspace_id","user_id") REFERENCES "public"."workspace_members"("workspace_id","user_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_channels" ADD CONSTRAINT "chat_channels_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_channels" ADD CONSTRAINT "chat_channels_owner_member_fk" FOREIGN KEY ("workspace_id","owner_user_id") REFERENCES "public"."workspace_members"("workspace_id","user_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_message_mentions" ADD CONSTRAINT "chat_message_mentions_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_message_mentions" ADD CONSTRAINT "chat_message_mentions_message_fk" FOREIGN KEY ("workspace_id","message_id") REFERENCES "public"."chat_messages"("workspace_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_message_mentions" ADD CONSTRAINT "chat_message_mentions_workspace_member_fk" FOREIGN KEY ("workspace_id","user_id") REFERENCES "public"."workspace_members"("workspace_id","user_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_messages" ADD CONSTRAINT "chat_messages_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_messages" ADD CONSTRAINT "chat_messages_channel_fk" FOREIGN KEY ("workspace_id","channel_id") REFERENCES "public"."chat_channels"("workspace_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_messages" ADD CONSTRAINT "chat_messages_parent_fk" FOREIGN KEY ("workspace_id","channel_id","parent_message_id") REFERENCES "public"."chat_messages"("workspace_id","channel_id","id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_messages" ADD CONSTRAINT "chat_messages_human_author_member_fk" FOREIGN KEY ("workspace_id","author_user_id") REFERENCES "public"."workspace_members"("workspace_id","user_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_outbox" ADD CONSTRAINT "chat_outbox_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_outbox" ADD CONSTRAINT "chat_outbox_message_fk" FOREIGN KEY ("workspace_id","message_id") REFERENCES "public"."chat_messages"("workspace_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_read_cursors" ADD CONSTRAINT "chat_read_cursors_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_read_cursors" ADD CONSTRAINT "chat_read_cursors_channel_fk" FOREIGN KEY ("workspace_id","channel_id") REFERENCES "public"."chat_channels"("workspace_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_read_cursors" ADD CONSTRAINT "chat_read_cursors_workspace_member_fk" FOREIGN KEY ("workspace_id","user_id") REFERENCES "public"."workspace_members"("workspace_id","user_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "chat_channel_members_workspace_id_idx" ON "chat_channel_members" USING btree ("workspace_id");--> statement-breakpoint
CREATE INDEX "chat_channel_members_workspace_id_user_id_idx" ON "chat_channel_members" USING btree ("workspace_id","user_id");--> statement-breakpoint
CREATE INDEX "chat_channels_workspace_id_idx" ON "chat_channels" USING btree ("workspace_id");--> statement-breakpoint
CREATE UNIQUE INDEX "chat_channels_workspace_id_direct_key_unique" ON "chat_channels" USING btree ("workspace_id","direct_key") WHERE "chat_channels"."direct_key" is not null;--> statement-breakpoint
CREATE INDEX "chat_message_mentions_workspace_id_idx" ON "chat_message_mentions" USING btree ("workspace_id");--> statement-breakpoint
CREATE INDEX "chat_messages_workspace_id_idx" ON "chat_messages" USING btree ("workspace_id");--> statement-breakpoint
CREATE INDEX "chat_outbox_workspace_id_idx" ON "chat_outbox" USING btree ("workspace_id");--> statement-breakpoint
CREATE INDEX "chat_outbox_unpublished_idx" ON "chat_outbox" USING btree ("created_at") WHERE "chat_outbox"."published_at" is null;--> statement-breakpoint
CREATE INDEX "chat_read_cursors_workspace_id_idx" ON "chat_read_cursors" USING btree ("workspace_id");

View file

@ -1,19 +0,0 @@
CREATE TABLE "chat_mutation_receipts" (
"workspace_id" uuid NOT NULL,
"actor_user_id" uuid NOT NULL,
"client_mutation_id" uuid NOT NULL,
"operation" varchar(50) NOT NULL,
"fingerprint" text NOT NULL,
"result" jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "chat_mutation_receipts_workspace_id_actor_user_id_client_mutation_id_pk" PRIMARY KEY("workspace_id","actor_user_id","client_mutation_id")
);
--> statement-breakpoint
ALTER TABLE "chat_outbox" ALTER COLUMN "message_id" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "chat_outbox" ADD COLUMN "channel_id" uuid;--> statement-breakpoint
UPDATE "chat_outbox" AS o SET "channel_id" = m."channel_id" FROM "chat_messages" AS m WHERE o."workspace_id" = m."workspace_id" AND o."message_id" = m."id";--> statement-breakpoint
ALTER TABLE "chat_outbox" ALTER COLUMN "channel_id" SET NOT NULL;--> statement-breakpoint
ALTER TABLE "chat_mutation_receipts" ADD CONSTRAINT "chat_mutation_receipts_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_mutation_receipts" ADD CONSTRAINT "chat_mutation_receipts_workspace_member_fk" FOREIGN KEY ("workspace_id","actor_user_id") REFERENCES "public"."workspace_members"("workspace_id","user_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "chat_mutation_receipts_workspace_id_idx" ON "chat_mutation_receipts" USING btree ("workspace_id");--> statement-breakpoint
ALTER TABLE "chat_outbox" ADD CONSTRAINT "chat_outbox_channel_fk" FOREIGN KEY ("workspace_id","channel_id") REFERENCES "public"."chat_channels"("workspace_id","id") ON DELETE cascade ON UPDATE no action;

View file

@ -1,48 +0,0 @@
CREATE FUNCTION public.enforce_chat_direct_channel_invariant() RETURNS trigger
LANGUAGE plpgsql
SET search_path = pg_catalog, public
AS $$
DECLARE canonical_key text;
DECLARE member_count integer;
BEGIN
IF OLD.kind = 'direct' AND (NEW.kind IS DISTINCT FROM OLD.kind OR NEW.direct_key IS DISTINCT FROM OLD.direct_key OR NEW.workspace_id IS DISTINCT FROM OLD.workspace_id) THEN
RAISE EXCEPTION 'direct channel identity is immutable' USING ERRCODE = '23514', CONSTRAINT = 'chat_direct_channel_identity_immutable';
END IF;
IF OLD.kind <> 'direct' AND NEW.kind = 'direct' THEN
IF NEW.workspace_id IS DISTINCT FROM OLD.workspace_id OR NEW.name IS NOT NULL OR NEW.direct_key IS NULL THEN
RAISE EXCEPTION 'invalid direct channel transition' USING ERRCODE = '23514', CONSTRAINT = 'chat_direct_channel_transition_valid';
END IF;
SELECT md5(string_agg(m.user_id::text, ':' ORDER BY m.user_id::text)), count(*)
INTO canonical_key, member_count
FROM public.chat_channel_members m
WHERE m.workspace_id = NEW.workspace_id AND m.channel_id = NEW.id;
IF member_count < 2 OR canonical_key IS DISTINCT FROM NEW.direct_key OR NOT EXISTS (
SELECT 1 FROM public.chat_channel_members m WHERE m.workspace_id = NEW.workspace_id AND m.channel_id = NEW.id AND m.user_id = NEW.owner_user_id
) THEN
RAISE EXCEPTION 'direct channel participants do not match canonical key' USING ERRCODE = '23514', CONSTRAINT = 'chat_direct_channel_participants_valid';
END IF;
END IF;
RETURN NEW;
END;
$$;
--> statement-breakpoint
REVOKE ALL ON FUNCTION public.enforce_chat_direct_channel_invariant() FROM PUBLIC;
--> statement-breakpoint
CREATE TRIGGER chat_direct_channel_invariant BEFORE UPDATE ON public.chat_channels FOR EACH ROW EXECUTE FUNCTION public.enforce_chat_direct_channel_invariant();
--> statement-breakpoint
CREATE FUNCTION public.enforce_chat_direct_membership_immutability() RETURNS trigger
LANGUAGE plpgsql
SET search_path = pg_catalog, public
AS $$
BEGIN
IF EXISTS (SELECT 1 FROM public.chat_channels c WHERE c.workspace_id = COALESCE(OLD.workspace_id, NEW.workspace_id) AND c.id = COALESCE(OLD.channel_id, NEW.channel_id) AND c.kind = 'direct')
OR (TG_OP = 'UPDATE' AND EXISTS (SELECT 1 FROM public.chat_channels c WHERE c.workspace_id = NEW.workspace_id AND c.id = NEW.channel_id AND c.kind = 'direct')) THEN
RAISE EXCEPTION 'direct channel membership is immutable' USING ERRCODE = '23514', CONSTRAINT = 'chat_direct_membership_immutable';
END IF;
RETURN COALESCE(NEW, OLD);
END;
$$;
--> statement-breakpoint
REVOKE ALL ON FUNCTION public.enforce_chat_direct_membership_immutability() FROM PUBLIC;
--> statement-breakpoint
CREATE TRIGGER chat_direct_membership_immutability BEFORE INSERT OR UPDATE OR DELETE ON public.chat_channel_members FOR EACH ROW EXECUTE FUNCTION public.enforce_chat_direct_membership_immutability();

View file

@ -1,3 +0,0 @@
ALTER TABLE "chat_messages" ADD COLUMN "send_intent_fingerprint" text;--> statement-breakpoint
ALTER TABLE "chat_messages" ADD COLUMN "revision" integer DEFAULT 1 NOT NULL;--> statement-breakpoint
ALTER TABLE "chat_messages" ADD CONSTRAINT "chat_messages_revision_check" CHECK ("chat_messages"."revision" > 0);

View file

@ -1,18 +0,0 @@
CREATE TABLE "chat_message_mutations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"workspace_id" uuid NOT NULL,
"channel_id" uuid NOT NULL,
"message_id" uuid NOT NULL,
"mutation_sequence" bigint NOT NULL,
"revision" integer NOT NULL,
"kind" varchar(16) NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "chat_message_mutations_channel_sequence_unique" UNIQUE("workspace_id","channel_id","mutation_sequence"),
CONSTRAINT "chat_message_mutations_revision_check" CHECK ("chat_message_mutations"."revision" > 1)
);
--> statement-breakpoint
ALTER TABLE "chat_channels" ADD COLUMN "next_mutation_sequence" bigint DEFAULT 1 NOT NULL;--> statement-breakpoint
ALTER TABLE "chat_message_mutations" ADD CONSTRAINT "chat_message_mutations_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_message_mutations" ADD CONSTRAINT "chat_message_mutations_channel_fk" FOREIGN KEY ("workspace_id","channel_id") REFERENCES "public"."chat_channels"("workspace_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_message_mutations" ADD CONSTRAINT "chat_message_mutations_message_fk" FOREIGN KEY ("workspace_id","message_id") REFERENCES "public"."chat_messages"("workspace_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "chat_message_mutations_workspace_id_idx" ON "chat_message_mutations" USING btree ("workspace_id");

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -127,48 +127,6 @@
"when": 1787698170280, "when": 1787698170280,
"tag": "0017_lively_squadron_supreme", "tag": "0017_lively_squadron_supreme",
"breakpoints": true "breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1792737772691,
"tag": "0018_early_risque",
"breakpoints": true
},
{
"idx": 19,
"version": "7",
"when": 1792763738904,
"tag": "0019_petite_monster_badoon",
"breakpoints": true
},
{
"idx": 20,
"version": "7",
"when": 1792766022303,
"tag": "0020_demonic_hammerhead",
"breakpoints": true
},
{
"idx": 21,
"version": "7",
"when": 1792766022304,
"tag": "0021_chat_direct_membership_immutability",
"breakpoints": true
},
{
"idx": 22,
"version": "7",
"when": 1793053728379,
"tag": "0022_skinny_masked_marvel",
"breakpoints": true
},
{
"idx": 23,
"version": "7",
"when": 1793053797739,
"tag": "0023_thick_shinko_yamashiro",
"breakpoints": true
} }
] ]
} }

View file

@ -9,7 +9,6 @@
".": "./src/index.ts", ".": "./src/index.ts",
"./schema": "./src/schema/index.ts", "./schema": "./src/schema/index.ts",
"./client": "./src/client.ts", "./client": "./src/client.ts",
"./chat": "./src/chat/index.ts",
"./markdown-backlog": "./src/markdown-backlog/index.ts", "./markdown-backlog": "./src/markdown-backlog/index.ts",
"./model-routing": "./src/model-routing/index.ts" "./model-routing": "./src/model-routing/index.ts"
}, },

View file

@ -1,206 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { createChannelTransactionRunner } from "./channel-drizzle-adapter";
type ChannelDatabase = NonNullable<
Parameters<typeof createChannelTransactionRunner>[0]
>;
type DatabaseTransaction = Parameters<
Parameters<ChannelDatabase["transaction"]>[0]
>[0];
type TransactionCallback = (tx: DatabaseTransaction) => Promise<unknown>;
function fakeDatabase(
transaction: (callback: TransactionCallback) => Promise<unknown>,
): ChannelDatabase {
return { transaction } as unknown as ChannelDatabase;
}
const fakeTx = {} as DatabaseTransaction;
function pgError(code: string): Error & { code: string } {
return Object.assign(new Error(`PostgreSQL ${code}`), { code });
}
describe("createChannelTransactionRunner", () => {
it("projects only ChannelRecord fields from database rows for strict receipts", async () => {
const expected = {
id: "10000000-0000-4000-8000-000000000001",
workspaceId: "20000000-0000-4000-8000-000000000001",
kind: "public",
name: "general",
ownerUserId: "30000000-0000-4000-8000-000000000001",
directKey: null,
archivedAt: null,
};
const records = [
{
...expected,
nextSequence: 42n,
nextMutationSequence: 9n,
createdAt: new Date(),
updatedAt: new Date(),
},
];
const tx = {
select: () => ({
from: () => ({
where: () => ({
for: async () => records,
}),
}),
}),
} as unknown as DatabaseTransaction;
const result = await createChannelTransactionRunner(
fakeDatabase(async (run) => run(tx)),
)((adapter) => adapter.lockChannel(expected.workspaceId, expected.id));
expect(result).toEqual(expected);
expect(() => JSON.stringify(result)).not.toThrow();
});
it.each(["40001", "40P01"])(
"replays the callback in a fresh transaction after %s",
async (code) => {
const transaction = vi.fn(async (callback: TransactionCallback) =>
callback(fakeTx),
);
const run = vi.fn(async () => {
if (run.mock.calls.length === 1) throw pgError(code);
return "committed";
});
await expect(
createChannelTransactionRunner(fakeDatabase(transaction))(run),
).resolves.toBe("committed");
expect(transaction).toHaveBeenCalledTimes(2);
expect(run).toHaveBeenCalledTimes(2);
},
);
it("replays the callback when a wrapped deadlock rejects after the callback returns", async () => {
const transaction = vi.fn(async (callback: TransactionCallback) => {
const result = await callback(fakeTx);
if (transaction.mock.calls.length === 1) {
throw new Error("transaction failed", { cause: pgError("40P01") });
}
return result;
});
const run = vi.fn(async () => "committed");
await expect(
createChannelTransactionRunner(fakeDatabase(transaction))(run),
).resolves.toBe("committed");
expect(transaction).toHaveBeenCalledTimes(2);
expect(run).toHaveBeenCalledTimes(2);
});
it("rethrows the original wrapped serialization error after three attempts", async () => {
const original = new Error("transaction failed", {
cause: new Error("driver failed", { cause: pgError("40001") }),
});
const transaction = vi.fn(async (callback: TransactionCallback) => {
await callback(fakeTx);
throw original;
});
const run = vi.fn(async () => "not committed");
await expect(
createChannelTransactionRunner(fakeDatabase(transaction))(run),
).rejects.toBe(original);
expect(transaction).toHaveBeenCalledTimes(3);
expect(run).toHaveBeenCalledTimes(3);
});
it("does not retry unrelated failures", async () => {
const original = new Error("invalid input", { cause: pgError("23505") });
const transaction = vi.fn(async (callback: TransactionCallback) =>
callback(fakeTx),
);
const run = vi.fn(async () => {
throw original;
});
await expect(
createChannelTransactionRunner(fakeDatabase(transaction))(run),
).rejects.toBe(original);
expect(transaction).toHaveBeenCalledTimes(1);
expect(run).toHaveBeenCalledTimes(1);
});
});
describe("channel membership persistence", () => {
it("does not mark a no-op insert as changed", async () => {
const returning = vi.fn().mockResolvedValue([]);
const tx = {
insert: vi.fn().mockReturnValue({
values: vi.fn().mockReturnValue({
onConflictDoNothing: vi.fn().mockReturnValue({ returning }),
}),
}),
};
const database = {
transaction: async (fn: (transaction: typeof tx) => Promise<boolean>) =>
fn(tx),
};
await expect(
createChannelTransactionRunner(
database as unknown as Parameters<
typeof createChannelTransactionRunner
>[0],
)((adapter) => adapter.addMember("workspace", "channel", "member")),
).resolves.toBe(false);
});
it("does not mark a no-op delete as changed", async () => {
const returning = vi.fn().mockResolvedValue([]);
const tx = {
delete: vi
.fn()
.mockReturnValue({ where: vi.fn().mockReturnValue({ returning }) }),
};
const database = {
transaction: async (fn: (transaction: typeof tx) => Promise<boolean>) =>
fn(tx),
};
await expect(
createChannelTransactionRunner(
database as unknown as Parameters<
typeof createChannelTransactionRunner
>[0],
)((adapter) => adapter.removeMember("workspace", "channel", "member")),
).resolves.toBe(false);
});
it("reports inserted members using returned rows rather than driver command metadata", async () => {
const returning = vi.fn().mockResolvedValue([{ userId: "member" }]);
const onConflictDoNothing = vi.fn().mockReturnValue({ returning });
const values = vi.fn().mockReturnValue({ onConflictDoNothing });
const tx = { insert: vi.fn().mockReturnValue({ values }) };
const database = {
transaction: async (fn: (transaction: typeof tx) => Promise<boolean>) =>
fn(tx),
};
const changed = await createChannelTransactionRunner(
database as unknown as Parameters<
typeof createChannelTransactionRunner
>[0],
)((adapter) => adapter.addMember("workspace", "channel", "member"));
expect(changed).toBe(true);
expect(returning).toHaveBeenCalled();
});
it("reports deleted members using returned rows rather than driver command metadata", async () => {
const returning = vi.fn().mockResolvedValue([{ userId: "member" }]);
const where = vi.fn().mockReturnValue({ returning });
const tx = { delete: vi.fn().mockReturnValue({ where }) };
const database = {
transaction: async (fn: (transaction: typeof tx) => Promise<boolean>) =>
fn(tx),
};
const changed = await createChannelTransactionRunner(
database as unknown as Parameters<
typeof createChannelTransactionRunner
>[0],
)((adapter) => adapter.removeMember("workspace", "channel", "member"));
expect(changed).toBe(true);
expect(returning).toHaveBeenCalled();
});
});

View file

@ -1,348 +0,0 @@
import { and, asc, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { chatChannelLifecycleEventV1Schema } from "@tasks/shared";
import { db as defaultDb } from "../client";
import {
chatChannelMembers,
chatChannels,
chatMutationReceipts,
chatOutbox,
workspaceMembers,
} from "../schema";
import {
ChannelPersistenceError,
type ChannelRunner,
type ChannelTransaction,
} from "./manage-channels";
type Tx = Parameters<Parameters<typeof defaultDb.transaction>[0]>[0];
const row = (c: typeof chatChannels.$inferSelect) => ({
id: c.id,
workspaceId: c.workspaceId,
kind: c.kind,
name: c.name,
ownerUserId: c.ownerUserId,
directKey: c.directKey,
archivedAt: c.archivedAt,
});
function translate(error: unknown): never {
const record =
typeof error === "object" && error
? (error as { code?: string; constraint?: string })
: {};
if (
record.constraint === "chat_direct_membership_immutable" ||
record.constraint === "chat_direct_channel_identity_immutable"
)
throw new ChannelPersistenceError("READ_ONLY");
if (
record.constraint === "chat_direct_channel_transition_valid" ||
record.constraint === "chat_direct_channel_participants_valid"
)
throw new ChannelPersistenceError("INVALID");
if (
record.constraint ===
"chat_mutation_receipts_workspace_id_actor_user_id_client_mutation_id_pk" ||
record.constraint === "chat_channels_workspace_id_direct_key_unique"
)
throw new ChannelPersistenceError("CONFLICT");
if (
record.constraint === "chat_channel_members_workspace_member_fk" ||
record.constraint === "chat_channels_owner_member_fk"
)
throw new ChannelPersistenceError("NOT_FOUND");
throw error;
}
function adapter(tx: Tx): ChannelTransaction {
return {
async loadActor(workspaceId, userId) {
const [m] = await tx
.select({
userId: workspaceMembers.userId,
role: workspaceMembers.role,
})
.from(workspaceMembers)
.where(
and(
eq(workspaceMembers.workspaceId, workspaceId),
eq(workspaceMembers.userId, userId),
),
)
.limit(1);
return m ?? null;
},
async listVisible(workspaceId, actorUserId) {
return (
await tx
.selectDistinct({ channel: chatChannels })
.from(chatChannels)
.leftJoin(
chatChannelMembers,
and(
eq(chatChannelMembers.workspaceId, workspaceId),
eq(chatChannelMembers.channelId, chatChannels.id),
eq(chatChannelMembers.userId, actorUserId),
),
)
.where(
and(
eq(chatChannels.workspaceId, workspaceId),
or(
eq(chatChannels.kind, "public"),
eq(chatChannelMembers.userId, actorUserId),
),
),
)
.orderBy(asc(chatChannels.createdAt))
).map((x) => row(x.channel));
},
async lockChannel(workspaceId, channelId) {
const [c] = await tx
.select()
.from(chatChannels)
.where(
and(
eq(chatChannels.workspaceId, workspaceId),
eq(chatChannels.id, channelId),
),
)
.for("update");
return c ? row(c) : null;
},
async isChannelMember(workspaceId, channelId, userId) {
const [m] = await tx
.select({ userId: chatChannelMembers.userId })
.from(chatChannelMembers)
.where(
and(
eq(chatChannelMembers.workspaceId, workspaceId),
eq(chatChannelMembers.channelId, channelId),
eq(chatChannelMembers.userId, userId),
),
)
.limit(1);
return Boolean(m);
},
async areWorkspaceMembers(workspaceId, userIds) {
if (!userIds.length) return false;
const found = await tx
.select({ userId: workspaceMembers.userId })
.from(workspaceMembers)
.where(
and(
eq(workspaceMembers.workspaceId, workspaceId),
inArray(workspaceMembers.userId, userIds),
),
);
return found.length === new Set(userIds).size;
},
async insertChannel(input) {
try {
const [c] = await tx.insert(chatChannels).values(input).returning();
return row(c!);
} catch (e) {
translate(e);
}
},
async renameActiveChannel(workspaceId, channelId, name) {
const [c] = await tx
.update(chatChannels)
.set({ name, updatedAt: new Date() })
.where(
and(
eq(chatChannels.workspaceId, workspaceId),
eq(chatChannels.id, channelId),
isNull(chatChannels.archivedAt),
),
)
.returning();
return c ? row(c) : null;
},
async archiveActiveChannel(workspaceId, channelId) {
const [c] = await tx
.update(chatChannels)
.set({ archivedAt: new Date(), updatedAt: new Date() })
.where(
and(
eq(chatChannels.workspaceId, workspaceId),
eq(chatChannels.id, channelId),
isNull(chatChannels.archivedAt),
),
)
.returning();
return c ? row(c) : null;
},
async addMember(workspaceId, channelId, userId) {
try {
const inserted = await tx
.insert(chatChannelMembers)
.values({ workspaceId, channelId, userId })
.onConflictDoNothing()
.returning({ userId: chatChannelMembers.userId });
return inserted.length > 0;
} catch (e) {
translate(e);
}
},
async removeMember(workspaceId, channelId, userId) {
const deleted = await tx
.delete(chatChannelMembers)
.where(
and(
eq(chatChannelMembers.workspaceId, workspaceId),
eq(chatChannelMembers.channelId, channelId),
eq(chatChannelMembers.userId, userId),
),
)
.returning({ userId: chatChannelMembers.userId });
return deleted.length > 0;
},
async lockDirect(workspaceId, directKey) {
await tx.execute(
sql`select pg_advisory_xact_lock(hashtextextended(${`${workspaceId}:${directKey}`},0))`,
);
},
async findDirect(workspaceId, directKey) {
const [c] = await tx
.select()
.from(chatChannels)
.where(
and(
eq(chatChannels.workspaceId, workspaceId),
eq(chatChannels.directKey, directKey),
),
)
.limit(1);
return c ? row(c) : null;
},
async insertDirect(input) {
try {
const [c] = await tx
.insert(chatChannels)
.values({
...input,
kind: "private",
name: "__direct_staging__",
directKey: null,
})
.returning();
return c ? row(c) : null;
} catch (e) {
translate(e);
}
},
async addInitialDirectMembers(workspaceId, channelId, userIds) {
await tx
.insert(chatChannelMembers)
.values(userIds.map((userId) => ({ workspaceId, channelId, userId })));
},
async finalizeDirect(workspaceId, channelId, directKey) {
const [channel] = await tx
.update(chatChannels)
.set({ kind: "direct", name: null, directKey, updatedAt: new Date() })
.where(
and(
eq(chatChannels.workspaceId, workspaceId),
eq(chatChannels.id, channelId),
eq(chatChannels.kind, "private"),
),
)
.returning();
if (!channel) throw new ChannelPersistenceError("CONFLICT");
return row(channel);
},
async listMemberIds(workspaceId, channelId) {
const rows = await tx
.select({ userId: chatChannelMembers.userId })
.from(chatChannelMembers)
.where(
and(
eq(chatChannelMembers.workspaceId, workspaceId),
eq(chatChannelMembers.channelId, channelId),
),
)
.orderBy(asc(chatChannelMembers.userId));
return rows.map((x) => x.userId);
},
async lockMutation(scope) {
await tx.execute(
sql`select pg_advisory_xact_lock(hashtextextended(${`${scope.workspaceId}:${scope.actorUserId}:${scope.clientMutationId}`}, 0))`,
);
},
async loadReceipt(scope) {
const [receipt] = await tx
.select()
.from(chatMutationReceipts)
.where(
and(
eq(chatMutationReceipts.workspaceId, scope.workspaceId),
eq(chatMutationReceipts.actorUserId, scope.actorUserId),
eq(chatMutationReceipts.clientMutationId, scope.clientMutationId),
),
)
.limit(1);
return receipt
? {
operation: receipt.operation,
fingerprint: receipt.fingerprint,
result: receipt.result,
}
: null;
},
async saveReceipt(scope, operation, fingerprint, result) {
try {
await tx.insert(chatMutationReceipts).values({
...scope,
operation,
fingerprint,
result: result as Record<string, unknown>,
});
} catch (error) {
translate(error);
}
},
async emit(value) {
const e = chatChannelLifecycleEventV1Schema.parse(value);
const subject = "subjectUserId" in e ? e.subjectUserId : "channel";
await tx.insert(chatOutbox).values({
workspaceId: e.workspaceId,
channelId: e.channelId,
eventType: e.eventType,
eventVersion: e.eventVersion,
dedupeKey: `${e.eventType}:${e.channelId}:${subject}:${e.clientMutationId}`,
payload: e,
});
},
};
}
function isRetryableTransactionError(error: unknown): boolean {
const seen = new Set<unknown>();
let current = error;
while (
current !== null &&
typeof current === "object" &&
!seen.has(current)
) {
seen.add(current);
if (
"code" in current &&
(current.code === "40001" || current.code === "40P01")
) {
return true;
}
current = "cause" in current ? current.cause : undefined;
}
return false;
}
export function createChannelTransactionRunner(
database: typeof defaultDb = defaultDb,
): ChannelRunner {
return async (fn) => {
for (let attempt = 1; ; attempt++) {
try {
return await database.transaction((tx) => fn(adapter(tx)));
} catch (error) {
if (attempt >= 3 || !isRetryableTransactionError(error)) throw error;
}
}
};
}

View file

@ -1,139 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import {
createChatTransactionRunner,
type ChatDatabase,
} from "./drizzle-adapter";
type DatabaseTransaction = Parameters<
Parameters<ChatDatabase["transaction"]>[0]
>[0];
type TransactionCallback = (tx: DatabaseTransaction) => Promise<unknown>;
// These fakes exercise the transaction boundary, not a live PostgreSQL connection.
function fakeDatabase(
transaction: (callback: TransactionCallback) => Promise<unknown>,
): ChatDatabase {
return { transaction } as unknown as ChatDatabase;
}
const fakeTx = {} as DatabaseTransaction;
function pgError(code: string): Error & { code: string } {
return Object.assign(new Error(`PostgreSQL ${code}`), { code });
}
describe("createChatTransactionRunner", () => {
it.each(["40001", "40P01"])(
"restarts the entire callback in a fresh transaction after %s",
async (code) => {
const transaction = vi.fn(async (callback: TransactionCallback) =>
callback(fakeTx),
);
const run = vi.fn(async () => {
if (run.mock.calls.length === 1) throw pgError(code);
return "committed";
});
await expect(
createChatTransactionRunner(fakeDatabase(transaction)).transaction(run),
).resolves.toBe("committed");
expect(transaction).toHaveBeenCalledTimes(2);
expect(run).toHaveBeenCalledTimes(2);
},
);
it("replays the callback when the transaction rejects after it returns", async () => {
const transaction = vi.fn(async (callback: TransactionCallback) => {
const result = await callback(fakeTx);
if (transaction.mock.calls.length === 1) throw pgError("40001");
return result;
});
const run = vi.fn(async () => "committed");
await expect(
createChatTransactionRunner(fakeDatabase(transaction)).transaction(run),
).resolves.toBe("committed");
expect(transaction).toHaveBeenCalledTimes(2);
expect(run).toHaveBeenCalledTimes(2);
});
it("retries a serialization failure wrapped in a cause", async () => {
const transaction = vi.fn(async (callback: TransactionCallback) =>
callback(fakeTx),
);
const run = vi.fn(async () => {
if (run.mock.calls.length === 1)
throw new Error("query failed", { cause: pgError("40001") });
return 42;
});
await expect(
createChatTransactionRunner(fakeDatabase(transaction)).transaction(run),
).resolves.toBe(42);
expect(transaction).toHaveBeenCalledTimes(2);
expect(run).toHaveBeenCalledTimes(2);
});
it("stops after three total attempts and rethrows the last error unchanged", async () => {
const failures = [pgError("40001"), pgError("40P01"), pgError("40001")];
const transaction = vi.fn(async (callback: TransactionCallback) =>
callback(fakeTx),
);
const run = vi.fn(async () => {
throw failures[run.mock.calls.length - 1];
});
await expect(
createChatTransactionRunner(fakeDatabase(transaction)).transaction(run),
).rejects.toBe(failures[2]);
expect(transaction).toHaveBeenCalledTimes(3);
expect(run).toHaveBeenCalledTimes(3);
});
it.each([
new Error("unrelated"),
pgError("23505"),
pgError("CHAT_FORBIDDEN"),
])("does not retry unrelated or domain failures: %s", async (failure) => {
const transaction = vi.fn(async (callback: TransactionCallback) =>
callback(fakeTx),
);
const run = vi.fn(async () => {
throw failure;
});
await expect(
createChatTransactionRunner(fakeDatabase(transaction)).transaction(run),
).rejects.toBe(failure);
expect(transaction).toHaveBeenCalledTimes(1);
expect(run).toHaveBeenCalledTimes(1);
});
it("replays the whole callback after a rolled-back attempt, not just the failed operation", async () => {
const committed: string[] = [];
let attempts = 0;
let staged: string[] = [];
const transaction = vi.fn(async (callback: TransactionCallback) => {
staged = [];
attempts++;
// A fake rollback model: failed attempts never append staged writes.
const result = await callback(fakeTx);
committed.push(...staged);
return result;
});
const run = vi.fn(async () => {
staged.push(`first-${attempts}`);
if (attempts === 1) throw pgError("40P01");
staged.push(`second-${attempts}`);
return "done";
});
await expect(
createChatTransactionRunner(fakeDatabase(transaction)).transaction(run),
).resolves.toBe("done");
expect(committed).toEqual(["first-2", "second-2"]);
expect(run).toHaveBeenCalledTimes(2);
expect(transaction).toHaveBeenCalledTimes(2);
});
});

View file

@ -1,245 +0,0 @@
import { and, eq, inArray, sql } from "drizzle-orm";
import { db as defaultDb } from "../client";
import {
chatChannelMembers,
chatChannels,
chatMessageMentions,
chatMessages,
chatOutbox,
workspaceMembers,
} from "../schema";
import {
sendChatMessage,
type ChatPersistenceTransaction,
type ChatTransactionRunner,
} from "./send-message";
export type ChatDatabase = typeof defaultDb;
type DatabaseTransaction = Parameters<
Parameters<ChatDatabase["transaction"]>[0]
>[0];
export function transactionAdapter(
tx: DatabaseTransaction,
): ChatPersistenceTransaction {
return {
async claimIdempotency(scope) {
const lockKey = `${scope.workspaceId}:${scope.channelId}:${scope.clientIdempotencyKey}`;
// A 64-bit hash collision only adds serialization; it cannot merge keys
// because the following lookup still compares every original scope field.
await tx.execute(
sql`select pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))`,
);
const [message] = await tx
.select()
.from(chatMessages)
.where(
and(
eq(chatMessages.workspaceId, scope.workspaceId),
eq(chatMessages.channelId, scope.channelId),
eq(chatMessages.clientIdempotencyKey, scope.clientIdempotencyKey),
),
)
.limit(1);
return message
? {
id: message.id,
workspaceId: message.workspaceId,
channelId: message.channelId,
sequence: message.sequence,
body: message.body,
parentMessageId: message.parentMessageId,
authorUserId: message.authorUserId!,
clientIdempotencyKey: message.clientIdempotencyKey,
sendIntentFingerprint: message.sendIntentFingerprint,
status: message.status,
}
: null;
},
async loadMessageMentions(scope) {
const rows = await tx
.select({ userId: chatMessageMentions.userId })
.from(chatMessageMentions)
.innerJoin(
chatMessages,
and(
eq(chatMessages.workspaceId, chatMessageMentions.workspaceId),
eq(chatMessages.id, chatMessageMentions.messageId),
),
)
.where(
and(
eq(chatMessageMentions.workspaceId, scope.workspaceId),
eq(chatMessageMentions.messageId, scope.messageId),
eq(chatMessages.workspaceId, scope.workspaceId),
eq(chatMessages.channelId, scope.channelId),
),
);
return rows.map(({ userId }) => userId);
},
async loadSendFacts(scope) {
const [member] = await tx
.select({ role: workspaceMembers.role })
.from(workspaceMembers)
.where(
and(
eq(workspaceMembers.workspaceId, scope.workspaceId),
eq(workspaceMembers.userId, scope.actorUserId),
),
)
.limit(1);
const [channel] = await tx
.select()
.from(chatChannels)
.where(
and(
eq(chatChannels.workspaceId, scope.workspaceId),
eq(chatChannels.id, scope.channelId),
),
)
.limit(1);
if (!member || !channel) return null;
const [channelMember] = await tx
.select({ userId: chatChannelMembers.userId })
.from(chatChannelMembers)
.where(
and(
eq(chatChannelMembers.workspaceId, scope.workspaceId),
eq(chatChannelMembers.channelId, scope.channelId),
eq(chatChannelMembers.userId, scope.actorUserId),
),
)
.limit(1);
const role = member.role;
if (
!(["owner", "admin", "member", "guest"] as const).includes(
role as never,
)
)
return null;
return {
actor: {
userId: scope.actorUserId,
workspaceId: scope.workspaceId,
activeWorkspaceMember: true,
role: role as "owner" | "admin" | "member" | "guest",
},
channel: {
id: channel.id,
workspaceId: channel.workspaceId,
kind: channel.kind,
archived: channel.archivedAt !== null,
activeMember: Boolean(channelMember),
ownerUserId: channel.ownerUserId,
},
};
},
async loadParent(scope) {
const [parent] = await tx
.select({
workspaceId: chatMessages.workspaceId,
channelId: chatMessages.channelId,
parentMessageId: chatMessages.parentMessageId,
})
.from(chatMessages)
.where(
and(
eq(chatMessages.workspaceId, scope.workspaceId),
eq(chatMessages.channelId, scope.channelId),
eq(chatMessages.id, scope.messageId),
),
)
.limit(1);
return parent ?? null;
},
async validateMentionMembers(scope) {
if (!scope.userIds.length) return [];
const rows = await tx
.select({ userId: workspaceMembers.userId })
.from(workspaceMembers)
.where(
and(
eq(workspaceMembers.workspaceId, scope.workspaceId),
inArray(workspaceMembers.userId, scope.userIds),
),
);
return rows.map(({ userId }) => userId);
},
async allocateSequence(scope) {
const [row] = await tx
.update(chatChannels)
.set({ nextSequence: sql`${chatChannels.nextSequence} + 1` })
.where(
and(
eq(chatChannels.workspaceId, scope.workspaceId),
eq(chatChannels.id, scope.channelId),
),
)
.returning({ sequence: sql<bigint>`${chatChannels.nextSequence} - 1` });
if (!row)
throw new Error("Channel disappeared during message transaction");
return row.sequence;
},
async insertMessage(values) {
const [message] = await tx
.insert(chatMessages)
.values({
...values,
authorType: "human",
authorAgentId: null,
})
.returning();
if (!message) throw new Error("Message insert returned no row");
return { ...values, id: message.id, sequence: message.sequence };
},
async insertMentions(values) {
await tx.insert(chatMessageMentions).values(
values.userIds.map((userId) => ({
workspaceId: values.workspaceId,
messageId: values.messageId,
userId,
})),
);
},
async insertOutbox(values) {
await tx.insert(chatOutbox).values(values);
},
};
}
function retryable(error: unknown): boolean {
if (!error || typeof error !== "object") return false;
const candidate = error as { code?: unknown; cause?: unknown };
return (
candidate.code === "40001" ||
candidate.code === "40P01" ||
(candidate.cause !== undefined && retryable(candidate.cause))
);
}
export function createChatTransactionRunner(
database: ChatDatabase = defaultDb,
): ChatTransactionRunner {
return {
async transaction(run) {
for (let attempt = 0; attempt < 3; attempt++) {
try {
return await database.transaction((tx) =>
run(transactionAdapter(tx)),
);
} catch (error) {
if (attempt === 2 || !retryable(error)) throw error;
}
}
throw new Error("Unreachable transaction retry state");
},
};
}
export async function sendChatMessageWithDb(
input: Parameters<typeof sendChatMessage>[1],
database: ChatDatabase = defaultDb,
) {
return sendChatMessage(createChatTransactionRunner(database), input);
}

View file

@ -1,7 +0,0 @@
export * from "./channel-drizzle-adapter";
export * from "./drizzle-adapter";
export * from "./manage-channels";
export * from "./mutate-message";
export * from "./message-drizzle-adapter";
export * from "./read-history";
export * from "./send-message";

View file

@ -1,297 +0,0 @@
import { createHash } from "node:crypto";
import { describe, expect, it, vi } from "vitest";
import {
addPrivateMember,
createChannel,
createDirectChannel,
removePrivateMember,
renameChannel,
type ChannelRecord,
type ChannelTransaction,
} from "./manage-channels";
const workspaceId = "11111111-1111-4111-8111-111111111111",
actorUserId = "22222222-2222-4222-8222-222222222222",
target = "33333333-3333-4333-8333-333333333333",
channelId = "44444444-4444-4444-8444-444444444444",
clientMutationId = "55555555-5555-4555-8555-555555555555";
const channel = (patch: Partial<ChannelRecord> = {}): ChannelRecord => ({
id: channelId,
workspaceId,
kind: "private",
name: "private",
ownerUserId: actorUserId,
directKey: null,
archivedAt: null,
...patch,
});
function seam(patch: Partial<ChannelTransaction> = {}) {
const events: unknown[] = [];
const tx: ChannelTransaction = {
loadActor: async () => ({ userId: actorUserId, role: "member" }),
listVisible: async () => [],
lockChannel: async () => channel(),
isChannelMember: async () => true,
areWorkspaceMembers: async () => true,
insertChannel: async () => channel(),
renameActiveChannel: async (_w, _c, name) => channel({ name }),
archiveActiveChannel: async () => channel({ archivedAt: new Date() }),
addMember: async () => true,
removeMember: async () => true,
lockDirect: async () => {},
findDirect: async () => null,
insertDirect: async (input) =>
channel({ kind: "direct", name: null, directKey: input.directKey }),
addInitialDirectMembers: async () => {},
finalizeDirect: async (_w, _c, directKey) =>
channel({ kind: "direct", name: null, directKey }),
listMemberIds: async () => [],
lockMutation: async () => {},
loadReceipt: async () => null,
saveReceipt: async () => {},
emit: async (e) => {
events.push(e);
},
...patch,
};
return {
run: async <T>(fn: (tx: ChannelTransaction) => Promise<T>) => fn(tx),
events,
tx,
};
}
const scope = { workspaceId, actorUserId, clientMutationId };
describe("channel lifecycle orchestration", () => {
it("does not emit for idempotent membership add/remove", async () => {
const s = seam({
addMember: async () => false,
removeMember: async () => false,
});
await addPrivateMember(s.run, { ...scope, channelId, userId: target });
await removePrivateMember(s.run, { ...scope, channelId, userId: target });
expect(s.events).toEqual([]);
});
it("emits membership events only when add and remove change rows", async () => {
const s = seam();
await addPrivateMember(s.run, { ...scope, channelId, userId: target });
await removePrivateMember(s.run, { ...scope, channelId, userId: target });
expect(s.events).toMatchObject([
{ eventType: "chat.channel.member.added.v1", subjectUserId: target },
{ eventType: "chat.channel.member.removed.v1", subjectUserId: target },
]);
});
it("protects the private owner", async () => {
const s = seam();
await expect(
removePrivateMember(s.run, { ...scope, channelId, userId: actorUserId }),
).rejects.toMatchObject({ code: "INVALID" });
});
it("gives workspace admins no private membership bypass", async () => {
const s = seam({
loadActor: async () => ({ userId: actorUserId, role: "admin" }),
isChannelMember: async () => false,
});
await expect(
addPrivateMember(s.run, { ...scope, channelId, userId: target }),
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
it("denies mutation after archive", async () => {
const s = seam({
lockChannel: async () => channel({ archivedAt: new Date() }),
});
await expect(
renameChannel(s.run, { ...scope, channelId, name: "next" }),
).rejects.toMatchObject({ code: "READ_ONLY" });
});
it("canonicalizes and deduplicates direct participants and orders events", async () => {
let inserted: string[] = [];
const s = seam({
addInitialDirectMembers: async (_w, _c, ids) => {
inserted = ids;
},
});
await createDirectChannel(s.run, {
...scope,
participantUserIds: [target, actorUserId, target],
});
expect(inserted).toEqual([actorUserId, target].sort());
expect(s.events).toHaveLength(3);
});
it("requires actor and two distinct participants", async () => {
const s = seam();
await expect(
createDirectChannel(s.run, {
...scope,
participantUserIds: [target, target],
}),
).rejects.toMatchObject({ code: "INVALID" });
});
it("rejects an existing direct with a mismatched exact set", async () => {
const s = seam({
findDirect: async () =>
channel({ kind: "direct", name: null, directKey: "x" }),
listMemberIds: async () => [actorUserId],
});
await expect(
createDirectChannel(s.run, {
...scope,
participantUserIds: [actorUserId, target],
}),
).rejects.toMatchObject({ code: "CONFLICT" });
});
it("denies replay of a private channel receipt after membership revocation", async () => {
const original = channel();
const s = seam({
loadReceipt: async () => ({
operation: "create_channel",
fingerprint: createHash("sha256")
.update(JSON.stringify({ kind: "private", name: "private" }))
.digest("hex"),
result: { operation: "create_channel", channel: original },
}),
isChannelMember: async () => false,
});
await expect(
createChannel(s.run, { ...scope, kind: "private", name: "private" }),
).rejects.toMatchObject({ code: "NOT_FOUND" });
expect(s.events).toEqual([]);
});
it("denies replay of a private member receipt after membership revocation", async () => {
const s = seam({
loadReceipt: async () => ({
operation: "add_private_member",
fingerprint: createHash("sha256")
.update(JSON.stringify({ channelId, userId: target }))
.digest("hex"),
result: {
operation: "add_private_member",
member: { channelId, userId: target, changed: true },
},
}),
isChannelMember: async () => false,
});
await expect(
addPrivateMember(s.run, { ...scope, channelId, userId: target }),
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
it("does not disclose a saved remove-self result after leaving a private channel", async () => {
const s = seam({
lockChannel: async () => channel({ ownerUserId: target }),
loadReceipt: async () => ({
operation: "remove_private_member",
fingerprint: createHash("sha256")
.update(JSON.stringify({ channelId, userId: actorUserId }))
.digest("hex"),
result: {
operation: "remove_private_member",
member: { channelId, userId: actorUserId, changed: true },
},
}),
isChannelMember: async () => false,
});
await expect(
removePrivateMember(s.run, { ...scope, channelId, userId: actorUserId }),
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
it("allows public receipt replay without channel membership", async () => {
const original = channel({ kind: "public" });
const s = seam({
lockChannel: async () => original,
isChannelMember: async () => false,
loadReceipt: async () => ({
operation: "create_channel",
fingerprint: createHash("sha256")
.update(JSON.stringify({ kind: "public", name: "private" }))
.digest("hex"),
result: { operation: "create_channel", channel: original },
}),
});
await expect(
createChannel(s.run, { ...scope, kind: "public", name: "private" }),
).resolves.toMatchObject({ id: channelId });
});
it("denies replay of a direct channel receipt after membership revocation", async () => {
const participants = [actorUserId, target].sort();
const original = channel({ kind: "direct", name: null, directKey: "key" });
const s = seam({
lockChannel: async () => original,
isChannelMember: async () => false,
loadReceipt: async () => ({
operation: "create_direct",
fingerprint: createHash("sha256")
.update(JSON.stringify({ participantUserIds: participants }))
.digest("hex"),
result: { operation: "create_direct", channel: original },
}),
});
await expect(
createDirectChannel(s.run, {
...scope,
participantUserIds: participants,
}),
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
it("replays readable archived channels without rechecking write permissions", async () => {
const archived = channel({ archivedAt: new Date("2026-01-01T00:00:00Z") });
const s = seam({
lockChannel: async () => archived,
loadReceipt: async () => ({
operation: "rename_channel",
fingerprint: createHash("sha256")
.update(JSON.stringify({ channelId, name: "next" }))
.digest("hex"),
result: {
operation: "rename_channel",
channel: {
...archived,
archivedAt: archived.archivedAt?.toISOString(),
},
},
}),
});
await expect(
renameChannel(s.run, { ...scope, channelId, name: "next" }),
).resolves.toMatchObject({ id: channelId, name: "private" });
expect(s.events).toEqual([]);
});
it("replays a matching receipt without state or event calls", async () => {
const original = channel();
const insert = vi.fn();
const fingerprint = createHash("sha256")
.update(JSON.stringify({ kind: "public", name: "private" }))
.digest("hex");
const s = seam({
loadReceipt: async () => ({
operation: "create_channel",
fingerprint,
result: {
operation: "create_channel",
channel: { ...original, archivedAt: null },
},
}),
insertChannel: insert,
});
const result = await createChannel(s.run, {
...scope,
kind: "public",
name: "private",
});
expect(result.id).toBe(channelId);
expect(insert).not.toHaveBeenCalled();
expect(s.events).toEqual([]);
});
it("rejects receipt intent collisions before state calls", async () => {
const lockChannel = vi.fn();
const s = seam({
loadReceipt: async () => ({
operation: "archive_channel",
fingerprint: "different",
result: {},
}),
lockChannel,
});
await expect(
renameChannel(s.run, { ...scope, channelId, name: "next" }),
).rejects.toMatchObject({ code: "CONFLICT" });
expect(lockChannel).not.toHaveBeenCalled();
});
});

View file

@ -1,479 +0,0 @@
import { createHash } from "node:crypto";
import {
chatChannelLifecycleEventV1Schema,
chatChannelNameSchema,
chatLifecycleReceiptResultSchema,
type ChatChannelLifecycleEventV1,
type ChatLifecycleReceiptResult,
} from "@tasks/shared";
export type ChannelKind = "public" | "private" | "direct";
export type ChannelRecord = {
id: string;
workspaceId: string;
kind: ChannelKind;
name: string | null;
ownerUserId: string;
directKey: string | null;
archivedAt: Date | null;
};
export type WorkspaceActor = { userId: string; role: string };
export class ChannelPersistenceError extends Error {
constructor(
public readonly code: "NOT_FOUND" | "INVALID" | "READ_ONLY" | "CONFLICT",
) {
super(code);
}
}
export type MutationScope = {
workspaceId: string;
actorUserId: string;
clientMutationId: string;
};
export type ChannelTransaction = {
loadActor(
workspaceId: string,
userId: string,
): Promise<WorkspaceActor | null>;
listVisible(
workspaceId: string,
actorUserId: string,
): Promise<ChannelRecord[]>;
lockChannel(
workspaceId: string,
channelId: string,
): Promise<ChannelRecord | null>;
isChannelMember(
workspaceId: string,
channelId: string,
userId: string,
): Promise<boolean>;
areWorkspaceMembers(workspaceId: string, userIds: string[]): Promise<boolean>;
insertChannel(
input: Omit<ChannelRecord, "id" | "archivedAt">,
): Promise<ChannelRecord>;
renameActiveChannel(
workspaceId: string,
channelId: string,
name: string,
): Promise<ChannelRecord | null>;
archiveActiveChannel(
workspaceId: string,
channelId: string,
): Promise<ChannelRecord | null>;
addMember(
workspaceId: string,
channelId: string,
userId: string,
): Promise<boolean>;
removeMember(
workspaceId: string,
channelId: string,
userId: string,
): Promise<boolean>;
lockDirect(workspaceId: string, directKey: string): Promise<void>;
findDirect(
workspaceId: string,
directKey: string,
): Promise<ChannelRecord | null>;
insertDirect(
input: Omit<ChannelRecord, "id" | "archivedAt">,
): Promise<ChannelRecord | null>;
addInitialDirectMembers(
workspaceId: string,
channelId: string,
userIds: string[],
): Promise<void>;
finalizeDirect(
workspaceId: string,
channelId: string,
directKey: string,
): Promise<ChannelRecord>;
listMemberIds(workspaceId: string, channelId: string): Promise<string[]>;
lockMutation(scope: MutationScope): Promise<void>;
loadReceipt(scope: MutationScope): Promise<{
operation: string;
fingerprint: string;
result: unknown;
} | null>;
saveReceipt(
scope: MutationScope,
operation: string,
fingerprint: string,
result: unknown,
): Promise<void>;
emit(event: ChatChannelLifecycleEventV1): Promise<void>;
};
export type ChannelRunner = <T>(
fn: (tx: ChannelTransaction) => Promise<T>,
) => Promise<T>;
const isAdmin = (role: string) => role === "admin" || role === "owner";
async function requireActor(tx: ChannelTransaction, scope: MutationScope) {
const actor = await tx.loadActor(scope.workspaceId, scope.actorUserId);
if (!actor) throw new ChannelPersistenceError("NOT_FOUND");
return actor;
}
async function lockMutable(
tx: ChannelTransaction,
scope: MutationScope,
channelId: string,
) {
const [actor, channel] = await Promise.all([
requireActor(tx, scope),
tx.lockChannel(scope.workspaceId, channelId),
]);
if (!channel) throw new ChannelPersistenceError("NOT_FOUND");
if (channel.archivedAt) throw new ChannelPersistenceError("READ_ONLY");
return { actor, channel };
}
async function requirePrivateManager(
tx: ChannelTransaction,
scope: MutationScope,
channelId: string,
) {
const facts = await lockMutable(tx, scope, channelId);
if (
facts.channel.kind !== "private" ||
!(await tx.isChannelMember(
scope.workspaceId,
channelId,
scope.actorUserId,
)) ||
(facts.channel.ownerUserId !== scope.actorUserId &&
!isAdmin(facts.actor.role))
)
throw new ChannelPersistenceError("NOT_FOUND");
return facts.channel;
}
function parseEvent(event: ChatChannelLifecycleEventV1) {
return chatChannelLifecycleEventV1Schema.parse(event);
}
async function withReceipt(
tx: ChannelTransaction,
scope: MutationScope,
operation: ChatLifecycleReceiptResult["operation"],
intent: unknown,
execute: () => Promise<ChatLifecycleReceiptResult>,
): Promise<ChatLifecycleReceiptResult> {
const fingerprint = createHash("sha256")
.update(JSON.stringify(intent))
.digest("hex");
await tx.lockMutation(scope);
const receipt = await tx.loadReceipt(scope);
if (receipt) {
if (receipt.operation !== operation || receipt.fingerprint !== fingerprint)
throw new ChannelPersistenceError("CONFLICT");
const result = chatLifecycleReceiptResultSchema.parse(receipt.result);
const channelId =
"channel" in result ? result.channel.id : result.member.channelId;
const channel = await tx.lockChannel(scope.workspaceId, channelId);
if (
!channel ||
("channel" in result &&
result.channel.workspaceId !== scope.workspaceId) ||
(channel.kind !== "public" &&
!(await tx.isChannelMember(
scope.workspaceId,
channelId,
scope.actorUserId,
)))
)
throw new ChannelPersistenceError("NOT_FOUND");
return result;
}
const result = chatLifecycleReceiptResultSchema.parse(await execute());
if (result.operation !== operation)
throw new ChannelPersistenceError("CONFLICT");
await tx.saveReceipt(scope, operation, fingerprint, result);
return result;
}
function channelResult(channel: ChannelRecord) {
return { ...channel, archivedAt: channel.archivedAt?.toISOString() ?? null };
}
function resultChannel(result: ChatLifecycleReceiptResult): ChannelRecord {
if (!("channel" in result)) throw new ChannelPersistenceError("CONFLICT");
return {
...result.channel,
archivedAt: result.channel.archivedAt
? new Date(result.channel.archivedAt)
: null,
};
}
function eventBase(scope: MutationScope, channelId: string) {
return {
eventVersion: 1 as const,
workspaceId: scope.workspaceId,
channelId,
actorUserId: scope.actorUserId,
clientMutationId: scope.clientMutationId,
};
}
export const listVisibleChannels = (
run: ChannelRunner,
scope: Omit<MutationScope, "clientMutationId">,
) =>
run(async (tx) => {
if (!(await tx.loadActor(scope.workspaceId, scope.actorUserId)))
throw new ChannelPersistenceError("NOT_FOUND");
return tx.listVisible(scope.workspaceId, scope.actorUserId);
});
export const createChannel = (
run: ChannelRunner,
scope: MutationScope & { kind: "public" | "private"; name: string },
) =>
run(async (tx) => {
await requireActor(tx, scope);
const name = chatChannelNameSchema.parse(scope.name);
const result = await withReceipt(
tx,
scope,
"create_channel",
{ kind: scope.kind, name },
async () => {
const channel = await tx.insertChannel({
workspaceId: scope.workspaceId,
kind: scope.kind,
name,
ownerUserId: scope.actorUserId,
directKey: null,
});
if (scope.kind === "private")
await tx.addMember(scope.workspaceId, channel.id, scope.actorUserId);
await tx.emit(
parseEvent({
...eventBase(scope, channel.id),
eventType: "chat.channel.created.v1",
kind: scope.kind,
}),
);
if (scope.kind === "private")
await tx.emit(
parseEvent({
...eventBase(scope, channel.id),
eventType: "chat.channel.member.added.v1",
subjectUserId: scope.actorUserId,
}),
);
return { operation: "create_channel", channel: channelResult(channel) };
},
);
return resultChannel(result);
});
export const renameChannel = (
run: ChannelRunner,
scope: MutationScope & { channelId: string; name: string },
) =>
run(async (tx) => {
await requireActor(tx, scope);
const name = chatChannelNameSchema.parse(scope.name);
const result = await withReceipt(
tx,
scope,
"rename_channel",
{ channelId: scope.channelId, name },
async () => {
const { actor, channel } = await lockMutable(
tx,
scope,
scope.channelId,
);
if (
channel.kind === "direct" ||
(channel.ownerUserId !== scope.actorUserId && !isAdmin(actor.role)) ||
(channel.kind === "private" &&
!(await tx.isChannelMember(
scope.workspaceId,
channel.id,
scope.actorUserId,
)))
)
throw new ChannelPersistenceError("NOT_FOUND");
let updated = channel;
if (channel.name !== name) {
const changed = await tx.renameActiveChannel(
scope.workspaceId,
channel.id,
name,
);
if (!changed) throw new ChannelPersistenceError("READ_ONLY");
updated = changed;
await tx.emit(
parseEvent({
...eventBase(scope, channel.id),
eventType: "chat.channel.renamed.v1",
name,
}),
);
}
return { operation: "rename_channel", channel: channelResult(updated) };
},
);
return resultChannel(result);
});
export const archiveChannel = (
run: ChannelRunner,
scope: MutationScope & { channelId: string },
) =>
run(async (tx) => {
await requireActor(tx, scope);
const result = await withReceipt(
tx,
scope,
"archive_channel",
{ channelId: scope.channelId },
async () => {
const { actor, channel } = await lockMutable(
tx,
scope,
scope.channelId,
);
if (
channel.kind === "direct" ||
(channel.ownerUserId !== scope.actorUserId && !isAdmin(actor.role)) ||
(channel.kind === "private" &&
!(await tx.isChannelMember(
scope.workspaceId,
channel.id,
scope.actorUserId,
)))
)
throw new ChannelPersistenceError("NOT_FOUND");
const updated = await tx.archiveActiveChannel(
scope.workspaceId,
channel.id,
);
if (!updated) throw new ChannelPersistenceError("READ_ONLY");
await tx.emit(
parseEvent({
...eventBase(scope, channel.id),
eventType: "chat.channel.archived.v1",
}),
);
return {
operation: "archive_channel",
channel: channelResult(updated),
};
},
);
return resultChannel(result);
});
async function memberMutation(
tx: ChannelTransaction,
scope: MutationScope & { channelId: string; userId: string },
action: "add_private_member" | "remove_private_member",
) {
await requireActor(tx, scope);
return withReceipt(
tx,
scope,
action,
{ channelId: scope.channelId, userId: scope.userId },
async () => {
const channel = await requirePrivateManager(tx, scope, scope.channelId);
if (
action === "add_private_member" &&
!(await tx.areWorkspaceMembers(scope.workspaceId, [scope.userId]))
)
throw new ChannelPersistenceError("NOT_FOUND");
if (
action === "remove_private_member" &&
channel.ownerUserId === scope.userId
)
throw new ChannelPersistenceError("INVALID");
const changed =
action === "add_private_member"
? await tx.addMember(scope.workspaceId, channel.id, scope.userId)
: await tx.removeMember(scope.workspaceId, channel.id, scope.userId);
if (changed)
await tx.emit(
parseEvent({
...eventBase(scope, channel.id),
eventType:
action === "add_private_member"
? "chat.channel.member.added.v1"
: "chat.channel.member.removed.v1",
subjectUserId: scope.userId,
}),
);
return {
operation: action,
member: { channelId: channel.id, userId: scope.userId, changed },
};
},
);
}
export const addPrivateMember = (
run: ChannelRunner,
scope: MutationScope & { channelId: string; userId: string },
) => run((tx) => memberMutation(tx, scope, "add_private_member"));
export const removePrivateMember = (
run: ChannelRunner,
scope: MutationScope & { channelId: string; userId: string },
) => run((tx) => memberMutation(tx, scope, "remove_private_member"));
export const createDirectChannel = (
run: ChannelRunner,
scope: MutationScope & { participantUserIds: string[] },
) =>
run(async (tx) => {
await requireActor(tx, scope);
const ids = [...new Set(scope.participantUserIds)].sort();
const result = await withReceipt(
tx,
scope,
"create_direct",
{ participantUserIds: ids },
async () => {
if (
ids.length < 2 ||
!ids.includes(scope.actorUserId) ||
!(await tx.areWorkspaceMembers(scope.workspaceId, ids))
)
throw new ChannelPersistenceError("INVALID");
const key = createHash("md5").update(ids.join(":")).digest("hex");
await tx.lockDirect(scope.workspaceId, key);
let channel = await tx.findDirect(scope.workspaceId, key);
if (channel) {
const existing = (
await tx.listMemberIds(scope.workspaceId, channel.id)
).sort();
if (
existing.length !== ids.length ||
existing.some((id, i) => id !== ids[i])
)
throw new ChannelPersistenceError("CONFLICT");
return {
operation: "create_direct",
channel: channelResult(channel),
};
}
channel = await tx.insertDirect({
workspaceId: scope.workspaceId,
kind: "direct",
name: null,
ownerUserId: scope.actorUserId,
directKey: key,
});
if (!channel) throw new ChannelPersistenceError("CONFLICT");
await tx.addInitialDirectMembers(scope.workspaceId, channel.id, ids);
channel = await tx.finalizeDirect(scope.workspaceId, channel.id, key);
await tx.emit(
parseEvent({
...eventBase(scope, channel.id),
eventType: "chat.channel.created.v1",
kind: "direct",
}),
);
for (const id of ids)
await tx.emit(
parseEvent({
...eventBase(scope, channel.id),
eventType: "chat.channel.member.added.v1",
subjectUserId: id,
}),
);
return { operation: "create_direct", channel: channelResult(channel) };
},
);
return resultChannel(result);
});

View file

@ -1,53 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { PgDialect } from "drizzle-orm/pg-core";
import { chatOutbox } from "../schema";
import { createMessageMutationRunner } from "./message-drizzle-adapter";
type Database = NonNullable<Parameters<typeof createMessageMutationRunner>[0]>;
type Tx = Parameters<Parameters<Database["transaction"]>[0]>[0];
describe("created outbox redaction", () => {
it("binds plain JSON metadata and scopes redaction to the workspace/channel/message/create event", async () => {
const where = vi.fn(async () => undefined);
const set = vi.fn(() => ({ where }));
const update = vi.fn(() => ({ set }));
const tx = { update } as unknown as Tx;
const database = {
transaction: async (run: (tx: Tx) => Promise<unknown>) => run(tx),
} as unknown as Database;
const scope = {
workspaceId: "10000000-0000-4000-8000-000000000001",
channelId: "20000000-0000-4000-8000-000000000001",
messageId: "30000000-0000-4000-8000-000000000001",
};
await createMessageMutationRunner(database).transaction((adapter) =>
adapter.redactCreatedOutbox(scope),
);
expect(update).toHaveBeenCalledWith(chatOutbox);
expect(set).toHaveBeenCalledWith({
payload: {
eventType: "chat.message.created.v1",
eventVersion: 1,
...scope,
},
});
const condition = (
where.mock.calls as unknown as [[Parameters<PgDialect["sqlToQuery"]>[0]]]
)[0][0];
const query = new PgDialect().sqlToQuery(condition);
expect(query.params).toEqual([
scope.workspaceId,
scope.channelId,
scope.messageId,
"chat.message.created.v1",
]);
for (const column of [
"workspace_id",
"channel_id",
"message_id",
"event_type",
]) {
expect(query.sql).toContain(`"${column}"`);
}
});
});

View file

@ -1,253 +0,0 @@
import { chatMessageMutationEventV1Schema } from "@tasks/shared";
import { createHash } from "node:crypto";
import { and, eq, sql } from "drizzle-orm";
import { db as defaultDb } from "../client";
import {
chatChannels,
chatMessageMutations,
chatMessageMentions,
chatMessages,
chatMutationReceipts,
chatOutbox,
} from "../schema";
import { transactionAdapter } from "./drizzle-adapter";
import {
mutateChatMessage,
type MessageMutationRunner,
type MessageMutationTransaction,
} from "./mutate-message";
// Reuse the send adapter's tenant-scoped membership lookup; mutations use the same channel policy.
type Db = typeof defaultDb;
type Tx = Parameters<Parameters<Db["transaction"]>[0]>[0];
function adapter(tx: Tx): MessageMutationTransaction {
return {
async claim(scope) {
const lockKey = `${scope.workspaceId}:${scope.actorUserId}:${scope.clientMutationId}`;
await tx.execute(
sql`select pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))`,
);
const [receipt] = await tx
.select()
.from(chatMutationReceipts)
.where(
and(
eq(chatMutationReceipts.workspaceId, scope.workspaceId),
eq(chatMutationReceipts.actorUserId, scope.actorUserId),
eq(chatMutationReceipts.clientMutationId, scope.clientMutationId),
),
)
.limit(1);
return receipt ?? null;
},
async facts(scope) {
// This is deliberately the same member/channel lookup as send, not a role-based shortcut.
return transactionAdapter(tx).loadSendFacts(scope);
},
async lockMessage(scope) {
const [message] = await tx
.select({
id: chatMessages.id,
workspaceId: chatMessages.workspaceId,
channelId: chatMessages.channelId,
authorUserId: chatMessages.authorUserId,
status: chatMessages.status,
revision: chatMessages.revision,
})
.from(chatMessages)
.where(
and(
eq(chatMessages.workspaceId, scope.workspaceId),
eq(chatMessages.channelId, scope.channelId),
eq(chatMessages.id, scope.messageId),
),
)
.for("update")
.limit(1);
return message ?? null;
},
async pinLegacySendIntent(scope) {
const [message] = await tx
.select({
body: chatMessages.body,
authorUserId: chatMessages.authorUserId,
parentMessageId: chatMessages.parentMessageId,
sendIntentFingerprint: chatMessages.sendIntentFingerprint,
})
.from(chatMessages)
.where(
and(
eq(chatMessages.workspaceId, scope.workspaceId),
eq(chatMessages.channelId, scope.channelId),
eq(chatMessages.id, scope.messageId),
),
)
.limit(1);
if (!message || message.sendIntentFingerprint) return;
const mentions = await tx
.select({ userId: chatMessageMentions.userId })
.from(chatMessageMentions)
.where(
and(
eq(chatMessageMentions.workspaceId, scope.workspaceId),
eq(chatMessageMentions.messageId, scope.messageId),
),
);
const fingerprint = createHash("sha256")
.update(
JSON.stringify([
scope.workspaceId,
scope.channelId,
message.authorUserId,
message.body,
message.parentMessageId,
mentions.map((row) => row.userId).sort(),
]),
)
.digest("hex");
await tx
.update(chatMessages)
.set({ sendIntentFingerprint: fingerprint })
.where(
and(
eq(chatMessages.workspaceId, scope.workspaceId),
eq(chatMessages.channelId, scope.channelId),
eq(chatMessages.id, scope.messageId),
),
);
},
async apply(scope) {
const [updated] = await tx
.update(chatMessages)
.set(
scope.type === "edit_message"
? {
body: scope.body!,
status: "edited",
editedAt: new Date(),
revision: scope.revision,
}
: {
body: "",
status: "deleted",
deletedAt: new Date(),
revision: scope.revision,
},
)
.where(
and(
eq(chatMessages.workspaceId, scope.workspaceId),
eq(chatMessages.channelId, scope.channelId),
eq(chatMessages.id, scope.messageId),
eq(chatMessages.revision, scope.revision - 1),
),
)
.returning({ id: chatMessages.id });
if (!updated) throw new Error("Concurrent message update");
const [channel] = await tx
.update(chatChannels)
.set({
nextMutationSequence: sql`${chatChannels.nextMutationSequence} + 1`,
})
.where(
and(
eq(chatChannels.workspaceId, scope.workspaceId),
eq(chatChannels.id, scope.channelId),
),
)
.returning({ nextMutationSequence: chatChannels.nextMutationSequence });
if (!channel) throw new Error("Channel disappeared during mutation");
const status = scope.type === "edit_message" ? "edited" : "deleted";
await tx.insert(chatMessageMutations).values({
workspaceId: scope.workspaceId,
channelId: scope.channelId,
messageId: scope.messageId,
mutationSequence: channel.nextMutationSequence - 1n,
revision: scope.revision,
kind: status,
});
return { messageId: updated.id, revision: scope.revision, status };
},
async redactCreatedOutbox(scope) {
// Historical create payloads contain the original body. Strip them before publishing
// a mutation so a delayed worker cannot replay stale content after edit/delete.
await tx
.update(chatOutbox)
.set({
// Plain JSON — avoid jsonb_build_object() params (Postgres: could not determine data type of parameter $1).
payload: {
eventType: "chat.message.created.v1",
eventVersion: 1,
workspaceId: scope.workspaceId,
channelId: scope.channelId,
messageId: scope.messageId,
},
})
.where(
and(
eq(chatOutbox.workspaceId, scope.workspaceId),
eq(chatOutbox.channelId, scope.channelId),
eq(chatOutbox.messageId, scope.messageId),
eq(chatOutbox.eventType, "chat.message.created.v1"),
),
);
},
async insertOutbox(scope) {
await tx.insert(chatOutbox).values({
workspaceId: scope.workspaceId,
channelId: scope.channelId,
messageId: scope.messageId,
eventVersion: 1,
eventType: scope.eventType,
dedupeKey: `mutation:${scope.actorUserId}:${scope.clientMutationId}`,
payload: chatMessageMutationEventV1Schema.parse({
eventType: scope.eventType,
eventVersion: 1,
workspaceId: scope.workspaceId,
channelId: scope.channelId,
messageId: scope.messageId,
revision: scope.result.revision,
status: scope.result.status,
}),
});
},
async saveReceipt(scope) {
await tx.insert(chatMutationReceipts).values({ ...scope });
},
};
}
function retryable(error: unknown): boolean {
if (!error || typeof error !== "object") return false;
const e = error as { code?: unknown; cause?: unknown };
return (
e.code === "40001" ||
e.code === "40P01" ||
(e.cause !== undefined && retryable(e.cause))
);
}
export function createMessageMutationRunner(
database: Db = defaultDb,
): MessageMutationRunner {
return {
async transaction(run) {
for (let attempt = 0; attempt < 3; attempt++) {
try {
return await database.transaction((tx) => run(adapter(tx)), {
isolationLevel: "repeatable read",
});
} catch (error) {
if (attempt === 2 || !retryable(error)) throw error;
}
}
throw new Error("Unreachable retry state");
},
};
}
export async function mutateChatMessageWithDb(
input: Parameters<typeof mutateChatMessage>[1],
database: Db = defaultDb,
) {
return mutateChatMessage(createMessageMutationRunner(database), input);
}

View file

@ -1,181 +0,0 @@
import { describe, expect, it } from "vitest";
import {
mutateChatMessage,
type MessageMutationTransaction,
} from "./mutate-message";
const workspaceId = "11111111-1111-4111-8111-111111111111";
const channelId = "22222222-2222-4222-8222-222222222222";
const actorUserId = "33333333-3333-4333-8333-333333333333";
const messageId = "44444444-4444-4444-8444-444444444444";
const clientMutationId = "55555555-5555-4555-8555-555555555555";
const command = (type: "edit_message" | "delete_message", extra = {}) => ({
type,
workspaceId,
channelId,
context: { type: "workspace", workspaceId },
messageId,
expectedRevision: 1,
clientMutationId,
...(type === "edit_message" ? { body: "updated" } : {}),
...extra,
});
function setup(
options: {
kind?: "public" | "private";
member?: boolean;
archived?: boolean;
author?: string;
receipt?: boolean;
revision?: number;
} = {},
) {
const calls: string[] = [];
const tx: MessageMutationTransaction = {
claim: async () => {
calls.push("claim");
return options.receipt
? {
operation: "edit_message",
fingerprint: "mismatch",
result: { revision: 2 },
}
: null;
},
facts: async () => {
calls.push("facts");
return {
actor: {
userId: actorUserId,
workspaceId,
activeWorkspaceMember: true,
role: "admin" as const,
},
channel: {
id: channelId,
workspaceId,
kind: options.kind ?? "public",
activeMember: options.member ?? false,
archived: options.archived ?? false,
ownerUserId: actorUserId,
},
};
},
lockMessage: async () => {
calls.push("lock");
return {
id: messageId,
workspaceId,
channelId,
authorUserId: options.author ?? actorUserId,
status: "sent" as const,
revision: options.revision ?? 1,
};
},
pinLegacySendIntent: async () => {
calls.push("pin");
},
apply: async () => {
calls.push("apply");
return { messageId, revision: 2, status: "edited" as const };
},
redactCreatedOutbox: async () => {
calls.push("redact");
},
insertOutbox: async () => {
calls.push("outbox");
},
saveReceipt: async () => {
calls.push("receipt");
},
};
return {
calls,
runner: {
transaction: async <T>(
fn: (tx: MessageMutationTransaction) => Promise<T>,
) => fn(tx),
},
};
}
describe("message mutations", () => {
it("rejects a private-channel nonmember even if admin", async () => {
const { runner, calls } = setup({ kind: "private" });
await expect(
mutateChatMessage(runner, {
actorUserId,
command: command("edit_message"),
}),
).rejects.toMatchObject({ code: "CHAT_FORBIDDEN" });
expect(calls).not.toContain("apply");
});
it("does not grant admin moderation over another author's message", async () => {
const { runner, calls } = setup({
author: "66666666-6666-4666-8666-666666666666",
});
await expect(
mutateChatMessage(runner, {
actorUserId,
command: command("delete_message"),
}),
).rejects.toMatchObject({ code: "CHAT_FORBIDDEN" });
expect(calls).not.toContain("apply");
});
it("rejects archived writes", async () => {
const { runner } = setup({ archived: true });
await expect(
mutateChatMessage(runner, {
actorUserId,
command: command("delete_message"),
}),
).rejects.toMatchObject({ code: "CHAT_FORBIDDEN" });
});
it("requires matching revision before writing", async () => {
const { runner, calls } = setup({ revision: 3 });
await expect(
mutateChatMessage(runner, {
actorUserId,
command: command("edit_message"),
}),
).rejects.toMatchObject({ code: "CHAT_REVISION_CONFLICT" });
expect(calls).not.toContain("apply");
});
it("rejects reused mutation IDs before writing", async () => {
const { runner, calls } = setup({ receipt: true });
await expect(
mutateChatMessage(runner, {
actorUserId,
command: command("edit_message"),
}),
).rejects.toMatchObject({ code: "CHAT_IDEMPOTENCY_CONFLICT" });
expect(calls).not.toContain("apply");
});
it("redacts queued created events in the same transaction", async () => {
const { runner, calls } = setup();
await mutateChatMessage(runner, {
actorUserId,
command: command("edit_message"),
});
expect(calls).toEqual([
"claim",
"facts",
"lock",
"pin",
"apply",
"redact",
"outbox",
"receipt",
]);
});
it("rejects object-scoped commands at validation", async () => {
const { runner } = setup();
await expect(
mutateChatMessage(runner, {
actorUserId,
command: command("edit_message", {
context: { type: "object", objectId: messageId },
}),
}),
).rejects.toThrow();
});
});

View file

@ -1,249 +0,0 @@
import { createHash } from "node:crypto";
import {
canPerformChatAction,
chatCommandSchema,
type ChatCommand,
type ChatPolicyInput,
} from "@tasks/shared";
import { z } from "zod";
const commandSchema = chatCommandSchema.refine(
(
command,
): command is Extract<
ChatCommand,
{ type: "edit_message" | "delete_message" }
> => command.type === "edit_message" || command.type === "delete_message",
"Expected message mutation",
);
type Command = z.infer<typeof commandSchema>;
type Facts = Pick<
Extract<ChatPolicyInput, { action: "send" }>,
"actor" | "channel"
>;
export type MutationResult = {
messageId: string;
revision: number;
status: "edited" | "deleted";
};
export type MessageMutationTransaction = {
claim(scope: {
workspaceId: string;
actorUserId: string;
clientMutationId: string;
}): Promise<{
operation: string;
fingerprint: string;
result: unknown;
} | null>;
facts(scope: {
workspaceId: string;
channelId: string;
actorUserId: string;
}): Promise<Facts | null>;
lockMessage(scope: {
workspaceId: string;
channelId: string;
messageId: string;
}): Promise<{
id: string;
workspaceId: string;
channelId: string;
authorUserId: string | null;
status: string;
revision: number;
} | null>;
pinLegacySendIntent(scope: {
workspaceId: string;
channelId: string;
messageId: string;
}): Promise<void>;
apply(scope: {
workspaceId: string;
channelId: string;
messageId: string;
revision: number;
type: Command["type"];
body?: string;
}): Promise<MutationResult>;
redactCreatedOutbox(scope: {
workspaceId: string;
channelId: string;
messageId: string;
}): Promise<void>;
insertOutbox(scope: {
workspaceId: string;
channelId: string;
messageId: string;
actorUserId: string;
result: MutationResult;
eventType: "chat.message.edited.v1" | "chat.message.deleted.v1";
clientMutationId: string;
}): Promise<void>;
saveReceipt(scope: {
workspaceId: string;
actorUserId: string;
clientMutationId: string;
operation: string;
fingerprint: string;
result: MutationResult;
}): Promise<void>;
};
export type MessageMutationRunner = {
transaction<T>(
callback: (tx: MessageMutationTransaction) => Promise<T>,
): Promise<T>;
};
export class ChatMutationError extends Error {
constructor(
readonly code:
| "CHAT_FORBIDDEN"
| "CHAT_NOT_FOUND"
| "CHAT_REVISION_CONFLICT"
| "CHAT_IDEMPOTENCY_CONFLICT",
) {
super(code);
this.name = "ChatMutationError";
}
}
const resultSchema = z
.object({
messageId: z.string().uuid(),
revision: z.number().int().positive(),
status: z.enum(["edited", "deleted"]),
})
.strict();
export async function mutateChatMessage(
runner: MessageMutationRunner,
input: { actorUserId: string; command: unknown },
): Promise<{ changed: boolean; result: MutationResult }> {
const actorUserId = z.string().uuid().parse(input.actorUserId);
const command = commandSchema.parse(input.command);
const fingerprint = createHash("sha256")
.update(
JSON.stringify([
command.type,
command.workspaceId,
command.channelId,
command.messageId,
command.expectedRevision,
command.type === "edit_message" ? command.body : null,
]),
)
.digest("hex");
return runner.transaction(async (tx) => {
const scope = {
workspaceId: command.workspaceId,
channelId: command.channelId,
actorUserId,
};
const receipt = await tx.claim({
workspaceId: command.workspaceId,
actorUserId,
clientMutationId: command.clientMutationId,
});
const facts = await tx.facts(scope);
// Receipts remain readable after archive, but new writes cannot target an archived channel.
if (
!facts ||
!canPerformChatAction({
action: "read",
...facts,
context: command.context,
}) ||
(!receipt && facts.channel.archived)
)
throw new ChatMutationError("CHAT_FORBIDDEN");
if (receipt) {
if (
receipt.operation !== command.type ||
receipt.fingerprint !== fingerprint
)
throw new ChatMutationError("CHAT_IDEMPOTENCY_CONFLICT");
const result = resultSchema.parse(receipt.result);
const message = await tx.lockMessage({
workspaceId: command.workspaceId,
channelId: command.channelId,
messageId: command.messageId,
});
if (
!message ||
message.authorUserId !== actorUserId ||
result.messageId !== command.messageId ||
result.status !==
(command.type === "edit_message" ? "edited" : "deleted")
)
throw new ChatMutationError("CHAT_FORBIDDEN");
return { changed: false, result };
}
const message = await tx.lockMessage({
workspaceId: command.workspaceId,
channelId: command.channelId,
messageId: command.messageId,
});
if (
!message ||
message.workspaceId !== command.workspaceId ||
message.channelId !== command.channelId
)
throw new ChatMutationError("CHAT_NOT_FOUND");
if (
!message.authorUserId ||
!canPerformChatAction({
action: "edit_own",
...facts,
context: command.context,
message: {
id: message.id,
workspaceId: message.workspaceId,
channelId: message.channelId,
author: { type: "human", userId: message.authorUserId },
},
})
)
throw new ChatMutationError("CHAT_FORBIDDEN");
if (
message.revision !== command.expectedRevision ||
message.status === "deleted"
)
throw new ChatMutationError("CHAT_REVISION_CONFLICT");
await tx.pinLegacySendIntent({
workspaceId: command.workspaceId,
channelId: command.channelId,
messageId: command.messageId,
});
const result = await tx.apply({
workspaceId: command.workspaceId,
channelId: command.channelId,
messageId: command.messageId,
revision: command.expectedRevision + 1,
type: command.type,
...(command.type === "edit_message" ? { body: command.body } : {}),
});
await tx.redactCreatedOutbox({
workspaceId: command.workspaceId,
channelId: command.channelId,
messageId: command.messageId,
});
await tx.insertOutbox({
...scope,
messageId: command.messageId,
clientMutationId: command.clientMutationId,
result,
eventType:
command.type === "edit_message"
? "chat.message.edited.v1"
: "chat.message.deleted.v1",
});
await tx.saveReceipt({
workspaceId: command.workspaceId,
actorUserId,
clientMutationId: command.clientMutationId,
operation: command.type,
fingerprint,
result,
});
return { changed: true, result };
});
}

View file

@ -1,405 +0,0 @@
import { afterAll, describe, expect, it } from "vitest";
import { drizzle } from "drizzle-orm/postgres-js";
import { and, eq, sql } from "drizzle-orm";
import postgres from "postgres";
import * as schema from "../schema";
import {
chatChannelMembers,
chatChannels,
chatMessageMutations,
chatMessageMentions,
chatMessages,
chatOutbox,
chatReadCursors,
users,
workspaceMembers,
workspaces,
} from "../schema";
import {
chatCatchUp,
chatMutationCatchUp,
chatHistory,
chatMarkChannelRead,
chatThreadHistory,
chatUnreadSummary,
ChatReadError,
} from "./read-history";
// Run explicitly against a disposable PostgreSQL instance with migrations through 0023.
const url = process.env.PG_URL;
const client = url ? postgres(url, { max: 12 }) : null;
const db = client ? drizzle(client, { schema }) : null;
afterAll(async () => {
await client?.end();
});
describe.skipIf(!url)("chat read APIs on PostgreSQL (0023)", () => {
it("recovers mutation pages with current safe projections and denies private/direct outsiders", async () => {
if (!db) throw new Error("PG_URL is required");
const [owner, reader] = await db.insert(users).values([
{ email: `mutation-owner-${crypto.randomUUID()}@example.test` },
{ email: `mutation-reader-${crypto.randomUUID()}@example.test` },
]).returning();
const [workspace] = await db.insert(workspaces).values({
slug: `mutation-${crypto.randomUUID()}`, name: "Mutation PG", ownerUserId: owner.id,
}).returning();
await db.insert(workspaceMembers).values([owner, reader].map((user) => ({
workspaceId: workspace.id, userId: user.id, role: "member" as const,
})));
const [channel, hidden, direct] = await db.insert(chatChannels).values([
{ workspaceId: workspace.id, ownerUserId: owner.id, kind: "private", name: "private", nextSequence: 3n, nextMutationSequence: 4n },
{ workspaceId: workspace.id, ownerUserId: owner.id, kind: "private", name: "hidden" },
{ workspaceId: workspace.id, ownerUserId: owner.id, kind: "direct", directKey: `${owner.id}:${reader.id}` },
]).returning();
await db.insert(chatChannelMembers).values([
{ workspaceId: workspace.id, channelId: channel.id, userId: owner.id },
{ workspaceId: workspace.id, channelId: hidden.id, userId: owner.id },
{ workspaceId: workspace.id, channelId: direct.id, userId: owner.id },
]);
const [edited, deleted] = await db.insert(chatMessages).values([
{ workspaceId: workspace.id, channelId: channel.id, sequence: 1n, authorType: "human", authorUserId: owner.id, body: "latest", status: "edited", revision: 4, clientIdempotencyKey: crypto.randomUUID() },
{ workspaceId: workspace.id, channelId: channel.id, sequence: 2n, authorType: "human", authorUserId: owner.id, body: "stored secret", status: "deleted", revision: 3, clientIdempotencyKey: crypto.randomUUID() },
]).returning();
await db.insert(chatMessageMutations).values([
{ workspaceId: workspace.id, channelId: channel.id, messageId: edited.id, mutationSequence: 1n, revision: 2, kind: "edited" },
{ workspaceId: workspace.id, channelId: channel.id, messageId: edited.id, mutationSequence: 2n, revision: 4, kind: "edited" },
{ workspaceId: workspace.id, channelId: channel.id, messageId: deleted.id, mutationSequence: 3n, revision: 3, kind: "deleted" },
]);
const scope = { workspaceId: workspace.id, channelId: channel.id, actorUserId: owner.id };
const first = await chatMutationCatchUp(db, { ...scope, after: "0", limit: 1 });
expect(first).toMatchObject({ next: "1", through: "3", hasMore: true,
mutations: [{ messageId: edited.id, mutationSequence: "1", revision: 2 }],
messages: [{ id: edited.id, body: "latest", revision: 4 }],
});
expect(JSON.stringify(first)).not.toContain("clientIdempotencyKey");
const second = await chatMutationCatchUp(db, { ...scope, after: first.next, through: first.through, limit: 2 });
expect(second).toMatchObject({ next: "3", through: "3", hasMore: false,
mutations: [{ messageId: edited.id }, { messageId: deleted.id }],
messages: [{ id: edited.id, body: "latest" }, { id: deleted.id, body: "", status: "deleted" }],
});
expect(JSON.stringify(second)).not.toContain("stored secret");
await expect(chatMutationCatchUp(db, { ...scope, actorUserId: reader.id, after: "0", limit: 1 })).rejects.toBeInstanceOf(ChatReadError);
for (const channelId of [hidden.id, direct.id]) {
await expect(chatMutationCatchUp(db, { ...scope, channelId, actorUserId: reader.id, after: "0", limit: 1 })).rejects.toBeInstanceOf(ChatReadError);
}
await expect(chatMutationCatchUp(db, { ...scope, channelId: hidden.id, after: "0", through: "3", limit: 1 })).rejects.toBeInstanceOf(ChatReadError);
});
it("paginates roots and replies, catches up, counts unread, and protects archived/private channels", async () => {
if (!db) throw new Error("PG_URL is required");
const [owner, reader, outsider] = await db
.insert(users)
.values([
{ email: `chat-pg-owner-${crypto.randomUUID()}@example.test` },
{ email: `chat-pg-reader-${crypto.randomUUID()}@example.test` },
{ email: `chat-pg-outsider-${crypto.randomUUID()}@example.test` },
])
.returning();
const [workspace] = await db
.insert(workspaces)
.values({
slug: `chat-pg-${crypto.randomUUID()}`,
name: "Chat PG",
ownerUserId: owner.id,
})
.returning();
await db.insert(workspaceMembers).values(
[owner, reader, outsider].map((user) => ({
workspaceId: workspace.id,
userId: user.id,
})),
);
const [publicChannel, archived, hidden] = await db
.insert(chatChannels)
.values([
{
workspaceId: workspace.id,
ownerUserId: owner.id,
kind: "public",
name: "general",
nextSequence: 5n,
},
{
workspaceId: workspace.id,
ownerUserId: owner.id,
kind: "private",
name: "old",
archivedAt: new Date(),
nextSequence: 2n,
},
{
workspaceId: workspace.id,
ownerUserId: owner.id,
kind: "private",
name: "secret",
nextSequence: 2n,
},
])
.returning();
await db.insert(chatChannelMembers).values([
{ workspaceId: workspace.id, channelId: archived.id, userId: owner.id },
{ workspaceId: workspace.id, channelId: archived.id, userId: reader.id },
{ workspaceId: workspace.id, channelId: hidden.id, userId: owner.id },
]);
const scope = {
workspaceId: workspace.id,
actorUserId: reader.id,
channelId: publicChannel.id,
};
const [root, reply, tombstone, secondRoot] = await db
.insert(chatMessages)
.values([
{
workspaceId: workspace.id,
channelId: publicChannel.id,
sequence: 1n,
authorType: "human",
authorUserId: owner.id,
body: "root",
clientIdempotencyKey: crypto.randomUUID(),
},
{
workspaceId: workspace.id,
channelId: publicChannel.id,
sequence: 2n,
authorType: "human",
authorUserId: owner.id,
body: "reply",
clientIdempotencyKey: crypto.randomUUID(),
},
{
workspaceId: workspace.id,
channelId: publicChannel.id,
sequence: 3n,
authorType: "human",
authorUserId: owner.id,
body: "",
status: "deleted",
clientIdempotencyKey: crypto.randomUUID(),
},
{
workspaceId: workspace.id,
channelId: publicChannel.id,
sequence: 4n,
authorType: "human",
authorUserId: owner.id,
body: "new root",
clientIdempotencyKey: crypto.randomUUID(),
},
])
.returning();
await db
.update(chatMessages)
.set({ parentMessageId: root.id })
.where(
and(
eq(chatMessages.workspaceId, workspace.id),
eq(chatMessages.id, reply.id),
),
);
await db.insert(chatMessageMentions).values({
workspaceId: workspace.id,
messageId: secondRoot.id,
userId: reader.id,
});
await db.insert(chatMessages).values([
{
workspaceId: workspace.id,
channelId: archived.id,
sequence: 1n,
authorType: "human",
authorUserId: owner.id,
body: "archived",
clientIdempotencyKey: crypto.randomUUID(),
},
{
workspaceId: workspace.id,
channelId: hidden.id,
sequence: 1n,
authorType: "human",
authorUserId: owner.id,
body: "secret",
clientIdempotencyKey: crypto.randomUUID(),
},
]);
try {
const page = await chatHistory(db, { ...scope, limit: 1 });
expect(page.messages.map((m) => m.sequence)).toEqual(["4"]);
expect(page.next).toBe("4");
expect(page.hasMore).toBe(true);
const older = await chatHistory(db, { ...scope, before: "4", limit: 2 });
expect(older.messages.map((m) => m.sequence)).toEqual(["3", "1"]);
expect(older.messages[1]).toMatchObject({
replyCount: 1,
latestReplySequence: "2",
});
const thread = await chatThreadHistory(db, {
...scope,
rootMessageId: root.id,
limit: 1,
});
expect(thread.root).toMatchObject({ sequence: "1", replyCount: 1 });
expect(thread.replies.map((m) => m.sequence)).toEqual(["2"]);
const catchUp = await chatCatchUp(db, { ...scope, after: "1", limit: 2 });
expect(catchUp.messages.map((m) => [m.sequence, m.status])).toEqual([
["2", "sent"],
["3", "deleted"],
]);
expect(catchUp).toMatchObject({ next: "3", hasMore: true, through: "4" });
const readCursorBefore = await db
.select()
.from(chatReadCursors)
.where(eq(chatReadCursors.workspaceId, workspace.id));
const readOutboxBefore = await db
.select()
.from(chatOutbox)
.where(eq(chatOutbox.workspaceId, workspace.id));
const bounded = await chatCatchUp(db, {
...scope,
after: "1",
through: catchUp.through,
limit: 1,
});
expect(bounded).toMatchObject({ next: "2", through: "4", hasMore: true });
const tail = await chatCatchUp(db, {
...scope,
after: "3",
through: bounded.through,
limit: 5,
});
expect(tail).toMatchObject({ next: "4", through: "4", hasMore: false });
await expect(
chatCatchUp(db, { ...scope, after: "0", through: "5", limit: 2 }),
).rejects.toBeInstanceOf(ChatReadError);
await expect(
chatCatchUp(db, { ...scope, after: "5", limit: 2 }),
).rejects.toBeInstanceOf(ChatReadError);
expect(
(
await chatCatchUp(db, { ...scope, after: "3", limit: 2 })
).messages.map((m) => m.sequence),
).toEqual(["4"]);
expect(
await db
.select()
.from(chatReadCursors)
.where(eq(chatReadCursors.workspaceId, workspace.id)),
).toEqual(readCursorBefore);
expect(
await db
.select()
.from(chatOutbox)
.where(eq(chatOutbox.workspaceId, workspace.id)),
).toEqual(readOutboxBefore);
const summary = await chatUnreadSummary(db, {
workspaceId: workspace.id,
actorUserId: reader.id,
});
expect(summary.channels).toEqual(
expect.arrayContaining([
{
channelId: publicChannel.id,
archived: false,
readSequence: "0",
unreadCount: 4,
mentionCount: 1,
},
{
channelId: archived.id,
archived: true,
readSequence: "0",
unreadCount: 1,
mentionCount: 0,
},
]),
);
expect(summary.channels).toHaveLength(2);
expect(
(
await chatHistory(db, { ...scope, channelId: archived.id, limit: 10 })
).messages.map((m) => m.body),
).toEqual(["archived"]);
await expect(
chatHistory(db, { ...scope, channelId: hidden.id, limit: 10 }),
).rejects.toBeInstanceOf(ChatReadError);
await expect(
chatMarkChannelRead(db, { ...scope, sequence: "5" }),
).rejects.toBeInstanceOf(ChatReadError);
expect(
await chatMarkChannelRead(db, {
...scope,
channelId: archived.id,
sequence: "1",
}),
).toMatchObject({ sequence: "1", changed: true });
expect(
(
await chatUnreadSummary(db, {
workspaceId: workspace.id,
actorUserId: reader.id,
})
).channels.find((channel) => channel.channelId === archived.id),
).toMatchObject({ readSequence: "1", unreadCount: 0 });
const results = await Promise.allSettled(
["2", "4", "3"].map((sequence) =>
chatMarkChannelRead(db, { ...scope, sequence }),
),
);
const [initialCursor] = await db
.select()
.from(chatReadCursors)
.where(
and(
eq(chatReadCursors.workspaceId, workspace.id),
eq(chatReadCursors.channelId, publicChannel.id),
eq(chatReadCursors.userId, reader.id),
),
);
expect(initialCursor.sequence).toBeGreaterThanOrEqual(2n);
expect(initialCursor.sequence).toBeLessThanOrEqual(4n);
expect(
results
.filter((result) => result.status === "rejected")
.every(
(result) =>
result.status === "rejected" && result.reason.code === "40001",
),
).toBe(true);
// The bounded transaction retry cannot lower a concurrently advanced cursor.
const retry = await chatMarkChannelRead(db, { ...scope, sequence: "4" });
expect(retry.sequence).toBe("4");
expect(
await chatMarkChannelRead(db, { ...scope, sequence: "2" }),
).toEqual({ sequence: "4", changed: false });
const events = await db
.select()
.from(chatOutbox)
.where(
and(
eq(chatOutbox.workspaceId, workspace.id),
eq(chatOutbox.channelId, publicChannel.id),
eq(chatOutbox.eventType, "chat.read_state.changed.v1"),
),
);
expect(events.length).toBe(
results.filter(
(result) => result.status === "fulfilled" && result.value.changed,
).length + Number(retry.changed),
);
const readSummary = await chatUnreadSummary(db, {
workspaceId: workspace.id,
actorUserId: reader.id,
});
expect(
readSummary.channels.find(
(channel) => channel.channelId === publicChannel.id,
),
).toMatchObject({ readSequence: "4", unreadCount: 0, mentionCount: 0 });
} finally {
await db.delete(workspaces).where(eq(workspaces.id, workspace.id));
await db
.delete(users)
.where(sql`${users.id} in (${owner.id}, ${reader.id}, ${outsider.id})`);
}
}, 20000);
});

View file

@ -1,277 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import type { db as defaultDb } from "../client";
import {
chatCatchUp,
chatMutationCatchUp,
chatHistory,
chatThreadHistory,
chatMarkChannelRead,
ChatReadError,
} from "./read-history";
import { chatCursorSchema, chatSequenceSchema } from "./read-history";
// Sequence/cursor inputs are canonical decimal bigint strings, not JS numbers or aliases.
describe("chat read sequence validation", () => {
it("accepts positive sequences and the zero reconnect cursor", () => {
expect(chatSequenceSchema.parse("9223372036854775807")).toBe(
"9223372036854775807",
);
expect(chatCursorSchema.parse("0")).toBe("0");
});
it.each([
"",
"00",
"01",
"-1",
"+1",
"1.0",
" 1",
"1e3",
"9223372036854775808",
1,
])("rejects malformed cursor %s", (value) => {
expect(chatCursorSchema.safeParse(value).success).toBe(false);
});
it("requires a positive sequence for a viewed message", () => {
expect(chatSequenceSchema.safeParse("0").success).toBe(false);
});
});
const scope = {
workspaceId: "11111111-1111-4111-8111-111111111111",
channelId: "33333333-3333-4333-8333-333333333333",
actorUserId: "22222222-2222-4222-8222-222222222222",
};
describe("mutation recovery projections", () => {
it("deduplicates repeated mutations and redacts the current tombstone", async () => {
const messageId = "77777777-7777-4777-8777-777777777777";
const results = [
[{ role: "member" }],
[{ id: scope.channelId, workspaceId: scope.workspaceId, kind: "public", ownerUserId: scope.actorUserId, archivedAt: null, nextMutationSequence: 3n }],
[],
[{ messageId, mutationSequence: 1n, revision: 2, kind: "edited" }, { messageId, mutationSequence: 2n, revision: 3, kind: "deleted" }],
[{ id: messageId, workspaceId: scope.workspaceId, channelId: scope.channelId, sequence: 1n, parentMessageId: null, body: "secret", status: "deleted", revision: 3, deletedAt: new Date(), createdAt: new Date(), authorUserId: scope.actorUserId, sendIntentFingerprint: "private" }],
];
const tx = {
execute: vi.fn(),
select: () => {
const rows = results.shift();
const query = {
from: () => query, where: () => query, orderBy: () => query,
limit: () => Promise.resolve(rows),
then: (resolve: (value: unknown) => unknown) => Promise.resolve(rows).then(resolve),
};
return query;
},
};
const db = { transaction: (fn: (value: typeof tx) => unknown) => fn(tx) } as unknown as typeof defaultDb;
const result = await chatMutationCatchUp(db, { ...scope, after: "0", limit: 10 });
expect(result.mutations).toHaveLength(2);
expect(result.messages).toHaveLength(1);
expect(result.messages[0]).toMatchObject({ id: messageId, revision: 3, body: "", status: "deleted" });
expect(JSON.stringify(result)).not.toContain("secret");
expect(JSON.stringify(result)).not.toContain("private");
expect(result).toMatchObject({ next: "2", through: "2", hasMore: false });
});
});
describe("mutation recovery input", () => {
const db = { transaction: vi.fn() } as unknown as typeof defaultDb;
it("rejects noncanonical cursors and inverted fixed high-water before querying", async () => {
await expect(
chatMutationCatchUp(db, { ...scope, after: "01", limit: 10 }),
).rejects.toThrow();
await expect(
chatMutationCatchUp(db, {
...scope,
after: "3",
through: "2",
limit: 10,
}),
).rejects.toThrow();
expect(db.transaction).not.toHaveBeenCalled();
});
});
describe("mark-read transaction retries", () => {
const error = (code: string) =>
Object.assign(new Error("transaction failed"), { code });
it.each(["40001", "40P01"])(
"retries a failed transaction for %s",
async (code) => {
const transaction = vi
.fn()
.mockRejectedValueOnce(error(code))
.mockResolvedValueOnce({ changed: true, sequence: "9007199254740993" });
const result = await chatMarkChannelRead(
{ transaction } as unknown as typeof defaultDb,
{ ...scope, sequence: "9007199254740993" },
);
expect(result).toEqual({ changed: true, sequence: "9007199254740993" });
expect(transaction).toHaveBeenCalledTimes(2);
},
);
it("does not retry unrelated errors or safe authorization failures", async () => {
for (const failure of [error("23505"), new ChatReadError()]) {
const transaction = vi.fn().mockRejectedValue(failure);
await expect(
chatMarkChannelRead({ transaction } as unknown as typeof defaultDb, {
...scope,
sequence: "1",
}),
).rejects.toBe(failure);
expect(transaction).toHaveBeenCalledTimes(1);
}
});
it("exhausts bounded retries without masking the database error", async () => {
const failure = error("40001");
const transaction = vi.fn().mockRejectedValue(failure);
await expect(
chatMarkChannelRead({ transaction } as unknown as typeof defaultDb, {
...scope,
sequence: "1",
}),
).rejects.toBe(failure);
expect(transaction.mock.calls.length).toBeGreaterThan(1);
expect(transaction.mock.calls.length).toBeLessThanOrEqual(5);
});
});
describe("read-history transaction retries", () => {
const retryable = (code: string) =>
new Error("transaction failed", {
cause: new Error("driver wrapper", {
cause: Object.assign(new Error("postgres failure"), { code }),
}),
});
const requests = [
{
name: "history",
run: (db: typeof defaultDb) => chatHistory(db, { ...scope, limit: 10 }),
},
{
name: "thread history",
run: (db: typeof defaultDb) =>
chatThreadHistory(db, {
...scope,
rootMessageId: "44444444-4444-4444-8444-444444444444",
limit: 10,
}),
},
{
name: "catch-up",
run: (db: typeof defaultDb) =>
chatCatchUp(db, { ...scope, after: "0", limit: 10 }),
},
{
name: "mark-read",
run: (db: typeof defaultDb) =>
chatMarkChannelRead(db, { ...scope, sequence: "1" }),
},
];
it.each(requests)(
"$name starts a fresh transaction on a cause-wrapped serialization failure",
async ({ run }) => {
const callbacks: unknown[] = [];
const transactions: unknown[] = [];
const failure = retryable("40001");
const transaction = vi.fn(
async (callback: (tx: unknown) => Promise<unknown>) => {
callbacks.push(callback);
const tx = {
execute: vi.fn().mockRejectedValue(failure),
select: () => ({
from: () => ({
where: () => ({ limit: () => Promise.reject(failure) }),
}),
}),
};
transactions.push(tx);
return callback(tx);
},
);
await expect(
run({ transaction } as unknown as typeof defaultDb),
).rejects.toBe(failure);
expect(transaction).toHaveBeenCalledTimes(3);
expect(new Set(callbacks).size).toBe(3);
expect(new Set(transactions).size).toBe(3);
},
);
it.each(requests)(
"$name retries wrapped deadlocks but not unrelated errors",
async ({ run }) => {
const deadlock = retryable("40P01");
const transaction = vi
.fn()
.mockRejectedValueOnce(deadlock)
.mockResolvedValueOnce("committed");
await expect(
run({ transaction } as unknown as typeof defaultDb),
).resolves.toBe("committed");
expect(transaction).toHaveBeenCalledTimes(2);
const unrelated = retryable("23505");
const noRetry = vi.fn().mockRejectedValue(unrelated);
await expect(
run({ transaction: noRetry } as unknown as typeof defaultDb),
).rejects.toBe(unrelated);
expect(noRetry).toHaveBeenCalledTimes(1);
},
);
it("does not retry individual statements outside the mark-read transaction", async () => {
const failure = retryable("40001");
const externalEffects: number[] = [];
const transaction = vi.fn(
async (callback: (tx: unknown) => Promise<unknown>) => {
const attempt = transaction.mock.calls.length;
return callback({
select: () => {
externalEffects.push(attempt);
return {
from: () => ({
where: () => ({ limit: () => Promise.reject(failure) }),
}),
};
},
});
},
);
await expect(
chatMarkChannelRead({ transaction } as unknown as typeof defaultDb, {
...scope,
sequence: "1",
}),
).rejects.toBe(failure);
expect(externalEffects).toEqual([1, 2, 3]);
// External side effects survive retries; cursor changes and outbox insertion must
// remain inside the DB transaction to benefit from rollback.
});
});
describe("catch-up bounds", () => {
it.each([
{ after: "9223372036854775808", limit: 1 },
{ after: "0", through: "01", limit: 1 },
{ after: "2", through: "1", limit: 1 },
])(
"rejects malformed bounds before starting a transaction %#",
async (bounds) => {
const transaction = vi.fn();
await expect(
chatCatchUp({ transaction } as unknown as typeof defaultDb, {
...scope,
...bounds,
}),
).rejects.toThrow();
expect(transaction).not.toHaveBeenCalled();
},
);
});

View file

@ -1,617 +0,0 @@
import {
canPerformChatAction,
chatCursorSchema,
chatSequenceSchema,
} from "@tasks/shared";
import { and, asc, desc, eq, gt, inArray, isNull, lt, sql } from "drizzle-orm";
import { z } from "zod";
import { db as defaultDb } from "../client";
import {
chatChannelMembers,
chatChannels,
chatMessageMentions,
chatMessageMutations,
chatMessages,
chatOutbox,
chatReadCursors,
workspaceMembers,
} from "../schema";
export { chatCursorSchema, chatSequenceSchema } from "@tasks/shared";
export type ChatSequence = z.infer<typeof chatSequenceSchema>;
export type ChatCursor = z.infer<typeof chatCursorSchema>;
export const chatPageSizeSchema = z.number().int().min(1).max(100);
export const chatCatchUpSizeSchema = z.number().int().min(1).max(500);
const uuid = z.string().uuid();
const scopeSchema = z
.object({ workspaceId: uuid, channelId: uuid, actorUserId: uuid })
.strict();
const historySchema = scopeSchema
.extend({ before: chatSequenceSchema.optional(), limit: chatPageSizeSchema })
.strict();
const threadSchema = historySchema.extend({ rootMessageId: uuid }).strict();
const catchUpSchema = scopeSchema
.extend({
after: chatCursorSchema,
through: chatCursorSchema.optional(),
limit: chatCatchUpSizeSchema,
})
.strict()
.refine(
(input) =>
input.through === undefined ||
BigInt(input.after) <= BigInt(input.through),
{ message: "After exceeds through", path: ["through"] },
);
const mutationsSchema = scopeSchema
.extend({
after: chatCursorSchema,
through: chatCursorSchema.optional(),
limit: chatCatchUpSizeSchema,
})
.strict()
.refine(
(input) =>
input.through === undefined ||
BigInt(input.after) <= BigInt(input.through),
{ path: ["through"] },
);
const markReadSchema = scopeSchema
.extend({ sequence: chatSequenceSchema })
.strict();
const summarySchema = z
.object({ workspaceId: uuid, actorUserId: uuid })
.strict();
type Scope = z.infer<typeof scopeSchema>;
type Tx = Parameters<Parameters<typeof defaultDb.transaction>[0]>[0];
export class ChatReadError extends Error {
constructor() {
super("Chat channel not found");
}
}
// The same workspace+channel+actor policy guards every read surface, including archived channels.
async function authorize(tx: Tx, scope: Scope) {
const [actor] = await tx
.select({ role: workspaceMembers.role })
.from(workspaceMembers)
.where(
and(
eq(workspaceMembers.workspaceId, scope.workspaceId),
eq(workspaceMembers.userId, scope.actorUserId),
),
)
.limit(1);
const [channel] = await tx
.select()
.from(chatChannels)
.where(
and(
eq(chatChannels.workspaceId, scope.workspaceId),
eq(chatChannels.id, scope.channelId),
),
)
.limit(1);
if (!actor || !channel) throw new ChatReadError();
const [member] = await tx
.select({ userId: chatChannelMembers.userId })
.from(chatChannelMembers)
.where(
and(
eq(chatChannelMembers.workspaceId, scope.workspaceId),
eq(chatChannelMembers.channelId, scope.channelId),
eq(chatChannelMembers.userId, scope.actorUserId),
),
)
.limit(1);
if (
!canPerformChatAction({
action: "read",
actor: {
userId: scope.actorUserId,
workspaceId: scope.workspaceId,
activeWorkspaceMember: true,
role: actor.role,
},
context: { type: "workspace", workspaceId: scope.workspaceId },
channel: {
id: channel.id,
workspaceId: channel.workspaceId,
kind: channel.kind,
archived: channel.archivedAt !== null,
activeMember: !!member,
ownerUserId: channel.ownerUserId,
},
})
)
throw new ChatReadError();
return channel;
}
type Message = typeof chatMessages.$inferSelect;
function serialize(message: Message) {
// Explicit projection prevents internal send-intent fingerprints (and future storage keys)
// from leaking through history and catch-up DTOs.
return {
id: message.id,
workspaceId: message.workspaceId,
channelId: message.channelId,
sequence: message.sequence.toString(),
parentMessageId: message.parentMessageId,
authorType: message.authorType,
authorUserId: message.authorUserId,
authorAgentId: message.authorAgentId,
body: message.status === "deleted" ? "" : message.body,
status: message.status,
revision: message.revision,
createdAt: message.createdAt,
editedAt: message.editedAt,
deletedAt: message.deletedAt,
};
}
async function replyMetadata(tx: Tx, scope: Scope, roots: Message[]) {
if (!roots.length)
return new Map<
string,
{ replyCount: number; latestReplySequence: string | null }
>();
const rows = await tx
.select({
parentMessageId: chatMessages.parentMessageId,
count: sql<number>`count(*)::int`,
latest: sql<bigint>`max(${chatMessages.sequence})`,
})
.from(chatMessages)
.where(
and(
eq(chatMessages.workspaceId, scope.workspaceId),
eq(chatMessages.channelId, scope.channelId),
inArray(
chatMessages.parentMessageId,
roots.map((root) => root.id),
),
),
)
.groupBy(chatMessages.parentMessageId);
return new Map(
rows.map((row) => [
row.parentMessageId!,
{ replyCount: row.count, latestReplySequence: row.latest.toString() },
]),
);
}
// Backward root-page snapshot: limit+1 detects older roots, while reply metadata uses the same snapshot.
export async function chatHistory(
db: typeof defaultDb,
raw: z.input<typeof historySchema>,
) {
const input = historySchema.parse(raw);
return retryTransaction(
db,
async (tx) => {
await tx.execute(sql`set transaction read only`);
await authorize(tx, input);
const rows = await tx
.select()
.from(chatMessages)
.where(
and(
eq(chatMessages.workspaceId, input.workspaceId),
eq(chatMessages.channelId, input.channelId),
isNull(chatMessages.parentMessageId),
input.before
? lt(chatMessages.sequence, BigInt(input.before))
: undefined,
),
)
.orderBy(desc(chatMessages.sequence))
.limit(input.limit + 1);
const hasMore = rows.length > input.limit;
const page = rows.slice(0, input.limit);
const metadata = await replyMetadata(tx, input, page);
return {
messages: page.map((message) => ({
...serialize(message),
replyCount: metadata.get(message.id)?.replyCount ?? 0,
latestReplySequence:
metadata.get(message.id)?.latestReplySequence ?? null,
})),
next: hasMore ? page.at(-1)!.sequence.toString() : null,
hasMore,
};
},
{ isolationLevel: "repeatable read" },
);
}
// Thread pages include the root and only its direct replies; a reply is never a root.
export async function chatThreadHistory(
db: typeof defaultDb,
raw: z.input<typeof threadSchema>,
) {
const input = threadSchema.parse(raw);
return retryTransaction(
db,
async (tx) => {
await tx.execute(sql`set transaction read only`);
await authorize(tx, input);
const [root] = await tx
.select()
.from(chatMessages)
.where(
and(
eq(chatMessages.workspaceId, input.workspaceId),
eq(chatMessages.channelId, input.channelId),
eq(chatMessages.id, input.rootMessageId),
isNull(chatMessages.parentMessageId),
),
)
.limit(1);
if (!root) throw new ChatReadError();
const rows = await tx
.select()
.from(chatMessages)
.where(
and(
eq(chatMessages.workspaceId, input.workspaceId),
eq(chatMessages.channelId, input.channelId),
eq(chatMessages.parentMessageId, root.id),
input.before
? lt(chatMessages.sequence, BigInt(input.before))
: undefined,
),
)
.orderBy(desc(chatMessages.sequence))
.limit(input.limit + 1);
const hasMore = rows.length > input.limit;
const page = rows.slice(0, input.limit);
const metadata = await replyMetadata(tx, input, [root]);
return {
root: {
...serialize(root),
replyCount: metadata.get(root.id)?.replyCount ?? 0,
latestReplySequence:
metadata.get(root.id)?.latestReplySequence ?? null,
},
replies: page.map(serialize),
next: hasMore ? page.at(-1)!.sequence.toString() : null,
hasMore,
};
},
{ isolationLevel: "repeatable read" },
);
}
// Bounded reconnect scan includes replies and tombstones in channel sequence order.
export async function chatCatchUp(
db: typeof defaultDb,
raw: z.input<typeof catchUpSchema>,
) {
const input = catchUpSchema.parse(raw);
return retryTransaction(
db,
async (tx) => {
await tx.execute(sql`set transaction read only`);
const channel = await authorize(tx, input);
const highWater = channel.nextSequence - 1n;
if (
BigInt(input.after) > highWater ||
(input.through !== undefined && BigInt(input.through) > highWater)
)
throw new ChatReadError();
const through = input.through ?? highWater.toString();
const rows = await tx
.select()
.from(chatMessages)
.where(
and(
eq(chatMessages.workspaceId, input.workspaceId),
eq(chatMessages.channelId, input.channelId),
gt(chatMessages.sequence, BigInt(input.after)),
sql`${chatMessages.sequence} <= ${BigInt(through)}`,
),
)
.orderBy(asc(chatMessages.sequence))
.limit(input.limit + 1);
const hasMore = rows.length > input.limit;
const page = rows.slice(0, input.limit);
const roots = page.filter((message) => message.parentMessageId === null);
const metadata = await replyMetadata(tx, input, roots);
return {
messages: page.map((message) => ({
...serialize(message),
replyCount:
message.parentMessageId === null
? (metadata.get(message.id)?.replyCount ?? 0)
: 0,
latestReplySequence:
message.parentMessageId === null
? (metadata.get(message.id)?.latestReplySequence ?? null)
: null,
})),
next: page.at(-1)?.sequence.toString() ?? input.after,
through,
hasMore,
};
},
{ isolationLevel: "repeatable read" },
);
}
// Durable metadata-only mutation scan: fixed high-water survives pagination and reconnects.
export async function chatMutationCatchUp(
db: typeof defaultDb,
raw: z.input<typeof mutationsSchema>,
) {
const input = mutationsSchema.parse(raw);
return retryTransaction(
db,
async (tx) => {
await tx.execute(sql`set transaction read only`);
const channel = await authorize(tx, input);
const highWater = channel.nextMutationSequence - 1n;
if (
BigInt(input.after) > highWater ||
(input.through !== undefined && BigInt(input.through) > highWater)
)
throw new ChatReadError();
const through = input.through ?? highWater.toString();
const rows = await tx
.select({
messageId: chatMessageMutations.messageId,
mutationSequence: chatMessageMutations.mutationSequence,
revision: chatMessageMutations.revision,
kind: chatMessageMutations.kind,
})
.from(chatMessageMutations)
.where(
and(
eq(chatMessageMutations.workspaceId, input.workspaceId),
eq(chatMessageMutations.channelId, input.channelId),
gt(chatMessageMutations.mutationSequence, BigInt(input.after)),
sql`${chatMessageMutations.mutationSequence} <= ${BigInt(through)}`,
),
)
.orderBy(asc(chatMessageMutations.mutationSequence))
.limit(input.limit + 1);
const page = rows.slice(0, input.limit);
const messageIds = [...new Set(page.map((row) => row.messageId))];
const messagesRows = messageIds.length ? await tx
.select()
.from(chatMessages)
.where(
and(
eq(chatMessages.workspaceId, input.workspaceId),
eq(chatMessages.channelId, input.channelId),
inArray(chatMessages.id, messageIds),
),
) : [];
const messagesMap = new Map(messagesRows.map((m) => [m.id, serialize(m)]));
if (messagesMap.size !== messageIds.length) {
throw new ChatReadError();
}
return {
mutations: page.map((row) => ({
messageId: row.messageId,
mutationSequence: row.mutationSequence.toString(),
revision: row.revision,
kind: row.kind,
})),
messages: messageIds.map((id) => messagesMap.get(id)!),
next: page.at(-1)?.mutationSequence.toString() ?? input.after,
through,
hasMore: rows.length > input.limit,
};
},
{ isolationLevel: "repeatable read" },
);
}
// Mark-read validates that the claimed viewed sequence actually exists, then advances and emits
// exactly one invalidation in the same transaction. A no-op never changes the cursor or emits.
export async function chatMarkChannelRead(
db: typeof defaultDb,
raw: z.input<typeof markReadSchema>,
) {
const input = markReadSchema.parse(raw);
return retryTransaction(
db,
async (tx) => {
await authorize(tx, input);
const [viewed] = await tx
.select({ id: chatMessages.id })
.from(chatMessages)
.where(
and(
eq(chatMessages.workspaceId, input.workspaceId),
eq(chatMessages.channelId, input.channelId),
eq(chatMessages.sequence, BigInt(input.sequence)),
),
)
.limit(1);
if (!viewed) throw new ChatReadError();
const [current] = await tx
.select({ sequence: chatReadCursors.sequence })
.from(chatReadCursors)
.where(
and(
eq(chatReadCursors.workspaceId, input.workspaceId),
eq(chatReadCursors.channelId, input.channelId),
eq(chatReadCursors.userId, input.actorUserId),
),
)
.limit(1);
if (current && current.sequence >= BigInt(input.sequence))
return { sequence: current.sequence.toString(), changed: false };
const [updated] = await tx
.insert(chatReadCursors)
.values({
workspaceId: input.workspaceId,
channelId: input.channelId,
userId: input.actorUserId,
sequence: BigInt(input.sequence),
})
.onConflictDoUpdate({
target: [
chatReadCursors.workspaceId,
chatReadCursors.channelId,
chatReadCursors.userId,
],
set: {
sequence: sql`greatest(${chatReadCursors.sequence}, excluded.sequence)`,
updatedAt: sql`now()`,
},
setWhere: lt(chatReadCursors.sequence, BigInt(input.sequence)),
})
.returning({ sequence: chatReadCursors.sequence });
if (!updated)
return {
sequence: current?.sequence.toString() ?? "0",
changed: false,
};
await tx.insert(chatOutbox).values({
workspaceId: input.workspaceId,
channelId: input.channelId,
eventType: "chat.read_state.changed.v1",
eventVersion: 1,
dedupeKey: `read:${input.channelId}:${input.actorUserId}:${updated.sequence}`,
payload: {
eventType: "chat.read_state.changed.v1",
eventVersion: 1,
workspaceId: input.workspaceId,
channelId: input.channelId,
userId: input.actorUserId,
sequence: updated.sequence.toString(),
},
});
return { sequence: updated.sequence.toString(), changed: true };
},
{ isolationLevel: "repeatable read" },
);
}
// Retry the transaction as a unit so a failed attempt cannot commit a cursor without its outbox event.
async function retryTransaction<T>(
db: typeof defaultDb,
callback: (tx: Tx) => Promise<T>,
config: Parameters<typeof defaultDb.transaction>[1],
): Promise<T> {
for (let attempt = 0; attempt < 3; attempt++) {
try {
return await db.transaction((tx) => callback(tx), config);
} catch (error) {
if (attempt === 2 || !isRetryableTransactionError(error)) throw error;
}
}
throw new Error("Unreachable transaction retry state");
}
function isRetryableTransactionError(error: unknown): boolean {
if (typeof error !== "object" || error === null) return false;
const candidate = error as { code?: unknown; cause?: unknown };
return (
candidate.code === "40001" ||
candidate.code === "40P01" ||
(candidate.cause !== undefined &&
isRetryableTransactionError(candidate.cause))
);
}
// Unread aggregation considers every authorized channel, including archived ones, but never
// grants private/direct visibility based on the actor's workspace role alone.
export async function chatUnreadSummary(
db: typeof defaultDb,
raw: z.input<typeof summarySchema>,
) {
const input = summarySchema.parse(raw);
return db.transaction(
async (tx) => {
await tx.execute(sql`set transaction read only`);
const [actor] = await tx
.select({ userId: workspaceMembers.userId })
.from(workspaceMembers)
.where(
and(
eq(workspaceMembers.workspaceId, input.workspaceId),
eq(workspaceMembers.userId, input.actorUserId),
),
)
.limit(1);
if (!actor) throw new ChatReadError();
const channels = await tx
.select({ channel: chatChannels })
.from(chatChannels)
.leftJoin(
chatChannelMembers,
and(
eq(chatChannelMembers.workspaceId, chatChannels.workspaceId),
eq(chatChannelMembers.channelId, chatChannels.id),
eq(chatChannelMembers.userId, input.actorUserId),
),
)
.where(
and(
eq(chatChannels.workspaceId, input.workspaceId),
sql`(${chatChannels.kind} = 'public' or ${chatChannelMembers.userId} is not null)`,
),
);
// A single set-based count query keeps the summary bounded by channels, not messages.
const counts = await tx
.select({
channelId: chatChannels.id,
readSequence: sql<bigint>`coalesce(${chatReadCursors.sequence}, 0)`,
unreadCount: sql<number>`count(${chatMessages.id})::int`,
mentionCount: sql<number>`count(${chatMessageMentions.messageId})::int`,
})
.from(chatChannels)
.leftJoin(
chatReadCursors,
and(
eq(chatReadCursors.workspaceId, chatChannels.workspaceId),
eq(chatReadCursors.channelId, chatChannels.id),
eq(chatReadCursors.userId, input.actorUserId),
),
)
.leftJoin(
chatMessages,
and(
eq(chatMessages.workspaceId, chatChannels.workspaceId),
eq(chatMessages.channelId, chatChannels.id),
sql`${chatMessages.sequence} > coalesce(${chatReadCursors.sequence}, 0)`,
),
)
.leftJoin(
chatMessageMentions,
and(
eq(chatMessageMentions.workspaceId, chatMessages.workspaceId),
eq(chatMessageMentions.messageId, chatMessages.id),
eq(chatMessageMentions.userId, input.actorUserId),
),
)
.where(
and(
eq(chatChannels.workspaceId, input.workspaceId),
inArray(
chatChannels.id,
channels.map(({ channel }) => channel.id),
),
),
)
.groupBy(chatChannels.id, chatReadCursors.sequence);
const byId = new Map(counts.map((count) => [count.channelId, count]));
return {
channels: channels.map(({ channel }) => ({
channelId: channel.id,
archived: channel.archivedAt !== null,
readSequence: byId.get(channel.id)?.readSequence.toString() ?? "0",
unreadCount: byId.get(channel.id)?.unreadCount ?? 0,
mentionCount: byId.get(channel.id)?.mentionCount ?? 0,
})),
};
},
{ isolationLevel: "repeatable read" },
);
}

View file

@ -1,350 +0,0 @@
import { describe, expect, it } from "vitest";
import { createHash } from "node:crypto";
import {
sendChatMessage,
ChatPersistenceError,
type ChatPersistenceTransaction,
} from "./send-message";
const workspaceId = "11111111-1111-4111-8111-111111111111";
const channelId = "22222222-2222-4222-8222-222222222222";
const userId = "33333333-3333-4333-8333-333333333333";
const mentionId = "44444444-4444-4444-8444-444444444444";
const parentId = "55555555-5555-4555-8555-555555555555";
function command(overrides: Record<string, unknown> = {}) {
return {
type: "send_message",
workspaceId,
channelId,
context: { type: "workspace", workspaceId },
body: "hello",
clientIdempotencyKey: "client-1",
...overrides,
};
}
function harness(
options: {
kind?: "public" | "private" | "direct";
archived?: boolean;
channelMember?: boolean;
workspaceMember?: boolean;
role?: "owner" | "admin" | "member" | "guest";
factsMissing?: boolean;
factsWorkspaceId?: string;
factsChannelId?: string;
existing?: boolean;
persistedMentions?: string[];
pinnedFingerprint?: boolean;
changedBody?: boolean;
parent?: {
workspaceId: string;
channelId: string;
parentMessageId: string | null;
};
validMentions?: string[];
} = {},
) {
const calls: Array<[string, unknown]> = [];
const message = {
id: "66666666-6666-4666-8666-666666666666",
workspaceId,
channelId,
sequence: 7n,
parentMessageId: null,
authorUserId: userId,
clientIdempotencyKey: "client-1",
body: options.changedBody ? "edited later" : "hello",
sendIntentFingerprint: options.pinnedFingerprint
? createHash("sha256")
.update(
JSON.stringify([workspaceId, channelId, userId, "hello", null, []]),
)
.digest("hex")
: null,
};
const tx: ChatPersistenceTransaction = {
claimIdempotency: async (scope) => {
calls.push(["idempotency", scope]);
return options.existing ? message : null;
},
loadMessageMentions: async (scope) => {
calls.push(["existing-mentions", scope]);
return options.persistedMentions ?? [];
},
loadSendFacts: async (scope) => {
calls.push(["facts", scope]);
if (options.factsMissing) return null;
return {
actor: {
userId: scope.actorUserId,
workspaceId: options.factsWorkspaceId ?? workspaceId,
activeWorkspaceMember: options.workspaceMember ?? true,
role: options.role ?? "member",
},
channel: {
id: options.factsChannelId ?? channelId,
workspaceId: options.factsWorkspaceId ?? workspaceId,
kind: options.kind ?? "public",
archived: options.archived ?? false,
activeMember: options.channelMember ?? false,
ownerUserId: userId,
},
};
},
loadParent: async (scope) => {
calls.push(["parent", scope]);
return (
options.parent ?? { workspaceId, channelId, parentMessageId: null }
);
},
validateMentionMembers: async (scope) => {
calls.push(["mentions-valid", scope]);
return options.validMentions ?? scope.userIds;
},
allocateSequence: async (scope) => {
calls.push(["sequence", scope]);
return 7n;
},
insertMessage: async (values) => {
calls.push(["message", values]);
return values;
},
insertMentions: async (values) => {
calls.push(["mentions", values]);
},
insertOutbox: async (values) => {
calls.push(["outbox", values]);
},
};
return {
calls,
message,
run: (
input: unknown = command(),
mentionedUserIds: string[] = [],
actorUserId = userId,
) =>
sendChatMessage(
{ transaction: (fn) => fn(tx) },
{ actorUserId, command: input, mentionedUserIds },
),
};
}
describe("sendChatMessage", () => {
it("orchestrates message, validated mentions, and one deterministic outbox event through the transaction seam", async () => {
const h = harness();
const result = await h.run(command(), [mentionId, mentionId]);
expect(result.created).toBe(true);
expect(h.calls.map(([name]) => name)).toEqual([
"idempotency",
"facts",
"mentions-valid",
"sequence",
"message",
"mentions",
"outbox",
]);
expect(h.calls.find(([name]) => name === "outbox")?.[1]).toMatchObject({
workspaceId,
messageId: result.message.id,
eventVersion: 1,
eventType: "chat.message.created.v1",
dedupeKey: `message:${result.message.id}:created:v1`,
});
});
it("returns an idempotent retry before allocating sequence or writing outbox", async () => {
const h = harness({ existing: true });
expect((await h.run()).created).toBe(false);
expect(h.calls.map(([name]) => name)).toEqual([
"idempotency",
"facts",
"existing-mentions",
]);
});
it.each([
{
reason: "removed workspace membership",
options: { workspaceMember: false },
},
{ reason: "missing current facts", options: { factsMissing: true } },
{
reason: "different workspace facts",
options: { factsWorkspaceId: "99999999-9999-4999-8999-999999999999" },
},
{
reason: "different channel facts",
options: { factsChannelId: "99999999-9999-4999-8999-999999999999" },
},
{
reason: "revoked private admin membership",
options: {
kind: "private" as const,
role: "admin" as const,
channelMember: false,
},
},
{
reason: "revoked direct admin membership",
options: {
kind: "direct" as const,
role: "admin" as const,
channelMember: false,
},
},
])(
"rejects a replay after $reason without leaking message contents",
async ({ options }) => {
const h = harness({ existing: true, ...options });
await expect(h.run()).rejects.toMatchObject({ code: "CHAT_FORBIDDEN" });
expect(h.calls.map(([name]) => name)).toEqual(["idempotency", "facts"]);
},
);
it.each(["private", "direct"] as const)(
"allows a visible %s channel replay without creating another message",
async (kind) => {
const h = harness({ existing: true, kind, channelMember: true });
expect(await h.run()).toMatchObject({
created: false,
message: { id: h.message.id },
});
expect(h.calls.map(([name]) => name)).toEqual([
"idempotency",
"facts",
"existing-mentions",
]);
},
);
it("allows replay in an archived channel if it remains readable, but not a new send", async () => {
const retry = harness({
existing: true,
archived: true,
kind: "private",
channelMember: true,
});
expect(await retry.run()).toMatchObject({
created: false,
message: { id: retry.message.id },
});
expect(retry.calls.map(([name]) => name)).toEqual([
"idempotency",
"facts",
"existing-mentions",
]);
const fresh = harness({ archived: true });
await expect(fresh.run()).rejects.toMatchObject({ code: "CHAT_FORBIDDEN" });
expect(fresh.calls.map(([name]) => name)).toEqual(["idempotency", "facts"]);
});
it("treats duplicate and reordered requested mentions as the same intent", async () => {
const secondMentionId = "88888888-8888-4888-8888-888888888888";
const h = harness({
existing: true,
persistedMentions: [mentionId, secondMentionId],
});
expect(
(await h.run(command(), [secondMentionId, mentionId, secondMentionId]))
.created,
).toBe(false);
expect(h.calls[2]).toEqual([
"existing-mentions",
{ workspaceId, channelId, messageId: h.message.id },
]);
});
it.each([
{ persisted: [mentionId], requested: [] },
{ persisted: [], requested: [mentionId] },
{
persisted: [mentionId],
requested: ["88888888-8888-4888-8888-888888888888"],
},
])(
"rejects changed mention intent without allocating sequence or writing",
async ({ persisted, requested }) => {
const h = harness({ existing: true, persistedMentions: persisted });
await expect(h.run(command(), requested)).rejects.toMatchObject({
code: "CHAT_IDEMPOTENCY_CONFLICT",
});
expect(h.calls.map(([name]) => name)).toEqual([
"idempotency",
"facts",
"existing-mentions",
]);
},
);
it("matches immutable send intent after a subsequent edit without exposing the fingerprint", async () => {
const h = harness({
existing: true,
pinnedFingerprint: true,
changedBody: true,
});
const replay = await h.run();
expect(replay.created).toBe(false);
expect(replay.message).not.toHaveProperty("sendIntentFingerprint");
await expect(
h.run(command({ body: "edited later" })),
).rejects.toMatchObject({ code: "CHAT_IDEMPOTENCY_CONFLICT" });
});
it("rejects a channel-key collision from another actor before allocating sequence", async () => {
const h = harness({ existing: true });
await expect(
h.run(command(), [], "77777777-7777-4777-8777-777777777777"),
).rejects.toMatchObject({ code: "CHAT_IDEMPOTENCY_CONFLICT" });
expect(h.calls.map(([name]) => name)).toEqual(["idempotency", "facts"]);
});
it("rejects archived, nonmember, and unauthorized private channel writes", async () => {
for (const options of [
{ archived: true },
{ workspaceMember: false },
{ kind: "private" as const, channelMember: false },
]) {
const h = harness(options);
await expect(h.run()).rejects.toBeInstanceOf(ChatPersistenceError);
expect(h.calls.map(([name]) => name)).toEqual(["idempotency", "facts"]);
}
});
it("requires replies to target a root in the same tenant and channel", async () => {
const h = harness({
parent: { workspaceId, channelId, parentMessageId: parentId },
});
await expect(
h.run(command({ parentMessageId: parentId })),
).rejects.toMatchObject({ code: "CHAT_INVALID_PARENT" });
expect(h.calls[2]?.[1]).toEqual({
workspaceId,
channelId,
messageId: parentId,
});
});
it("rejects mentions that are not active members of this workspace", async () => {
const h = harness({ validMentions: [] });
await expect(h.run(command(), [mentionId])).rejects.toMatchObject({
code: "CHAT_INVALID_MENTION",
});
expect(h.calls[2]?.[1]).toEqual({ workspaceId, userIds: [mentionId] });
});
it("parses the accepted command contract and rejects mismatched context", async () => {
const h = harness();
await expect(
h.run(
command({ context: { type: "workspace", workspaceId: mentionId } }),
),
).rejects.toThrow();
expect(h.calls).toEqual([]);
});
});

View file

@ -1,304 +0,0 @@
import { createHash } from "node:crypto";
import {
canPerformChatAction,
chatCommandSchema,
chatMessageCreatedV1Schema,
type ChatCommand,
type ChatPolicyInput,
} from "@tasks/shared";
import { z } from "zod";
const uuidSchema = z.string().uuid();
const mentionIdsSchema = z.array(uuidSchema).max(100);
const sendMessageCommandSchema = chatCommandSchema.refine(
(command): command is Extract<ChatCommand, { type: "send_message" }> =>
command.type === "send_message",
{ message: "Expected a send_message command" },
);
type SendCommand = z.infer<typeof sendMessageCommandSchema>;
type SendFacts = Pick<
Extract<ChatPolicyInput, { action: "send" }>,
"actor" | "channel"
>;
export type PersistedChatMessage = {
id: string;
workspaceId: string;
channelId: string;
sequence: bigint;
body: string;
parentMessageId: string | null;
authorUserId: string;
clientIdempotencyKey: string;
sendIntentFingerprint?: string | null;
status?: "sent" | "edited" | "deleted";
};
type IdempotencyScope = {
workspaceId: string;
channelId: string;
clientIdempotencyKey: string;
};
export type ChatPersistenceTransaction = {
/**
* Serialize this channel-scoped key for the transaction, then return its
* message if already committed. A Postgres adapter can take a transaction-
* scoped advisory lock derived from all three fields before selecting.
*/
claimIdempotency(
scope: IdempotencyScope,
): Promise<PersistedChatMessage | null>;
loadMessageMentions(scope: {
workspaceId: string;
channelId: string;
messageId: string;
}): Promise<string[]>;
loadSendFacts(scope: {
workspaceId: string;
channelId: string;
actorUserId: string;
}): Promise<SendFacts | null>;
loadParent(scope: {
workspaceId: string;
channelId: string;
messageId: string;
}): Promise<{
workspaceId: string;
channelId: string;
parentMessageId: string | null;
} | null>;
validateMentionMembers(scope: {
workspaceId: string;
userIds: string[];
}): Promise<string[]>;
allocateSequence(scope: {
workspaceId: string;
channelId: string;
}): Promise<bigint>;
insertMessage(values: PersistedChatMessage): Promise<PersistedChatMessage>;
insertMentions(values: {
workspaceId: string;
messageId: string;
userIds: string[];
}): Promise<void>;
insertOutbox(values: {
workspaceId: string;
channelId: string;
messageId: string;
eventVersion: 1;
eventType: "chat.message.created.v1";
dedupeKey: string;
payload: Record<string, unknown>;
}): Promise<void>;
};
export type ChatTransactionRunner = {
/** Rolls back sequence, message, mention, and outbox writes if run rejects. */
transaction<T>(
run: (tx: ChatPersistenceTransaction) => Promise<T>,
): Promise<T>;
};
export type ChatPersistenceErrorCode =
| "CHAT_FORBIDDEN"
| "CHAT_INVALID_PARENT"
| "CHAT_INVALID_MENTION"
| "CHAT_IDEMPOTENCY_CONFLICT";
export class ChatPersistenceError extends Error {
constructor(readonly code: ChatPersistenceErrorCode) {
super(code);
this.name = "ChatPersistenceError";
}
}
function publicMessage(message: PersistedChatMessage): PersistedChatMessage {
return {
id: message.id,
workspaceId: message.workspaceId,
channelId: message.channelId,
sequence: message.sequence,
body: message.body,
parentMessageId: message.parentMessageId,
authorUserId: message.authorUserId,
clientIdempotencyKey: message.clientIdempotencyKey,
};
}
function sendIntentFingerprint(
command: SendCommand,
actorUserId: string,
mentionedUserIds: string[],
): string {
return createHash("sha256")
.update(
JSON.stringify([
command.workspaceId,
command.channelId,
actorUserId,
command.body,
command.parentMessageId ?? null,
mentionedUserIds,
]),
)
.digest("hex");
}
function matchesIntent(
message: PersistedChatMessage,
command: SendCommand,
actorUserId: string,
mentionedUserIds: string[],
): boolean {
return (
message.workspaceId === command.workspaceId &&
message.channelId === command.channelId &&
message.clientIdempotencyKey === command.clientIdempotencyKey &&
message.authorUserId === actorUserId &&
(message.sendIntentFingerprint
? message.sendIntentFingerprint ===
sendIntentFingerprint(command, actorUserId, mentionedUserIds)
: message.status !== "edited" && message.status !== "deleted" &&
message.body === command.body &&
message.parentMessageId === (command.parentMessageId ?? null))
);
}
export async function sendChatMessage(
runner: ChatTransactionRunner,
input: {
actorUserId: string;
command: unknown;
mentionedUserIds?: string[];
},
): Promise<{ created: boolean; message: PersistedChatMessage }> {
const command = sendMessageCommandSchema.parse(input.command);
const actorUserId = uuidSchema.parse(input.actorUserId);
const mentionIds = [
...new Set(mentionIdsSchema.parse(input.mentionedUserIds ?? [])),
].sort();
return runner.transaction(async (tx) => {
const existing = await tx.claimIdempotency({
workspaceId: command.workspaceId,
channelId: command.channelId,
clientIdempotencyKey: command.clientIdempotencyKey,
});
const facts = await tx.loadSendFacts({
workspaceId: command.workspaceId,
channelId: command.channelId,
actorUserId,
});
if (
!facts ||
facts.channel.id !== command.channelId ||
!canPerformChatAction({
action: existing ? "read" : "send",
actor: facts.actor,
channel: facts.channel,
context: command.context,
})
)
throw new ChatPersistenceError("CHAT_FORBIDDEN");
if (existing) {
if (!matchesIntent(existing, command, actorUserId, mentionIds))
throw new ChatPersistenceError("CHAT_IDEMPOTENCY_CONFLICT");
const persistedMentionIds = [
...new Set(
await tx.loadMessageMentions({
workspaceId: command.workspaceId,
channelId: command.channelId,
messageId: existing.id,
}),
),
].sort();
if (!existing.sendIntentFingerprint && existing.body !== command.body)
throw new ChatPersistenceError("CHAT_IDEMPOTENCY_CONFLICT");
if (
persistedMentionIds.length !== mentionIds.length ||
persistedMentionIds.some((id, index) => id !== mentionIds[index])
)
throw new ChatPersistenceError("CHAT_IDEMPOTENCY_CONFLICT");
return { created: false, message: publicMessage(existing) };
}
if (command.parentMessageId) {
const parent = await tx.loadParent({
workspaceId: command.workspaceId,
channelId: command.channelId,
messageId: command.parentMessageId,
});
if (
!parent ||
parent.workspaceId !== command.workspaceId ||
parent.channelId !== command.channelId ||
parent.parentMessageId !== null
)
throw new ChatPersistenceError("CHAT_INVALID_PARENT");
}
const validMentionIds = await tx.validateMentionMembers({
workspaceId: command.workspaceId,
userIds: mentionIds,
});
if (
validMentionIds.length !== mentionIds.length ||
mentionIds.some((id) => !validMentionIds.includes(id))
)
throw new ChatPersistenceError("CHAT_INVALID_MENTION");
const sequence = await tx.allocateSequence({
workspaceId: command.workspaceId,
channelId: command.channelId,
});
const inserted = await tx.insertMessage({
id: crypto.randomUUID(),
workspaceId: command.workspaceId,
channelId: command.channelId,
sequence,
body: command.body,
parentMessageId: command.parentMessageId ?? null,
authorUserId: actorUserId,
clientIdempotencyKey: command.clientIdempotencyKey,
sendIntentFingerprint: sendIntentFingerprint(
command,
actorUserId,
mentionIds,
),
});
if (mentionIds.length)
await tx.insertMentions({
workspaceId: command.workspaceId,
messageId: inserted.id,
userIds: mentionIds,
});
const event = chatMessageCreatedV1Schema.parse({
eventType: "chat.message.created.v1",
eventVersion: 1,
workspaceId: command.workspaceId,
channelId: command.channelId,
messageId: inserted.id,
sequence: inserted.sequence.toString(),
parentMessageId: inserted.parentMessageId,
author: { type: "human", userId: actorUserId },
body: inserted.body,
mentionedUserIds: mentionIds,
clientIdempotencyKey: command.clientIdempotencyKey,
});
await tx.insertOutbox({
workspaceId: command.workspaceId,
channelId: command.channelId,
messageId: inserted.id,
eventVersion: event.eventVersion,
eventType: event.eventType,
dedupeKey: `message:${inserted.id}:created:v1`,
payload: event,
});
return { created: true, message: publicMessage(inserted) };
});
}

View file

@ -1,3 +1,2 @@
export * from "./schema"; export * from "./schema";
export * from "./chat";
export { db } from "./client"; export { db } from "./client";

View file

@ -1,25 +0,0 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const migration = (name: string) =>
readFileSync(new URL(`../../migrations/${name}`, import.meta.url), "utf8");
describe("chat lifecycle migrations", () => {
it("backfills channel outbox identity before making it required", () => {
const sql = migration("0020_demonic_hammerhead.sql");
expect(sql).toContain('ADD COLUMN "channel_id" uuid;');
expect(sql).toContain('UPDATE "chat_outbox"');
expect(sql).toContain('ALTER COLUMN "channel_id" SET NOT NULL');
expect(sql).toContain('CREATE TABLE "chat_mutation_receipts"');
});
it("validates staging transitions and makes direct identity immutable without a caller bypass", () => {
const sql = migration("0021_chat_direct_membership_immutability.sql");
expect(sql).toContain(
"md5(string_agg(m.user_id::text, ':' ORDER BY m.user_id::text))",
);
expect(sql).toContain("member_count < 2");
expect(sql).toContain("direct channel membership is immutable");
expect(sql).toContain("SET search_path = pg_catalog, public");
expect(sql).toContain("REVOKE ALL ON FUNCTION");
expect(sql).not.toContain("current_setting");
expect(sql).not.toContain("SECURITY DEFINER");
});
});

View file

@ -1,220 +0,0 @@
import { SQL } from "drizzle-orm";
import { getTableConfig } from "drizzle-orm/pg-core";
import { describe, expect, it } from "vitest";
import {
chatChannelMembers,
chatChannels,
chatMessageMentions,
chatMessages,
chatOutbox,
chatReadCursors,
} from "./chat";
const config = (table: Parameters<typeof getTableConfig>[0]) =>
getTableConfig(table);
const columnNames = (columns: { name: string }[]) =>
columns.map(({ name }) => name);
const foreignKey = (
table: Parameters<typeof getTableConfig>[0],
name: string,
) => {
const key = config(table).foreignKeys.find((item) => item.getName() === name);
expect(key, `missing foreign key ${name}`).toBeDefined();
return key!;
};
const expectForeignKey = (
table: Parameters<typeof getTableConfig>[0],
name: string,
columns: string[],
foreignColumns: string[],
onDelete: string,
) => {
const key = foreignKey(table, name);
const reference = key.reference();
expect(columnNames(reference.columns)).toEqual(columns);
expect(columnNames(reference.foreignColumns)).toEqual(foreignColumns);
expect(key.onDelete).toBe(onDelete);
};
const uniqueColumns = (
table: Parameters<typeof getTableConfig>[0],
name: string,
) => {
const constraint = config(table).uniqueConstraints.find(
(item) => item.name === name,
);
expect(constraint, `missing unique constraint ${name}`).toBeDefined();
return columnNames(constraint!.columns);
};
describe("chat persistence schema", () => {
it.each([
chatChannels,
chatChannelMembers,
chatMessages,
chatMessageMentions,
chatReadCursors,
chatOutbox,
])("requires and indexes the workspace tenant key", (table) => {
expect(table.workspaceId.notNull).toBe(true);
const workspaceIndex = config(table).indexes.find((item) =>
item.config.name?.endsWith("workspace_id_idx"),
);
expect(workspaceIndex).toBeDefined();
expect(
columnNames(workspaceIndex!.config.columns as { name: string }[]),
).toEqual(["workspace_id"]);
});
it("cascades tenant deletion through each chat aggregate root", () => {
for (const table of [
chatChannels,
chatChannelMembers,
chatMessages,
chatMessageMentions,
chatReadCursors,
chatOutbox,
]) {
const key = config(table).foreignKeys.find(
(item) =>
item.reference().foreignColumns[0]?.name === "id" &&
item.reference().foreignTable ===
config(chatChannels).foreignKeys[0]?.reference().foreignTable,
);
expect(key?.onDelete).toBe("cascade");
}
});
it("anchors channel owners and channel members in the same workspace membership", () => {
expectForeignKey(
chatChannels,
"chat_channels_owner_member_fk",
["workspace_id", "owner_user_id"],
["workspace_id", "user_id"],
"no action",
);
expectForeignKey(
chatChannelMembers,
"chat_channel_members_workspace_member_fk",
["workspace_id", "user_id"],
["workspace_id", "user_id"],
"no action",
);
});
it("exposes archived_at as the structural signal for transaction-enforced read-only channels", () => {
expect(chatChannels.archivedAt.name).toBe("archived_at");
expect(chatChannels.archivedAt.notNull).toBe(false);
expect(config(chatChannels).checks.map((item) => item.name)).not.toContain(
"chat_channels_archived_read_only_check",
);
});
it("supports canonical immutable direct identity for transaction-layer enforcement", () => {
const names = config(chatChannels).checks.map((item) => item.name);
expect(names).toEqual(
expect.arrayContaining([
"chat_channels_kind_check",
"chat_channels_direct_key_check",
]),
);
const directIndex = config(chatChannels).indexes.find(
(item) =>
item.config.name === "chat_channels_workspace_id_direct_key_unique",
);
expect(directIndex?.config.unique).toBe(true);
expect(
columnNames(directIndex!.config.columns as { name: string }[]),
).toEqual(["workspace_id", "direct_key"]);
});
it("keeps parent messages in the same workspace and channel without nulling identity", () => {
expect(
uniqueColumns(chatMessages, "chat_messages_workspace_channel_id_unique"),
).toEqual(["workspace_id", "channel_id", "id"]);
expectForeignKey(
chatMessages,
"chat_messages_parent_fk",
["workspace_id", "channel_id", "parent_message_id"],
["workspace_id", "channel_id", "id"],
"no action",
);
});
it("preserves the exact author union and durable human membership identity", () => {
expect(config(chatMessages).checks.map((item) => item.name)).toContain(
"chat_messages_author_check",
);
expectForeignKey(
chatMessages,
"chat_messages_human_author_member_fk",
["workspace_id", "author_user_id"],
["workspace_id", "user_id"],
"no action",
);
expect(
config(chatMessages).foreignKeys.some((item) =>
item.reference().columns.includes(chatMessages.authorAgentId),
),
).toBe(false);
});
it("anchors mentions and read cursors in workspace membership", () => {
expectForeignKey(
chatMessageMentions,
"chat_message_mentions_workspace_member_fk",
["workspace_id", "user_id"],
["workspace_id", "user_id"],
"no action",
);
expectForeignKey(
chatReadCursors,
"chat_read_cursors_workspace_member_fk",
["workspace_id", "user_id"],
["workspace_id", "user_id"],
"no action",
);
});
it("uses bigint-backed channel and cursor sequences", () => {
expect(chatChannels.nextSequence.columnType).toBe("PgBigInt64");
expect(chatMessages.sequence.columnType).toBe("PgBigInt64");
expect(chatReadCursors.sequence.columnType).toBe("PgBigInt64");
const bigintColumns = [
chatChannels.nextSequence,
chatMessages.sequence,
chatReadCursors.sequence,
];
for (const column of bigintColumns) {
if (column.hasDefault) expect(column.default).toBeInstanceOf(SQL);
}
expect(config(chatChannels).checks.map((item) => item.name)).toContain(
"chat_channels_next_sequence_check",
);
expect(config(chatMessages).checks.map((item) => item.name)).toContain(
"chat_messages_sequence_check",
);
});
it("declares sequence, idempotency, and outbox dedupe uniqueness by tenant scope", () => {
expect(
uniqueColumns(
chatMessages,
"chat_messages_workspace_channel_sequence_unique",
),
).toEqual(["workspace_id", "channel_id", "sequence"]);
expect(
uniqueColumns(
chatMessages,
"chat_messages_workspace_channel_client_key_unique",
),
).toEqual(["workspace_id", "channel_id", "client_idempotency_key"]);
expect(
uniqueColumns(chatOutbox, "chat_outbox_workspace_id_dedupe_key_unique"),
).toEqual(["workspace_id", "dedupe_key"]);
});
});

View file

@ -1,387 +0,0 @@
import { sql } from "drizzle-orm";
import {
bigint,
check,
foreignKey,
index,
integer,
jsonb,
pgTable,
primaryKey,
text,
timestamp,
unique,
uniqueIndex,
uuid,
varchar,
} from "drizzle-orm/pg-core";
import { workspaceMembers } from "./objects";
import { workspaces } from "./workspaces";
export const chatChannels = pgTable(
"chat_channels",
{
id: uuid("id").primaryKey().defaultRandom(),
workspaceId: uuid("workspace_id")
.notNull()
.references(() => workspaces.id, { onDelete: "cascade" }),
kind: varchar("kind", { length: 16 })
.notNull()
.$type<"public" | "private" | "direct">(),
name: varchar("name", { length: 200 }),
ownerUserId: uuid("owner_user_id").notNull(),
directKey: text("direct_key"),
nextSequence: bigint("next_sequence", { mode: "bigint" })
.notNull()
.default(sql`1`),
nextMutationSequence: bigint("next_mutation_sequence", { mode: "bigint" })
.notNull()
.default(sql`1`),
/** Structural signal; transaction policy must reject writes when this is non-null. */
archivedAt: timestamp("archived_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.defaultNow()
.notNull(),
},
(table) => ({
workspaceIdx: index("chat_channels_workspace_id_idx").on(table.workspaceId),
workspaceIdUnique: unique("chat_channels_workspace_id_id_unique").on(
table.workspaceId,
table.id,
),
ownerFk: foreignKey({
columns: [table.workspaceId, table.ownerUserId],
foreignColumns: [workspaceMembers.workspaceId, workspaceMembers.userId],
name: "chat_channels_owner_member_fk",
}),
directKeyUnique: uniqueIndex("chat_channels_workspace_id_direct_key_unique")
.on(table.workspaceId, table.directKey)
.where(sql`${table.directKey} is not null`),
kindCheck: check(
"chat_channels_kind_check",
sql`${table.kind} in ('public', 'private', 'direct')`,
),
directKeyCheck: check(
"chat_channels_direct_key_check",
sql`(${table.kind} = 'direct' and ${table.directKey} is not null and ${table.name} is null) or (${table.kind} <> 'direct' and ${table.directKey} is null)`,
),
sequenceCheck: check(
"chat_channels_next_sequence_check",
sql`${table.nextSequence} > 0`,
),
}),
);
export const chatChannelMembers = pgTable(
"chat_channel_members",
{
workspaceId: uuid("workspace_id")
.notNull()
.references(() => workspaces.id, { onDelete: "cascade" }),
channelId: uuid("channel_id").notNull(),
userId: uuid("user_id").notNull(),
joinedAt: timestamp("joined_at", { withTimezone: true })
.defaultNow()
.notNull(),
},
(table) => ({
pk: primaryKey({
columns: [table.workspaceId, table.channelId, table.userId],
}),
workspaceIdx: index("chat_channel_members_workspace_id_idx").on(
table.workspaceId,
),
userIdx: index("chat_channel_members_workspace_id_user_id_idx").on(
table.workspaceId,
table.userId,
),
channelFk: foreignKey({
columns: [table.workspaceId, table.channelId],
foreignColumns: [chatChannels.workspaceId, chatChannels.id],
name: "chat_channel_members_channel_fk",
}).onDelete("cascade"),
memberFk: foreignKey({
columns: [table.workspaceId, table.userId],
foreignColumns: [workspaceMembers.workspaceId, workspaceMembers.userId],
name: "chat_channel_members_workspace_member_fk",
}),
}),
);
export const chatMessages = pgTable(
"chat_messages",
{
id: uuid("id").primaryKey().defaultRandom(),
workspaceId: uuid("workspace_id")
.notNull()
.references(() => workspaces.id, { onDelete: "cascade" }),
channelId: uuid("channel_id").notNull(),
sequence: bigint("sequence", { mode: "bigint" }).notNull(),
parentMessageId: uuid("parent_message_id"),
authorType: varchar("author_type", { length: 16 })
.notNull()
.$type<"human" | "agent" | "system">(),
authorUserId: uuid("author_user_id"),
/** Opaque, pinned agent identity until the agent registry schema exists. */
authorAgentId: uuid("author_agent_id"),
body: text("body").notNull(),
/** Pinned send intent survives edits and tombstones. */
sendIntentFingerprint: text("send_intent_fingerprint"),
revision: integer("revision").notNull().default(1),
status: varchar("status", { length: 16 })
.notNull()
.default("sent")
.$type<"sent" | "edited" | "deleted">(),
clientIdempotencyKey: varchar("client_idempotency_key", {
length: 200,
}).notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
editedAt: timestamp("edited_at", { withTimezone: true }),
deletedAt: timestamp("deleted_at", { withTimezone: true }),
},
(table) => ({
workspaceIdx: index("chat_messages_workspace_id_idx").on(table.workspaceId),
revisionCheck: check(
"chat_messages_revision_check",
sql`${table.revision} > 0`,
),
workspaceIdUnique: unique("chat_messages_workspace_id_id_unique").on(
table.workspaceId,
table.id,
),
workspaceChannelIdUnique: unique(
"chat_messages_workspace_channel_id_unique",
).on(table.workspaceId, table.channelId, table.id),
channelSequenceUnique: unique(
"chat_messages_workspace_channel_sequence_unique",
).on(table.workspaceId, table.channelId, table.sequence),
clientKeyUnique: unique(
"chat_messages_workspace_channel_client_key_unique",
).on(table.workspaceId, table.channelId, table.clientIdempotencyKey),
channelFk: foreignKey({
columns: [table.workspaceId, table.channelId],
foreignColumns: [chatChannels.workspaceId, chatChannels.id],
name: "chat_messages_channel_fk",
}).onDelete("cascade"),
parentFk: foreignKey({
columns: [table.workspaceId, table.channelId, table.parentMessageId],
foreignColumns: [table.workspaceId, table.channelId, table.id],
name: "chat_messages_parent_fk",
}),
humanAuthorFk: foreignKey({
columns: [table.workspaceId, table.authorUserId],
foreignColumns: [workspaceMembers.workspaceId, workspaceMembers.userId],
name: "chat_messages_human_author_member_fk",
}),
authorCheck: check(
"chat_messages_author_check",
sql`(${table.authorType} = 'human' and ${table.authorUserId} is not null and ${table.authorAgentId} is null) or (${table.authorType} = 'agent' and ${table.authorUserId} is null and ${table.authorAgentId} is not null) or (${table.authorType} = 'system' and ${table.authorUserId} is null and ${table.authorAgentId} is null)`,
),
statusCheck: check(
"chat_messages_status_check",
sql`${table.status} in ('sent', 'edited', 'deleted')`,
),
sequenceCheck: check(
"chat_messages_sequence_check",
sql`${table.sequence} >= 0`,
),
}),
);
export const chatMessageMentions = pgTable(
"chat_message_mentions",
{
workspaceId: uuid("workspace_id")
.notNull()
.references(() => workspaces.id, { onDelete: "cascade" }),
messageId: uuid("message_id").notNull(),
userId: uuid("user_id").notNull(),
},
(table) => ({
pk: primaryKey({
columns: [table.workspaceId, table.messageId, table.userId],
}),
workspaceIdx: index("chat_message_mentions_workspace_id_idx").on(
table.workspaceId,
),
messageFk: foreignKey({
columns: [table.workspaceId, table.messageId],
foreignColumns: [chatMessages.workspaceId, chatMessages.id],
name: "chat_message_mentions_message_fk",
}).onDelete("cascade"),
mentionedMemberFk: foreignKey({
columns: [table.workspaceId, table.userId],
foreignColumns: [workspaceMembers.workspaceId, workspaceMembers.userId],
name: "chat_message_mentions_workspace_member_fk",
}),
}),
);
export const chatMessageMutations = pgTable(
"chat_message_mutations",
{
id: uuid("id").primaryKey().defaultRandom(),
workspaceId: uuid("workspace_id")
.notNull()
.references(() => workspaces.id, { onDelete: "cascade" }),
channelId: uuid("channel_id").notNull(),
messageId: uuid("message_id").notNull(),
mutationSequence: bigint("mutation_sequence", { mode: "bigint" }).notNull(),
revision: integer("revision").notNull(),
kind: varchar("kind", { length: 16 })
.notNull()
.$type<"edited" | "deleted">(),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
},
(table) => ({
workspaceIdx: index("chat_message_mutations_workspace_id_idx").on(
table.workspaceId,
),
sequenceUnique: unique("chat_message_mutations_channel_sequence_unique").on(
table.workspaceId,
table.channelId,
table.mutationSequence,
),
channelFk: foreignKey({
columns: [table.workspaceId, table.channelId],
foreignColumns: [chatChannels.workspaceId, chatChannels.id],
name: "chat_message_mutations_channel_fk",
}).onDelete("cascade"),
messageFk: foreignKey({
columns: [table.workspaceId, table.messageId],
foreignColumns: [chatMessages.workspaceId, chatMessages.id],
name: "chat_message_mutations_message_fk",
}).onDelete("cascade"),
revisionCheck: check(
"chat_message_mutations_revision_check",
sql`${table.revision} > 1`,
),
}),
);
export const chatReadCursors = pgTable(
"chat_read_cursors",
{
workspaceId: uuid("workspace_id")
.notNull()
.references(() => workspaces.id, { onDelete: "cascade" }),
channelId: uuid("channel_id").notNull(),
userId: uuid("user_id").notNull(),
sequence: bigint("sequence", { mode: "bigint" })
.notNull()
.default(sql`0`),
updatedAt: timestamp("updated_at", { withTimezone: true })
.defaultNow()
.notNull(),
},
(table) => ({
pk: primaryKey({
columns: [table.workspaceId, table.channelId, table.userId],
}),
workspaceIdx: index("chat_read_cursors_workspace_id_idx").on(
table.workspaceId,
),
channelFk: foreignKey({
columns: [table.workspaceId, table.channelId],
foreignColumns: [chatChannels.workspaceId, chatChannels.id],
name: "chat_read_cursors_channel_fk",
}).onDelete("cascade"),
readerMemberFk: foreignKey({
columns: [table.workspaceId, table.userId],
foreignColumns: [workspaceMembers.workspaceId, workspaceMembers.userId],
name: "chat_read_cursors_workspace_member_fk",
}),
sequenceCheck: check(
"chat_read_cursors_sequence_check",
sql`${table.sequence} >= 0`,
),
}),
);
export const chatMutationReceipts = pgTable(
"chat_mutation_receipts",
{
workspaceId: uuid("workspace_id")
.notNull()
.references(() => workspaces.id, { onDelete: "cascade" }),
actorUserId: uuid("actor_user_id").notNull(),
clientMutationId: uuid("client_mutation_id").notNull(),
operation: varchar("operation", { length: 50 }).notNull(),
fingerprint: text("fingerprint").notNull(),
result: jsonb("result").$type<Record<string, unknown>>().notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
},
(table) => ({
pk: primaryKey({
columns: [table.workspaceId, table.actorUserId, table.clientMutationId],
}),
workspaceIdx: index("chat_mutation_receipts_workspace_id_idx").on(
table.workspaceId,
),
actorFk: foreignKey({
columns: [table.workspaceId, table.actorUserId],
foreignColumns: [workspaceMembers.workspaceId, workspaceMembers.userId],
name: "chat_mutation_receipts_workspace_member_fk",
}).onDelete("cascade"),
}),
);
export const chatOutbox = pgTable(
"chat_outbox",
{
id: uuid("id").primaryKey().defaultRandom(),
workspaceId: uuid("workspace_id")
.notNull()
.references(() => workspaces.id, { onDelete: "cascade" }),
channelId: uuid("channel_id").notNull(),
messageId: uuid("message_id"),
eventVersion: integer("event_version").notNull().default(1),
eventType: varchar("event_type", { length: 100 }).notNull(),
dedupeKey: varchar("dedupe_key", { length: 250 }).notNull(),
payload: jsonb("payload").$type<Record<string, unknown>>().notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
publishedAt: timestamp("published_at", { withTimezone: true }),
attempts: integer("attempts").notNull().default(0),
lastError: text("last_error"),
},
(table) => ({
workspaceIdx: index("chat_outbox_workspace_id_idx").on(table.workspaceId),
unpublishedIdx: index("chat_outbox_unpublished_idx")
.on(table.createdAt)
.where(sql`${table.publishedAt} is null`),
dedupeUnique: unique("chat_outbox_workspace_id_dedupe_key_unique").on(
table.workspaceId,
table.dedupeKey,
),
channelFk: foreignKey({
columns: [table.workspaceId, table.channelId],
foreignColumns: [chatChannels.workspaceId, chatChannels.id],
name: "chat_outbox_channel_fk",
}).onDelete("cascade"),
messageFk: foreignKey({
columns: [table.workspaceId, table.messageId],
foreignColumns: [chatMessages.workspaceId, chatMessages.id],
name: "chat_outbox_message_fk",
}).onDelete("cascade"),
versionCheck: check(
"chat_outbox_event_version_check",
sql`${table.eventVersion} > 0`,
),
attemptsCheck: check(
"chat_outbox_attempts_check",
sql`${table.attempts} >= 0`,
),
}),
);

View file

@ -22,4 +22,3 @@ export * from "./harness_providers";
export * from "./convoy_operator_messages"; export * from "./convoy_operator_messages";
export * from "./completion_artifacts"; export * from "./completion_artifacts";
export * from "./budget_caps"; export * from "./budget_caps";
export * from "./chat";

View file

@ -7,7 +7,6 @@
"types": "./src/index.ts", "types": "./src/index.ts",
"exports": { "exports": {
".": "./src/index.ts", ".": "./src/index.ts",
"./chat": "./src/chat/index.ts",
"./connected-repos": "./src/connected-repos/index.ts", "./connected-repos": "./src/connected-repos/index.ts",
"./convoy-lifecycle": "./src/convoy-lifecycle/index.ts", "./convoy-lifecycle": "./src/convoy-lifecycle/index.ts",
"./orchestrator": "./src/orchestrator/index.ts", "./orchestrator": "./src/orchestrator/index.ts",

View file

@ -1,540 +0,0 @@
import { describe, expect, it } from "vitest";
import {
CHAT_IDEMPOTENCY_KEY_MAX_LENGTH,
CHAT_MESSAGE_MAX_BODY_LENGTH,
chatCommandSchema,
chatCursorSchema,
chatSequenceSchema,
chatContextSchema,
chatMessageCreatedV1Schema,
chatPolicyInputSchema,
canPerformChatAction,
getChatCommandAuthorizationAction,
} from "./index";
describe("chat decimal cursors", () => {
it("accepts the full signed PostgreSQL bigint range without JS number loss", () => {
expect(chatSequenceSchema.parse("9223372036854775807")).toBe(
"9223372036854775807",
);
expect(chatCursorSchema.parse("0")).toBe("0");
expect(
chatCommandSchema.parse({
type: "mark_read",
workspaceId: "11111111-1111-4111-8111-111111111111",
channelId: "33333333-3333-4333-8333-333333333333",
context: {
type: "workspace",
workspaceId: "11111111-1111-4111-8111-111111111111",
},
sequence: "9007199254740993",
}),
).toMatchObject({ sequence: "9007199254740993" });
});
it.each([
"",
"00",
"01",
"-1",
"+1",
"1.0",
" 1",
"1e3",
"9223372036854775808",
1,
])("rejects malformed cursor %s", (value) => {
expect(chatCursorSchema.safeParse(value).success).toBe(false);
});
it("requires a positive message sequence and rejects numeric mark-read", () => {
expect(chatSequenceSchema.safeParse("0").success).toBe(false);
expect(
chatMessageCreatedV1Schema.safeParse({
eventType: "chat.message.created.v1",
eventVersion: 1,
workspaceId: "11111111-1111-4111-8111-111111111111",
channelId: "33333333-3333-4333-8333-333333333333",
messageId: "44444444-4444-4444-8444-444444444444",
sequence: "9223372036854775808",
author: {
type: "human",
userId: "22222222-2222-4222-8222-222222222222",
},
body: "hello",
parentMessageId: null,
mentionedUserIds: [],
clientIdempotencyKey: "key",
}).success,
).toBe(false);
});
});
const workspaceId = "11111111-1111-4111-8111-111111111111";
const userId = "22222222-2222-4222-8222-222222222222";
const channelId = "33333333-3333-4333-8333-333333333333";
const messageId = "44444444-4444-4444-8444-444444444444";
const otherId = "55555555-5555-4555-8555-555555555555";
const targetUserId = "66666666-6666-4666-8666-666666666666";
const context = { type: "workspace" as const, workspaceId };
const actor = {
userId,
workspaceId,
activeWorkspaceMember: true,
role: "member" as const,
};
const channel = {
id: channelId,
workspaceId,
kind: "public" as const,
archived: false,
activeMember: false,
ownerUserId: otherId,
};
const base = { actor, context, channel };
describe("chat.message.created.v1 contract", () => {
const event = {
eventType: "chat.message.created.v1",
eventVersion: 1,
workspaceId,
channelId,
messageId,
sequence: "42",
author: { type: "human", userId },
body: "hello",
parentMessageId: null,
mentionedUserIds: [targetUserId],
clientIdempotencyKey: "local-1",
} as const;
it("accepts the strict versioned payload with a serialized bigint", () => {
expect(chatMessageCreatedV1Schema.parse(event)).toEqual(event);
});
it.each([
{ sequence: "0" },
{ sequence: "1.5" },
{ eventVersion: 2 },
{ unexpected: true },
])("rejects invalid payload fields %#", (override) => {
expect(
chatMessageCreatedV1Schema.safeParse({ ...event, ...override }).success,
).toBe(false);
});
});
const message = {
id: messageId,
workspaceId,
channelId,
author: { type: "human" as const, userId },
};
function validCommands() {
const scope = { workspaceId, channelId, context };
return [
{
...scope,
type: "send_message" as const,
body: "hello",
clientIdempotencyKey: "local-1",
},
{
...scope,
type: "edit_message" as const,
messageId,
body: "updated",
expectedRevision: 1,
clientMutationId: messageId,
},
{
...scope,
type: "delete_message" as const,
messageId,
expectedRevision: 1,
clientMutationId: messageId,
},
{ ...scope, type: "mark_read" as const, sequence: "1" },
{ ...scope, type: "archive_channel" as const },
{
...scope,
type: "change_membership" as const,
userId,
operation: "add" as const,
},
];
}
describe("chat command contracts", () => {
it("accepts every command variant", () => {
for (const command of validCommands())
expect(chatCommandSchema.safeParse(command).success).toBe(true);
});
it.each([
{ type: "object", workspaceId, objectId: messageId },
{ type: "future", workspaceId },
])("rejects unsupported context $type", (value) =>
expect(chatContextSchema.safeParse(value).success).toBe(false),
);
it("requires matching workspace context on every variant", () => {
for (const command of validCommands()) {
expect(
chatCommandSchema.safeParse({
...command,
context: { ...context, workspaceId: otherId },
}).success,
).toBe(false);
expect(
chatCommandSchema.safeParse({
...command,
context: { type: "object", workspaceId },
}).success,
).toBe(false);
}
});
it("rejects unknown keys on every variant", () => {
for (const command of validCommands()) {
expect(
chatCommandSchema.safeParse({ ...command, unexpected: true }).success,
).toBe(false);
}
});
it("enforces numeric, body, and idempotency bounds", () => {
expect(
chatCommandSchema.safeParse({ ...validCommands()[3], sequence: -1 })
.success,
).toBe(false);
expect(
chatCommandSchema.safeParse({
...validCommands()[0],
body: "x".repeat(CHAT_MESSAGE_MAX_BODY_LENGTH + 1),
}).success,
).toBe(false);
expect(
chatCommandSchema.safeParse({
...validCommands()[0],
clientIdempotencyKey: "x".repeat(CHAT_IDEMPOTENCY_KEY_MAX_LENGTH + 1),
}).success,
).toBe(false);
});
it("maps every command to a non-weaker authorization action", () => {
expect(validCommands().map(getChatCommandAuthorizationAction)).toEqual([
"send",
"edit_own",
"delete_message",
"read",
"manage_channel",
"manage_members",
]);
});
});
describe("chat authorization policy", () => {
it("explicitly permits public reads and sends but fails closed for unknown actions", () => {
expect(canPerformChatAction({ ...base, action: "read" })).toBe(true);
expect(canPerformChatAction({ ...base, action: "send" })).toBe(true);
expect(canPerformChatAction({ ...base, action: "future_action" })).toBe(
false,
);
});
it("denies inactive and cross-workspace access", () => {
expect(
canPerformChatAction({
...base,
action: "read",
actor: { ...actor, activeWorkspaceMember: false },
}),
).toBe(false);
expect(
canPerformChatAction({
...base,
action: "read",
actor: { ...actor, workspaceId: otherId },
}),
).toBe(false);
});
it.each(["private", "direct"] as const)(
"requires active membership for %s",
(kind) => {
expect(
canPerformChatAction({
...base,
action: "read",
channel: { ...channel, kind },
}),
).toBe(false);
expect(
canPerformChatAction({
...base,
action: "read",
channel: { ...channel, kind, activeMember: true },
}),
).toBe(true);
},
);
it("denies every archived mutation but permits authorized read", () => {
const archived = { ...channel, archived: true, activeMember: true };
expect(
canPerformChatAction({ ...base, action: "read", channel: archived }),
).toBe(true);
const inputs = [
{ ...base, action: "send", channel: archived },
{ ...base, action: "edit_own", channel: archived, message },
{
...base,
action: "moderate",
channel: archived,
message,
actor: { ...actor, role: "admin" as const },
},
{
...base,
action: "manage_channel",
channel: archived,
actor: { ...actor, role: "admin" as const },
},
{
...base,
action: "manage_members",
channel: archived,
operation: "add",
target: {
userId: otherId,
workspaceId,
activeWorkspaceMember: true,
activeChannelMember: false,
},
actor: { ...actor, role: "admin" as const },
},
{ ...base, action: "join", channel: archived },
];
for (const input of inputs) expect(canPerformChatAction(input)).toBe(false);
});
it("requires matching message scope and ownership for edit", () => {
expect(canPerformChatAction({ ...base, action: "edit_own", message })).toBe(
true,
);
expect(canPerformChatAction({ ...base, action: "edit_own" })).toBe(false);
expect(
canPerformChatAction({
...base,
action: "edit_own",
message: { ...message, channelId: otherId },
}),
).toBe(false);
expect(
canPerformChatAction({
...base,
action: "edit_own",
message: { ...message, workspaceId: otherId },
}),
).toBe(false);
expect(
canPerformChatAction({
...base,
action: "edit_own",
message: { ...message, author: { type: "human", userId: otherId } },
}),
).toBe(false);
expect(
canPerformChatAction({
...base,
action: "edit_own",
message: { ...message, author: { type: "agent", agentId: otherId } },
}),
).toBe(false);
});
it("allows authors to delete their own message and moderators to delete others", () => {
expect(
canPerformChatAction({ ...base, action: "delete_message", message }),
).toBe(true);
expect(
canPerformChatAction({
...base,
action: "delete_message",
message: { ...message, author: { type: "human", userId: otherId } },
}),
).toBe(false);
expect(
canPerformChatAction({
...base,
action: "delete_message",
actor: { ...actor, role: "admin" },
message: { ...message, author: { type: "human", userId: otherId } },
}),
).toBe(true);
});
it("requires message scope, admin role, and access for moderation", () => {
const admin = { ...actor, role: "admin" as const };
expect(
canPerformChatAction({
...base,
action: "moderate",
actor: admin,
message,
}),
).toBe(true);
expect(
canPerformChatAction({ ...base, action: "moderate", actor: admin }),
).toBe(false);
expect(
canPerformChatAction({
...base,
action: "moderate",
actor: admin,
message: { ...message, workspaceId: otherId },
}),
).toBe(false);
expect(
canPerformChatAction({
...base,
action: "moderate",
actor: admin,
message,
channel: { ...channel, kind: "private" },
}),
).toBe(false);
});
it("allows channel owner or workspace admin to manage a channel, subject to access", () => {
expect(
canPerformChatAction({
...base,
action: "manage_channel",
channel: { ...channel, ownerUserId: userId },
}),
).toBe(true);
expect(
canPerformChatAction({
...base,
action: "manage_channel",
actor: { ...actor, role: "admin" },
}),
).toBe(true);
expect(canPerformChatAction({ ...base, action: "manage_channel" })).toBe(
false,
);
expect(
canPerformChatAction({
...base,
action: "manage_channel",
actor: { ...actor, role: "admin" },
channel: { ...channel, kind: "private" },
}),
).toBe(false);
});
it("applies operation-specific membership target rules", () => {
const admin = { ...actor, role: "admin" as const };
const target = {
userId: targetUserId,
workspaceId,
activeWorkspaceMember: true,
activeChannelMember: false,
};
expect(
canPerformChatAction({
...base,
action: "manage_members",
operation: "add",
actor: admin,
target,
}),
).toBe(false);
const manage = (operation: "add" | "remove", overrides = {}) =>
canPerformChatAction({
...base,
action: "manage_members",
operation,
actor: admin,
channel: { ...channel, kind: "private", activeMember: true },
target: { ...target, ...overrides },
});
expect(manage("add")).toBe(true);
expect(manage("add", { activeWorkspaceMember: false })).toBe(false);
expect(manage("add", { activeChannelMember: true })).toBe(false);
expect(manage("remove", { activeChannelMember: false })).toBe(false);
expect(
manage("remove", {
activeWorkspaceMember: false,
activeChannelMember: true,
}),
).toBe(true);
expect(
manage("remove", { workspaceId: otherId, activeChannelMember: true }),
).toBe(false);
});
it("never permits removing the channel owner", () => {
const ownerTarget = {
userId: channel.ownerUserId,
workspaceId,
activeWorkspaceMember: true,
activeChannelMember: true,
};
for (const role of ["owner", "admin"] as const) {
expect(
canPerformChatAction({
...base,
action: "manage_members",
operation: "remove",
actor: { ...actor, role },
channel: { ...channel, kind: "private", activeMember: true },
target: ownerTarget,
}),
).toBe(false);
}
});
it("keeps direct membership immutable and denies admin self-join", () => {
const admin = { ...actor, role: "admin" as const };
const target = {
userId: otherId,
workspaceId,
activeWorkspaceMember: true,
activeChannelMember: false,
};
expect(
canPerformChatAction({
...base,
action: "manage_members",
operation: "add",
actor: admin,
target,
channel: { ...channel, kind: "direct", activeMember: true },
}),
).toBe(false);
expect(
canPerformChatAction({
...base,
action: "join",
actor: admin,
channel: { ...channel, kind: "private" },
}),
).toBe(false);
});
it("fails schema validation for unsupported contexts", () => {
expect(
chatPolicyInputSchema.safeParse({
...base,
action: "read",
context: { type: "object", workspaceId },
}).success,
).toBe(false);
});
});

Some files were not shown because too many files have changed in this diff Show more