feat: implement durable chat persistence with PostgreSQL integration and advisory lock verification

This commit is contained in:
varutasu 2026-10-24 15:02:47 +00:00
parent 4a1bea95c5
commit a9a4ccb9f2
85 changed files with 23508 additions and 219 deletions

117
.convoys/ai-agent-studio.md Normal file
View file

@ -0,0 +1,117 @@
---
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

@ -0,0 +1,50 @@
---
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

@ -0,0 +1,51 @@
---
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

@ -0,0 +1,49 @@
---
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

@ -0,0 +1,47 @@
---
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

@ -0,0 +1,50 @@
---
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

@ -0,0 +1,40 @@
---
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

@ -0,0 +1,51 @@
---
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

@ -0,0 +1,48 @@
---
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

@ -0,0 +1,108 @@
---
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.
- Chat Brief 1 remains the next implementable slice; no chat implementation is claimed started. The independent AI shell implementer is 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.
## 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

@ -0,0 +1,75 @@
---
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

@ -0,0 +1,55 @@
---
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

@ -0,0 +1,55 @@
---
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

@ -1,212 +1,23 @@
"use client";
import { Suspense } from "react";
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";
import { AIWorkspace } from "@/components/ai/ai-workspace";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export default function AIPage() {
const params = useParams();
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]);
export default async function AIPage({
params,
}: {
params: Promise<{ workspaceSlug: string }>;
}) {
const { workspaceSlug } = await params;
return (
<div className="flex h-full min-h-0 flex-col">
<div className="flex shrink-0 items-center gap-3 border-b px-6 py-4">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
<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>
</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"
>
{suggestion}
</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"}
<Suspense
fallback={
<p role="status" className="p-6">
Loading AI workspace…
</p>
<p className="mt-1 break-words text-xs opacity-90">{message}</p>
</div>
</div>
}
>
<AIWorkspace key={workspaceSlug} workspaceSlug={workspaceSlug} />
</Suspense>
);
}
/**
* `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

@ -0,0 +1,54 @@
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,22 +5,13 @@ import { z } from "zod";
import { auth } from "@/lib/auth";
import { resolveWorkspace } from "@/server/lib/resolve-workspace";
import { chatRequestSchema } from "./schema";
export const runtime = "nodejs";
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 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 {
return new Response(JSON.stringify({ error: message }), {
status,
@ -34,9 +25,9 @@ export async function POST(req: Request): Promise<Response> {
return errorResponse(401, "Sign in to use the assistant");
}
let body: z.infer<typeof bodySchema>;
let body: z.infer<typeof chatRequestSchema>;
try {
body = bodySchema.parse(await req.json());
body = chatRequestSchema.parse(await req.json());
} catch (e) {
if (e instanceof z.ZodError) {
return errorResponse(400, e.issues[0]?.message ?? "Invalid request");

View file

@ -0,0 +1,28 @@
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

@ -0,0 +1,20 @@
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

@ -0,0 +1,103 @@
"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

@ -0,0 +1,155 @@
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

@ -0,0 +1,98 @@
"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

@ -0,0 +1,166 @@
"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

@ -0,0 +1,12 @@
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

@ -0,0 +1,121 @@
"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

@ -0,0 +1,125 @@
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

@ -0,0 +1,50 @@
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

@ -0,0 +1,27 @@
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

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

View file

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

View file

@ -0,0 +1,205 @@
import { describe, expect, it, vi } from "vitest";
import { TRPCError } from "@trpc/server";
import { createChatTransactionRunner } from "@tasks/database/chat";
import { listChannels, createChannel, renameChannel, archiveChannel, addPrivateMember, removePrivateMember, createDirectChannel } from "./manage-channels";
import { resolveWorkspace } from "@/server/lib/resolve-workspace";
import { auth } from "@/lib/auth";
import { ChatPersistenceError } from "@tasks/database/chat";
const workspaceId = "11111111-1111-4111-8111-111111111111";
const otherWorkspaceId = "22222222-2222-4222-8222-222222222222";
const channelId = "33333333-3333-4333-8333-333333333333";
const userId = "44444444-4444-4444-8444-444444444444";
const parentMessageId = "55555555-5555-4555-8555-555555555555";
const mentionId = "66666666-6666-4666-8666-666666666666";
function command(overrides: Record<string, unknown> = {}) {
return {
workspace: workspaceId,
command: { type: "send_message", workspaceId, channelId, context: { type: "workspace", workspaceId }, body: "hello", clientIdempotencyKey: "client-1", ...overrides },
};
}
function harness(options: {
kind?: "public" | "private";
archived?: boolean;
channelMember?: boolean;
workspaceMember?: boolean;
existing?: boolean;
parent?: {
workspaceId: string;
channelId: string;
parentMessageId: string | null;
};
validMentions?: string[];
}) {
return {
kind: options.kind,
archived: options.archived,
channelMember: options.channelMember,
workspaceMember: options.workspaceMember,
existing: options.existing,
parent: options.parent,
validMentions: options.validMentions,
});
}
describe("Chat router lifecycle", () => {
it("lists visible channels with correct workspace scoping", async () => {
// Mock workspace resolution
vi.spyOn(resolveWorkspace, "default").mockImplementation(() => workspaceId);
// Mock channel list
const mockChannels = [
{ id: "1", workspaceId: workspaceId, kind: "public", name: "Public Channel" },
{ id: "2", workspaceId: workspaceId, kind: "private", name: "Private Channel" },
];
vi.spyOn(createChatTransactionRunner, "run").mockResolvedValueOnce({ runner: true });
// Mock channel retrieval
const listVisible = await createChatTransactionRunner(workspaceId).listVisible(workspaceId, userId);
expect(listVisible).toEqual(mockChannels);
});
it("creates public channel with owner membership", async () => {
// Mock workspace resolution
vi.spyOn(resolveWorkspace, "default").mockImplementation(() => workspaceId);
// Mock channel creation
const mockChannel = { id: "4", workspaceId: workspaceId, kind: "public", name: "New Public", ownerUserId: userId, directKey: null, archivedAt: null };
vi.spyOn(createChatTransactionRunner, "run").mockImplementation(async (tx) => {
const result = await tx.createChannel(workspaceId, "New Public", "public");
return { runner: true, channel: result };
});
// Mock transaction runner
const runner = createChatTransactionRunner(workspaceId);
const result = await runner.createChannel(workspaceId, "New Public", "public");
expect(runner).toHaveBeenCalledTimes(1);
expect(result).toMatchObject(mockChannel);
});
it("creates private channel with owner and member", async () => {
// Mock workspace resolution
vi.spyOn(resolveWorkspace, "default").mockImplementation(() => workspaceId);
// Mock channel creation
const mockChannel = { id: "5", workspaceId: workspaceId, kind: "private", name: "New Private", ownerUserId: userId, directKey: null, archivedAt: null };
vi.spyOn(createChatTransactionRunner, "run").mockImplementation(async (tx) => {
const result = await tx.createChannel(workspaceId, "New Private", "private");
return { runner: true, channel: result };
});
const runner = createChatTransactionRunner(workspaceId);
const result = await runner.createChannel(workspaceId, "New Private", "private");
expect(runner).toHaveBeenCalledTimes(1);
expect(result).toMatchObject(mockChannel);
});
it("renames channel with owner permission", async () => {
// Mock workspace resolution
vi.spyOn(resolveWorkspace, "default").mockImplementation(() => workspaceId);
// Mock rename
const mockChannel = { id: "6", workspaceId: workspaceId, kind: "private", name: "Renamed", ownerUserId: userId, directKey: null, archivedAt: null };
vi.spyOn(createChatTransactionRunner, "run").mockImplementation(async (tx) => {
const result = await tx.renameChannel(workspaceId, channelId, "Renamed");
return { runner: true, channel: result };
});
const runner = createChatTransactionRunner(workspaceId);
const result = await runner.renameChannel(workspaceId, channelId, "Renamed");
expect(runner).toHaveBeenCalledTimes(1);
expect(result).toMatchObject(mockChannel);
});
it("archives channel when owner", async () => {
// Mock workspace resolution
vi.spyOn(resolveWorkspace, "default").mockImplementation(() => workspaceId);
// Mock archive
const mockChannel = { id: "7", workspaceId: workspaceId, kind: "private", name: "Archived", ownerUserId: userId, directKey: null, archivedAt: new Date() };
vi.spyOn(createChatTransactionRunner, "run").mockImplementation(async (tx) => {
const result = await tx.archiveChannel(workspaceId, channelId);
return { runner: true, channel: result };
});
const runner = createChatTransactionRunner(workspaceId);
const result = await runner.archiveChannel(workspaceId, channelId);
expect(runner).toHaveBeenCalledTimes(1);
expect(result).toMatchObject(mockChannel);
});
it("adds private member with owner permission", async () => {
// Mock workspace resolution
vi.spyOn(resolveWorkspace, "default").mockImplementation(() => workspaceId);
// Mock member addition
const mockChannel = { id: "8", workspaceId: workspaceId, kind: "private", name: "With Member", ownerUserId: userId, directKey: null, archivedAt: null, memberCount: 2 };
vi.spyOn(createChatTransactionRunner, "run").mockImplementation(async (tx) => {
const result = await tx.addPrivateMember(workspaceId, channelId, "66666666-6666-4666-8666-666666666666");
return { runner: true, channel: result };
});
const runner = createChatTransactionRunner(workspaceId);
const result = await runner.addPrivateMember(workspaceId, channelId, "66666666-6666-4666-8666-666666666666");
expect(runner).toHaveBeenCalledTimes(1);
expect(result.memberCount).toBe(2);
});
it("removes private member", async () => {
// Mock workspace resolution
vi.spyOn(resolveWorkspace, "default").mockImplementation(() => workspaceId);
// Mock member removal
const mockChannel = { id: "9", workspaceId: workspaceId, kind: "private", name: "Without Member", ownerUserId: userId, directKey: null, archivedAt: null, memberCount: 1 };
vi.spyOn(createChatTransactionRunner, "run").mockImplementation(async (tx) => {
const result = await tx.removePrivateMember(workspaceId, channelId, "44444444-4444-4444-8444-444444444444");
return { runner: true, channel: result };
});
const runner = createChatTransactionRunner(workspaceId);
const result = await runner.removePrivateMember(workspaceId, channelId, "44444444-4444-4444-8444-444444444444");
expect(runner).toHaveBeenCalledTimes(1);
expect(result.memberCount).toBe(1);
});
it("creates direct channel with exact participant set", async () => {
// Mock workspace resolution
vi.spyOn(resolveWorkspace, "default").mockImplementation(() => workspaceId);
// Mock direct channel creation
const participantIds = ["44444444-4444-4444-8444-444444444444", "55555555-5555-4555-8555-555555555555"];
const mockChannel = { id: "10", workspaceId: workspaceId, kind: "direct", name: "Direct", ownerUserId: userId, directKey: "44444444-4444-4444-8444-444444444444:55555555-5555-4555-8555-555555555555", archivedAt: null };
vi.spyOn(createChatTransactionRunner, "run").mockImplementation(async (tx) => {
const result = await tx.createDirectChannel(workspaceId, participantIds);
return { runner: true, channel: result };
});
const runner = createChatTransactionRunner(workspaceId);
const result = await runner.createDirectChannel(workspaceId, participantIds);
expect(runner).toHaveBeenCalledTimes(1);
expect(result).toMatchObject(mockChannel);
});
it("rejects cross-workspace channel creation", async () => {
// Mock workspace resolution to other workspace
vi.spyOn(resolveWorkspace, "default").mockImplementation(() => otherWorkspaceId);
// Mock channel creation attempt
vi.spyOn(createChatTransactionRunner, "run").mockImplementation(async (tx) => {
const result = await tx.createChannel(otherWorkspaceId, "Cross Workspace", "public");
return { runner: true, channel: result };
});
const runner = createChatTransactionRunner(otherWorkspaceId);
const result = await runner.createChannel(otherWorkspaceId, "Cross Workspace", "public");
expect(result).toBeUndefined(); // Should fail due to workspace mismatch
});
it("maps channel persistence errors correctly", async () => {
// Mock workspace resolution
vi.spyOn(resolveWorkspace, "default").mockImplementation(() => workspaceId);
// Mock channel creation failure
vi.spyOn(createChatTransactionRunner, "run").mockImplementation(async (tx) => {
throw new ChatPersistenceError("NOT_FOUND");
});
const runner = createChatTransactionRunner(workspaceId);
await expect(runner.createChannel(workspaceId, "Invalid", "private")).rejects.toMatchObject({
code: "NOT_FOUND",
message: "Chat channel not found",
});
});
});

View file

@ -0,0 +1,218 @@
import { TRPCError } from "@trpc/server";
import {
chatChannelNameSchema,
chatClientMutationIdSchema,
chatCommandSchema,
chatCreateChannelInputSchema,
chatCreateDirectInputSchema,
} from "@tasks/shared";
import {
addPrivateMember,
archiveChannel,
ChannelPersistenceError,
ChatPersistenceError,
createChannel,
createChannelTransactionRunner,
createChatTransactionRunner,
createDirectChannel,
listVisibleChannels,
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 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",
});
}
}
export const chatRouter = router({
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,
}),
),
),
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);
}
}),
});

15
apps/web/vitest.config.ts Normal file
View file

@ -0,0 +1,15 @@
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

@ -0,0 +1,26 @@
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

@ -0,0 +1,109 @@
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

@ -0,0 +1,19 @@
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

@ -0,0 +1,48 @@
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();

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,6 +127,34 @@
"when": 1787698170280,
"tag": "0017_lively_squadron_supreme",
"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
}
]
}
}

View file

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

View file

@ -0,0 +1,312 @@
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) => ({ ...c });
const changed = (result: unknown) =>
Number((result as { rowCount?: number }).rowCount ?? 0) > 0;
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 result = await tx.execute(
sql`select * from ${chatChannels} where ${chatChannels.workspaceId}=${workspaceId} and ${chatChannels.id}=${channelId} for update`,
);
const c = (
result as unknown as Array<typeof chatChannels.$inferSelect>
)[0];
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 {
return changed(
await tx
.insert(chatChannelMembers)
.values({ workspaceId, channelId, userId })
.onConflictDoNothing(),
);
} catch (e) {
translate(e);
}
},
async removeMember(workspaceId, channelId, userId) {
return changed(
await tx
.delete(chatChannelMembers)
.where(
and(
eq(chatChannelMembers.workspaceId, workspaceId),
eq(chatChannelMembers.channelId, channelId),
eq(chatChannelMembers.userId, userId),
),
),
);
},
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,
});
},
};
}
export function createChannelTransactionRunner(
database: typeof defaultDb = defaultDb,
): ChannelRunner {
return (fn) => database.transaction((tx) => fn(adapter(tx)));
}

View file

@ -0,0 +1,223 @@
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];
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,
}
: 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);
},
};
}
export function createChatTransactionRunner(
database: ChatDatabase = defaultDb,
): ChatTransactionRunner {
return {
transaction: (run) =>
database.transaction((tx) => run(transactionAdapter(tx))),
};
}
export async function sendChatMessageWithDb(
input: Parameters<typeof sendChatMessage>[1],
database: ChatDatabase = defaultDb,
) {
return sendChatMessage(createChatTransactionRunner(database), input);
}

View file

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

View file

@ -0,0 +1,173 @@
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("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("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

@ -0,0 +1,463 @@
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");
return chatLifecycleReceiptResultSchema.parse(receipt.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

@ -0,0 +1,242 @@
import { describe, expect, it } from "vitest";
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";
archived?: boolean;
channelMember?: boolean;
workspaceMember?: boolean;
existing?: boolean;
persistedMentions?: string[];
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,
body: "hello",
parentMessageId: null,
authorUserId: userId,
clientIdempotencyKey: "client-1",
};
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]);
return {
actor: {
userId,
workspaceId,
activeWorkspaceMember: options.workspaceMember ?? true,
role: "member",
},
channel: {
id: channelId,
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",
"existing-mentions",
]);
});
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[1]).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",
"existing-mentions",
]);
},
);
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"]);
});
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

@ -0,0 +1,256 @@
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;
};
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 matchesIntent(
message: PersistedChatMessage,
command: SendCommand,
actorUserId: string,
): boolean {
return (
message.workspaceId === command.workspaceId &&
message.channelId === command.channelId &&
message.clientIdempotencyKey === command.clientIdempotencyKey &&
message.authorUserId === actorUserId &&
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,
});
if (existing) {
if (!matchesIntent(existing, command, actorUserId))
throw new ChatPersistenceError("CHAT_IDEMPOTENCY_CONFLICT");
const persistedMentionIds = [
...new Set(
await tx.loadMessageMentions({
workspaceId: command.workspaceId,
channelId: command.channelId,
messageId: existing.id,
}),
),
].sort();
if (
persistedMentionIds.length !== mentionIds.length ||
persistedMentionIds.some((id, index) => id !== mentionIds[index])
)
throw new ChatPersistenceError("CHAT_IDEMPOTENCY_CONFLICT");
return { created: false, message: existing };
}
const facts = await tx.loadSendFacts({
workspaceId: command.workspaceId,
channelId: command.channelId,
actorUserId,
});
if (
!facts ||
!canPerformChatAction({
action: "send",
actor: facts.actor,
channel: facts.channel,
context: command.context,
})
)
throw new ChatPersistenceError("CHAT_FORBIDDEN");
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,
});
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: inserted };
});
}

View file

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

View file

@ -0,0 +1,25 @@
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

@ -0,0 +1,220 @@
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

@ -0,0 +1,333 @@
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`),
/** 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(),
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),
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 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,3 +22,4 @@ export * from "./harness_providers";
export * from "./convoy_operator_messages";
export * from "./completion_artifacts";
export * from "./budget_caps";
export * from "./chat";

View file

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

View file

@ -0,0 +1,468 @@
import { describe, expect, it } from "vitest";
import {
CHAT_IDEMPOTENCY_KEY_MAX_LENGTH,
CHAT_MESSAGE_MAX_BODY_LENGTH,
chatCommandSchema,
chatContextSchema,
chatMessageCreatedV1Schema,
chatPolicyInputSchema,
canPerformChatAction,
getChatCommandAuthorizationAction,
} from "./index";
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" },
{ ...scope, type: "delete_message" as const, 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);
});
});

View file

@ -0,0 +1,387 @@
import { z } from "zod";
import { workspaceRoles } from "../types/roles";
const idSchema = z.string().uuid();
export const CHAT_MESSAGE_MAX_BODY_LENGTH = 20_000;
export const CHAT_IDEMPOTENCY_KEY_MAX_LENGTH = 200;
export const chatMessageCreatedV1Schema = z
.object({
eventType: z.literal("chat.message.created.v1"),
eventVersion: z.literal(1),
workspaceId: idSchema,
channelId: idSchema,
messageId: idSchema,
sequence: z.string().regex(/^[1-9]\d*$/),
author: z.object({ type: z.literal("human"), userId: idSchema }).strict(),
body: z.string().min(1).max(CHAT_MESSAGE_MAX_BODY_LENGTH),
parentMessageId: idSchema.nullable(),
mentionedUserIds: z.array(idSchema).max(100),
clientIdempotencyKey: z
.string()
.min(1)
.max(CHAT_IDEMPOTENCY_KEY_MAX_LENGTH),
})
.strict();
export type ChatMessageCreatedV1 = z.infer<typeof chatMessageCreatedV1Schema>;
const channelEventBase = {
eventVersion: z.literal(1),
workspaceId: idSchema,
channelId: idSchema,
actorUserId: idSchema,
clientMutationId: idSchema,
};
export const chatChannelLifecycleEventV1Schema = z.discriminatedUnion(
"eventType",
[
z
.object({
...channelEventBase,
eventType: z.literal("chat.channel.created.v1"),
kind: z.enum(["public", "private", "direct"]),
})
.strict(),
z
.object({
...channelEventBase,
eventType: z.literal("chat.channel.renamed.v1"),
name: z.string().trim().min(1).max(200),
})
.strict(),
z
.object({
...channelEventBase,
eventType: z.literal("chat.channel.archived.v1"),
})
.strict(),
z
.object({
...channelEventBase,
eventType: z.literal("chat.channel.member.added.v1"),
subjectUserId: idSchema,
})
.strict(),
z
.object({
...channelEventBase,
eventType: z.literal("chat.channel.member.removed.v1"),
subjectUserId: idSchema,
})
.strict(),
],
);
export type ChatChannelLifecycleEventV1 = z.infer<
typeof chatChannelLifecycleEventV1Schema
>;
export const chatChannelResultSchema = z
.object({
id: idSchema,
workspaceId: idSchema,
kind: z.enum(["public", "private", "direct"]),
name: z.string().nullable(),
ownerUserId: idSchema,
directKey: z.string().nullable(),
archivedAt: z.string().datetime().nullable(),
})
.strict();
export const chatMemberMutationResultSchema = z
.object({
channelId: idSchema,
userId: idSchema,
changed: z.boolean(),
})
.strict();
export const chatLifecycleReceiptResultSchema = z.discriminatedUnion(
"operation",
[
z
.object({
operation: z.literal("create_channel"),
channel: chatChannelResultSchema,
})
.strict(),
z
.object({
operation: z.literal("rename_channel"),
channel: chatChannelResultSchema,
})
.strict(),
z
.object({
operation: z.literal("archive_channel"),
channel: chatChannelResultSchema,
})
.strict(),
z
.object({
operation: z.literal("add_private_member"),
member: chatMemberMutationResultSchema,
})
.strict(),
z
.object({
operation: z.literal("remove_private_member"),
member: chatMemberMutationResultSchema,
})
.strict(),
z
.object({
operation: z.literal("create_direct"),
channel: chatChannelResultSchema,
})
.strict(),
],
);
export type ChatLifecycleReceiptResult = z.infer<
typeof chatLifecycleReceiptResultSchema
>;
export const chatChannelNameSchema = z.string().trim().min(1).max(200);
export const chatClientMutationIdSchema = idSchema;
export const chatCreateChannelInputSchema = z
.object({
workspace: z.string().min(1),
kind: z.enum(["public", "private"]),
name: chatChannelNameSchema,
clientMutationId: chatClientMutationIdSchema,
})
.strict();
export const chatCreateDirectInputSchema = z
.object({
workspace: z.string().min(1),
participantUserIds: z.array(idSchema).min(2).max(100),
clientMutationId: chatClientMutationIdSchema,
})
.strict();
export const chatContextSchema = z
.object({ type: z.literal("workspace"), workspaceId: idSchema })
.strict();
export type ChatContext = z.infer<typeof chatContextSchema>;
const commandScopeShape = {
workspaceId: idSchema,
channelId: idSchema,
context: chatContextSchema,
};
const commandVariant = <T extends z.ZodRawShape>(shape: T) =>
z.object({ ...commandScopeShape, ...shape }).strict();
const commandVariants = [
commandVariant({
type: z.literal("send_message"),
body: z.string().trim().min(1).max(CHAT_MESSAGE_MAX_BODY_LENGTH),
parentMessageId: idSchema.optional(),
clientIdempotencyKey: z
.string()
.trim()
.min(1)
.max(CHAT_IDEMPOTENCY_KEY_MAX_LENGTH),
}),
commandVariant({
type: z.literal("edit_message"),
messageId: idSchema,
body: z.string().trim().min(1).max(CHAT_MESSAGE_MAX_BODY_LENGTH),
}),
commandVariant({ type: z.literal("delete_message"), messageId: idSchema }),
commandVariant({
type: z.literal("mark_read"),
sequence: z.number().int().nonnegative(),
}),
commandVariant({ type: z.literal("archive_channel") }),
commandVariant({
type: z.literal("change_membership"),
userId: idSchema,
operation: z.enum(["add", "remove"]),
}),
] as const;
export const chatCommandSchema = z
.discriminatedUnion("type", commandVariants)
.superRefine((command, ctx) => {
if (command.workspaceId !== command.context.workspaceId)
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Command and context workspaces must match",
path: ["context", "workspaceId"],
});
});
export type ChatCommand = z.infer<typeof chatCommandSchema>;
export type ChatAuthorizationAction =
| "read"
| "send"
| "edit_own"
| "moderate"
| "delete_message"
| "manage_channel"
| "manage_members"
| "join";
export function getChatCommandAuthorizationAction(
command: ChatCommand,
): ChatAuthorizationAction {
const actions: Record<ChatCommand["type"], ChatAuthorizationAction> = {
send_message: "send",
edit_message: "edit_own",
delete_message: "delete_message",
mark_read: "read",
archive_channel: "manage_channel",
change_membership: "manage_members",
};
return actions[command.type];
}
const actorSchema = z
.object({
userId: idSchema,
workspaceId: idSchema,
activeWorkspaceMember: z.boolean(),
role: z.enum(workspaceRoles),
})
.strict();
const channelSchema = z
.object({
id: idSchema,
workspaceId: idSchema,
kind: z.enum(["public", "private", "direct"]),
archived: z.boolean(),
activeMember: z.boolean(),
ownerUserId: idSchema,
})
.strict();
const messageAuthorSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("human"), userId: idSchema }).strict(),
z.object({ type: z.literal("agent"), agentId: idSchema }).strict(),
z.object({ type: z.literal("system") }).strict(),
]);
const messageSchema = z
.object({
id: idSchema,
workspaceId: idSchema,
channelId: idSchema,
author: messageAuthorSchema,
})
.strict();
const targetSchema = z
.object({
userId: idSchema,
workspaceId: idSchema,
activeWorkspaceMember: z.boolean(),
activeChannelMember: z.boolean(),
})
.strict();
const policyBase = {
actor: actorSchema,
context: chatContextSchema,
channel: channelSchema,
};
export const chatPolicyInputSchema = z.discriminatedUnion("action", [
z.object({ ...policyBase, action: z.literal("read") }).strict(),
z.object({ ...policyBase, action: z.literal("send") }).strict(),
z
.object({
...policyBase,
action: z.literal("edit_own"),
message: messageSchema,
})
.strict(),
z
.object({
...policyBase,
action: z.literal("moderate"),
message: messageSchema,
})
.strict(),
z
.object({
...policyBase,
action: z.literal("delete_message"),
message: messageSchema,
})
.strict(),
z.object({ ...policyBase, action: z.literal("manage_channel") }).strict(),
z
.object({
...policyBase,
action: z.literal("manage_members"),
operation: z.enum(["add", "remove"]),
target: targetSchema,
})
.strict(),
z.object({ ...policyBase, action: z.literal("join") }).strict(),
]);
export type ChatPolicyInput = z.infer<typeof chatPolicyInputSchema>;
export function canPerformChatAction(input: unknown): boolean {
const result = chatPolicyInputSchema.safeParse(input);
if (!result.success) return false;
const { action, actor, context, channel } = result.data;
if (
!actor.activeWorkspaceMember ||
actor.workspaceId !== context.workspaceId ||
actor.workspaceId !== channel.workspaceId
)
return false;
const hasAccess = channel.kind === "public" || channel.activeMember;
if (!hasAccess) return false;
if (channel.archived) return action === "read";
const canManage =
actor.userId === channel.ownerUserId ||
actor.role === "admin" ||
actor.role === "owner";
const hasMatchingMessageScope = (message: {
workspaceId: string;
channelId: string;
}) =>
message.workspaceId === channel.workspaceId &&
message.channelId === channel.id;
switch (result.data.action) {
case "read":
return true;
case "send":
return true;
case "edit_own":
return (
hasMatchingMessageScope(result.data.message) &&
result.data.message.author.type === "human" &&
result.data.message.author.userId === actor.userId
);
case "moderate":
return hasMatchingMessageScope(result.data.message) && canManage;
case "delete_message": {
if (!hasMatchingMessageScope(result.data.message)) return false;
const isHumanAuthor =
result.data.message.author.type === "human" &&
result.data.message.author.userId === actor.userId;
return isHumanAuthor || canManage;
}
case "manage_channel":
return canManage;
case "manage_members": {
const { operation, target } = result.data;
if (
channel.kind !== "private" ||
target.workspaceId !== channel.workspaceId ||
target.userId === channel.ownerUserId ||
!canManage
)
return false;
if (operation === "add")
return target.activeWorkspaceMember && !target.activeChannelMember;
return target.activeChannelMember;
}
case "join":
return channel.kind === "public";
default:
return assertNever(result.data);
}
}
function assertNever(_value: never): false {
return false;
}

View file

@ -1,3 +1,4 @@
export * from "./chat";
export * from "./connected-repos";
export * from "./convoy-lifecycle";
export * from "./model-routing";

View file

@ -0,0 +1,66 @@
---
kind: epic
slug: repo-discovery-onboarding
title: Repository discovery, context import, and agent onboarding
plan_slug: automation-foundation
status: ready
priority: P1
tenant_id: global
cursor_epic_id: null
updated_at: "2026-09-25"
---
# Epic objective
Replace one-repository-at-a-time URL entry with a secure account/instance flow: discover repositories visible to the current operator, explicitly select repositories for the current workspace, import bounded repository context, and show accurate agent setup instructions.
## Product decisions
- Repositories are attached to the current workspace; discovery does not implicitly create workspaces.
- GitHub uses the current user's linked Auth.js account. Access tokens remain server-side and users explicitly re-authorize when additional scope is required.
- Forgejo/Gitea v1 uses an instance URL and a server-side token environment-variable reference. Plaintext PATs are not persisted in the application database.
- Batch connection and context ingestion are separate operations. Connecting repos is fast and atomic; remote content import is retryable and reports per-repo progress.
- V1 context import is bounded to root `README.md`, `AGENTS.md`, `CONTRIBUTING.md`, and `.convoys/*.md`. Recursive `docs/**` and `plans/**` import is deferred until source identity and conflict semantics are specified.
- Agent setup instructions must match a transport the MCP server actually supports and must never expose `DATABASE_URL`, forge tokens, or provider API keys.
## In scope
- Paginated GitHub and Forgejo/Gitea repository discovery with normalized, zod-validated results.
- Searchable multi-select picker and admin-only, idempotent batch connection.
- Idempotent import of supported docs as `document` objects and existing convoy manifests with source provenance.
- OpenCode and Cursor onboarding guidance for the deployed MCP transport.
- Optional bootstrap PR only when branch/file commit support exists; otherwise a separate follow-up task is created.
## Out of scope
- Automatic workspace creation for each repository.
- Unbounded recursive import or two-way synchronization of remote documentation.
- Browser-visible forge credentials or direct database credentials in generated snippets.
- Full git worktree management, which remains in `apps/worker`.
## Dependencies
- Epic `connected-repos` for the base schema, CRUD router, and forge adapters.
- Auth.js account linking in `apps/web/lib/auth.ts`.
- Convoy lifecycle schemas and local reconciliation behavior.
- A configured workspace model route, Redis, and worker are runtime dependencies for executing the implementation convoy, not for using the finished repo picker.
## Acceptance criteria
- [ ] A GitHub-linked user or configured Forgejo/Gitea admin sees all accessible repositories through explicit pagination without receiving a token in the browser.
- [ ] An admin selects multiple repos and connects them to the current workspace in an idempotent operation; manual URL connection still works.
- [ ] Supported documentation imports as `document` objects with repo/path/revision provenance and no duplicates on retry.
- [ ] Valid `.convoys/*.md` manifests reconcile into workspace-scoped convoy objects; malformed files are reported without aborting other imports.
- [ ] OpenCode and Cursor setup instructions are syntactically accurate for the supported transport and contain no secrets.
- [ ] Tenant isolation, credential redaction, pagination, idempotency, and partial-failure behavior have automated coverage.
## Tasks
- [ ] [Task-forge-discovery-api](./Task-forge-discovery-api.md)
- [ ] [Task-repo-picker-modal](./Task-repo-picker-modal.md)
- [ ] [Task-repo-doc-ingestion](./Task-repo-doc-ingestion.md)
- [ ] [Task-agent-mcp-starter](./Task-agent-mcp-starter.md)
## Execution convoy
`.convoys/repo-discovery-onboarding.md`

View file

@ -0,0 +1,55 @@
---
kind: task
slug: agent-mcp-starter
title: Agent onboarding and repository bootstrap
plan_slug: automation-foundation
epic_slug: repo-discovery-onboarding
status: ready
priority: P2
tenant_id: global
owner: unassigned
cursor_todo_id: null
updated_at: "2026-09-25"
---
# Task summary
Show client-specific, copyable setup instructions for connecting OpenCode and Cursor to Echodo's supported MCP transport, with an optional repository bootstrap PR only when forge commit primitives exist.
## Description
Generated setup must reflect the actual MCP server contract. A browser-visible snippet must never contain `DATABASE_URL`, forge credentials, or model-provider secrets. Workspace selection should use a non-secret workspace slug/tool argument unless a supported authenticated remote transport defines another mechanism.
## Subtasks
- [ ] Verify and document the supported MCP transport and configuration shape before generating templates.
- [ ] Add a typed onboarding-config endpoint returning only non-secret deployment, workspace, and selected-repo identifiers.
- [ ] Add OpenCode and Cursor tabs with validated configuration examples, prerequisites, copy feedback, and a `query_manifest_status` smoke test.
- [ ] Detect unsupported deployment/client combinations and show actionable guidance instead of fabricated JSON.
- [ ] Determine whether forge adapters support branch creation plus file commits. If not, defer bootstrap PR to a new task.
- [ ] If supported, create an idempotent branch/file/commit/PR flow that never overwrites an existing `.convoys/README.md` without confirmation.
## Security constraints
- Never render `DATABASE_URL`, PAT values, OAuth access tokens, or API-key values.
- Remote-write actions require workspace owner/admin.
- `createPullRequest` alone is insufficient to bootstrap a file; the branch and commit must exist first.
## Estimation
M
## Acceptance criteria
- [ ] OpenCode and Cursor examples are syntactically valid for their actual clients and the deployed transport.
- [ ] Instructions identify the workspace/repo without embedding a secret.
- [ ] Copy success/error feedback works in supported browser contexts.
- [ ] The smoke test confirms the agent can query convoy state.
- [ ] Bootstrap is either implemented end-to-end with an idempotent PR URL or explicitly deferred with a new backlog task.
- [ ] Web lint, type-check, and relevant tests pass.
## Links
- Epic: `./Epic-repo-discovery-onboarding.md`
- Depends on: `./Task-repo-picker-modal.md`, `./Task-repo-doc-ingestion.md`
- Convoy brief: `../../../.convoys/repo-discovery-onboarding/brief-4-agent-mcp.md`

View file

@ -0,0 +1,55 @@
---
kind: task
slug: forge-discovery-api
title: Forge account repository discovery API
plan_slug: automation-foundation
epic_slug: repo-discovery-onboarding
status: ready
priority: P1
tenant_id: global
owner: unassigned
cursor_todo_id: null
updated_at: "2026-09-25"
---
# Task summary
Expose an admin-only, paginated tRPC query that discovers repositories visible to the current user's linked GitHub identity or a configured Forgejo/Gitea instance and marks repos already connected to the current workspace.
## Description
The existing forge adapter is repository-specific and resolves credentials through `token_env_var`. Discovery needs a separate account/instance context and typed connection states. Credentials stay on the server: GitHub uses the current user's `accounts` row, while Forgejo/Gitea uses an instance URL plus a token environment-variable reference.
## Subtasks
- [ ] Add shared zod schemas for discovery source, normalized repository summary, pagination, and typed states such as `connected`, `configuration_required`, and `reauthorization_required`.
- [ ] Configure GitHub authorization scopes and detect linked accounts whose stored scope is insufficient.
- [ ] Implement paginated GitHub `/user/repos` and Forgejo/Gitea `/user/repos` adapters with validated response mapping.
- [ ] Add `connectedRepos.listAvailable`, restricted to workspace owner/admin.
- [ ] Resolve credentials using the current user/workspace only and redact provider response details from client errors.
- [ ] Mark existing repos using `(workspace_id, api_base_url, owner, repo_name)`.
- [ ] Add mocked tests for pagination, private repos, malformed responses, missing credentials/scopes, and tenant-scoped matching.
## Security constraints
- No raw token in tRPC input or output.
- Never select another member's linked GitHub account.
- Do not store a plaintext Forgejo/Gitea PAT in the database.
- Preserve existing manual connect/test/update/disconnect behavior.
## Estimation
M
## Acceptance criteria
- [ ] Normalized results include stable remote ID, full name, owner, HTTPS clone URL, web URL, default branch, privacy, description, and updated time.
- [ ] Accounts with more than one provider page are not silently truncated.
- [ ] Missing/insufficient credentials return an actionable typed state without leaking secrets.
- [ ] Already-connected status is accurate only within the resolved workspace.
- [ ] Shared tests, web lint, and web type-check pass.
## Links
- Epic: `./Epic-repo-discovery-onboarding.md`
- Convoy brief: `../../../.convoys/repo-discovery-onboarding/brief-1-api.md`

View file

@ -0,0 +1,59 @@
---
kind: task
slug: repo-doc-ingestion
title: Idempotent repository context ingestion
plan_slug: automation-foundation
epic_slug: repo-discovery-onboarding
status: ready
priority: P1
tenant_id: global
owner: unassigned
cursor_todo_id: null
updated_at: "2026-09-25"
---
# Task summary
Provide an explicit, retryable import that reads bounded repository documentation and existing convoy manifests through the forge API, stores source provenance, and avoids duplicate Echodo objects.
## Description
Remote reads should not lengthen or roll back the batch-connect transaction. After connection, an admin starts import and sees per-repo status. V1 scans only root `README.md`, `AGENTS.md`, `CONTRIBUTING.md`, and `.convoys/*.md`; recursive `docs/**` and `plans/**` import is deferred.
## Subtasks
- [ ] Add bounded file/list-directory forge contracts with response, media type, file-count, and byte-size validation.
- [ ] Extract pure convoy markdown parsing into a neutral shared package so web and MCP do not import each other's app source.
- [ ] Define durable source identity and import status by workspace, connected repo, and source path; generate a new migration if schema changes are needed.
- [ ] Store docs as the canonical `document` object type with forge/repo/path/revision metadata.
- [ ] Reconcile valid `.convoys/*.md` into workspace-scoped projects and valid brief metadata into child tasks.
- [ ] Add admin-only import start/status/retry APIs or an equivalent bounded background execution mechanism.
- [ ] Report imported, unchanged, skipped, and failed counts without aborting successful files.
- [ ] Test idempotency, malformed manifests, missing optional files, partial failure, limits, and tenant isolation.
## Security and sync constraints
- Remote files are read-only in this task.
- Re-import updates only objects with matching source identity and a changed revision.
- Missing files are `skipped`, while authentication/rate-limit/parse/persistence failures are visible.
- Read `config/CursorSync.md` before changing reconciliation behavior.
## Estimation
L
## Acceptance criteria
- [ ] Re-running an unchanged import creates no duplicate documents, convoys, or briefs.
- [ ] Imported docs use `type: "document"` and record source repo/path/revision.
- [ ] Valid convoy manifests appear in Mission Control; malformed manifests do not block other imports.
- [ ] File count and byte limits prevent unbounded repository ingestion.
- [ ] Failures are retryable and reported per repo/file.
- [ ] New schema changes include generated migration SQL.
- [ ] Lint, type-check, test, and relevant builds pass.
## Links
- Epic: `./Epic-repo-discovery-onboarding.md`
- Depends on: `./Task-forge-discovery-api.md`, `./Task-repo-picker-modal.md`
- Convoy brief: `../../../.convoys/repo-discovery-onboarding/brief-3-ingestion.md`

View file

@ -0,0 +1,51 @@
---
kind: task
slug: repo-picker-modal
title: Repository picker and batch connection
plan_slug: automation-foundation
epic_slug: repo-discovery-onboarding
status: ready
priority: P1
tenant_id: global
owner: unassigned
cursor_todo_id: null
updated_at: "2026-09-25"
---
# Task summary
Add a searchable, paginated repository picker to Connected Repos and an admin-only batch mutation that safely connects selected remote repositories to the current workspace.
## Description
The discovery result from `Task-forge-discovery-api` is an untrusted client boundary. The server must re-resolve or validate selected remote IDs against the authorized forge account instead of accepting arbitrary URLs supplied by the browser. The existing manual URL dialog remains available.
## Subtasks
- [ ] Add `connectedRepos.batchConnect` with zod validation and workspace owner/admin authorization.
- [ ] Re-resolve selected remote IDs server-side and derive forge URL, owner, repo, default branch, and metadata from the authorized provider response.
- [ ] Insert in one transaction with conflict-safe handling of `connected_repos_workspace_repo_unique`.
- [ ] Add a discovery dialog using existing Radix/UI primitives with provider connection states, search, pagination/load-more, multi-select, and connected/disabled rows.
- [ ] Preserve selection while paging and show loading, empty, partial-error, and import-progress states.
- [ ] Invalidate both connected and available repo queries after success.
- [ ] Add tests for forged selections, duplicate imports, tenant isolation, and selection helpers where feasible.
## Estimation
M
## Acceptance criteria
- [ ] GitHub and Forgejo/Gitea sources display actionable authorization/configuration states.
- [ ] Search matches full name and description while selected repos remain selected across pages.
- [ ] Already-connected repos cannot be selected.
- [ ] Batch connection is atomic for valid selections, idempotent for duplicates, and returns per-repo results.
- [ ] A stale or manipulated browser payload cannot connect an arbitrary repository URL.
- [ ] Manual URL connection continues to work.
- [ ] Web lint and type-check pass.
## Links
- Epic: `./Epic-repo-discovery-onboarding.md`
- Depends on: `./Task-forge-discovery-api.md`
- Convoy brief: `../../../.convoys/repo-discovery-onboarding/brief-2-ui.md`

View file

@ -0,0 +1,80 @@
---
kind: epic
slug: agent-participation
title: AI agent studio, marketplace, and chat participation
plan_slug: workspace-communications
status: ready
priority: P1
tenant_id: global
cursor_epic_id: null
updated_at: "2026-10-22"
---
# Epic objective
Turn the existing AI rail destination into a unified AI workspace where users switch between chatting with agents and creating/managing them through an AI-assisted studio and agent marketplace. Workspace-scoped agents can then join channels, respond to explicit invocations, use approved tools, and stream normalized run events without coupling the UI to a provider or execution runtime.
## In scope / out of scope
**In scope**
- A single `/<workspaceSlug>/ai` destination with URL-backed `Chat` and `Agents` tabs.
- A shared composer shell whose behavior changes by tab: conversation in Chat and natural-language agent building in Agents.
- A workspace agent registry and marketplace library for templates, installed agents, workspace-created agents, drafts, and new-agent entry points.
- AI-assisted agent drafting that produces editable structured configuration before explicit save/install/publish actions.
- Agent identities, channel membership, mention resolution, and invocation policy.
- Durable agent jobs with claim, retry, cancellation, budget, and idempotency semantics.
- A normalized harness event contract for text, tools, approvals, failures, and completion.
- Streaming agent state to chat and persisting the final response and run linkage.
**Out of scope**
- Autonomous reading of every channel by default.
- A chat-specific model registry.
- Installing or publishing marketplace agents without an explicit review/confirmation step.
- Public ratings, billing, or cross-workspace commercial distribution in the first release.
- Bypassing workspace model policy or tenant-scoped MCP authorization.
## Related tasks
| Task | Link |
|------|------|
| Build the AI Chat/Agents workspace shell | `./Task-ai-workspace-tabs.md` |
| Define the versioned agent registry schema and contracts | `./Task-agent-registry-marketplace.md` |
| Expose the shared catalog and private draft API | `./Task-agent-registry-api.md` |
| Enforce reviewed installation and lifecycle | `./Task-agent-install-lifecycle.md` |
| Build the agent marketplace library UI | `./Task-agent-marketplace-library-ui.md` |
| Generate structured agent drafts with AI | `./Task-ai-assisted-agent-builder.md` |
| Edit and persist AI-generated private drafts | `./Task-agent-builder-editor-drafts.md` |
| Review, test, and activate agent drafts | `./Task-agent-builder-review-sandbox.md` |
| Add durable chat-agent jobs | `./Task-agent-identities-and-jobs.md` |
| Chat directly with saved agents | `./Task-direct-agent-chat.md` |
| Add agents to workspace channels | `./Task-agent-channel-membership.md` |
| Normalize harness run events | `./Task-agent-harness-chat-streaming.md` |
| Stream and finalize agent runs in chat | `./Task-agent-chat-run-delivery.md` |
## Dependencies
- The AI workspace shell and registry can begin from the existing AI route, model routing, harness registry, template UI patterns, and current AI SDK chat endpoint.
- Registry schema → registry API → install/lifecycle are explicit server slices. Library/editor/review UIs consume those APIs. All AI selectors, builders, mentions and jobs share this registry, never a channel-local agent identity.
- Durable jobs depend on registry/lifecycle. Direct-agent chat and reviewed channel membership depend on jobs; workspace membership integration needs chat persistence, while project/object participation also waits for authoritative contextual authorization. Unified notifications consumes outcomes later rather than blocking permission enforcement. Harness execution and shared delivery follow these contracts.
- Full product includes mention-to-add with authorized confirmation, scoped history/tools, idempotency and revocation per the parent plan. Current AI convoy delivers only shell, schema/contracts and inert builder service. On 2026-10-22 the user approved kickoff and the independent shell implementer is active; no other role completion, DB transition, merge or release is asserted.
- Blocks: richer approval cards, marketplace publishing, and autonomous channel workflows.
## Acceptance criteria
- [ ] The existing AI rail destination exposes accessible, deep-linkable Chat and Agents tabs without adding another rail item.
- [ ] The Agents tab presents templates, installed/workspace agents, drafts, and creation entry points as a coherent library.
- [ ] A natural-language request creates an editable agent draft; nothing is installed, published, or granted tools until the user reviews and confirms it.
- [ ] Explicitly mentioning an authorized agent creates one idempotent durable job.
- [ ] Chat and the builder request roles/capabilities and do not select providers directly.
- [ ] Harness adapters emit one normalized event protocol regardless of runtime.
- [ ] Tool calls and generated messages preserve workspace authorization and audit linkage.
- [ ] Approval-required, failed, and completed runs notify the initiating user.
## Proposed timeline
| Phase | Window | Notes |
|-------|--------|-------|
| Identity and jobs | 1 | Persistence, mention policy, queue semantics |
| Harness integration | 2 | Normalized events, streaming, finalization |

View file

@ -0,0 +1,57 @@
---
kind: task
slug: agent-builder-editor-drafts
title: Edit and persist AI-generated private agent drafts
plan_slug: workspace-communications
epic_slug: agent-participation
status: ready
priority: P1
tenant_id: global
owner: unassigned
cursor_todo_id: null
updated_at: "2026-10-22"
---
# Task summary
Consume builder clarification/draft-patch events in the Agents tab and let members refine, save, resume, and discard their own private drafts.
## Description
Build the transcript plus structured editor on top of the builder service and private-draft registry API. Conversational patches and direct field edits share one revision history with optimistic concurrency. Requested tools/access remain visibly unapproved and inert. This task does not sandbox, publish, install, or activate agents.
## Subtasks
- [ ] Build builder transcript, clarification, streaming, retry, and typed-error UI.
- [ ] Build structured draft editor and normalized revision/diff state.
- [ ] Consume `Task-agent-registry-api.md` create/save/resume/discard procedures and present optimistic-concurrency conflicts; do not create a second draft API.
- [ ] Add accessibility, stale-revision, private-draft isolation, and malformed-event tests.
## Owner or assignee
`unassigned`
## Status
`ready`
## Estimation
`M`
## Acceptance criteria
- [ ] Users can alternate between builder refinements and direct edits without silently losing changes.
- [ ] Members can access only their own private drafts; `admin`/`owner` do not implicitly see them before publication.
- [ ] Requested tools/access are clearly unapproved and cannot execute from the editor.
- [ ] Saving creates immutable versions with actor and base-revision audit metadata.
- [ ] `pnpm lint && pnpm type-check && pnpm test` pass.
## Dependencies
`Task-ai-assisted-agent-builder.md` and `Task-agent-registry-api.md`. This task consumes, not implements, canonical private-draft persistence.
## Links to related Epic / Plan
- Epic: `./Epic-agent-participation.md`
- Plan: `../Plan-workspace-communications.md`

View file

@ -0,0 +1,57 @@
---
kind: task
slug: agent-builder-review-sandbox
title: Review, test, and activate agent drafts
plan_slug: workspace-communications
epic_slug: agent-participation
status: ready
priority: P1
tenant_id: global
owner: unassigned
cursor_todo_id: null
updated_at: "2026-10-22"
---
# Task summary
Add permission review, constrained sandbox testing, and explicit publish/activate flows for generated or manually edited agent drafts.
## Description
Turn a private draft into a trusted workspace agent through a structured review of instructions, requested tools, context/channel access, model role, harness route, budget, and provenance. Sandbox runs default to read-only/non-mutating tools, bounded context, timeout, and cost limits. Members may test their drafts read-only; `admin`/`owner` approve shared publication and normal tool grants; only `owner` approves mutating sandbox policy or workspace-wide sensitive tools.
## Subtasks
- [ ] Build normalized draft diff and permission review UI with validation warnings.
- [ ] Add bounded sandbox-run service/state with cancellation and auditable results.
- [ ] Wire review UI to canonical `Task-agent-install-lifecycle.md` publish/install/activate procedures and `Task-agent-registry-api.md` draft discard; do not duplicate lifecycle endpoints. Requested context access remains ungranted until separately authorized.
- [ ] Add tests for privilege escalation, mutating tool denial, budget enforcement, stale versions, and audit records.
## Owner or assignee
`unassigned`
## Status
`ready`
## Estimation
`M`
## Acceptance criteria
- [ ] Members can run read-only bounded tests for their own drafts but cannot publish, activate, or grant tools.
- [ ] `admin`/`owner` can approve workspace publication and ordinary allowlisted tools; only `owner` can approve mutating sandbox policy or sensitive workspace-wide grants.
- [ ] Every activation references an immutable reviewed version and records actor, diff, grants, budget, and test result.
- [ ] Invalid/stale drafts cannot be activated, and no test leaks provider credentials or bypasses tenant-scoped tools.
- [ ] `pnpm lint && pnpm type-check && pnpm test` pass.
## Dependencies and access boundary
`Task-agent-builder-editor-drafts.md`, `Task-agent-install-lifecycle.md`, and the bounded execution contract in `Task-agent-harness-chat-streaming.md`. Review UI consumes canonical lifecycle APIs; sandbox context/tool reads also enforce actual context authority, never workspace role alone. Test data defaults to caller-supplied or explicitly authorized context, not arbitrary private project content.
## Links to related Epic / Plan
- Epic: `./Epic-agent-participation.md`
- Plan: `../Plan-workspace-communications.md`

View file

@ -0,0 +1,65 @@
---
kind: task
slug: agent-channel-membership
title: Add agents to workspace channels
plan_slug: workspace-communications
epic_slug: agent-participation
status: ready
priority: P1
tenant_id: global
owner: unassigned
cursor_todo_id: null
updated_at: "2026-10-22"
---
# Task summary
Let authorized chat senders mention a saved registry agent to request addition, review any new access, and enqueue only after authorized idempotent confirmation.
## Description
Use the single registry's immutable IDs/versions, not fake users or a channel-local registry. Follow the [plan's context/role/mention contract](../Plan-workspace-communications.md). Membership scopes exact channel/context, approved history boundary, invokers, tools and invocation policy. This task owns mention resolution, review/confirmation, membership and enqueueing; execution/delivery are separate.
An authorized sender can request an eligible active agent by mention. An absent agent cannot join, read history, run or gain tools until confirmation. Review shows version, context, default post-confirmation history boundary (older history only by explicit grant), tools, budget and policy. Confirmer must manage the channel AND hold context delegation authority; `admin`/`owner` is additionally required for new shared/tool grants, with sensitive grants owner-only. A channel owner may confirm an already-approved ordinary agent without tool expansion only within context authority. Workspace role alone never delegates private-object access. Unauthorized requesters get approval-required, not silent escalation; missing installations route to Agents.
Confirmation revalidates actor/access/lifecycle/version and scope, then atomically stores membership, scoped grant, audit and optional invocation. Bind request keys to channel/context + agent/version + grant revision and dedupe source-message jobs. Stale reviews, denial/dismissal and changed scope grant nothing. Existing authorized membership with a sufficient current grant may enqueue an explicit invocation once without a fresh dialog. Recheck grants before enqueue, claim, tools and delivery; removal stops pending use. Workspace channels can integrate first; object support waits for contextual authorization.
## Subtasks
- [ ] Add scoped membership/grant/review contracts and registry-backed eligible mention lookup with no private-draft leakage.
- [ ] Add mention-to-add and add/remove UI with safe approval-required, review, confirm, deny and stale-review states.
- [ ] Implement transactional confirmation/audit and idempotent enqueue; no membership or job before authority is validated.
- [ ] Test private-context denial, role-versus-delegation rules, absent/already-present agents, deny/dismiss, stale version/scope, concurrent confirmation, retries, revocation and reply-loop prevention.
## Owner or assignee
`unassigned`
## Status
`ready`
## Estimation
`M`
## Acceptance criteria
- [ ] A sender can mention-to-request; only an approver with channel management AND context delegation authority may confirm scoped access. Required workspace/tool approval roles are enforced server-side.
- [ ] Eligible absent agents can be discovered safely for review; existing membership/grants are required for invocation. No private-draft or cross-tenant existence leaks.
- [ ] New memberships/expanded grants require confirmation; retry/concurrent confirmation yields one membership/grant and at most one requested job, with audit.
- [ ] Private-context access, older history and tools cannot be silently granted; stale/denied reviews grant nothing and revocation stops future use/delivery.
- [ ] Mention-only is the default; no agent autonomously reads every channel.
- [ ] A valid invocation passes a pinned agent/version and authorized source to the durable job layer exactly once.
- [ ] `pnpm lint && pnpm type-check && pnpm test` pass.
## Dependencies
- `../Epic-workspace-chat/Task-chat-persistence.md`, `Task-agent-registry-api.md`, `Task-agent-install-lifecycle.md`, and `Task-agent-identities-and-jobs.md`.
- Object contexts additionally require `../Epic-workspace-chat/Task-contextual-chat-authorization.md`.
- Unified notifications consumes durable outcomes later; it is not required to authorize addition.
## Links to related Epic / Plan
- Epic: `./Epic-agent-participation.md`
- Plan: `../Plan-workspace-communications.md`

View file

@ -0,0 +1,56 @@
---
kind: task
slug: agent-chat-run-delivery
title: Stream and finalize agent runs in chat
plan_slug: workspace-communications
epic_slug: agent-participation
status: ready
priority: P1
tenant_id: global
owner: unassigned
cursor_todo_id: null
updated_at: "2026-10-22"
---
# Task summary
Project normalized harness events into direct/channel chat, persist final agent messages, and notify users of approvals, failure, cancellation, or completion.
## Description
Consume the harness-neutral run event stream for a durable agent job. Broadcast ephemeral text/progress deltas from harness-owned events/checkpoints, render tool and approval cards, and atomically finalize one durable message linked to the agent version, run, source message, and workspace. Browser disconnects must not cancel worker execution or lose final output.
## Subtasks
- [ ] Consume harness-owned events/checkpoints and map them to versioned realtime chat projection events without creating a second checkpoint store.
- [ ] Add UI reconciliation for text deltas, tool calls, approvals, cancellation, failure, and retry.
- [ ] Finalize one durable agent-authored message and run linkage idempotently.
- [ ] Produce unified notifications for approval-required, failed, cancelled, and completed outcomes.
- [ ] Add disconnect/reconnect, duplicate event, finalization, and cross-tenant tests.
## Owner or assignee
`unassigned`
## Status
`ready`
## Estimation
`M`
## Acceptance criteria
- [ ] Stream/final-response/notification delivery rechecks current workspace, context, conversation and scoped-agent grants; revoked access cannot receive payloads from an otherwise valid run.
- [ ] The same delivery logic works for direct-agent and workspace-channel conversations.
- [ ] Duplicate/reordered ephemeral events do not duplicate the final durable message.
- [ ] Browser disconnect does not stop the job; reconnect restores checkpoint/final state.
- [ ] Agent messages and notifications preserve agent version, run, source, and workspace audit linkage.
- [ ] `pnpm lint && pnpm type-check && pnpm test` pass.
## Links to related Epic / Plan
- Epic: `./Epic-agent-participation.md`
- Plan: `../Plan-workspace-communications.md`

View file

@ -0,0 +1,56 @@
---
kind: task
slug: agent-harness-chat-streaming
title: Normalize harness run events for saved agents
plan_slug: workspace-communications
epic_slug: agent-participation
status: ready
priority: P1
tenant_id: global
owner: unassigned
cursor_todo_id: null
updated_at: "2026-10-22"
---
# Task summary
Execute saved-agent jobs through the existing runtime-agnostic harness and normalize progress, tool, approval, failure, cancellation, and completion events for downstream consumers.
## Description
A durable job supplies a pinned agent version and requested role or capability. The worker loads that definition, resolves model policy through `ResolvedModel`, selects an execution harness adapter, and emits a normalized event stream such as `run.started`, `text.delta`, `tool.requested`, `approval.required`, `run.failed`, and `run.completed`. OmniRoute remains an inference gateway reachable through provider resolution; it is not the harness. This task owns runtime execution, normalized persisted run/tool/approval events, and recoverable checkpoints; it does not project events into chat, persist the final chat message, or create notifications.
## Subtasks
- [ ] Define or extend the harness contract for normalized asynchronous run events.
- [ ] Adapt the current OpenCode worker runtime and model-routing flow without chat-specific provider branches.
- [ ] Persist normalized run/tool/approval events and recoverable checkpoints behind a consumer-neutral interface.
- [ ] Add cancellation, approval, tool authorization, adapter parity, and failure-recovery tests.
## Owner or assignee
`unassigned`
## Status
`ready`
## Estimation
`L`
## Acceptance criteria
- [ ] Harness context assembly and each tool call revalidate context/grant/lifecycle authority; same workspace, prior approval or model-requested access cannot bypass private-object restrictions.
- [ ] Chat code contains no direct OmniRoute, Anthropic, OpenAI, Google, or Vercel-specific branch.
- [ ] The same chat job can run through different harness adapters using one event contract.
- [ ] Normalized run/tool/approval events and checkpoints are durable and workspace-scoped.
- [ ] Browser connectivity is not part of harness execution and cannot cancel a run implicitly.
- [ ] Chat projection, final chat-message persistence, and notifications remain exclusively owned by `Task-agent-chat-run-delivery.md`.
- [ ] `pnpm lint && pnpm type-check && pnpm test` pass; `pnpm build` passes for worker changes.
## Links to related Epic / Plan
- Epic: `./Epic-agent-participation.md`
- Plan: `../Plan-workspace-communications.md`

View file

@ -0,0 +1,56 @@
---
kind: task
slug: agent-identities-and-jobs
title: Add durable chat-agent jobs
plan_slug: workspace-communications
epic_slug: agent-participation
status: ready
priority: P1
tenant_id: global
owner: unassigned
cursor_todo_id: null
updated_at: "2026-10-22"
---
# Task summary
Turn authorized direct-chat or channel invocations of saved agents into durable, bounded jobs.
## Description
Use workspace-scoped, versioned agent definitions; do not introduce a second agent profile model or fake human users. Resolve an already-authorized direct-chat invocation or channel mention into one idempotent job carrying the workspace, conversation/channel, initiating message, pinned agent version, requested role/capability, budget, and invocation policy. Add claim, retry, cancellation, and audit semantics suitable for horizontal workers. Channel membership and mention authorization are supplied by a separate task.
## Subtasks
- [ ] Add chat-agent job and attempt schemas with generated migration.
- [ ] Define zod contracts for source invocation, pinned agent version, role/capability, policy, budget, and context bounds.
- [ ] Enqueue authorized direct-chat/channel invocations transactionally through the outbox.
- [ ] Add claim/retry/cancel logic and state-machine/idempotency tests.
## Owner or assignee
`unassigned`
## Status
`ready`
## Estimation
`L`
## Acceptance criteria
- [ ] Jobs reference the single registry and pin its immutable version; new job states do not create another agent identity store.
- [ ] Enqueue/claim/execution recheck active agent lifecycle, reviewed grants and workspace AND context AND conversation access. Revocation/disable stops future use; a queued job or old approval cannot confer private-object access.
- [ ] Agent authors are distinguishable from authenticated users in storage and UI contracts.
- [ ] Unauthorized or ambiguous mentions do not enqueue a run.
- [ ] A retried message/event creates at most one agent job per invocation.
- [ ] Jobs include bounded context and workspace policy but no plaintext provider secrets.
- [ ] `pnpm lint && pnpm type-check && pnpm test` pass.
## Links to related Epic / Plan
- Epic: `./Epic-agent-participation.md`
- Plan: `../Plan-workspace-communications.md`

View file

@ -0,0 +1,58 @@
---
kind: task
slug: agent-install-lifecycle
title: Enforce reviewed agent installation and lifecycle mutations
plan_slug: workspace-communications
epic_slug: agent-participation
status: ready
priority: P1
tenant_id: global
owner: unassigned
cursor_todo_id: null
updated_at: "2026-10-22"
---
# Task summary
Add the canonical server-side review/confirm boundary for agent installation, shared publication, activation, update and disable/archive.
## Description
Build workspace-authorized tRPC procedures on the single registry and version contracts. This task owns lifecycle enforcement/audit, not marketplace or review UI, sandbox execution, agent jobs, or chat membership. Consuming UIs call these procedures rather than implementing separate mutations. Installation never grants channel/object access or executes an agent.
## Subtasks
- [ ] Return a review of immutable target version, provenance, permission changes and expected current revision for installation, draft publication/activation or update.
- [ ] Require explicit confirmation from an `admin`/`owner` for shared lifecycle and ordinary allowlisted grants; only `owner` may approve mutating sandbox policy or sensitive workspace-wide tools. Private drafts must first be submitted by their creator without enabling arbitrary admin browsing.
- [ ] Bind confirmation to workspace, actor authority, agent/template/version, revision and capability diff; revalidate in the transaction, record audit and enforce idempotency for retries/concurrent confirmation.
- [ ] Implement disable/archive without erasing conversation/run provenance; expose authoritative lifecycle checks so enqueue/execution/delivery deny disabled versions and removal/revocation cannot leave stale access.
- [ ] Test unauthorized role, stale review/version, scope escalation, duplicate install/confirmation, explicit update adoption and disable behavior.
## Owner or assignee
`unassigned`
## Status
`ready` for scheduling after registry schema and API; not currently running.
## Estimation
`M`
## Acceptance criteria
- [ ] A member cannot install, publish/activate or grant capabilities through direct API calls; a review screen is not the security boundary.
- [ ] Duplicate confirmations produce one installation/lifecycle transition and audit outcome; stale or changed capability requests require a fresh review.
- [ ] Version updates are explicit; existing pinned runs/conversations do not silently adopt them.
- [ ] Workspace role/tool approval never substitutes for context delegation, adds chat membership or grants private-project reads.
- [ ] Disable/archive preserve history but prevent new use; runtime consumers must recheck lifecycle rather than trusting old confirmation.
- [ ] `pnpm lint && pnpm type-check && pnpm test` pass.
## Links to related Epic / Plan
- Epic: [Agent participation](./Epic-agent-participation.md)
- Plan: [Workspace communications](../Plan-workspace-communications.md)
- Depends on: [Registry schema](./Task-agent-registry-marketplace.md), [Registry API](./Task-agent-registry-api.md)
- Consumers: [Library UI](./Task-agent-marketplace-library-ui.md), [Review/sandbox UI](./Task-agent-builder-review-sandbox.md)
- Separate access grant: [Agent channel membership](./Task-agent-channel-membership.md)

View file

@ -0,0 +1,57 @@
---
kind: task
slug: agent-marketplace-library-ui
title: Build the agent marketplace library UI
plan_slug: workspace-communications
epic_slug: agent-participation
status: ready
priority: P1
tenant_id: global
owner: unassigned
cursor_todo_id: null
updated_at: "2026-10-22"
---
# Task summary
Present curated templates and permitted workspace agents as a searchable, actionable library below the Agents composer.
## Description
Consume the versioned registry API to render Featured/Templates, Installed, Created by your workspace, and My Drafts. Cards and detail views distinguish provenance, lifecycle, visibility, disabled state, and available updates. UI actions obey the registry permission matrix and never imply that a template is active before installation/review.
## Subtasks
- [ ] Build responsive library sections with search, filters, sorting, loading, empty, and error states.
- [ ] Build cards and detail/version views for preview, duplicate, install, edit, disable, remove, and update actions as permitted.
- [ ] Surface tool requests, model role, harness route, budget, provenance, visibility, and version changes before confirmation.
- [ ] Add accessible keyboard navigation, focus management, and component tests.
## Owner or assignee
`unassigned`
## Status
`ready`
## Estimation
`M`
## Acceptance criteria
- [ ] Members see curated templates, active workspace agents, and only their own private drafts; `admin`/`owner` can manage shared lifecycle actions.
- [ ] Template, draft, installed, active, disabled, and update states are visually and semantically distinct.
- [ ] Sensitive actions show a review/confirmation step and server authorization remains authoritative.
- [ ] The library integrates beneath the shared Agents composer without adding another rail destination.
- [ ] `pnpm lint && pnpm type-check && pnpm test` pass.
## Dependencies
`Task-ai-workspace-tabs.md`, `Task-agent-registry-api.md`, and `Task-agent-install-lifecycle.md`. Library actions call these APIs; installation does not grant chat/context access.
## Links to related Epic / Plan
- Epic: `./Epic-agent-participation.md`
- Plan: `../Plan-workspace-communications.md`

View file

@ -0,0 +1,56 @@
---
kind: task
slug: agent-registry-api
title: Expose the shared agent catalog and private draft API
plan_slug: workspace-communications
epic_slug: agent-participation
status: ready
priority: P1
tenant_id: global
owner: unassigned
cursor_todo_id: null
updated_at: "2026-10-22"
---
# Task summary
Add one workspace-authorized tRPC registry API for catalog/detail reads and member-owned private draft persistence.
## Description
Consume the schema/contracts from `Task-agent-registry-marketplace.md`. This task owns list/detail/filter and private draft create/read/save/discard procedures; library/editor tasks consume them. AI Chat selectors, Agents builder/library, mention completion and jobs all reference the same immutable agent IDs and versions. Curated templates supply provenance, not a second installed-agent identity. Do not duplicate model routing, harness aliases, users or run records as an agent registry.
## Subtasks
- [ ] Add zod-validated, tenant-filtered catalog/detail queries for visible curated templates, installed/workspace agents and the caller's private drafts, with stable pagination.
- [ ] Add private draft create/read/save/discard with optimistic base-revision checks, immutable versions and actor audit metadata; requested tools/context access remain inert.
- [ ] Expose safe lifecycle/provenance/version/update metadata for library/selector clients without leaking another user's private drafts or restricted configuration.
- [ ] Test cross-tenant ID guessing, same-workspace draft isolation including admins/owners, stale saves, retries and handle uniqueness conflicts.
## Owner or assignee
`unassigned`
## Status
`ready` for scheduling after registry schema/contracts; not currently running.
## Estimation
`M`
## Acceptance criteria
- [ ] One registry API supports all AI and chat surfaces; clients cannot create competing agent identities.
- [ ] Only the draft owner can mutate/read their private draft; workspace admin status alone grants no private-draft access.
- [ ] Saves are concurrency-safe and never install, publish, activate, grant tools/context, or enqueue execution.
- [ ] List/detail/mention projections reveal only authorized agents and safe fields.
- [ ] `pnpm lint && pnpm type-check && pnpm test` pass.
## Links to related Epic / Plan
- Epic: [Agent participation](./Epic-agent-participation.md)
- Plan: [Workspace communications](../Plan-workspace-communications.md)
- Depends on: [Registry schema](./Task-agent-registry-marketplace.md)
- Consumers: [Library UI](./Task-agent-marketplace-library-ui.md), [Draft editor](./Task-agent-builder-editor-drafts.md)
- Separate mutations: [Install lifecycle](./Task-agent-install-lifecycle.md)

View file

@ -0,0 +1,58 @@
---
kind: task
slug: agent-registry-marketplace
title: Define the versioned agent registry schema and contracts
plan_slug: workspace-communications
epic_slug: agent-participation
status: ready
priority: P1
tenant_id: global
owner: unassigned
cursor_todo_id: null
updated_at: "2026-10-22"
---
# Task summary
Define first-class, versioned workspace agent persistence and shared validation contracts that later draft, marketplace, and builder APIs can consume.
## Description
Agents are service identities, not fake users and not aliases for model providers. Add workspace-scoped agent definitions with immutable IDs and stable handles, versioned configuration, provenance, status, role/capability bindings, instructions, tool policy, invocation/context policy, harness route, model role, budget, and creator metadata. Add a curated global template catalog and workspace installation records. This task owns persistence, migration, shared contracts, and the permission-policy definitions consumed by later API tasks; CRUD, installation, lifecycle behavior, and marketplace presentation remain separate.
## Subtasks
- [ ] Add `agent_templates`, `agents`, `agent_versions`, and workspace installation/provenance schemas with generated migration and indexes.
- [ ] Add shared zod contracts for identity, configuration, provenance, lifecycle, permissions, filters, and optimistic-concurrency inputs.
- [ ] Encode the explicit `owner | admin | member` permission matrix as reusable policy functions/contracts.
- [ ] Add schema/contract tests for tenant isolation, creator ownership, handle uniqueness, immutable versions, and invalid configurations.
## Owner or assignee
`unassigned`
## Status
`ready`
## Estimation
`L`
## Acceptance criteria
- [ ] Agent definitions are distinguishable from users, roles, model providers, harnesses, and runs.
- [ ] Contracts represent: members manage their private drafts/use active agents; `admin`/`owner` manage shared lifecycle and ordinary grants; only `owner` approves mutating sandbox or sensitive workspace-wide grants.
- [ ] Agent configurations can reference model roles, harness routes, and tools but cannot contain provider credentials.
- [ ] Schemas expose lifecycle, provenance, permission, and update metadata without weakening workspace/private-draft boundaries.
- [ ] Tenant-owned schemas require and index `workspace_id`; follow-on CRUD/query tasks remain responsible for workspace authorization and explicit `workspace_id` filters.
- [ ] `pnpm lint && pnpm type-check && pnpm test` pass.
## Follow-on ownership and access contract
This task supplies the **single** workspace agent identity/version registry used by AI Chat, Agents builder/library, mention completion and durable jobs. `Task-agent-registry-api.md` owns catalog/private-draft procedures; `Task-agent-install-lifecycle.md` owns reviewed server lifecycle mutations. UI and execution tasks consume them. No installation/publication grants channel or private-object access. Permission contracts must distinguish workspace lifecycle/tool approval from context delegation and reviewed per-conversation membership; follow the parent plan's role matrix, with fail-closed context policy and no silent grants.
## Links to related Epic / Plan
- Epic: `./Epic-agent-participation.md`
- Plan: `../Plan-workspace-communications.md`

View file

@ -0,0 +1,55 @@
---
kind: task
slug: ai-assisted-agent-builder
title: Generate structured agent drafts with AI
plan_slug: workspace-communications
epic_slug: agent-participation
status: ready
priority: P1
tenant_id: global
owner: unassigned
cursor_todo_id: null
updated_at: "2026-09-25"
---
# Task summary
Use the Agents-tab composer to turn a natural-language request into a validated structured draft that can be refined conversationally or through direct field edits.
## Description
In Agents mode, prompts such as “Create an agent that triages failed convoy runs and asks before retrying” start a builder conversation rather than a normal workspace chat. The server returns a typed agent-draft proposal: identity, purpose, instructions, capabilities, requested tools, invocation/context policy, model role, harness route, budget, and unresolved questions. Users can refine the draft conversationally, edit fields directly, and save a private draft. Permission approval, sandbox testing, installation, and activation are handled by a dependent review task. Generated configuration never silently grants tools, selects plaintext credentials, publishes, or activates an agent.
## Subtasks
- [ ] Define a versioned zod schema for agent-builder requests, clarifying questions, structured draft patches, warnings, and save readiness.
- [ ] Add a workspace-authorized streaming builder endpoint/service that uses model routing rather than a hard-coded provider.
- [ ] Reuse the shared composer shell with Agents-specific placeholder text, examples, attachments policy, and submit handling.
- [ ] Add prompt-injection boundaries, requested-tool validation, budget caps, audit logging, and malformed-model-output tests.
- [ ] Define the follow-on UI contract for transcript, structured editing, revision history, and private-draft save/resume without implementing persistence in this task.
## Owner or assignee
`unassigned`
## Status
`ready`
## Estimation
`L`
## Acceptance criteria
- [ ] Natural-language requests produce zod-validated structured drafts or clarifying questions, never unvalidated persisted configuration.
- [ ] The builder requests a model role/capability and works through the existing provider/harness abstraction.
- [ ] Initial requests and conversational refinements produce ordered clarification/draft-patch events with deterministic base revisions.
- [ ] Generated requested tools, activation, channel access, and workspace publication remain unapproved fields and cannot take effect in this task.
- [ ] This service does not persist, publish, activate, or execute an agent; UI editing and private-draft persistence are a follow-on task.
- [ ] `pnpm lint && pnpm type-check && pnpm test` pass.
## Links to related Epic / Plan
- Epic: `./Epic-agent-participation.md`
- Plan: `../Plan-workspace-communications.md`

View file

@ -0,0 +1,54 @@
---
kind: task
slug: ai-workspace-tabs
title: Build the AI Chat and Agents workspace shell
plan_slug: workspace-communications
epic_slug: agent-participation
status: in_progress
priority: P1
tenant_id: global
owner: unassigned
cursor_todo_id: null
updated_at: "2026-10-22"
---
# Task summary
Evolve the existing AI rail destination into a unified workspace with Chat and Agents tabs while preserving one consistent composer shell.
## Description
Keep `/<workspaceSlug>/ai` as the canonical AI destination and the existing Sparkles rail entry. Add URL-backed `Chat` and `Agents` tabs above the primary content/composer so browser navigation and deep links preserve mode. Extract the current full-page AI composer and frame into reusable components. Chat mode retains conversational behavior; Agents mode changes the prompt, suggestions, submit contract, and result panel for agent creation without making the input visually feel like a different product.
## Subtasks
- [ ] Define canonical tab URLs and redirects, such as `/ai?tab=chat` and `/ai?tab=agents`, with Chat as the backward-compatible default.
- [ ] Extract a shared AI workspace frame, tab switcher, transcript/result area, and composer shell from the current AI page.
- [ ] Preserve existing chat streaming behavior under the Chat tab without coupling the Agents tab to `/api/chat`.
- [ ] Add Agents empty/loading/error states and placeholders for library and builder content.
- [ ] Cover keyboard tab behavior, focus preservation, responsive layout, and accessible mode announcements.
## Owner or assignee
`unassigned`
## Status
`in_progress` — 2026-10-22: independent implementer active under the user's kickoff approval. This planning session does not own its app code or claim completion.
## Estimation
`M`
## Acceptance criteria
- [ ] The existing AI rail item remains the only rail entry for this feature.
- [ ] Chat and Agents modes are deep-linkable and survive refresh/back/forward navigation.
- [ ] Both modes use the same composer dimensions, interaction affordances, and visual system while exposing mode-specific prompts and submit behavior.
- [ ] Existing AI chat remains functional and workspace-scoped.
- [ ] `pnpm lint && pnpm type-check && pnpm test` pass.
## Links to related Epic / Plan
- Epic: `./Epic-agent-participation.md`
- Plan: `../Plan-workspace-communications.md`

View file

@ -0,0 +1,56 @@
---
kind: task
slug: direct-agent-chat
title: Chat directly with saved agents
plan_slug: workspace-communications
epic_slug: agent-participation
status: ready
priority: P1
tenant_id: global
owner: unassigned
cursor_todo_id: null
updated_at: "2026-10-22"
---
# Task summary
Let users select an active saved agent in the AI Chat tab and start or resume a durable direct conversation with its pinned version and policies.
## Description
Add an agent selector and persistent direct-agent conversations to Chat mode. Starting a conversation pins an agent version for reproducibility; updates may be adopted explicitly. Each send is workspace-authorized and enqueues through the durable job contract using the selected agent's policies. This task owns conversation authorization, version pinning, history, selection, and enqueueing—not harness execution, streaming projection, final-message persistence, or notifications.
## Subtasks
- [ ] Add direct-agent conversation and membership/message linkage with generated migration and workspace-scoped API.
- [ ] Add Chat-mode agent selector, new/resume conversation flows, and explicit version-update handling.
- [ ] Enqueue messages through the durable job contract and expose stable conversation/source identifiers for the delivery layer.
- [ ] Add tenant isolation, disabled-agent, version pinning, cancellation, and conversation-history tests.
## Owner or assignee
`unassigned`
## Status
`ready`
## Estimation
`L`
## Acceptance criteria
- [ ] Selection uses the canonical registry API; a same-workspace agent is not automatically usable or entitled to the user's other conversations/project objects.
- [ ] Context reads/tools require scoped reviewed grants and current authority; creating a direct chat never silently grants private project/object access. Reuse durable message/author contracts rather than inventing agent identities.
- [ ] Users can select only active agents they are authorized to use and can resume their own permitted conversations.
- [ ] Every conversation and run pins an immutable agent version; updates never silently change an existing conversation.
- [ ] Agent instructions, tool/context policy, budget, model role, and harness route are enforced server-side.
- [ ] Conversation messages and enqueued jobs preserve workspace, pinned version, source, and audit linkage; streaming/tool/approval rendering is supplied by the shared delivery task.
- [ ] `pnpm lint && pnpm type-check && pnpm test` pass.
## Links to related Epic / Plan
- Epic: `./Epic-agent-participation.md`
- Plan: `../Plan-workspace-communications.md`

View file

@ -0,0 +1,55 @@
---
kind: epic
slug: unified-notifications
title: Unified notification inbox
plan_slug: workspace-communications
status: ready
priority: P1
tenant_id: global
cursor_epic_id: null
updated_at: "2026-09-25"
---
# Epic objective
Create a durable inbox for chat mentions, replies, assignments, gates, and agent outcomes, with accurate unread counts and user-controlled delivery preferences.
## In scope / out of scope
**In scope**
- Notification, preference, and delivery-attempt persistence.
- Inbox queries, read/archive mutations, aggregation, and deep links.
- Producers for chat mentions/replies and existing human-attention events.
- Global and chat-specific badges.
**Out of scope**
- Shipping email or mobile push in the first release.
- Treating transient WebSocket events as notification truth.
## Related tasks
| Task | Link |
|------|------|
| Build notification persistence and inbox API | `./Task-notification-inbox-foundation.md` |
| Connect producers, preferences, and badges | `./Task-notification-producers-and-preferences.md` |
## Dependencies
- Depends on: workspace chat persistence for chat notifications.
- Blocks: agent completion and approval notifications.
## Acceptance criteria
- [ ] Notifications are recipient-specific and workspace-scoped.
- [ ] Inbox counts remain correct after reconnect, multi-device reads, and event replay.
- [ ] Chat mentions and direct messages produce deep-linked inbox entries.
- [ ] Existing gate events can appear in the unified inbox without losing their domain state.
## Proposed timeline
| Phase | Window | Notes |
|-------|--------|-------|
| Inbox foundation | 1 | Schema, API, read/archive semantics |
| Producers and UI | 2 | Event mapping, preferences, badges |

View file

@ -0,0 +1,53 @@
---
kind: task
slug: notification-inbox-foundation
title: Build notification persistence and inbox API
plan_slug: workspace-communications
epic_slug: unified-notifications
status: ready
priority: P1
tenant_id: global
owner: unassigned
cursor_todo_id: null
updated_at: "2026-09-25"
---
# Task summary
Create the durable, recipient-scoped notification substrate and query/mutation API for a unified inbox.
## Description
Add notifications and delivery-attempt tables with polymorphic source/target metadata and safe deep links. Preserve domain state in its originating tables; notifications are projections for a recipient. Add workspace-scoped procedures for pagination, unread counts, mark-read, mark-all-read, and archive.
## Subtasks
- [ ] Add `notifications` and `notification_deliveries` schemas and generated migration.
- [ ] Define validated categories, actor/source/target contracts, and deduplication keys.
- [ ] Add recipient-scoped inbox procedures and indexes for unread queries.
- [ ] Add isolation, deduplication, and concurrent mark-read tests.
## Owner or assignee
`unassigned`
## Status
`ready`
## Estimation
`M`
## Acceptance criteria
- [ ] Each notification belongs to one workspace and recipient.
- [ ] Duplicate source events cannot create duplicate recipient notifications.
- [ ] Read/archive operations are idempotent and safe across devices.
- [ ] Deep links are generated from typed internal targets, not arbitrary untrusted URLs.
- [ ] `pnpm lint && pnpm type-check && pnpm test` pass.
## Links to related Epic / Plan
- Epic: `./Epic-unified-notifications.md`
- Plan: `../Plan-workspace-communications.md`

View file

@ -0,0 +1,55 @@
---
kind: task
slug: notification-producers-and-preferences
title: Connect notification producers, preferences, inbox, and badges
plan_slug: workspace-communications
epic_slug: unified-notifications
status: ready
priority: P1
tenant_id: global
owner: unassigned
cursor_todo_id: null
updated_at: "2026-10-22"
---
# Task summary
Build the inbox UI and map chat and operational events into preference-aware notifications and badges.
## Description
Add notification preferences by category and channel, then produce notifications for chat mentions/replies/direct messages, task assignments, human gates, and agent outcomes. Present one global inbox while retaining a chat-specific badge in the left rail. Existing gate inbox state remains authoritative and is projected into notifications.
## Subtasks
- [ ] Add `notification_preferences` schema, defaults, and update procedures.
- [ ] Add outbox consumers that map domain events to recipient notifications.
- [ ] Build `/[workspaceSlug]/inbox` with filters, grouping, deep links, and bulk read/archive.
- [ ] Add global and Chat rail badges backed by durable unread counts.
## Owner or assignee
`unassigned`
## Status
`ready`
## Estimation
`L`
## Acceptance criteria
- [ ] Recipient production, inbox previews, deep links and unread counts apply current workspace AND context AND channel permissions; membership/ACL revocation hides inaccessible content and stops delivery without leaking private-context titles.
- [ ] Mention, reply, assignment, gate, and agent-result events create correct recipient notifications.
- [ ] Preference changes affect future delivery without deleting inbox history.
- [ ] Rail badges converge to server truth after reconnect and multi-device reads.
- [ ] Existing convoy/gate workflows remain functional and are not duplicated as domain records.
- [ ] `pnpm lint && pnpm type-check && pnpm test` pass.
## Links to related Epic / Plan
- Epic: `./Epic-unified-notifications.md`
- Plan: `../Plan-workspace-communications.md`

View file

@ -0,0 +1,59 @@
---
kind: epic
slug: workspace-chat
title: Workspace chat foundation
plan_slug: workspace-communications
status: ready
priority: P1
tenant_id: global
cursor_epic_id: null
updated_at: "2026-10-22"
---
# Epic objective
Deliver durable channels and messages with a left-rail destination, reliable unread state, and horizontally scalable realtime delivery. The approved product includes project/supported-object contexts, but the current three-brief foundation accepts workspace contexts only. A bounded contextual integration task remains blocked on an authoritative object permission policy; existing generic `objects` project rows and tenant checks are not private-object ACLs. All paths follow the parent plan's workspace AND context AND channel access contract, including counts, notifications and realtime revocation.
## In scope / out of scope
**In scope**
- Channel, membership, message, thread, and read-cursor persistence.
- Workspace-authorized tRPC queries and mutations.
- WebSocket event delivery with Redis cross-instance fan-out and reconnect catch-up.
- Chat routes, channel navigation, composer, message list, and left-rail badge.
**Out of scope**
- Agent execution and tool calls.
- Unified non-chat notifications.
- Search indexing, voice/video, and external federation.
## Related tasks
| Task | Link |
|------|------|
| Persist channels, messages, and read state | `./Task-chat-persistence.md` |
| Deliver scalable realtime chat events | `./Task-chat-realtime-delivery.md` |
| Build chat routes and left-rail navigation | `./Task-chat-ui-and-navigation.md` |
| Bind project/supported-object contexts safely | `./Task-contextual-chat-authorization.md` |
## Dependencies
- Depends on: existing workspace authorization and Redis infrastructure.
- Blocks: unified chat notifications and agent participation.
## Acceptance criteria
- [ ] Every chat row and query is scoped by `workspace_id`.
- [ ] Sending a message commits durably before an event is broadcast.
- [ ] Reconnecting clients catch up from a durable cursor without relying on Redis history.
- [ ] Chat is reachable from the left rail and displays durable unread/mention state.
## Proposed timeline
| Phase | Window | Notes |
|-------|--------|-------|
| Persistence | 1 | Schema, migrations, authorization, API tests |
| Realtime | 2 | Outbox, WebSockets, Redis fan-out, catch-up |
| UI | 3 | Routes, composer, feed, rail badge |

View file

@ -0,0 +1,68 @@
---
kind: task
slug: chat-persistence
title: Persist workspace channels, messages, and read state
plan_slug: workspace-communications
epic_slug: workspace-chat
status: in_progress
priority: P1
tenant_id: global
owner: unassigned
cursor_todo_id: null
updated_at: "2026-10-22"
---
# Task summary
Add the tenant-scoped relational foundation and tRPC API for durable workspace chat.
## Description
Model channels, memberships, messages, threads, member read cursors, and the chat outbox contract in Postgres. Use `workspaceProcedure` and explicit `workspace_id` filters for every operation. This foundation accepts workspace context only and rejects object/unknown contexts without fallback. Authorization is workspace AND context AND channel permission: public workspace channels require active workspace access; private/direct require membership too; archived channels are read-only. Workspace admins/owners have no private/direct read or self-join bypass. Membership targets must be authorized, and all discovery/history/mention/count endpoints share the policy seam. Project/private-object access is deferred to `Task-contextual-chat-authorization.md`, not inferred from tenant ownership. Message writes must atomically include an outbox event, support idempotency, and allocate per-channel monotonic sequence cursors. Generate a new Drizzle migration; do not alter existing migrations.
## Live PostgreSQL gates
- [ ] Concurrent identical direct-channel creation produces one channel, one exact membership set, and one lifecycle event set.
- [ ] Apply migrations and verify private staging transitions only with an exact canonical participant set; reject malformed transitions and direct membership/identity mutations.
- [ ] Concurrent identical direct creation produces one channel/event set; mutation receipt replay returns the original result, collisions reject, and failed mutations roll back receipt/events.
## Subtasks
- [ ] Add chat channel, membership, message, read-state, and outbox schemas with workspace indexes and foreign keys.
- [ ] Define public/private/direct and archived authorization, channel ownership/admin rules, immutable exact-participant direct channels, per-channel sequence cursors, one-level thread semantics, author unions, message statuses, persisted message mentions, and client idempotency keys.
- [ ] Add zod inputs and a workspace-scoped chat router for channel create/list/rename/archive, private membership changes, idempotent direct-channel creation, root-only channel history, one-level thread history, send/reply/edit/tombstone, mention counts, and mark-read.
- [ ] Atomically write versioned outbox events for message, reply, tombstone, lifecycle, membership, and read-state changes.
- [ ] Add authorization, provisioning, idempotency, thread, mark-read, and cross-workspace isolation tests.
- [ ] Against disposable PostgreSQL with migrations through `0019`, verify advisory-lock blocking for simultaneous same-key sends; exactly one message, mention set, logical outbox event, and sequence increment; rollback after an injected post-allocation failure; and Drizzle `bigint` runtime shape from the atomic sequence `RETURNING` expression. Unit transaction fakes do not satisfy this gate.
## Owner or assignee
`unassigned`
## Status
`ready`
## Estimation
`L`
## Acceptance criteria
- [ ] Unsupported/same-workspace object contexts fail closed without a public fallback; private/direct admin bypass and unauthorized title/preview/mention/count leaks are denied.
- [ ] Foundation human mention parsing never installs, adds or invokes an agent; reviewed agent membership is a separate task.
- [ ] Every tenant-owned table includes indexed, non-null `workspace_id`.
- [ ] Public, private, direct, archived, channel-owner, workspace-admin/owner, author-edit, and moderation operations enforce their documented rules without cross-tenant disclosure.
- [ ] Direct-channel membership is immutable and exact sorted participant sets resolve idempotently.
- [ ] Channel history excludes replies; replies are one level and their root is constrained to the same workspace/channel.
- [ ] Unread mention counts are derived from persisted workspace-scoped mention rows above the recipient's channel read cursor.
- [ ] Duplicate message/reply retries with the same scoped idempotency key produce one durable row and one logical outbox event, demonstrated under real concurrent PostgreSQL transactions rather than only unit fakes.
- [ ] Channel and thread history support stable per-channel sequence pagination and reconnect catch-up.
- [ ] `markChannelRead` only advances the highest intentionally viewed sequence and emits an invalidation event for other devices.
- [ ] `pnpm lint && pnpm type-check && pnpm test` pass.
## Links to related Epic / Plan
- Epic: `./Epic-workspace-chat.md`
- Plan: `../Plan-workspace-communications.md`

View file

@ -0,0 +1,55 @@
---
kind: task
slug: chat-realtime-delivery
title: Deliver scalable realtime chat events
plan_slug: workspace-communications
epic_slug: workspace-chat
status: ready
priority: P1
tenant_id: global
owner: unassigned
cursor_todo_id: null
updated_at: "2026-10-22"
---
# Task summary
Broadcast committed chat events over authenticated WebSockets and support horizontal fan-out and reconnect recovery.
## Description
Use HTTP/tRPC for commands and Postgres as the source of truth. Consume the outbox contract and atomic writes established by the persistence task; do not create a second message transaction. A worker claims and publishes normalized events through Redis to authenticated WebSocket subscribers. Each channel uses its own `(channel_id, sequence)` cursor, and clients reconnect by replaying missed rows from Postgres per authorized channel. Reuse Hocuspocus/Yjs only for presence, typing, or drafts rather than canonical history.
## Subtasks
- [ ] Finalize one versioned envelope for message, reply, tombstone, channel lifecycle, membership, and read-state events using the existing outbox contract.
- [ ] Add subscriptions/replay using workspace AND context AND channel access; recheck at delivery, close revoked subscriptions, and test that removed access never receives subsequent payloads. No same-workspace-only shortcut or unsupported-object fallback.
- [ ] Claim and publish outbox events through Redis with retry and idempotent consumption semantics.
- [ ] Add per-channel reconnect catch-up and cross-device read-state invalidation.
- [ ] Add heartbeat, backpressure, graceful-shutdown, outage, ordering, and authorization tests.
## Owner or assignee
`unassigned`
## Status
`ready`
## Estimation
`L`
## Acceptance criteria
- [ ] A message is visible through the query API even when Redis is unavailable.
- [ ] Multiple realtime instances deliver committed events to local subscribers.
- [ ] Clients recover each subscribed channel from its own sequence without duplicates or assumed cross-channel ordering.
- [ ] Subscription authorization matches public/private/direct/archived rules and is rechecked against current workspace/channel access.
- [ ] Durable mark-read events reconcile unread badges on another connected device.
- [ ] `pnpm lint && pnpm type-check && pnpm test` pass; `pnpm build` passes for changed server entry points.
## Links to related Epic / Plan
- Epic: `./Epic-workspace-chat.md`
- Plan: `../Plan-workspace-communications.md`

View file

@ -0,0 +1,55 @@
---
kind: task
slug: chat-ui-and-navigation
title: Build chat routes and left-rail navigation
plan_slug: workspace-communications
epic_slug: workspace-chat
status: ready
priority: P1
tenant_id: global
owner: unassigned
cursor_todo_id: null
updated_at: "2026-10-22"
---
# Task summary
Add the workspace Chat destination, channel UI, composer, and durable unread indicators to the existing application shell.
## Description
Create workspace-context routes for channel selection and conversation, integrate Chat into the left icon rail, and use existing Radix/Tailwind UI primitives. Project/object entry points follow in `Task-contextual-chat-authorization.md`; do not imply they work yet. Show only authorized channel titles, previews, mention candidates and counts, and clear inaccessible cached content after revocation. Agent mention-to-add is a separate reviewed membership flow, not a composer side effect. The rail shows a dot when any channel sequence exceeds its durable read boundary and a numeric badge for validated mentions plus unread direct-channel activity. Badge counts come from durable server state, are refreshed by realtime events, and later transition to unified notification projections without changing the rail contract.
## Subtasks
- [ ] Add `/[workspaceSlug]/chat` and `/[workspaceSlug]/chat/[channelSlug]` routes.
- [ ] Build channel navigation, per-channel cursor-paginated history, a minimal complete thread list/reply surface, and composer states.
- [ ] Add Chat to the icon rail with accessible unread, mention, and direct-message indicators.
- [ ] Advance read state only to the highest intentionally viewed sequence and reconcile changes from other devices.
- [ ] Handle optimistic message/reply sends, retries, per-channel reconnect catch-up, empty states, keyboard navigation, and mobile layout.
## Owner or assignee
`unassigned`
## Status
`ready`
## Estimation
`L`
## Acceptance criteria
- [ ] Chat is keyboard-accessible from the existing left rail.
- [ ] Message/thread history and sequence-based unread state survive reload and multi-device use.
- [ ] Cross-device mark-read events reconcile rail and channel indicators to durable server truth.
- [ ] Failed optimistic messages and replies are visibly retryable and cannot silently disappear.
- [ ] Mention badges use an accessible label and do not rely on color alone.
- [ ] `pnpm lint && pnpm type-check && pnpm test` pass.
## Links to related Epic / Plan
- Epic: `./Epic-workspace-chat.md`
- Plan: `../Plan-workspace-communications.md`

View file

@ -0,0 +1,59 @@
---
kind: task
slug: contextual-chat-authorization
title: Bind chat to authorized project and supported object contexts
plan_slug: workspace-communications
epic_slug: workspace-chat
status: blocked
priority: P1
tenant_id: global
owner: unassigned
cursor_todo_id: null
updated_at: "2026-10-22"
---
# Task summary
Extend the workspace-chat foundation to project and explicitly supported object contexts without widening private-context access.
## Description
Follow the [plan's context authorization contract](../Plan-workspace-communications.md). Projects are generic `objects` rows (`type = 'project'`), not a separate table. Existing types include space, task, document, whiteboard, group and form; enable only types with a verified read/write/delegate policy and usable entry point. `object_type_defs` does not automatically enable custom contexts. Neither `object_assignees`, parent links nor workspace ownership are ACLs.
This is a bounded chat adapter/API/UI integration, not a general object-permissions rewrite. It is blocked on an authoritative context policy: current `objects` schema/router proves only tenant ownership. Keep private/unknown/policy-unavailable contexts disabled until that policy is supplied and tested; escalate the missing ACL prerequisite to the conductor rather than inventing one inside chat.
## Subtasks
- [ ] Resolve the explicit prerequisite: document the authoritative read/write/delegate policy API and which concrete object types it supports before enabling any object context.
- [ ] Extend the foundation's context union to a tenant-validated object reference; generate a new migration with referential integrity and context indexes as needed. Context binding cannot be silently moved to broaden an audience.
- [ ] Use workspace AND context AND channel checks for discovery, history, threads, writes, membership targets, mentions/counts, notifications, replay and live delivery; prevent channel membership from granting object access.
- [ ] Add project/supported-object conversation entry points that resolve the same durable channel surface as workspace Chat, showing context without leaking inaccessible titles.
- [ ] Test private same-workspace denial, unsupported types, archived/deleted contexts, ancestry-policy changes, revoked membership/subscriptions, and authorized access without duplicate histories.
## Owner or assignee
`unassigned`
## Status
`blocked` — workspace chat foundation and a verified object-level authorization/delegation policy must exist first. User kickoff approval does not waive this prerequisite.
## Estimation
`M` once the policy prerequisite is available.
## Acceptance criteria
- [ ] Project chat references actual `objects` records; no invented projects table or arbitrary context slug grants access.
- [ ] Workspace membership, workspace admin role, object assignment, and channel membership independently cannot bypass private-context denial.
- [ ] Public means public within the permitted context audience; private/direct require membership in addition.
- [ ] Removed access immediately stops subsequent reads, replay, notifications and live payloads; client caches are invalidated.
- [ ] Unsupported contexts fail closed; there is no fallback to workspace-public chat.
- [ ] `pnpm lint && pnpm type-check && pnpm test` pass; run build for changed server entry points/exports.
## Links to related Epic / Plan
- Epic: [Workspace chat](./Epic-workspace-chat.md)
- Plan: [Workspace communications](../Plan-workspace-communications.md)
- Dependencies: [Persistence](./Task-chat-persistence.md), [Realtime](./Task-chat-realtime-delivery.md), [UI](./Task-chat-ui-and-navigation.md)
- Agent integration: [Reviewed agent membership](../Epic-agent-participation/Task-agent-channel-membership.md)

View file

@ -0,0 +1,104 @@
---
kind: plan
slug: workspace-communications
title: Workspace communications — chat, notifications, and agent participation
status: ready
priority: P1
tenant_id: global
cursor_plan_id: null
updated_at: "2026-10-22"
agent_prompt: |
Build workspace communications as a durable, multitenant product surface. Keep Postgres authoritative, scope every query by workspace_id, validate external input with zod, and separate realtime delivery from durable writes. Agents request capabilities through the harness and model-routing contracts; do not couple chat to a model provider.
---
# Plan overview
Add persistent workspace chat where humans and agents collaborate, plus a unified notification inbox that brings mentions, replies, assignments, approvals, and agent outcomes into one reliable surface.
## Objectives and success criteria
- Workspace members can navigate to Chat from the left rail, participate in channels and threads, and recover complete history after reconnecting.
- A unified inbox provides durable read/unread state for chat and operational events, with chat mention counts reflected on the left rail.
- The existing AI rail destination provides Chat and Agents tabs, including an AI-assisted builder and marketplace library for explicit agent identities.
- Saved agents can participate in workspace channels through durable jobs and the provider-agnostic execution harness.
- Realtime delivery scales across web instances without making Redis or Yjs the source of truth.
- Every channel, message, notification, job, and preference is scoped by `workspace_id` and authorized against its context and channel, not merely workspace membership.
## Scope and boundaries
**In scope**
- Workspace and authorized project/supported-object conversations, memberships, messages, threads, read cursors, and attachments metadata.
- HTTP/tRPC commands and queries backed by Postgres, with WebSocket event delivery and Redis fan-out.
- Unified in-app notifications, preferences, delivery records, and left-rail badges.
- An AI workspace with Chat/Agents tabs, shared composer shell, agent registry, curated marketplace templates, and conversational agent building.
- Agent identities, mentions, durable invocation jobs, streaming run events, and approval-required states.
- Reuse of Hocuspocus/Yjs for ephemeral presence, typing, or collaborative drafts where useful.
**Out of scope**
- Replacing Postgres message history with a Yjs document.
- Browser-to-server gRPC.
- Coupling chat directly to OmniRoute, Vercel AI SDK, or any individual model provider.
- Email/mobile push delivery in the first release; the schema and outbox may prepare for them.
- Full Slack/Discord import or federation.
## Approved direction and execution boundary — 2026-10-22
The user approved beginning implementation now: conversations in workspaces, projects, and other supported contexts; mention a reusable agent to add it with explicit access review when necessary; create/manage those agents in the existing AI Chat/Agents destination. This is kickoff approval, **not** merge or release approval. Conductor and architect reconciled these artifacts in this session; no historical IA, UX, reviewer, DB registration, or MCP transition is asserted.
**Full product:** contextual conversations, reusable-agent creation/installation, mention-to-add, durable runs, and unified notifications. **Current foundation convoys:** workspace-only channel persistence/realtime/UI; AI tab shell, registry schema/contracts, and inert builder service. These foundations do not alone deliver project chat, agent installation APIs, saved-agent execution, or mention-to-add. The independent implementer is working on `Epic-agent-participation/Task-ai-workspace-tabs.md`; other implementation remains pending.
## Context authorization contract
- Inspected sources: `packages/database/src/schema/objects.ts`, `workspaces.ts`, `types.ts`, `packages/shared/src/types/objects.ts`, and `apps/web/server/routers/objects.ts`. Workspaces have a dedicated table; projects are `objects.type = 'project'`, not a separate projects table. Existing object types also include space, task, document, whiteboard, group, and form. Custom type definitions do not automatically confer chat support. The legacy `workspace` object type is not the tenant anchor.
- These schemas/router currently establish tenant ownership, not private project/object ACLs. `object_assignees`, `created_by`, parent links, and `assertObjectInWorkspace` are not proof of permission to read or delegate a private context. Do not invent a project-members table or infer inherited grants.
- Model context as an explicit workspace reference or a same-workspace object reference. Supported object contexts need an explicit allowlist and an authoritative read/write/delegate policy; unknown, archived, deleted, cross-tenant, and policy-unavailable contexts fail closed. Workspace-only foundation commands reject object context requests rather than silently downgrading them to public workspace chat.
- Effective chat access is **active workspace access AND context permission AND channel permission**. Public means public only within the authorized context audience. Private/direct channels additionally require explicit membership. Workspace `admin`/`owner` status alone is not a private-context read/delegation bypass. Joining a channel never grants access to its parent object.
- Apply that predicate to discovery, titles/previews, history/threads, writes, memberships, mentions, unread counts, notifications, subscriptions, replay, and agent context/tool reads. Recheck at use/delivery time and revoke active subscriptions/jobs when access is removed; cached membership or an old approval cannot keep access alive.
- `Epic-workspace-chat/Task-contextual-chat-authorization.md` owns the bounded project/object integration, blocked until an authoritative context policy exists. Do not broaden the first persistence brief into a general ACL product.
## Agent identity, roles, and mention-to-add contract
- One workspace agent registry supplies AI Chat selection, Agents library/builder, channel mention completion, installations, and run identity. Templates are provenance; model/harness aliases and `agent_runs` are not alternative agent identities. Runs and conversations pin immutable versions from this registry.
- Active members may create/edit their own private drafts and use explicitly available active agents; others, including admins, cannot browse those drafts. Registry API owns draft persistence, while editor and library tasks consume it. `admin`/`owner` approve installation, shared publication/activation and ordinary allowlisted capabilities; only `owner` approves mutating sandbox policy or sensitive workspace-wide tools. These roles never bypass context authority.
- Any authorized chat sender may mention an eligible visible agent and request addition. Mention text alone never installs, activates, adds, grants access, or runs an absent agent. If it is already an active member with a current sufficient grant, an explicit invocation can enqueue once without repeating review.
- A new membership or expanded/stale grant requires a review showing pinned agent version, exact conversation/context, history boundary (default: post-confirmation messages; older history only by explicit grant), requested tools, budget, and invocation policy. Confirmation requires a channel manager **and** context delegation authority; `admin`/`owner` is additionally required for new shared/tool grants, and sensitive policy remains owner-only. A channel creator can confirm an already-approved ordinary agent with no tool expansion only within context authority. Read/use permission alone is not delegation permission.
- If the sender lacks authority, return a safe approval-required state for an eligible approver without leaking restricted context or agent drafts. Denial, dismissal, or stale review grants nothing. Never silently install a missing agent from a mention; route installation to Agents.
- Confirmation binds actor, workspace, channel/context, agent/version, history/tool scope and grant revision; revalidate all inputs at commit. Atomically record audit + membership + grant and an optional requested invocation. Client request keys and source-message/job uniqueness make repeated mentions, retries, concurrent confirmation and replay idempotent. Changed scope/version invalidates review. Revocation/disable is rechecked before enqueue, execution, tools and delivery.
## Cross-team collaborators
| Name / group | Role |
|--------------|------|
| Web | Chat, inbox, navigation, accessibility |
| Database | Tenant-scoped persistence and migrations |
| Collab server | WebSocket delivery, Redis fan-out, presence |
| Worker and AI | Durable agent jobs, harness adapters, model routing |
| MCP | Tenant-scoped agent tools and deep links |
## Epics
| Epic | Link |
|------|------|
| Workspace chat foundation | `./Epic-workspace-chat/Epic-workspace-chat.md` |
| Unified notification inbox | `./Epic-unified-notifications/Epic-unified-notifications.md` |
| Agent participation in chat | `./Epic-agent-participation/Epic-agent-participation.md` |
## Metrics and milestones
| Milestone | Target date | Metric |
|-----------|-------------|--------|
| Chat foundation | TBD | Two members can exchange durable messages and reconnect without loss |
| Unified inbox | TBD | Mentions and operational events have accurate unread counts and deep links |
| Agent participation | TBD | A mentioned agent completes a harness-backed run and persists its response |
## Risks and mitigations
| Risk | Mitigation |
|------|------------|
| Lost or duplicated realtime events | Transactional outbox, idempotency keys, sequence cursors, Postgres replay |
| Cross-tenant data exposure | `workspaceProcedure`, workspace indexes, scoped joins, authorization tests |
| Redis outage interrupts chat | Durable writes remain available; clients catch up from Postgres after recovery |
| Agent loops or runaway spend | Explicit mention policy, rate limits, model policy, budgets, cancellation, approvals |
| Notification fatigue | Per-channel levels, category preferences, bundling, and durable read state |