Compare commits
12 commits
feat/deplo
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d64d0461a | ||
|
|
0d8db66021 | ||
|
|
32f76abc9f | ||
|
|
ff01a5f097 | ||
|
|
a9a4ccb9f2 | ||
|
|
4a1bea95c5 | ||
|
|
d90ff11f5b | ||
|
|
74f1e9eb0b | ||
|
|
15cd1f55b4 | ||
|
|
af676faa97 | ||
|
|
ff3bf83afe | ||
|
|
9572eac3eb |
150 changed files with 42823 additions and 295 deletions
117
.convoys/ai-agent-studio.md
Normal file
117
.convoys/ai-agent-studio.md
Normal 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.
|
||||||
50
.convoys/ai-agent-studio/brief-1-ai-workspace-tabs.md
Normal file
50
.convoys/ai-agent-studio/brief-1-ai-workspace-tabs.md
Normal 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.
|
||||||
|
|
@ -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.
|
||||||
49
.convoys/ai-agent-studio/brief-3-ai-agent-builder.md
Normal file
49
.convoys/ai-agent-studio/brief-3-ai-agent-builder.md
Normal 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.
|
||||||
47
.convoys/repo-discovery-onboarding.md
Normal file
47
.convoys/repo-discovery-onboarding.md
Normal 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.
|
||||||
50
.convoys/repo-discovery-onboarding/brief-1-api.md
Normal file
50
.convoys/repo-discovery-onboarding/brief-1-api.md
Normal 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.
|
||||||
40
.convoys/repo-discovery-onboarding/brief-2-ui.md
Normal file
40
.convoys/repo-discovery-onboarding/brief-2-ui.md
Normal 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.
|
||||||
51
.convoys/repo-discovery-onboarding/brief-3-ingestion.md
Normal file
51
.convoys/repo-discovery-onboarding/brief-3-ingestion.md
Normal 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/**`.
|
||||||
48
.convoys/repo-discovery-onboarding/brief-4-agent-mcp.md
Normal file
48
.convoys/repo-discovery-onboarding/brief-4-agent-mcp.md
Normal 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.
|
||||||
124
.convoys/workspace-chat-foundation.md
Normal file
124
.convoys/workspace-chat-foundation.md
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
---
|
||||||
|
name: workspace-chat-foundation
|
||||||
|
classification: feature
|
||||||
|
success_metric: Two workspace members can exchange durable channel messages, reconnect without loss, and open Chat from the left rail with accurate unread state.
|
||||||
|
skip: []
|
||||||
|
status: open
|
||||||
|
created: 2026-09-25
|
||||||
|
model_policy:
|
||||||
|
roles:
|
||||||
|
role-conductor: claude-planning
|
||||||
|
role-architect: claude-planning
|
||||||
|
role-implementer: qwen-coder
|
||||||
|
role-reviewer: qwen-coder
|
||||||
|
escalate_to: claude-planning
|
||||||
|
---
|
||||||
|
|
||||||
|
# Workspace chat foundation
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Echodo has collaborative documents, convoy operator notes, and AI chat surfaces, but no durable workspace conversation layer. The first slice must establish trustworthy persistence and realtime delivery before notifications or agent participation depend on it.
|
||||||
|
|
||||||
|
Canonical backlog: `plans/Plan-workspace-communications/Epic-workspace-chat/Epic-workspace-chat.md`.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Brief 1: tenant-scoped chat schema, channel lifecycle, message/outbox transaction, and workspace-authorized tRPC API.
|
||||||
|
- Brief 2: outbox publication, authenticated WebSockets, Redis fan-out, and reconnect catch-up.
|
||||||
|
- Brief 3: workspace Chat routes, channel/message UI, and left-rail unread/mention affordance.
|
||||||
|
- Postgres is authoritative; Redis and WebSockets accelerate delivery.
|
||||||
|
- Yjs/Hocuspocus may support presence/typing/drafts, but does not own message history.
|
||||||
|
|
||||||
|
Out of scope for this convoy: unified non-chat inbox, agent execution, external delivery, search, and federation.
|
||||||
|
|
||||||
|
## Role activity and kickoff — 2026-10-22
|
||||||
|
|
||||||
|
- User approved beginning the integrated contextual-chat/AI-agent direction; this does not authorize merge or release.
|
||||||
|
- This session performed conductor reconciliation and architect schema/access/dependency review. IA, UX, implementation, review and release work are not claimed complete by the existing section headings.
|
||||||
|
- At kickoff, Brief 1 was the next implementable slice. Superseded by the integration checkpoint below; the independent AI shell implementer remains tracked in the AI convoy.
|
||||||
|
- Files are local planning artifacts. No convoy DB registration, task claim, event logging, or MCP gate transition was performed; those tools are unavailable here. `open` is the local README status, not a server lifecycle claim.
|
||||||
|
|
||||||
|
## Go-live reconciliation — 2026-10-29
|
||||||
|
|
||||||
|
- Operator reports Chat deployed on CT 116 and 14/14 checks passed, including two-member messaging, reply, reconnect, unread, edit/delete recovery and revocation. Canonical endpoint: `wss://chat.stillwell.cloud/chat`.
|
||||||
|
- CT 102 ledger reported at 24 entries through `0023`; Chat schema verified and table ownership corrected to `echodo`. No migrations rerun here.
|
||||||
|
- Deployment evidence is now present in `docs/Current Progress.md`; local channel projection/plain-JSON redaction fixes preserved with regression coverage. Ticket origin handling is hardened locally to configured auth URL rather than forwarded headers; requires rebuild and post-deploy retest.
|
||||||
|
- Direct/PVE SSH trust in this harness remains unavailable, but is not a production rollout blocker. Operator production acceptance and local automated checks are separate evidence.
|
||||||
|
- Shared credentials password removal needs an operator decision. Source-built web rebuild, full checks and final review remain open; no lifecycle transition or release gate completion is inferred from the deployment report.
|
||||||
|
|
||||||
|
## Earlier integration checkpoint — 2026-10-29 (superseded by go-live report)
|
||||||
|
|
||||||
|
- Local implementation now includes persistence/edit/tombstone transactions, mutation catch-up, signed-ticket subscriptions, Redis outbox publication, and workspace Chat UI with scoped recovery. Cursors are in-memory, not persisted across browser reloads.
|
||||||
|
- Deployment wiring now extends `collab` with `CHAT_PORT` (default 1235), a shared runtime signing secret, dual-listener liveness checks, standalone publication, and a public TLS proxy example. The web URL is a build argument and Turbo build input.
|
||||||
|
- Configuration regression tests cover the wiring; actual Docker Compose/image validation still requires a Docker host. See [current progress](../docs/Current%20Progress.md) and the [deployment gate](../docs/deployment-production.md#workspace-chat-integration-gate).
|
||||||
|
- Production migrations `0022`/`0023` remain unverified. CT 116 SSH was blocked by a missing trusted host key; no remote changes were made. Disposable-Postgres, Redis/WebSocket, browser/revocation, accessibility and final review gates remain open.
|
||||||
|
- Earlier commit/push completion claims were incorrect: the implementation was still uncommitted at inspection. No merge, release, or server lifecycle transition is claimed by this checkpoint.
|
||||||
|
|
||||||
|
## Full product versus this foundation
|
||||||
|
|
||||||
|
The approved product includes workspace, project and supported-object chat plus mention-to-add agents. This convoy implements **workspace-context human chat only**, with an explicit fail-closed context policy seam. Object contexts must be rejected until the canonical follow-on [context authorization task](../plans/Plan-workspace-communications/Epic-workspace-chat/Task-contextual-chat-authorization.md) lands; do not treat a same-workspace object lookup as an ACL. Mention-to-add belongs to [agent channel membership](../plans/Plan-workspace-communications/Epic-agent-participation/Task-agent-channel-membership.md), not these three briefs.
|
||||||
|
|
||||||
|
All briefs follow the [plan's context and agent permission contracts](../plans/Plan-workspace-communications/Plan-workspace-communications.md). Projects are generic `objects` rows; the inspected schema has no private-object authorization model. This gap is a blocker for contextual rollout, not a reason to silently widen access.
|
||||||
|
|
||||||
|
## Todos
|
||||||
|
|
||||||
|
- [x] User approved kickoff of the integrated direction on 2026-10-22; merge/release gates remain pending.
|
||||||
|
- [ ] Implement brief 1 and validate tenant isolation and idempotency.
|
||||||
|
- [ ] Implement brief 2 and validate outage/reconnect behavior.
|
||||||
|
- [ ] Implement brief 3 and validate accessibility and multi-device unread state.
|
||||||
|
- [ ] Reviewer checks standards and the three linked backlog tasks.
|
||||||
|
- [ ] Human performs merge and release gates.
|
||||||
|
|
||||||
|
## IA
|
||||||
|
|
||||||
|
- Add `Chat` as a first-class left-rail destination within the current workspace.
|
||||||
|
- `/[workspaceSlug]/chat` selects or redirects to a channel.
|
||||||
|
- `/[workspaceSlug]/chat/[channelSlug]` renders channel navigation and conversation.
|
||||||
|
- Threads open in the existing right-panel pattern where practical, preserving channel context.
|
||||||
|
- The global notification inbox is a later epic; Chat still exposes its own durable unread and mention counts.
|
||||||
|
|
||||||
|
## UX
|
||||||
|
|
||||||
|
- Ordinary unread activity uses a dot; mentions/direct messages use a numeric badge.
|
||||||
|
- Badges include accessible labels and never rely on color alone.
|
||||||
|
- Optimistic messages show pending, failed, and retry states.
|
||||||
|
- Reconnect and catch-up are silent when successful and explicit when recovery fails.
|
||||||
|
- Keyboard navigation covers rail entry, channel switching, message history, composer, and thread opening.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Data and command path
|
||||||
|
|
||||||
|
1. A workspace-authorized tRPC mutation validates the command and channel membership.
|
||||||
|
2. Brief 1 owns one Postgres transaction that inserts the message and its versioned outbox event.
|
||||||
|
3. Brief 2 owns an outbox worker that claims and publishes committed events through Redis.
|
||||||
|
4. Realtime instances fan events out to authenticated local WebSocket subscribers.
|
||||||
|
5. Reconnecting clients query Postgres after their last durable sequence cursor.
|
||||||
|
|
||||||
|
### Boundaries
|
||||||
|
|
||||||
|
- `packages/database`: tenant-scoped chat and outbox schema plus migration.
|
||||||
|
- `packages/shared`: zod contracts and versioned realtime event types when shared across processes.
|
||||||
|
- `apps/web`: workspace-authorized tRPC router, routes, React Query state, and shell navigation.
|
||||||
|
- `apps/collab-server` or a narrowly separated realtime entry point: authenticated subscriptions and Redis fan-out.
|
||||||
|
- Existing Hocuspocus/Yjs infrastructure remains available for ephemeral presence, typing, and collaborative drafts.
|
||||||
|
|
||||||
|
### Security and scale
|
||||||
|
|
||||||
|
- Every query enforces workspace AND context AND channel access. Foundation accepts workspace context only; public workspace channels require active workspace membership, private/direct also require channel membership, and archived channels are read-only. Channel owners or workspace admins/owners administer only channels they may access; role alone grants no private/direct read or self-join bypass. Object contexts require a separate verified policy.
|
||||||
|
- Brief 1 defines the versioned outbox contract and owns the atomic message/outbox transaction; Brief 2 owns claim, publication, retry, and consumer delivery.
|
||||||
|
- Message commands accept client idempotency keys and generate a per-channel monotonic sequence; reconnect cursors are `(channel_id, sequence)` pairs.
|
||||||
|
- Realtime events carry identifiers and cursors, not authorization truth; subscription and catch-up endpoints authorize independently.
|
||||||
|
- Redis failure cannot roll back a committed message. Outbox retries and Postgres catch-up restore delivery.
|
||||||
|
- Use bounded pagination and backpressure; do not broadcast entire message histories.
|
||||||
|
- This convoy supports one-level threads in the persistence/API contract; full thread-panel polish may remain minimal in Brief 3.
|
||||||
|
- Read state is the highest message sequence the member has intentionally viewed. In this convoy, mention counts are derived from validated persisted message-mention rows above that cursor; the notification epic later projects those mentions into the unified inbox. Mark-read emits an invalidation event for other devices.
|
||||||
|
|
||||||
|
## Briefs
|
||||||
|
|
||||||
|
| Brief | Backlog task | Depends on |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | `Task-chat-persistence.md` | none |
|
||||||
|
| 2 | `Task-chat-realtime-delivery.md` | brief 1 |
|
||||||
|
| 3 | `Task-chat-ui-and-navigation.md` | briefs 1 and 2 |
|
||||||
|
|
@ -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.
|
||||||
55
.convoys/workspace-chat-foundation/brief-2-chat-realtime.md
Normal file
55
.convoys/workspace-chat-foundation/brief-2-chat-realtime.md
Normal 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.
|
||||||
55
.convoys/workspace-chat-foundation/brief-3-chat-ui.md
Normal file
55
.convoys/workspace-chat-foundation/brief-3-chat-ui.md
Normal 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.
|
||||||
28
.env.example
28
.env.example
|
|
@ -43,15 +43,24 @@ REDIS_URL="redis://:CHANGE_ME@192.168.68.102:6379/11"
|
||||||
# WORKER_REPO_ROOT= # defaults to monorepo root (pnpm-workspace.yaml walk-up)
|
# WORKER_REPO_ROOT= # defaults to monorepo root (pnpm-workspace.yaml walk-up)
|
||||||
# WORKER_WORKTREE_BASE=/tmp/echodo-worker-worktrees
|
# WORKER_WORKTREE_BASE=/tmp/echodo-worker-worktrees
|
||||||
#
|
#
|
||||||
# Phase B — OpenHands agent-server (optional; requires --profile agent-server)
|
# Phase B — coding harness (optional)
|
||||||
# WORKER_SESSION_TYPE=minimal # set to `openhands` when agent-server is running
|
# Preferred: OpenCode (COMPOSE_PROFILES=opencode)
|
||||||
|
# WORKER_SESSION_TYPE=minimal # set to `opencode` when opencode profile is running
|
||||||
|
# OPENCODE_SERVER_URL=http://opencode:4096
|
||||||
|
# OPENCODE_SERVER_PASSWORD= # openssl rand -hex 24; must match container
|
||||||
|
# OPENCODE_SERVER_USERNAME=opencode
|
||||||
|
# OPENCODE_PROVIDER_ID=omniroute
|
||||||
|
# WORKER_OPENCODE_WORKSPACE=/workspace/repo
|
||||||
|
# OMNIROUTE_BASE_URL=https://omniroute.stillwell.cloud/v1
|
||||||
|
# OMNIROUTE_API_KEY=
|
||||||
|
# OMNIROUTE_MODEL=auto/coding
|
||||||
|
#
|
||||||
|
# Legacy: OpenHands agent-server (COMPOSE_PROFILES=agent-server)
|
||||||
|
# WORKER_SESSION_TYPE=openhands
|
||||||
# AGENT_SERVER_URL=http://agent-server:8001
|
# AGENT_SERVER_URL=http://agent-server:8001
|
||||||
# WORKER_OPENHANDS_WORKSPACE=/workspace/repo
|
# WORKER_OPENHANDS_WORKSPACE=/workspace/repo
|
||||||
# SESSION_API_KEY= # must match LOCAL_BACKEND_API_KEY on agent-server
|
# SESSION_API_KEY= # must match LOCAL_BACKEND_API_KEY on agent-server
|
||||||
# LOCAL_BACKEND_API_KEY= # generate: openssl rand -hex 32
|
# LOCAL_BACKEND_API_KEY= # generate: openssl rand -hex 32
|
||||||
# OMNIROUTE_BASE_URL=https://omniroute.stillwell.cloud/v1
|
|
||||||
# OMNIROUTE_API_KEY=
|
|
||||||
# OMNIROUTE_MODEL=auto/coding
|
|
||||||
|
|
||||||
# ----- Auth (NextAuth v5) -----
|
# ----- Auth (NextAuth v5) -----
|
||||||
# Local: http://localhost:3000 • Prod: https://echodo.stillwell.cloud
|
# Local: http://localhost:3000 • Prod: https://echodo.stillwell.cloud
|
||||||
|
|
@ -162,4 +171,13 @@ POSTGRES_DB=echodo
|
||||||
POSTGRES_PORT=5432
|
POSTGRES_PORT=5432
|
||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
WEB_PORT=3000
|
WEB_PORT=3000
|
||||||
|
# ----- Chat (durable channel messaging) -----
|
||||||
|
# Shared secret for signing short-lived subscription tickets. Must match on web and collab.
|
||||||
|
# Generate at least 32 random bytes, for example: openssl rand -hex 32
|
||||||
|
CHAT_TICKET_SECRET="CHANGE_ME_USE_A_RANDOM_32_BYTE_SECRET"
|
||||||
|
# Internal collab chat listener port (distinct from Hocuspocus/Yjs on 1234)
|
||||||
|
CHAT_PORT=1235
|
||||||
|
# Public WSS endpoint, baked into the web bundle at build time. Rebuild web after changing.
|
||||||
|
NEXT_PUBLIC_CHAT_SERVER_URL="wss://echodo-chat.stillwell.cloud"
|
||||||
|
|
||||||
COLLAB_PORT=1234
|
COLLAB_PORT=1234
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,8 @@
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
"build": "tsup",
|
"build": "tsup",
|
||||||
"start": "node dist/index.mjs",
|
"start": "node dist/index.mjs",
|
||||||
"type-check": "tsc --noEmit"
|
"type-check": "tsc --noEmit",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hocuspocus/common": "^2.15.0",
|
"@hocuspocus/common": "^2.15.0",
|
||||||
|
|
@ -16,12 +17,15 @@
|
||||||
"@tasks/database": "workspace:*",
|
"@tasks/database": "workspace:*",
|
||||||
"drizzle-orm": "^0.38.0",
|
"drizzle-orm": "^0.38.0",
|
||||||
"ioredis": "^5.4.2",
|
"ioredis": "^5.4.2",
|
||||||
|
"ws": "^8.20.0",
|
||||||
"yjs": "^13.6.22",
|
"yjs": "^13.6.22",
|
||||||
"zod": "^3.24.0"
|
"zod": "^3.24.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/ws": "^8.18.2",
|
||||||
"tsup": "^8.3.5",
|
"tsup": "^8.3.5",
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
"typescript": "^5.7.0"
|
"typescript": "^5.7.0",
|
||||||
|
"vitest": "^2.1.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
86
apps/collab-server/src/chat.test.ts
Normal file
86
apps/collab-server/src/chat.test.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { createHmac } from "node:crypto";
|
||||||
|
import { authorizeChannel, parseTopic, verifyTicket } from "./chat.js";
|
||||||
|
|
||||||
|
const workspaceId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||||
|
const channelId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
|
||||||
|
const userId = "cccccccc-cccc-4ccc-8ccc-cccccccccccc";
|
||||||
|
const secret = "a".repeat(40);
|
||||||
|
const claims = {
|
||||||
|
iss: "echodo-web",
|
||||||
|
aud: "echodo-chat-v1",
|
||||||
|
scope: "chat:subscribe",
|
||||||
|
sub: userId,
|
||||||
|
workspaceId,
|
||||||
|
exp: Math.floor(Date.now() / 1000) + 30,
|
||||||
|
};
|
||||||
|
function ticket(payload: object, key = secret) {
|
||||||
|
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
||||||
|
return `${body}.${createHmac("sha256", key).update(body).digest("base64url")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("chat ticket", () => {
|
||||||
|
it("rejects unsigned, altered, expired and wrong-scope tickets", () => {
|
||||||
|
expect(verifyTicket(ticket(claims), secret)).toEqual(claims);
|
||||||
|
expect(verifyTicket(ticket(claims).replace(/.$/, "x"), secret)).toBeNull();
|
||||||
|
expect(verifyTicket(ticket({ ...claims, exp: 1 }), secret)).toBeNull();
|
||||||
|
expect(
|
||||||
|
verifyTicket(ticket({ ...claims, scope: "yjs" }), secret),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
verifyTicket(ticket({ ...claims, aud: "other" }), secret),
|
||||||
|
).toBeNull();
|
||||||
|
expect(verifyTicket(ticket(claims, "b".repeat(40)), secret)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("topic and authorization", () => {
|
||||||
|
it("accepts only exact workspace and channel UUID topics", () => {
|
||||||
|
expect(
|
||||||
|
parseTopic(`chat:v1:workspace:${workspaceId}:channel:${channelId}`),
|
||||||
|
).toEqual({ workspaceId, channelId });
|
||||||
|
expect(
|
||||||
|
parseTopic(`chat:v1:workspace:${workspaceId}:channel:${channelId}:evil`),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
it("requires workspace membership, and channel membership for private/direct (no admin bypass)", async () => {
|
||||||
|
const lookup = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
kind: "public",
|
||||||
|
workspaceMember: true,
|
||||||
|
channelMember: false,
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
kind: "private",
|
||||||
|
workspaceMember: true,
|
||||||
|
channelMember: false,
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
kind: "direct",
|
||||||
|
workspaceMember: true,
|
||||||
|
channelMember: true,
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
kind: "public",
|
||||||
|
workspaceMember: false,
|
||||||
|
channelMember: true,
|
||||||
|
})
|
||||||
|
.mockRejectedValueOnce(new Error("db down"));
|
||||||
|
expect(await authorizeChannel(lookup, workspaceId, channelId, userId)).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(await authorizeChannel(lookup, workspaceId, channelId, userId)).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(await authorizeChannel(lookup, workspaceId, channelId, userId)).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(await authorizeChannel(lookup, workspaceId, channelId, userId)).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(await authorizeChannel(lookup, workspaceId, channelId, userId)).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
400
apps/collab-server/src/chat.ts
Normal file
400
apps/collab-server/src/chat.ts
Normal file
|
|
@ -0,0 +1,400 @@
|
||||||
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||||
|
import { createServer } from "node:http";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import IoRedis from "ioredis";
|
||||||
|
import { WebSocketServer, type WebSocket } from "ws";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@tasks/database/client";
|
||||||
|
import {
|
||||||
|
chatChannelMembers,
|
||||||
|
chatChannels,
|
||||||
|
workspaceMembers,
|
||||||
|
} from "@tasks/database/schema";
|
||||||
|
|
||||||
|
const uuid = z.string().uuid();
|
||||||
|
const ticketClaims = z
|
||||||
|
.object({
|
||||||
|
iss: z.literal("echodo-web"),
|
||||||
|
aud: z.literal("echodo-chat-v1"),
|
||||||
|
scope: z.literal("chat:subscribe"),
|
||||||
|
sub: uuid,
|
||||||
|
workspaceId: uuid,
|
||||||
|
exp: z.number().int().positive(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
export type ChatTicket = z.infer<typeof ticketClaims>;
|
||||||
|
const topicPattern =
|
||||||
|
/^chat:v1:workspace:([0-9a-f-]{36}):channel:([0-9a-f-]{36})$/;
|
||||||
|
const subscribe = z
|
||||||
|
.object({ type: z.literal("subscribe"), channelId: uuid })
|
||||||
|
.strict();
|
||||||
|
const unsubscribe = z
|
||||||
|
.object({ type: z.literal("unsubscribe"), channelId: uuid })
|
||||||
|
.strict();
|
||||||
|
const clientMessage = z.discriminatedUnion("type", [subscribe, unsubscribe]);
|
||||||
|
const MAX_CONNECTIONS = 2000;
|
||||||
|
const MAX_CHANNELS = 64;
|
||||||
|
const MAX_MESSAGE_BYTES = 512;
|
||||||
|
const MAX_BUFFERED_BYTES = 64 * 1024;
|
||||||
|
const HEARTBEAT_MS = 15_000;
|
||||||
|
const AUTH_TIMEOUT_MS = 5_000;
|
||||||
|
|
||||||
|
export function verifyTicket(value: string, secret: string): ChatTicket | null {
|
||||||
|
if (secret.length < 32 || value.length > 2048) return null;
|
||||||
|
const parts = value.split(".");
|
||||||
|
if (
|
||||||
|
parts.length !== 2 ||
|
||||||
|
!parts[0] ||
|
||||||
|
!/^[A-Za-z0-9_-]+$/.test(parts[0]) ||
|
||||||
|
!/^[A-Za-z0-9_-]+$/.test(parts[1])
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
const expected = createHmac("sha256", secret).update(parts[0]).digest();
|
||||||
|
const supplied = Buffer.from(parts[1], "base64url");
|
||||||
|
if (
|
||||||
|
supplied.length !== expected.length ||
|
||||||
|
!timingSafeEqual(supplied, expected)
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
try {
|
||||||
|
const parsed = ticketClaims.safeParse(
|
||||||
|
JSON.parse(Buffer.from(parts[0], "base64url").toString("utf8")),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!parsed.success ||
|
||||||
|
parsed.data.exp <= Math.floor(Date.now() / 1000) ||
|
||||||
|
parsed.data.exp > Math.floor(Date.now() / 1000) + 60
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
return parsed.data;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTopic(
|
||||||
|
topic: string,
|
||||||
|
): { workspaceId: string; channelId: string } | null {
|
||||||
|
const match = topicPattern.exec(topic);
|
||||||
|
if (
|
||||||
|
!match ||
|
||||||
|
!uuid.safeParse(match[1]).success ||
|
||||||
|
!uuid.safeParse(match[2]).success
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
return { workspaceId: match[1], channelId: match[2] };
|
||||||
|
}
|
||||||
|
|
||||||
|
type Lookup = (
|
||||||
|
workspaceId: string,
|
||||||
|
channelId: string,
|
||||||
|
userId: string,
|
||||||
|
) => Promise<{
|
||||||
|
kind: string;
|
||||||
|
workspaceMember: boolean;
|
||||||
|
channelMember: boolean;
|
||||||
|
} | null>;
|
||||||
|
export async function authorizeChannel(
|
||||||
|
lookup: Lookup,
|
||||||
|
workspaceId: string,
|
||||||
|
channelId: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const row = await Promise.race([
|
||||||
|
lookup(workspaceId, channelId, userId),
|
||||||
|
new Promise<null>((resolve) => {
|
||||||
|
timeout = setTimeout(() => resolve(null), AUTH_TIMEOUT_MS);
|
||||||
|
}),
|
||||||
|
]).finally(() => clearTimeout(timeout));
|
||||||
|
return (
|
||||||
|
!!row &&
|
||||||
|
row.workspaceMember &&
|
||||||
|
(row.kind === "public" ||
|
||||||
|
((row.kind === "private" || row.kind === "direct") &&
|
||||||
|
row.channelMember))
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always query current membership; never cache authorization across notifications.
|
||||||
|
const lookupChannel: Lookup = async (workspaceId, channelId, userId) => {
|
||||||
|
const [row] = await db
|
||||||
|
.select({
|
||||||
|
kind: chatChannels.kind,
|
||||||
|
workspaceMember: workspaceMembers.userId,
|
||||||
|
channelMember: chatChannelMembers.userId,
|
||||||
|
})
|
||||||
|
.from(chatChannels)
|
||||||
|
.leftJoin(
|
||||||
|
workspaceMembers,
|
||||||
|
and(
|
||||||
|
eq(workspaceMembers.workspaceId, chatChannels.workspaceId),
|
||||||
|
eq(workspaceMembers.userId, userId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.leftJoin(
|
||||||
|
chatChannelMembers,
|
||||||
|
and(
|
||||||
|
eq(chatChannelMembers.workspaceId, chatChannels.workspaceId),
|
||||||
|
eq(chatChannelMembers.channelId, chatChannels.id),
|
||||||
|
eq(chatChannelMembers.userId, userId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(chatChannels.workspaceId, workspaceId),
|
||||||
|
eq(chatChannels.id, channelId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
return row
|
||||||
|
? {
|
||||||
|
kind: row.kind,
|
||||||
|
workspaceMember: !!row.workspaceMember,
|
||||||
|
channelMember: !!row.channelMember,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Peer = {
|
||||||
|
socket: WebSocket;
|
||||||
|
ticket: ChatTicket;
|
||||||
|
channels: Set<string>;
|
||||||
|
busy: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Separate port: Hocuspocus owns its existing HTTP/WebSocket upgrade handler. */
|
||||||
|
export async function startChatServer(): Promise<void> {
|
||||||
|
const secret = process.env.CHAT_TICKET_SECRET?.trim();
|
||||||
|
const redisUrl = process.env.REDIS_URL?.trim();
|
||||||
|
if (!secret || secret.length < 32 || !redisUrl) {
|
||||||
|
console.warn(
|
||||||
|
"[chat] disabled: CHAT_TICKET_SECRET (>=32 characters) and REDIS_URL required",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const port = Number(process.env.CHAT_PORT ?? 1235);
|
||||||
|
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
||||||
|
throw new Error("Invalid CHAT_PORT");
|
||||||
|
const subscriber = new IoRedis(redisUrl, { maxRetriesPerRequest: 1 });
|
||||||
|
await subscriber.ping(); // Fail closed: never accept subscribers without Redis.
|
||||||
|
const http = createServer((_req, res) => {
|
||||||
|
res.writeHead(404).end();
|
||||||
|
});
|
||||||
|
const wss = new WebSocketServer({
|
||||||
|
noServer: true,
|
||||||
|
maxPayload: MAX_MESSAGE_BYTES,
|
||||||
|
perMessageDeflate: false,
|
||||||
|
});
|
||||||
|
const peers = new Set<Peer>();
|
||||||
|
const topicPeers = new Map<string, Set<Peer>>();
|
||||||
|
const send = (peer: Peer, value: object) => {
|
||||||
|
if (peer.socket.readyState !== 1) return;
|
||||||
|
if (peer.socket.bufferedAmount > MAX_BUFFERED_BYTES) {
|
||||||
|
peer.socket.close(1013);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
peer.socket.send(JSON.stringify(value));
|
||||||
|
};
|
||||||
|
const remove = (peer: Peer, topic: string) => {
|
||||||
|
peer.channels.delete(topic);
|
||||||
|
const set = topicPeers.get(topic);
|
||||||
|
set?.delete(peer);
|
||||||
|
if (set?.size === 0) {
|
||||||
|
topicPeers.delete(topic);
|
||||||
|
void subscriber.unsubscribe(topic).catch(() => {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const revoke = (peer: Peer, topic: string, channelId: string) => {
|
||||||
|
remove(peer, topic);
|
||||||
|
send(peer, { type: "revoked", channelId });
|
||||||
|
};
|
||||||
|
const deliver = async (topic: string) => {
|
||||||
|
const ids = parseTopic(topic);
|
||||||
|
if (!ids) return;
|
||||||
|
for (const peer of [...(topicPeers.get(topic) ?? [])]) {
|
||||||
|
if (!peer.channels.has(topic)) continue;
|
||||||
|
if (
|
||||||
|
peer.ticket.exp <= Date.now() / 1000 ||
|
||||||
|
!(await authorizeChannel(
|
||||||
|
lookupChannel,
|
||||||
|
ids.workspaceId,
|
||||||
|
ids.channelId,
|
||||||
|
peer.ticket.sub,
|
||||||
|
))
|
||||||
|
) {
|
||||||
|
revoke(peer, topic, ids.channelId);
|
||||||
|
peer.socket.close(1008);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Deliberately ignore Redis envelope including message bodies and keys.
|
||||||
|
if (peer.channels.has(topic))
|
||||||
|
send(peer, { type: "invalidate", channelId: ids.channelId });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
subscriber.on("message", (topic) => {
|
||||||
|
void deliver(topic).catch(() => {});
|
||||||
|
});
|
||||||
|
subscriber.on("error", () => {
|
||||||
|
for (const peer of peers) peer.socket.close(1011);
|
||||||
|
});
|
||||||
|
subscriber.on("end", () => {
|
||||||
|
for (const peer of peers) peer.socket.close(1011);
|
||||||
|
});
|
||||||
|
http.on("upgrade", (req, socket, head) => {
|
||||||
|
if (
|
||||||
|
peers.size >= MAX_CONNECTIONS ||
|
||||||
|
req.url?.length === undefined ||
|
||||||
|
req.url.length > 2300
|
||||||
|
) {
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let token: string | null;
|
||||||
|
try {
|
||||||
|
const url = new URL(req.url, "http://localhost");
|
||||||
|
token = url.pathname === "/chat" ? url.searchParams.get("ticket") : null;
|
||||||
|
} catch {
|
||||||
|
token = null;
|
||||||
|
}
|
||||||
|
const ticket = token ? verifyTicket(token, secret) : null;
|
||||||
|
if (!ticket) {
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wss.handleUpgrade(req, socket, head, (ws) =>
|
||||||
|
wss.emit("connection", ws, ticket),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
wss.on("connection", (socket: WebSocket, ticket: ChatTicket) => {
|
||||||
|
const peer: Peer = { socket, ticket, channels: new Set(), busy: false };
|
||||||
|
peers.add(peer);
|
||||||
|
const expiry = setTimeout(
|
||||||
|
() => socket.close(1008),
|
||||||
|
Math.max(0, ticket.exp * 1000 - Date.now()),
|
||||||
|
);
|
||||||
|
expiry.unref();
|
||||||
|
socket.on("close", () => {
|
||||||
|
clearTimeout(expiry);
|
||||||
|
peers.delete(peer);
|
||||||
|
for (const topic of [...peer.channels]) remove(peer, topic);
|
||||||
|
});
|
||||||
|
socket.on("message", (bytes) => {
|
||||||
|
if (peer.busy) {
|
||||||
|
socket.close(1008);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
peer.busy = true;
|
||||||
|
void (async () => {
|
||||||
|
if (ticket.exp <= Date.now() / 1000) {
|
||||||
|
socket.close(1008);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let data: unknown;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(bytes.toString());
|
||||||
|
} catch {
|
||||||
|
socket.close(1008);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = clientMessage.safeParse(data);
|
||||||
|
if (!parsed.success) {
|
||||||
|
socket.close(1008);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { channelId } = parsed.data;
|
||||||
|
const topic = `chat:v1:workspace:${ticket.workspaceId}:channel:${channelId}`;
|
||||||
|
if (parsed.data.type === "unsubscribe") {
|
||||||
|
remove(peer, topic);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (peer.channels.size >= MAX_CHANNELS && !peer.channels.has(topic)) {
|
||||||
|
socket.close(1008);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!(await authorizeChannel(
|
||||||
|
lookupChannel,
|
||||||
|
ticket.workspaceId,
|
||||||
|
channelId,
|
||||||
|
ticket.sub,
|
||||||
|
))
|
||||||
|
) {
|
||||||
|
socket.close(1008);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (peer.channels.has(topic)) return;
|
||||||
|
if (!topicPeers.has(topic)) {
|
||||||
|
try {
|
||||||
|
await subscriber.subscribe(topic);
|
||||||
|
} catch {
|
||||||
|
socket.close(1011);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!topicPeers.has(topic)) topicPeers.set(topic, new Set());
|
||||||
|
}
|
||||||
|
// Recheck after the Redis subscribe await: access may have been revoked meanwhile.
|
||||||
|
if (
|
||||||
|
!(await authorizeChannel(
|
||||||
|
lookupChannel,
|
||||||
|
ticket.workspaceId,
|
||||||
|
channelId,
|
||||||
|
ticket.sub,
|
||||||
|
))
|
||||||
|
) {
|
||||||
|
socket.close(1008);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (socket.readyState !== 1) return;
|
||||||
|
topicPeers.get(topic)?.add(peer);
|
||||||
|
peer.channels.add(topic);
|
||||||
|
send(peer, { type: "subscribed", channelId });
|
||||||
|
})()
|
||||||
|
.catch(() => socket.close(1011))
|
||||||
|
.finally(() => {
|
||||||
|
peer.busy = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
setInterval(() => {
|
||||||
|
for (const peer of peers) {
|
||||||
|
if (peer.busy) continue;
|
||||||
|
peer.busy = true;
|
||||||
|
void (async () => {
|
||||||
|
if (peer.ticket.exp <= Date.now() / 1000) {
|
||||||
|
peer.socket.close(1008);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const topic of [...peer.channels]) {
|
||||||
|
const ids = parseTopic(topic);
|
||||||
|
if (
|
||||||
|
!ids ||
|
||||||
|
!(await authorizeChannel(
|
||||||
|
lookupChannel,
|
||||||
|
ids.workspaceId,
|
||||||
|
ids.channelId,
|
||||||
|
peer.ticket.sub,
|
||||||
|
))
|
||||||
|
) {
|
||||||
|
revoke(peer, topic, ids?.channelId ?? "");
|
||||||
|
peer.socket.close(1008);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
.catch(() => peer.socket.close(1011))
|
||||||
|
.finally(() => {
|
||||||
|
peer.busy = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, HEARTBEAT_MS).unref();
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
http.once("error", reject);
|
||||||
|
http.listen(port, "0.0.0.0", resolve);
|
||||||
|
});
|
||||||
|
console.log(`[chat] listening on port ${port}`);
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ import { objects } from "@tasks/database/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import IoRedis from "ioredis";
|
import IoRedis from "ioredis";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { startChatServer } from "./chat.js";
|
||||||
import {
|
import {
|
||||||
base64ToYUpdate,
|
base64ToYUpdate,
|
||||||
extractObjectId,
|
extractObjectId,
|
||||||
|
|
@ -186,6 +187,8 @@ function colorFromName(name: string): string {
|
||||||
async function main() {
|
async function main() {
|
||||||
const port = Number(process.env.PORT) || 1234;
|
const port = Number(process.env.PORT) || 1234;
|
||||||
|
|
||||||
|
await startChatServer();
|
||||||
|
|
||||||
const extensions: Extension[] = [buildDatabaseExtension()];
|
const extensions: Extension[] = [buildDatabaseExtension()];
|
||||||
const redis = await tryCreateRedisExtension();
|
const redis = await tryCreateRedisExtension();
|
||||||
if (redis) {
|
if (redis) {
|
||||||
|
|
|
||||||
|
|
@ -1,212 +1,23 @@
|
||||||
"use client";
|
import { Suspense } from "react";
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
import { AIWorkspace } from "@/components/ai/ai-workspace";
|
||||||
import { useParams } from "next/navigation";
|
|
||||||
import { useChat } from "@ai-sdk/react";
|
|
||||||
import { AlertTriangle, Bot, Loader2, Send, Sparkles, User } from "lucide-react";
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
export default async function AIPage({
|
||||||
import { cn } from "@/lib/utils";
|
params,
|
||||||
|
}: {
|
||||||
export default function AIPage() {
|
params: Promise<{ workspaceSlug: string }>;
|
||||||
const params = useParams();
|
}) {
|
||||||
const workspaceSlug =
|
const { workspaceSlug } = await params;
|
||||||
typeof params?.workspaceSlug === "string" ? params.workspaceSlug : "";
|
|
||||||
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
||||||
|
|
||||||
const {
|
|
||||||
messages,
|
|
||||||
input,
|
|
||||||
handleInputChange,
|
|
||||||
handleSubmit,
|
|
||||||
status,
|
|
||||||
error,
|
|
||||||
setInput,
|
|
||||||
} = useChat({
|
|
||||||
api: "/api/chat",
|
|
||||||
body: { workspace: workspaceSlug },
|
|
||||||
});
|
|
||||||
|
|
||||||
const isBusy = status === "submitted" || status === "streaming";
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (scrollRef.current) {
|
|
||||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
|
||||||
}
|
|
||||||
}, [messages, status]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-col">
|
<Suspense
|
||||||
<div className="flex shrink-0 items-center gap-3 border-b px-6 py-4">
|
fallback={
|
||||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
|
<p role="status" className="p-6">
|
||||||
<Sparkles className="size-5 text-primary" />
|
Loading AI workspace…
|
||||||
</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"}
|
|
||||||
</p>
|
</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";
|
|
||||||
}
|
|
||||||
|
|
|
||||||
10
apps/web/app/(app)/[workspaceSlug]/chat/[channelId]/page.tsx
Normal file
10
apps/web/app/(app)/[workspaceSlug]/chat/[channelId]/page.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { ChatWorkspace } from "@/components/chat/chat-workspace";
|
||||||
|
|
||||||
|
export default async function ChannelPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ workspaceSlug: string; channelId: string }>;
|
||||||
|
}) {
|
||||||
|
const { workspaceSlug, channelId } = await params;
|
||||||
|
return <ChatWorkspace workspace={workspaceSlug} channelId={channelId} />;
|
||||||
|
}
|
||||||
10
apps/web/app/(app)/[workspaceSlug]/chat/page.tsx
Normal file
10
apps/web/app/(app)/[workspaceSlug]/chat/page.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { ChatWorkspace } from "@/components/chat/chat-workspace";
|
||||||
|
|
||||||
|
export default async function ChatPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ workspaceSlug: string }>;
|
||||||
|
}) {
|
||||||
|
const { workspaceSlug } = await params;
|
||||||
|
return <ChatWorkspace workspace={workspaceSlug} />;
|
||||||
|
}
|
||||||
5
apps/web/app/(app)/[workspaceSlug]/fleet/page.tsx
Normal file
5
apps/web/app/(app)/[workspaceSlug]/fleet/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default function WorkspaceFleetRedirect() {
|
||||||
|
redirect("/fleet");
|
||||||
|
}
|
||||||
54
apps/web/app/api/chat/route.test.ts
Normal file
54
apps/web/app/api/chat/route.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -5,22 +5,13 @@ import { z } from "zod";
|
||||||
|
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { resolveWorkspace } from "@/server/lib/resolve-workspace";
|
import { resolveWorkspace } from "@/server/lib/resolve-workspace";
|
||||||
|
import { chatRequestSchema } from "./schema";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
const BASE_SYSTEM = `You are a helpful AI assistant embedded in a project management and collaboration app. Users organize work in workspaces with objects such as projects, tasks, documents, and groups. You help them plan work, clarify requirements, break down tasks, summarize content, and suggest next steps. Be concise, actionable, and friendly. Use markdown when it improves readability (bold, lists, short code snippets).`;
|
const BASE_SYSTEM = `You are a helpful AI assistant embedded in a project management and collaboration app. Users organize work in workspaces with objects such as projects, tasks, documents, and groups. You help them plan work, clarify requirements, break down tasks, summarize content, and suggest next steps. Be concise, actionable, and friendly. Use markdown when it improves readability (bold, lists, short code snippets).`;
|
||||||
|
|
||||||
const messageSchema = z.object({
|
|
||||||
role: z.enum(["user", "assistant", "system"]),
|
|
||||||
content: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const bodySchema = z.object({
|
|
||||||
workspace: z.string().min(1),
|
|
||||||
messages: z.array(messageSchema).min(1),
|
|
||||||
});
|
|
||||||
|
|
||||||
function errorResponse(status: number, message: string): Response {
|
function errorResponse(status: number, message: string): Response {
|
||||||
return new Response(JSON.stringify({ error: message }), {
|
return new Response(JSON.stringify({ error: message }), {
|
||||||
status,
|
status,
|
||||||
|
|
@ -34,9 +25,9 @@ export async function POST(req: Request): Promise<Response> {
|
||||||
return errorResponse(401, "Sign in to use the assistant");
|
return errorResponse(401, "Sign in to use the assistant");
|
||||||
}
|
}
|
||||||
|
|
||||||
let body: z.infer<typeof bodySchema>;
|
let body: z.infer<typeof chatRequestSchema>;
|
||||||
try {
|
try {
|
||||||
body = bodySchema.parse(await req.json());
|
body = chatRequestSchema.parse(await req.json());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof z.ZodError) {
|
if (e instanceof z.ZodError) {
|
||||||
return errorResponse(400, e.issues[0]?.message ?? "Invalid request");
|
return errorResponse(400, e.issues[0]?.message ?? "Invalid request");
|
||||||
|
|
|
||||||
28
apps/web/app/api/chat/schema.ts
Normal file
28
apps/web/app/api/chat/schema.ts
Normal 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();
|
||||||
87
apps/web/app/api/chat/ticket/route.test.ts
Normal file
87
apps/web/app/api/chat/ticket/route.test.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const { mockAuth } = vi.hoisted(() => ({
|
||||||
|
mockAuth: vi.fn<() => Promise<{ user: { id: string } } | null>>(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/auth", () => ({ auth: mockAuth }));
|
||||||
|
vi.mock("@/server/lib/resolve-workspace", () => ({
|
||||||
|
resolveWorkspace: vi.fn(),
|
||||||
|
}));
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { resolveWorkspace } from "@/server/lib/resolve-workspace";
|
||||||
|
import { POST } from "./route";
|
||||||
|
|
||||||
|
const publicOrigin = "https://echodo.stillwell.cloud";
|
||||||
|
function request(
|
||||||
|
origin: string | null,
|
||||||
|
forwardedHost = "echodo.stillwell.cloud",
|
||||||
|
) {
|
||||||
|
const headers = new Headers({
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"x-forwarded-host": forwardedHost,
|
||||||
|
"x-forwarded-proto": "https",
|
||||||
|
});
|
||||||
|
if (origin !== null) headers.set("origin", origin);
|
||||||
|
return new Request("http://0.0.0.0:3000/api/chat/ticket", {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ workspace: "example" }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubEnv("NODE_ENV", "production");
|
||||||
|
vi.stubEnv("NEXTAUTH_URL", publicOrigin);
|
||||||
|
vi.stubEnv("AUTH_URL", "");
|
||||||
|
vi.stubEnv("CHAT_TICKET_SECRET", "x".repeat(64));
|
||||||
|
mockAuth.mockResolvedValue({
|
||||||
|
user: { id: "10000000-0000-4000-8000-000000000001" },
|
||||||
|
});
|
||||||
|
vi.mocked(resolveWorkspace).mockResolvedValue({
|
||||||
|
id: "20000000-0000-4000-8000-000000000001",
|
||||||
|
} as Awaited<ReturnType<typeof resolveWorkspace>>);
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Chat ticket origin boundary", () => {
|
||||||
|
it("accepts the configured public origin behind an internal Docker URL", async () => {
|
||||||
|
const response = await POST(request(publicOrigin));
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(resolveWorkspace).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it.each([
|
||||||
|
null,
|
||||||
|
"null",
|
||||||
|
"https://evil.example",
|
||||||
|
`${publicOrigin}.evil.example`,
|
||||||
|
`${publicOrigin}/path`,
|
||||||
|
])("rejects origin %s before session lookup", async (origin) => {
|
||||||
|
expect((await POST(request(origin, "evil.example"))).status).toBe(403);
|
||||||
|
expect(auth).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it("does not trust forged forwarded host and protocol", async () => {
|
||||||
|
expect(
|
||||||
|
(await POST(request("https://evil.example", "evil.example"))).status,
|
||||||
|
).toBe(403);
|
||||||
|
});
|
||||||
|
it("fails closed without a configured production origin", async () => {
|
||||||
|
vi.stubEnv("NEXTAUTH_URL", "");
|
||||||
|
expect((await POST(request("http://0.0.0.0:3000"))).status).toBe(403);
|
||||||
|
});
|
||||||
|
it("fails closed with an invalid configured origin", async () => {
|
||||||
|
vi.stubEnv("NEXTAUTH_URL", "not a URL");
|
||||||
|
expect((await POST(request(publicOrigin))).status).toBe(403);
|
||||||
|
});
|
||||||
|
it("still requires an authenticated session", async () => {
|
||||||
|
mockAuth.mockResolvedValue(null);
|
||||||
|
expect((await POST(request(publicOrigin))).status).toBe(401);
|
||||||
|
});
|
||||||
|
it("does not mint a ticket when workspace authorization fails", async () => {
|
||||||
|
vi.mocked(resolveWorkspace).mockRejectedValue(new Error("denied"));
|
||||||
|
expect((await POST(request(publicOrigin))).status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
77
apps/web/app/api/chat/ticket/route.ts
Normal file
77
apps/web/app/api/chat/ticket/route.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import { createHmac } from "node:crypto";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { resolveWorkspace } from "@/server/lib/resolve-workspace";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
const input = z.object({ workspace: z.string().min(1).max(200) }).strict();
|
||||||
|
const noStore = { "Cache-Control": "no-store" };
|
||||||
|
|
||||||
|
function trustedOrigin(request: Request): string | null {
|
||||||
|
// Docker's request URL may be internal. Pin production to deployment configuration,
|
||||||
|
// not Host/forwarded headers that a caller could supply outside the trusted proxy.
|
||||||
|
const configured = process.env.AUTH_URL || process.env.NEXTAUTH_URL;
|
||||||
|
if (!configured && process.env.NODE_ENV === "production") return null;
|
||||||
|
const parsed = z
|
||||||
|
.string()
|
||||||
|
.url()
|
||||||
|
.safeParse(configured || request.url);
|
||||||
|
if (!parsed.success) return null;
|
||||||
|
const url = new URL(parsed.data);
|
||||||
|
if (
|
||||||
|
!["https:", "http:"].includes(url.protocol) ||
|
||||||
|
url.username ||
|
||||||
|
url.password
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
return url.origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request): Promise<Response> {
|
||||||
|
// A browser session cookie must never be usable by a foreign origin to mint tickets.
|
||||||
|
const origin = request.headers.get("origin");
|
||||||
|
if (!origin || origin !== trustedOrigin(request))
|
||||||
|
return new Response(null, { status: 403, headers: noStore });
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id)
|
||||||
|
return new Response(null, { status: 401, headers: noStore });
|
||||||
|
const secret = process.env.CHAT_TICKET_SECRET?.trim();
|
||||||
|
if (!secret || secret.length < 32)
|
||||||
|
return new Response(null, { status: 503, headers: noStore });
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return new Response(null, { status: 400, headers: noStore });
|
||||||
|
}
|
||||||
|
const parsed = input.safeParse(body);
|
||||||
|
if (!parsed.success)
|
||||||
|
return new Response(null, { status: 400, headers: noStore });
|
||||||
|
try {
|
||||||
|
const workspace = await resolveWorkspace({
|
||||||
|
handle: parsed.data.workspace,
|
||||||
|
userId: session.user.id,
|
||||||
|
});
|
||||||
|
const payload = Buffer.from(
|
||||||
|
JSON.stringify({
|
||||||
|
iss: "echodo-web",
|
||||||
|
aud: "echodo-chat-v1",
|
||||||
|
scope: "chat:subscribe",
|
||||||
|
sub: session.user.id,
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
exp: Math.floor(Date.now() / 1000) + 60,
|
||||||
|
}),
|
||||||
|
).toString("base64url");
|
||||||
|
const signature = createHmac("sha256", secret)
|
||||||
|
.update(payload)
|
||||||
|
.digest("base64url");
|
||||||
|
return Response.json(
|
||||||
|
{ ticket: `${payload}.${signature}` },
|
||||||
|
{ headers: noStore },
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Do not distinguish nonexistent workspaces from denied or failed membership checks.
|
||||||
|
return new Response(null, { status: 404, headers: noStore });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import Script from "next/script";
|
||||||
import "@/styles/globals.css";
|
import "@/styles/globals.css";
|
||||||
import { ThemeProvider } from "@/components/providers/theme-provider";
|
import { ThemeProvider } from "@/components/providers/theme-provider";
|
||||||
import { TRPCProvider } from "@/components/providers/trpc-provider";
|
import { TRPCProvider } from "@/components/providers/trpc-provider";
|
||||||
|
import { SessionProvider } from "next-auth/react";
|
||||||
|
|
||||||
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });
|
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });
|
||||||
|
|
||||||
|
|
@ -30,7 +31,9 @@ export default function RootLayout({
|
||||||
enableSystem
|
enableSystem
|
||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
>
|
>
|
||||||
<TRPCProvider>{children}</TRPCProvider>
|
<SessionProvider>
|
||||||
|
<TRPCProvider>{children}</TRPCProvider>
|
||||||
|
</SessionProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
{umamiWebsiteId ? (
|
{umamiWebsiteId ? (
|
||||||
<Script
|
<Script
|
||||||
|
|
|
||||||
20
apps/web/components/ai/agent-builder.ts
Normal file
20
apps/web/components/ai/agent-builder.ts
Normal 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.",
|
||||||
|
});
|
||||||
103
apps/web/components/ai/agents-mode.tsx
Normal file
103
apps/web/components/ai/agents-mode.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
155
apps/web/components/ai/ai-workspace.test.tsx
Normal file
155
apps/web/components/ai/ai-workspace.test.tsx
Normal 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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
98
apps/web/components/ai/ai-workspace.tsx
Normal file
98
apps/web/components/ai/ai-workspace.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
166
apps/web/components/ai/chat-mode.tsx
Normal file
166
apps/web/components/ai/chat-mode.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
apps/web/components/ai/composer-events.ts
Normal file
12
apps/web/components/ai/composer-events.ts
Normal 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();
|
||||||
|
}
|
||||||
121
apps/web/components/ai/workspace-composer.tsx
Normal file
121
apps/web/components/ai/workspace-composer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
125
apps/web/components/ai/workspace-interaction.test.ts
Normal file
125
apps/web/components/ai/workspace-interaction.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
50
apps/web/components/ai/workspace-interaction.ts
Normal file
50
apps/web/components/ai/workspace-interaction.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
27
apps/web/components/ai/workspace-mode.ts
Normal file
27
apps/web/components/ai/workspace-mode.ts
Normal 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}`;
|
||||||
|
}
|
||||||
61
apps/web/components/chat/chat-composer.tsx
Normal file
61
apps/web/components/chat/chat-composer.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useId, useState, type FormEvent, type KeyboardEvent } from "react";
|
||||||
|
import { Send } from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export function ChatComposer({
|
||||||
|
onSend,
|
||||||
|
disabled = false,
|
||||||
|
label = "Message",
|
||||||
|
}: {
|
||||||
|
onSend: (body: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
label?: string;
|
||||||
|
}) {
|
||||||
|
const [body, setBody] = useState("");
|
||||||
|
const id = useId();
|
||||||
|
const submit = (event?: FormEvent) => {
|
||||||
|
event?.preventDefault();
|
||||||
|
const text = body.trim();
|
||||||
|
if (!text || disabled) return;
|
||||||
|
onSend(text);
|
||||||
|
setBody("");
|
||||||
|
};
|
||||||
|
const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (
|
||||||
|
event.key === "Enter" &&
|
||||||
|
!event.shiftKey &&
|
||||||
|
!event.nativeEvent.isComposing
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
submit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<form onSubmit={submit} className="flex items-end gap-2 border-t p-3">
|
||||||
|
<label className="sr-only" htmlFor={id}>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id={id}
|
||||||
|
value={body}
|
||||||
|
onChange={(event) => setBody(event.target.value)}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
disabled={disabled}
|
||||||
|
maxLength={10000}
|
||||||
|
rows={2}
|
||||||
|
placeholder={`${label} (Enter to send, Shift+Enter for a new line)`}
|
||||||
|
className="min-h-12 max-h-36 flex-1 resize-y rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={disabled || !body.trim()}
|
||||||
|
aria-label="Send message"
|
||||||
|
>
|
||||||
|
<Send aria-hidden="true" /> Send
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
110
apps/web/components/chat/chat-message-list.test.tsx
Normal file
110
apps/web/components/chat/chat-message-list.test.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { ChatMessageList } from "./chat-message-list";
|
||||||
|
|
||||||
|
const message = {
|
||||||
|
id: "server",
|
||||||
|
sequence: "2",
|
||||||
|
body: "Confirmed",
|
||||||
|
authorUserId: "user",
|
||||||
|
createdAt: "2026-01-01",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("rendered message states", () => {
|
||||||
|
it("shows a visible sending state and a retry only on failure", () => {
|
||||||
|
const pending = {
|
||||||
|
key: "same-key",
|
||||||
|
body: "Hello",
|
||||||
|
failed: false,
|
||||||
|
retry: vi.fn(),
|
||||||
|
};
|
||||||
|
const sending = renderToStaticMarkup(
|
||||||
|
<ChatMessageList messages={[]} pending={[pending]} />,
|
||||||
|
);
|
||||||
|
expect(sending).toContain("Sending…");
|
||||||
|
expect(sending).not.toContain("Retry");
|
||||||
|
const failed = renderToStaticMarkup(
|
||||||
|
<ChatMessageList
|
||||||
|
messages={[]}
|
||||||
|
pending={[
|
||||||
|
{
|
||||||
|
...pending,
|
||||||
|
failed: true,
|
||||||
|
error: "Could not send message. Please retry.",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(failed).toContain('role="alert"');
|
||||||
|
expect(failed).toContain("Retry");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders only the messages supplied by the parent", () => {
|
||||||
|
const html = renderToStaticMarkup(<ChatMessageList messages={[message]} />);
|
||||||
|
expect(html).toContain("Confirmed");
|
||||||
|
expect(html).not.toContain("Sending…");
|
||||||
|
expect(html).not.toContain("Retry");
|
||||||
|
|
||||||
|
const empty = renderToStaticMarkup(<ChatMessageList messages={[]} />);
|
||||||
|
expect(empty).not.toContain("Confirmed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows an edited indicator for edited messages", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<ChatMessageList messages={[{ ...message, editedAt: "2026-01-02" }]} />,
|
||||||
|
);
|
||||||
|
expect(html).toContain("(edited)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only offers owner edit, owner or moderator delete, and never archived or deleted controls", () => {
|
||||||
|
const own = renderToStaticMarkup(
|
||||||
|
<ChatMessageList
|
||||||
|
messages={[message]}
|
||||||
|
currentUserId="user"
|
||||||
|
onEdit={vi.fn()}
|
||||||
|
onDelete={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(own).toContain("Edit message server");
|
||||||
|
expect(own).toContain("Delete message server");
|
||||||
|
const moderator = renderToStaticMarkup(
|
||||||
|
<ChatMessageList
|
||||||
|
messages={[message]}
|
||||||
|
currentUserId="moderator"
|
||||||
|
canModerate
|
||||||
|
onEdit={vi.fn()}
|
||||||
|
onDelete={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(moderator).not.toContain("Edit message server");
|
||||||
|
expect(moderator).toContain("Delete message server");
|
||||||
|
const archived = renderToStaticMarkup(
|
||||||
|
<ChatMessageList
|
||||||
|
messages={[message]}
|
||||||
|
currentUserId="user"
|
||||||
|
readOnly
|
||||||
|
onEdit={vi.fn()}
|
||||||
|
onDelete={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(archived).not.toContain("Edit message server");
|
||||||
|
const deleted = renderToStaticMarkup(
|
||||||
|
<ChatMessageList
|
||||||
|
messages={[{ ...message, status: "deleted" }]}
|
||||||
|
currentUserId="user"
|
||||||
|
onEdit={vi.fn()}
|
||||||
|
onDelete={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(deleted).not.toContain("Delete message server");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders deleted messages as a placeholder instead of their body", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<ChatMessageList messages={[{ ...message, deletedAt: "2026-01-02" }]} />,
|
||||||
|
);
|
||||||
|
expect(html).toContain("Message deleted");
|
||||||
|
expect(html).not.toContain("Confirmed");
|
||||||
|
});
|
||||||
|
});
|
||||||
179
apps/web/components/chat/chat-message-list.tsx
Normal file
179
apps/web/components/chat/chat-message-list.tsx
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export type ChatMessageView = {
|
||||||
|
id: string;
|
||||||
|
sequence: string;
|
||||||
|
body: string;
|
||||||
|
authorUserId: string | null;
|
||||||
|
createdAt: Date | string;
|
||||||
|
editedAt?: Date | string | null;
|
||||||
|
deletedAt?: Date | string | null;
|
||||||
|
status?: string;
|
||||||
|
revision?: number;
|
||||||
|
replyCount?: number;
|
||||||
|
parentMessageId?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ChatMessageList({
|
||||||
|
messages,
|
||||||
|
onViewed,
|
||||||
|
onThread,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
canModerate = false,
|
||||||
|
currentUserId,
|
||||||
|
readOnly = false,
|
||||||
|
pending = [],
|
||||||
|
}: {
|
||||||
|
messages: ChatMessageView[];
|
||||||
|
onViewed?: (sequence: string) => void;
|
||||||
|
onThread?: (id: string) => void;
|
||||||
|
onEdit?: (message: ChatMessageView) => void;
|
||||||
|
onDelete?: (message: ChatMessageView) => void;
|
||||||
|
canModerate?: boolean;
|
||||||
|
currentUserId?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
|
pending?: {
|
||||||
|
key: string;
|
||||||
|
body: string;
|
||||||
|
failed: boolean;
|
||||||
|
error?: string;
|
||||||
|
retry: () => void;
|
||||||
|
}[];
|
||||||
|
}) {
|
||||||
|
const viewed = useRef(onViewed);
|
||||||
|
useEffect(() => {
|
||||||
|
viewed.current = onViewed;
|
||||||
|
}, [onViewed]);
|
||||||
|
|
||||||
|
const list = useRef<HTMLOListElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
!viewed.current ||
|
||||||
|
!list.current ||
|
||||||
|
typeof IntersectionObserver === "undefined"
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (document.visibilityState !== "visible") return;
|
||||||
|
const visible = entries
|
||||||
|
.filter((entry) => entry.isIntersecting)
|
||||||
|
.map((entry) => (entry.target as HTMLElement).dataset.sequence)
|
||||||
|
.filter((sequence): sequence is string => !!sequence);
|
||||||
|
const highest = visible.reduce<string | null>(
|
||||||
|
(max, sequence) =>
|
||||||
|
max === null || BigInt(sequence) > BigInt(max) ? sequence : max,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
if (highest) viewed.current?.(highest);
|
||||||
|
},
|
||||||
|
{ threshold: 0.5 },
|
||||||
|
);
|
||||||
|
list.current
|
||||||
|
.querySelectorAll("[data-sequence]")
|
||||||
|
.forEach((element) => observer.observe(element));
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [messages, pending]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ol ref={list} aria-label="Messages" className="space-y-3 p-4">
|
||||||
|
{messages.map((message) => (
|
||||||
|
<li
|
||||||
|
key={message.id}
|
||||||
|
data-sequence={message.sequence}
|
||||||
|
className="rounded-md border p-3 text-sm"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-baseline gap-2 text-xs text-muted-foreground">
|
||||||
|
<span className="font-semibold text-foreground">
|
||||||
|
{message.authorUserId}
|
||||||
|
</span>
|
||||||
|
<time dateTime={new Date(message.createdAt).toISOString()}>
|
||||||
|
{new Date(message.createdAt).toLocaleString()}
|
||||||
|
</time>
|
||||||
|
{message.editedAt && (
|
||||||
|
<span className="ml-1 text-xs text-muted-foreground">
|
||||||
|
(edited)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 whitespace-pre-wrap break-words">
|
||||||
|
{message.deletedAt || message.status === "deleted"
|
||||||
|
? "Message deleted"
|
||||||
|
: message.body}
|
||||||
|
</p>
|
||||||
|
{!readOnly && !message.deletedAt && message.status !== "deleted" && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{onEdit && message.authorUserId === currentUserId && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onEdit(message)}
|
||||||
|
aria-label={`Edit message ${message.id}`}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{onDelete &&
|
||||||
|
(message.authorUserId === currentUserId || canModerate) && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onDelete(message)}
|
||||||
|
aria-label={`Delete message ${message.id}`}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{onThread && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onThread(message.id)}
|
||||||
|
aria-label={`Open replies to message by ${message.authorUserId}`}
|
||||||
|
>
|
||||||
|
{message.replyCount ? `${message.replyCount} replies` : "Reply"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{pending.map((message) => (
|
||||||
|
<li
|
||||||
|
key={message.key}
|
||||||
|
className={cn(
|
||||||
|
"rounded-md border p-3 text-sm",
|
||||||
|
message.failed && "border-destructive",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p className="whitespace-pre-wrap break-words">{message.body}</p>
|
||||||
|
<p
|
||||||
|
role={message.failed ? "alert" : "status"}
|
||||||
|
className="text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
{message.failed ? message.error : "Sending…"}
|
||||||
|
</p>
|
||||||
|
{message.failed && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
type="button"
|
||||||
|
onClick={message.retry}
|
||||||
|
>
|
||||||
|
Retry send
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
apps/web/components/chat/chat-scope.test.ts
Normal file
79
apps/web/components/chat/chat-scope.test.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createChatScope } from "./chat-utils";
|
||||||
|
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
let reject!: (error: Error) => void;
|
||||||
|
const promise = new Promise<T>((yes, no) => {
|
||||||
|
resolve = yes;
|
||||||
|
reject = no;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("chat async scope", () => {
|
||||||
|
it("ignores a send resolved after a channel switch", async () => {
|
||||||
|
const scope = createChatScope("acme", "first");
|
||||||
|
const request = deferred<string>();
|
||||||
|
const result: string[] = [];
|
||||||
|
const valid = scope.capture();
|
||||||
|
const completion = request.promise.then((message) => {
|
||||||
|
if (valid()) result.push(message);
|
||||||
|
});
|
||||||
|
scope.select("acme", "second");
|
||||||
|
request.resolve("first-channel message");
|
||||||
|
await completion;
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a late failed send, including a permission error", async () => {
|
||||||
|
const scope = createChatScope("acme", "first");
|
||||||
|
const request = deferred<string>();
|
||||||
|
const result: string[] = [];
|
||||||
|
const valid = scope.capture();
|
||||||
|
const completion = request.promise.catch((error: Error) => {
|
||||||
|
if (valid()) result.push(error.message);
|
||||||
|
});
|
||||||
|
scope.select("acme", "second");
|
||||||
|
request.reject(new Error("FORBIDDEN"));
|
||||||
|
await completion;
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a stale mutation result and its subsequent refresh", async () => {
|
||||||
|
const scope = createChatScope("acme", "first");
|
||||||
|
const mutation = deferred<string>();
|
||||||
|
const refresh = deferred<string>();
|
||||||
|
const result: string[] = [];
|
||||||
|
const valid = scope.capture();
|
||||||
|
const completion = mutation.promise.then(async (value) => {
|
||||||
|
if (!valid()) return;
|
||||||
|
result.push(value);
|
||||||
|
const page = await refresh.promise;
|
||||||
|
if (valid()) result.push(page);
|
||||||
|
});
|
||||||
|
mutation.resolve("edited");
|
||||||
|
await Promise.resolve();
|
||||||
|
scope.select("acme", "second");
|
||||||
|
refresh.resolve("old page");
|
||||||
|
await completion;
|
||||||
|
expect(result).toEqual(["edited"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("invalidates pending work on purge before redirect and cannot remove a new channel's cache", async () => {
|
||||||
|
const scope = createChatScope("acme", "first");
|
||||||
|
const cancellation = deferred<void>();
|
||||||
|
const result: string[] = [];
|
||||||
|
const valid = scope.capture();
|
||||||
|
scope.revoke();
|
||||||
|
result.push("redirect");
|
||||||
|
const cleanup = cancellation.promise.then(() => {
|
||||||
|
if (valid()) result.push("remove queries");
|
||||||
|
});
|
||||||
|
scope.select("acme", "second");
|
||||||
|
cancellation.resolve();
|
||||||
|
await cleanup;
|
||||||
|
expect(result).toEqual(["redirect"]);
|
||||||
|
expect(scope.capture()()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
101
apps/web/components/chat/chat-utils.test.ts
Normal file
101
apps/web/components/chat/chat-utils.test.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
advanceCatchUpWindow,
|
||||||
|
chatBadge,
|
||||||
|
mergeChatMessages,
|
||||||
|
messageError,
|
||||||
|
rootReadIsProven,
|
||||||
|
} from "./chat-utils";
|
||||||
|
|
||||||
|
describe("chat client state", () => {
|
||||||
|
it("retains through across pages, then starts a new interval after exhaustion", () => {
|
||||||
|
const first = advanceCatchUpWindow(
|
||||||
|
{ after: "5" },
|
||||||
|
{ next: "8", through: "10", hasMore: true },
|
||||||
|
);
|
||||||
|
expect(first).toEqual({ after: "8", through: "10" });
|
||||||
|
const second = advanceCatchUpWindow(first, {
|
||||||
|
next: "9",
|
||||||
|
through: "10",
|
||||||
|
hasMore: true,
|
||||||
|
});
|
||||||
|
expect(second).toEqual({ after: "9", through: "10" });
|
||||||
|
expect(
|
||||||
|
advanceCatchUpWindow(second, {
|
||||||
|
next: "9",
|
||||||
|
through: "10",
|
||||||
|
hasMore: false,
|
||||||
|
}),
|
||||||
|
).toEqual({ after: "10" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not lose the cursor on a stalled page or fold concurrent sends into the old interval", () => {
|
||||||
|
const window = { after: "8", through: "10" };
|
||||||
|
expect(
|
||||||
|
advanceCatchUpWindow(window, { next: "8", through: "10", hasMore: true }),
|
||||||
|
).toBe(window);
|
||||||
|
expect(
|
||||||
|
advanceCatchUpWindow(window, {
|
||||||
|
next: "10",
|
||||||
|
through: "10",
|
||||||
|
hasMore: false,
|
||||||
|
}),
|
||||||
|
).toEqual({ after: "10" });
|
||||||
|
expect(
|
||||||
|
advanceCatchUpWindow(
|
||||||
|
{ after: "10" },
|
||||||
|
{ next: "11", through: "12", hasMore: true },
|
||||||
|
),
|
||||||
|
).toEqual({ after: "11", through: "12" });
|
||||||
|
});
|
||||||
|
it("never replaces a newer edit or tombstone with an older page", () => {
|
||||||
|
const original = {
|
||||||
|
id: "m",
|
||||||
|
sequence: "1",
|
||||||
|
revision: 1,
|
||||||
|
body: "old",
|
||||||
|
status: "active",
|
||||||
|
};
|
||||||
|
const edited = { ...original, revision: 2, body: "new" };
|
||||||
|
const deleted = { ...edited, revision: 3, body: "", status: "deleted" };
|
||||||
|
expect(mergeChatMessages([edited], [original])).toEqual([edited]);
|
||||||
|
expect(mergeChatMessages([deleted], [edited])).toEqual([deleted]);
|
||||||
|
});
|
||||||
|
it("requires a channel-wide scan and does not claim unseen replies as read", () => {
|
||||||
|
expect(rootReadIsProven("20", null, null, "0")).toBe(false);
|
||||||
|
expect(rootReadIsProven("20", "19", null, "0")).toBe(false);
|
||||||
|
expect(rootReadIsProven("20", "25", "18", "0")).toBe(false);
|
||||||
|
expect(rootReadIsProven("20", "25", "22", "0")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deduplicates history and catch-up in sequence order", () => {
|
||||||
|
expect(
|
||||||
|
mergeChatMessages(
|
||||||
|
[
|
||||||
|
{ id: "b", sequence: "9" },
|
||||||
|
{ id: "a", sequence: "2" },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ id: "b", sequence: "9" },
|
||||||
|
{ id: "c", sequence: "10" },
|
||||||
|
],
|
||||||
|
).map((m) => m.id),
|
||||||
|
).toEqual(["a", "b", "c"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("distinguishes unread dots from numeric direct and mention activity", () => {
|
||||||
|
expect(
|
||||||
|
chatBadge({ unreadCount: 3, mentionCount: 0, directUnreadCount: 0 }),
|
||||||
|
).toEqual({ dot: true, count: 0 });
|
||||||
|
expect(
|
||||||
|
chatBadge({ unreadCount: 3, mentionCount: 2, directUnreadCount: 1 }),
|
||||||
|
).toEqual({ dot: true, count: 3 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps failed sends to actionable messages", () => {
|
||||||
|
expect(messageError("NOT_FOUND")).toMatch(/access/i);
|
||||||
|
expect(messageError("CONFLICT")).toMatch(/different message/i);
|
||||||
|
expect(messageError("INTERNAL_SERVER_ERROR")).toMatch(/retry/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
103
apps/web/components/chat/chat-utils.ts
Normal file
103
apps/web/components/chat/chat-utils.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
export function mergeChatMessages<
|
||||||
|
T extends { id: string; sequence: string; revision?: number },
|
||||||
|
>(current: T[], incoming: T[]): T[] {
|
||||||
|
const byId = new Map(current.map((message) => [message.id, message]));
|
||||||
|
for (const message of incoming) {
|
||||||
|
const previous = byId.get(message.id);
|
||||||
|
if (!previous || (message.revision ?? 1) >= (previous.revision ?? 1))
|
||||||
|
byId.set(message.id, message);
|
||||||
|
}
|
||||||
|
return [...byId.values()].sort((a, b) => {
|
||||||
|
const left = BigInt(a.sequence);
|
||||||
|
const right = BigInt(b.sequence);
|
||||||
|
return left < right ? -1 : left > right ? 1 : 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each async continuation retains the generation it started in. A return to the
|
||||||
|
// same channel is still a new generation, so old completions cannot re-enter it.
|
||||||
|
export function createChatScope(workspace: string, channelId?: string) {
|
||||||
|
let key = JSON.stringify([workspace, channelId ?? ""]);
|
||||||
|
let generation = 0;
|
||||||
|
let revoked = false;
|
||||||
|
return {
|
||||||
|
select(nextWorkspace: string, nextChannelId?: string) {
|
||||||
|
const next = JSON.stringify([nextWorkspace, nextChannelId ?? ""]);
|
||||||
|
if (next === key) return;
|
||||||
|
key = next;
|
||||||
|
generation++;
|
||||||
|
revoked = false;
|
||||||
|
},
|
||||||
|
revoke() {
|
||||||
|
if (!revoked) {
|
||||||
|
revoked = true;
|
||||||
|
generation++;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dispose() {
|
||||||
|
revoked = true;
|
||||||
|
generation++;
|
||||||
|
},
|
||||||
|
capture() {
|
||||||
|
const captured = generation;
|
||||||
|
return () => !revoked && captured === generation;
|
||||||
|
},
|
||||||
|
captureCleanup() {
|
||||||
|
const captured = generation;
|
||||||
|
return () => revoked && captured === generation;
|
||||||
|
},
|
||||||
|
isRevoked() {
|
||||||
|
return revoked;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CatchUpWindow = { after: string; through?: string };
|
||||||
|
|
||||||
|
// Keep the first response's high-water fixed until every page of that interval is read.
|
||||||
|
export function advanceCatchUpWindow(
|
||||||
|
window: CatchUpWindow,
|
||||||
|
page: { next: string; through: string; hasMore: boolean },
|
||||||
|
): CatchUpWindow {
|
||||||
|
if (page.hasMore) {
|
||||||
|
if (BigInt(page.next) <= BigInt(window.after)) return window;
|
||||||
|
return { after: page.next, through: window.through ?? page.through };
|
||||||
|
}
|
||||||
|
return { after: page.through };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rootReadIsProven(
|
||||||
|
sequence: string,
|
||||||
|
scanned: string | null,
|
||||||
|
unseenReply: string | null,
|
||||||
|
readBoundary: string,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
scanned !== null &&
|
||||||
|
BigInt(scanned) >= BigInt(sequence) &&
|
||||||
|
(unseenReply === null ||
|
||||||
|
BigInt(unseenReply) > BigInt(sequence) ||
|
||||||
|
BigInt(unseenReply) <= BigInt(readBoundary))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function chatBadge(summary: {
|
||||||
|
unreadCount: number;
|
||||||
|
mentionCount: number;
|
||||||
|
directUnreadCount: number;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
dot: summary.unreadCount > 0,
|
||||||
|
count: summary.mentionCount + summary.directUnreadCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function messageError(code?: string): string {
|
||||||
|
if (code === "NOT_FOUND" || code === "FORBIDDEN")
|
||||||
|
return "You no longer have access to this channel.";
|
||||||
|
if (code === "CONFLICT")
|
||||||
|
return "This request was already used for a different message. Please compose a new message.";
|
||||||
|
if (code === "BAD_REQUEST")
|
||||||
|
return "This message could not be sent. Check the text and retry.";
|
||||||
|
return "Could not send message. Please retry.";
|
||||||
|
}
|
||||||
229
apps/web/components/chat/chat-workspace.test.tsx
Normal file
229
apps/web/components/chat/chat-workspace.test.tsx
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { ChatWorkspace, ReadFailureAlert } from "./chat-workspace";
|
||||||
|
|
||||||
|
const mock = vi.hoisted(() => ({
|
||||||
|
channel: {
|
||||||
|
id: "channel",
|
||||||
|
kind: "public",
|
||||||
|
name: "General",
|
||||||
|
archivedAt: null as string | null,
|
||||||
|
},
|
||||||
|
channels: {
|
||||||
|
data: [] as unknown[],
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
error: null as unknown,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
},
|
||||||
|
resolved: {
|
||||||
|
data: { id: "workspace" },
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
},
|
||||||
|
history: {
|
||||||
|
data: { messages: [] as unknown[], hasMore: false },
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
error: null as unknown,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
},
|
||||||
|
thread: { data: null, isError: false, error: null, refetch: vi.fn() },
|
||||||
|
catchUp: { data: null, isError: false, error: null, refetch: vi.fn() },
|
||||||
|
summary: { data: { channels: [] as unknown[] }, error: null as unknown },
|
||||||
|
catchUpQuery: vi.fn(),
|
||||||
|
replace: vi.fn(),
|
||||||
|
recovery: { messages: [] as unknown[], error: null as unknown, refresh: vi.fn() },
|
||||||
|
}));
|
||||||
|
vi.mock("./use-chat-mutation-recovery", () => ({ useChatMutationRecovery: () => mock.recovery }));
|
||||||
|
vi.mock("next-auth/react", () => ({
|
||||||
|
useSession: () => ({ data: { user: { id: "user" } } }),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/use-chat-subscription", () => ({
|
||||||
|
useChatSubscription: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
useRouter: () => ({ replace: mock.replace }),
|
||||||
|
}));
|
||||||
|
vi.mock("@tanstack/react-query", () => ({
|
||||||
|
useQueryClient: () => ({ cancelQueries: vi.fn(), removeQueries: vi.fn() }),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/trpc", () => ({
|
||||||
|
api: {
|
||||||
|
useUtils: () => ({ chat: {} }),
|
||||||
|
workspaces: { resolve: { useQuery: () => mock.resolved } },
|
||||||
|
chat: {
|
||||||
|
viewer: { useQuery: () => ({ data: { role: "member" } }) },
|
||||||
|
editMessage: {
|
||||||
|
useMutation: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||||
|
},
|
||||||
|
deleteMessage: {
|
||||||
|
useMutation: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||||
|
},
|
||||||
|
listChannels: { useQuery: () => mock.channels },
|
||||||
|
unreadSummary: { useQuery: () => mock.summary },
|
||||||
|
history: { useQuery: () => mock.history },
|
||||||
|
threadHistory: { useQuery: () => mock.thread },
|
||||||
|
catchUp: {
|
||||||
|
useQuery: (...args: unknown[]) => {
|
||||||
|
mock.catchUpQuery(...args);
|
||||||
|
return mock.catchUp;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
send: { useMutation: () => ({ mutateAsync: vi.fn() }) },
|
||||||
|
markChannelRead: { useMutation: () => ({ mutate: vi.fn() }) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mock.recovery.messages = [];
|
||||||
|
mock.recovery.error = null;
|
||||||
|
mock.channel.archivedAt = null;
|
||||||
|
mock.channels.data = [mock.channel];
|
||||||
|
mock.channels.isLoading = false;
|
||||||
|
mock.channels.isError = false;
|
||||||
|
mock.resolved.isLoading = false;
|
||||||
|
mock.resolved.isError = false;
|
||||||
|
mock.history.data = { messages: [], hasMore: false };
|
||||||
|
mock.history.isLoading = false;
|
||||||
|
mock.history.isError = false;
|
||||||
|
mock.history.error = null;
|
||||||
|
mock.summary.data = { channels: [] };
|
||||||
|
mock.catchUp.error = null;
|
||||||
|
mock.catchUp.isError = false;
|
||||||
|
mock.catchUpQuery.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
const render = () =>
|
||||||
|
renderToStaticMarkup(<ChatWorkspace workspace="acme" channelId="channel" />);
|
||||||
|
|
||||||
|
describe("rendered chat workspace", () => {
|
||||||
|
it("exposes a retryable read failure without claiming it advanced", () => {
|
||||||
|
const html = renderToStaticMarkup(<ReadFailureAlert onRetry={vi.fn()} />);
|
||||||
|
expect(html).toContain('role="alert"');
|
||||||
|
expect(html).toContain("Unread status has not advanced");
|
||||||
|
expect(html).toContain("Retry");
|
||||||
|
});
|
||||||
|
it("renders an ordinary unread dot and numeric mentions/direct activity", () => {
|
||||||
|
mock.channels.data = [
|
||||||
|
mock.channel,
|
||||||
|
{ id: "dm", kind: "direct", name: null, archivedAt: null },
|
||||||
|
];
|
||||||
|
mock.summary.data = {
|
||||||
|
channels: [
|
||||||
|
{ channelId: "channel", unreadCount: 5, mentionCount: 0 },
|
||||||
|
{ channelId: "dm", unreadCount: 3, mentionCount: 0 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const html = render();
|
||||||
|
expect(html).toContain('aria-label="Unread messages"');
|
||||||
|
expect(html).toContain('aria-label="3 mentions or direct unread messages"');
|
||||||
|
expect(html).not.toContain(">5</span>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("separates loading, transient failure and unavailable channel", () => {
|
||||||
|
mock.channels.isLoading = true;
|
||||||
|
expect(render()).toContain("Loading channel");
|
||||||
|
mock.channels.isLoading = false;
|
||||||
|
mock.channels.isError = true;
|
||||||
|
expect(render()).toContain("Could not load channel");
|
||||||
|
expect(render()).not.toContain("Channel unavailable");
|
||||||
|
mock.channels.isError = false;
|
||||||
|
mock.channels.data = [];
|
||||||
|
expect(render()).toContain("Channel unavailable");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows history loading rather than a misleading empty state", () => {
|
||||||
|
mock.history.isLoading = true;
|
||||||
|
expect(render()).toContain("Loading messages");
|
||||||
|
expect(render()).not.toContain("No messages yet");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("overlays a recovered tombstone on stale history without adding unloaded rows", () => {
|
||||||
|
const original = { id: "m", sequence: "1", revision: 1, body: "secret original", authorUserId: "user", createdAt: "2026-01-01", status: "active" };
|
||||||
|
mock.history.data.messages = [original];
|
||||||
|
mock.recovery.messages = [{ ...original, revision: 3, body: "", status: "deleted", deletedAt: "2026-01-02" }, { ...original, id: "unloaded", sequence: "2", body: "unloaded edit" }];
|
||||||
|
const html = render();
|
||||||
|
expect(html).not.toContain("secret original");
|
||||||
|
expect(html).not.toContain("unloaded edit");
|
||||||
|
expect(html).toContain("deleted");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides history when mutation recovery discovers revoked access", () => {
|
||||||
|
mock.recovery.error = { data: { code: "NOT_FOUND" } };
|
||||||
|
expect(render()).toContain("Channel unavailable or access removed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides revoked content rather than showing a transient retry", () => {
|
||||||
|
mock.history.error = { data: { code: "NOT_FOUND" } };
|
||||||
|
mock.history.isError = true;
|
||||||
|
const html = render();
|
||||||
|
expect(html).toContain("Channel unavailable or access removed");
|
||||||
|
expect(html).not.toContain("Could not load messages");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows transient history failure with retry", () => {
|
||||||
|
mock.history.isError = true;
|
||||||
|
const html = render();
|
||||||
|
expect(html).toContain("Could not load messages");
|
||||||
|
expect(html).toContain("Retry");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps archived channels readable without a channel composer", () => {
|
||||||
|
mock.channel.archivedAt = "2026-01-01";
|
||||||
|
const html = render();
|
||||||
|
expect(html).toContain('aria-label="Conversation: General"');
|
||||||
|
expect(html).not.toContain('aria-label="Send message"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bounds initial catch-up to the oldest root predecessor when complete root history has no replies", () => {
|
||||||
|
mock.history.data = {
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: "a",
|
||||||
|
sequence: "12",
|
||||||
|
replyCount: 0,
|
||||||
|
body: "First",
|
||||||
|
authorUserId: "user",
|
||||||
|
createdAt: "2026-01-01",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "b",
|
||||||
|
sequence: "30",
|
||||||
|
replyCount: 0,
|
||||||
|
body: "Second",
|
||||||
|
authorUserId: "user",
|
||||||
|
createdAt: "2026-01-01",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
hasMore: false,
|
||||||
|
};
|
||||||
|
render();
|
||||||
|
expect(mock.catchUpQuery.mock.calls.at(-1)?.[0]).toMatchObject({
|
||||||
|
after: "11",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not skip replies before the initial root page", () => {
|
||||||
|
mock.history.data = {
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: "a",
|
||||||
|
sequence: "30",
|
||||||
|
replyCount: 1,
|
||||||
|
body: "root",
|
||||||
|
authorUserId: "user",
|
||||||
|
createdAt: "2026-01-01",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
hasMore: false,
|
||||||
|
};
|
||||||
|
render();
|
||||||
|
expect(mock.catchUpQuery.mock.calls.at(-1)?.[0]).toMatchObject({
|
||||||
|
after: "0",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
1075
apps/web/components/chat/chat-workspace.tsx
Normal file
1075
apps/web/components/chat/chat-workspace.tsx
Normal file
File diff suppressed because it is too large
Load diff
47
apps/web/components/chat/mutation-recovery.test.ts
Normal file
47
apps/web/components/chat/mutation-recovery.test.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { createMutationRecovery } from "./mutation-recovery";
|
||||||
|
|
||||||
|
const page = (next: string, through: string, hasMore = false) => ({
|
||||||
|
messages: [{ id: "m", sequence: "1", revision: Number(next) }], next, through, hasMore,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mutation recovery", () => {
|
||||||
|
it("drains a fixed high water, then resumes independently of creation sequences", async () => {
|
||||||
|
const fetch = vi.fn().mockResolvedValueOnce(page("2", "4", true)).mockResolvedValueOnce(page("4", "4")).mockResolvedValueOnce(page("5", "5"));
|
||||||
|
const apply = vi.fn();
|
||||||
|
const recovery = createMutationRecovery(fetch, apply);
|
||||||
|
await recovery.run();
|
||||||
|
await recovery.run();
|
||||||
|
expect(fetch.mock.calls.map(([cursor]) => cursor)).toEqual([{ after: "0" }, { after: "2", through: "4" }, { after: "4" }]);
|
||||||
|
expect(apply).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
it("retries the same cursor after projection application fails", async () => {
|
||||||
|
const fetch = vi.fn().mockResolvedValue(page("1", "1"));
|
||||||
|
const apply = vi.fn().mockImplementationOnce(() => { throw new Error("merge failed"); });
|
||||||
|
const recovery = createMutationRecovery(fetch, apply);
|
||||||
|
await expect(recovery.run()).rejects.toThrow("merge failed");
|
||||||
|
await recovery.run();
|
||||||
|
expect(fetch.mock.calls.map(([cursor]) => cursor)).toEqual([{ after: "0" }, { after: "0" }]);
|
||||||
|
});
|
||||||
|
it("ignores a response after scope disposal", async () => {
|
||||||
|
let resolve!: (value: ReturnType<typeof page>) => void;
|
||||||
|
const fetch = vi.fn(() => new Promise<ReturnType<typeof page>>((done) => { resolve = done; }));
|
||||||
|
const apply = vi.fn();
|
||||||
|
const recovery = createMutationRecovery(fetch, apply);
|
||||||
|
const pending = recovery.run();
|
||||||
|
recovery.stop();
|
||||||
|
resolve(page("1", "1"));
|
||||||
|
await pending;
|
||||||
|
expect(apply).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it("rejects stalled pages instead of spinning", async () => {
|
||||||
|
const recovery = createMutationRecovery(async () => page("0", "2", true), vi.fn());
|
||||||
|
await expect(recovery.run()).rejects.toThrow();
|
||||||
|
});
|
||||||
|
it("advances empty completed windows without looping", async () => {
|
||||||
|
const fetch = vi.fn().mockResolvedValue({ ...page("0", "0"), messages: [] });
|
||||||
|
const recovery = createMutationRecovery(fetch, vi.fn());
|
||||||
|
await recovery.run();
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
45
apps/web/components/chat/mutation-recovery.ts
Normal file
45
apps/web/components/chat/mutation-recovery.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import type { CatchUpWindow } from "./chat-utils";
|
||||||
|
|
||||||
|
type Page<T> = { messages: T[]; next: string; through: string; hasMore: boolean };
|
||||||
|
|
||||||
|
export function createMutationRecovery<T>(
|
||||||
|
fetchPage: (cursor: CatchUpWindow) => Promise<Page<T>>,
|
||||||
|
apply: (messages: T[]) => void,
|
||||||
|
initialCursor: CatchUpWindow = { after: "0" },
|
||||||
|
onCursor?: (cursor: CatchUpWindow) => void,
|
||||||
|
) {
|
||||||
|
let cursor: CatchUpWindow = { ...initialCursor };
|
||||||
|
let stopped = false;
|
||||||
|
let active: Promise<void> | undefined;
|
||||||
|
let requested = false;
|
||||||
|
async function drain() {
|
||||||
|
do {
|
||||||
|
requested = false;
|
||||||
|
let more: boolean;
|
||||||
|
do {
|
||||||
|
const page = await fetchPage({ ...cursor });
|
||||||
|
if (stopped) return;
|
||||||
|
if (
|
||||||
|
BigInt(page.through) < BigInt(cursor.after) ||
|
||||||
|
BigInt(page.next) < BigInt(cursor.after) ||
|
||||||
|
BigInt(page.next) > BigInt(page.through) ||
|
||||||
|
(cursor.through !== undefined && cursor.through !== page.through) ||
|
||||||
|
(page.hasMore && BigInt(page.next) <= BigInt(cursor.after))
|
||||||
|
) throw new Error("Invalid mutation recovery window");
|
||||||
|
apply(page.messages);
|
||||||
|
cursor = page.hasMore ? { after: page.next, through: page.through } : { after: page.through };
|
||||||
|
onCursor?.({ ...cursor });
|
||||||
|
more = page.hasMore;
|
||||||
|
} while (more && !stopped);
|
||||||
|
} while (requested && !stopped);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
run(): Promise<void> {
|
||||||
|
if (stopped) return Promise.resolve();
|
||||||
|
if (active) { requested = true; return active; }
|
||||||
|
active = drain().finally(() => { active = undefined; });
|
||||||
|
return active;
|
||||||
|
},
|
||||||
|
stop() { stopped = true; },
|
||||||
|
};
|
||||||
|
}
|
||||||
64
apps/web/components/chat/use-chat-mutation-recovery.tsx
Normal file
64
apps/web/components/chat/use-chat-mutation-recovery.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
import type { ChatMessageView } from "./chat-message-list";
|
||||||
|
import { mergeChatMessages } from "./chat-utils";
|
||||||
|
import { createMutationRecovery } from "./mutation-recovery";
|
||||||
|
|
||||||
|
export function useChatMutationRecovery({ workspace, channelId, enabled }: {
|
||||||
|
workspace: string;
|
||||||
|
channelId?: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}) {
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const identity = JSON.stringify([workspace, channelId, enabled]);
|
||||||
|
const scope = useRef({ identity });
|
||||||
|
if (scope.current.identity !== identity) scope.current = { identity };
|
||||||
|
const token = scope.current;
|
||||||
|
const [state, setState] = useState<{ token: typeof token; messages: ChatMessageView[]; error: unknown }>({ token, messages: [], error: null });
|
||||||
|
const cursors = useRef(new Map<string, { after: string; through?: string }>());
|
||||||
|
const run = useRef<(() => void) | undefined>(undefined);
|
||||||
|
const cursorKey = JSON.stringify([workspace, channelId ?? ""]);
|
||||||
|
const refresh = useCallback(() => { run.current?.(); }, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || !channelId) return;
|
||||||
|
let stopped = false;
|
||||||
|
const valid = () => !stopped && scope.current === token;
|
||||||
|
const cursorKey = JSON.stringify([workspace, channelId]);
|
||||||
|
const recovery = createMutationRecovery(
|
||||||
|
(cursor) => utils.chat.mutationCatchUp.fetch({ workspace, channelId, ...cursor, limit: 100 }),
|
||||||
|
(messages) => {
|
||||||
|
if (!valid()) return;
|
||||||
|
setState((previous) => ({ token, messages: mergeChatMessages(previous.token === token ? previous.messages : [], messages), error: null }));
|
||||||
|
},
|
||||||
|
cursors.current.get(cursorKey),
|
||||||
|
(cursor) => cursors.current.set(cursorKey, cursor),
|
||||||
|
);
|
||||||
|
const recover = () => {
|
||||||
|
void recovery.run().catch((error: unknown) => {
|
||||||
|
if (valid()) setState((previous) => ({ token, messages: previous.token === token ? previous.messages : [], error }));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
run.current = recover;
|
||||||
|
recover();
|
||||||
|
const timer = setInterval(recover, 15000);
|
||||||
|
window.addEventListener("online", recover);
|
||||||
|
window.addEventListener("focus", recover);
|
||||||
|
return () => {
|
||||||
|
stopped = true;
|
||||||
|
recovery.stop();
|
||||||
|
clearInterval(timer);
|
||||||
|
window.removeEventListener("online", recover);
|
||||||
|
window.removeEventListener("focus", recover);
|
||||||
|
if (run.current === recover) run.current = undefined;
|
||||||
|
};
|
||||||
|
}, [workspace, channelId, enabled, token, utils]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages: state.token === token && enabled ? state.messages : [],
|
||||||
|
error: state.token === token && enabled ? state.error : null,
|
||||||
|
refresh,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import type { ComponentType } from "react";
|
import type { ComponentType } from "react";
|
||||||
import {
|
import {
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
|
MessageCircle,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
FileText,
|
FileText,
|
||||||
Home,
|
Home,
|
||||||
|
|
@ -16,9 +17,15 @@ import {
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useParams, usePathname, useRouter } from "next/navigation";
|
import { useParams, usePathname, useRouter } from "next/navigation";
|
||||||
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
import { useWorkspaceStore } from "@/lib/stores/workspace-store";
|
import { useWorkspaceStore } from "@/lib/stores/workspace-store";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
import { chatBadge } from "@/components/chat/chat-utils";
|
||||||
|
|
||||||
export interface IconRailProps {
|
export interface IconRailProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
|
@ -35,11 +42,22 @@ const NAV_ITEMS: NavItem[] = [
|
||||||
{ id: "home", label: "Home", segment: "", icon: Home },
|
{ id: "home", label: "Home", segment: "", icon: Home },
|
||||||
{ id: "spaces", label: "Spaces", segment: "", icon: LayoutGrid },
|
{ id: "spaces", label: "Spaces", segment: "", icon: LayoutGrid },
|
||||||
{ id: "planner", label: "Planner", segment: "planner", icon: CalendarDays },
|
{ id: "planner", label: "Planner", segment: "planner", icon: CalendarDays },
|
||||||
{ id: "mission-control", label: "Mission control", segment: "mission-control", icon: Radar },
|
{
|
||||||
|
id: "mission-control",
|
||||||
|
label: "Mission control",
|
||||||
|
segment: "mission-control",
|
||||||
|
icon: Radar,
|
||||||
|
},
|
||||||
{ id: "ai", label: "AI", segment: "ai", icon: Sparkles },
|
{ id: "ai", label: "AI", segment: "ai", icon: Sparkles },
|
||||||
|
{ id: "chat", label: "Chat", segment: "chat", icon: MessageCircle },
|
||||||
{ id: "teams", label: "Teams", segment: "teams", icon: Users },
|
{ id: "teams", label: "Teams", segment: "teams", icon: Users },
|
||||||
{ id: "docs", label: "Docs", segment: "docs", icon: FileText },
|
{ id: "docs", label: "Docs", segment: "docs", icon: FileText },
|
||||||
{ id: "whiteboards", label: "Whiteboards", segment: "whiteboards", icon: Presentation },
|
{
|
||||||
|
id: "whiteboards",
|
||||||
|
label: "Whiteboards",
|
||||||
|
segment: "whiteboards",
|
||||||
|
icon: Presentation,
|
||||||
|
},
|
||||||
{ id: "forms", label: "Forms", segment: "forms", icon: ClipboardList },
|
{ id: "forms", label: "Forms", segment: "forms", icon: ClipboardList },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -51,6 +69,7 @@ const RESERVED_ROOT_SEGMENTS = new Set([
|
||||||
"planner",
|
"planner",
|
||||||
"mission-control",
|
"mission-control",
|
||||||
"ai",
|
"ai",
|
||||||
|
"chat",
|
||||||
"teams",
|
"teams",
|
||||||
"docs",
|
"docs",
|
||||||
"whiteboards",
|
"whiteboards",
|
||||||
|
|
@ -74,7 +93,8 @@ function isNavItemActive(
|
||||||
if (!pathname.startsWith(prefix)) return false;
|
if (!pathname.startsWith(prefix)) return false;
|
||||||
|
|
||||||
if (item.segment) {
|
if (item.segment) {
|
||||||
const next = pathname[prefix.length] === "/" ? pathname.slice(prefix.length + 1) : "";
|
const next =
|
||||||
|
pathname[prefix.length] === "/" ? pathname.slice(prefix.length + 1) : "";
|
||||||
const first = next.split("/")[0] ?? "";
|
const first = next.split("/")[0] ?? "";
|
||||||
return first === item.segment;
|
return first === item.segment;
|
||||||
}
|
}
|
||||||
|
|
@ -99,11 +119,39 @@ export function IconRail({ className }: IconRailProps) {
|
||||||
const slugParam =
|
const slugParam =
|
||||||
typeof params?.workspaceSlug === "string" ? params.workspaceSlug : "";
|
typeof params?.workspaceSlug === "string" ? params.workspaceSlug : "";
|
||||||
const workspaceSegment =
|
const workspaceSegment =
|
||||||
currentWorkspace?.slug ||
|
currentWorkspace?.slug || currentWorkspace?.id || slugParam || undefined;
|
||||||
currentWorkspace?.id ||
|
|
||||||
slugParam ||
|
|
||||||
undefined;
|
|
||||||
const workspaceName = currentWorkspace?.name ?? "";
|
const workspaceName = currentWorkspace?.name ?? "";
|
||||||
|
const unread = api.chat.unreadSummary.useQuery(
|
||||||
|
{ workspace: workspaceSegment ?? "" },
|
||||||
|
{ enabled: !!workspaceSegment, refetchInterval: 15000 },
|
||||||
|
);
|
||||||
|
const visibleChannels = api.chat.listChannels.useQuery(
|
||||||
|
{ workspace: workspaceSegment ?? "" },
|
||||||
|
{ enabled: !!workspaceSegment, refetchInterval: 15000 },
|
||||||
|
);
|
||||||
|
const directIds = new Set(
|
||||||
|
visibleChannels.data
|
||||||
|
?.filter((channel) => channel.kind === "direct")
|
||||||
|
.map((channel) => channel.id),
|
||||||
|
);
|
||||||
|
const badge = chatBadge({
|
||||||
|
unreadCount:
|
||||||
|
unread.data?.channels.reduce(
|
||||||
|
(total, channel) => total + channel.unreadCount,
|
||||||
|
0,
|
||||||
|
) ?? 0,
|
||||||
|
mentionCount:
|
||||||
|
unread.data?.channels.reduce(
|
||||||
|
(total, channel) => total + channel.mentionCount,
|
||||||
|
0,
|
||||||
|
) ?? 0,
|
||||||
|
directUnreadCount:
|
||||||
|
unread.data?.channels.reduce(
|
||||||
|
(total, channel) =>
|
||||||
|
total + (directIds.has(channel.channelId) ? channel.unreadCount : 0),
|
||||||
|
0,
|
||||||
|
) ?? 0,
|
||||||
|
});
|
||||||
|
|
||||||
const initials = workspaceName.trim().slice(0, 2).toUpperCase() || "—";
|
const initials = workspaceName.trim().slice(0, 2).toUpperCase() || "—";
|
||||||
|
|
||||||
|
|
@ -121,8 +169,6 @@ export function IconRail({ className }: IconRailProps) {
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
||||||
|
|
||||||
<nav
|
<nav
|
||||||
className="flex min-h-0 flex-1 flex-col items-stretch overflow-y-auto overflow-x-hidden px-1 py-2"
|
className="flex min-h-0 flex-1 flex-col items-stretch overflow-y-auto overflow-x-hidden px-1 py-2"
|
||||||
aria-label="Main"
|
aria-label="Main"
|
||||||
|
|
@ -136,6 +182,11 @@ export function IconRail({ className }: IconRailProps) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!workspaceSegment}
|
disabled={!workspaceSegment}
|
||||||
|
aria-label={
|
||||||
|
item.id === "chat" && badge.dot
|
||||||
|
? `Chat, ${badge.count ? `${badge.count} mentions and unread direct messages` : "unread messages"}`
|
||||||
|
: item.label
|
||||||
|
}
|
||||||
onClick={() => push(item.segment)}
|
onClick={() => push(item.segment)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full cursor-pointer rounded-lg px-1 py-2 transition-colors",
|
"w-full cursor-pointer rounded-lg px-1 py-2 transition-colors",
|
||||||
|
|
@ -145,7 +196,23 @@ export function IconRail({ className }: IconRailProps) {
|
||||||
: "text-white/70 hover:bg-white/10",
|
: "text-white/70 hover:bg-white/10",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="h-5 w-5 shrink-0" aria-hidden />
|
<span className="relative">
|
||||||
|
<Icon className="h-5 w-5 shrink-0" aria-hidden />
|
||||||
|
{item.id === "chat" && badge.dot && (
|
||||||
|
<span
|
||||||
|
className="absolute -right-1 -top-1 size-2 rounded-full bg-rose-400"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{item.id === "chat" && badge.count > 0 && (
|
||||||
|
<span
|
||||||
|
className="rounded-full bg-rose-500 px-1 text-[10px] text-white"
|
||||||
|
aria-label={`${badge.count} mentions and unread direct messages`}
|
||||||
|
>
|
||||||
|
{badge.count > 99 ? "99+" : badge.count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"max-w-[52px] truncate text-center text-[10px] font-medium leading-tight",
|
"max-w-[52px] truncate text-center text-[10px] font-medium leading-tight",
|
||||||
|
|
|
||||||
123
apps/web/lib/use-chat-subscription.ts
Normal file
123
apps/web/lib/use-chat-subscription.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { getCollabUrl } from "@/lib/yjs-provider";
|
||||||
|
|
||||||
|
/** Invalidation-only transport. Durable chat history must be fetched through the authenticated API. */
|
||||||
|
export function useChatSubscription(
|
||||||
|
workspace: string,
|
||||||
|
channelIds: readonly string[],
|
||||||
|
onHint?: () => void,
|
||||||
|
): void {
|
||||||
|
const hint = useRef(onHint);
|
||||||
|
hint.current = onHint;
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const channels = [...new Set(channelIds)].sort().join(",");
|
||||||
|
useEffect(() => {
|
||||||
|
if (!workspace || !channels) return;
|
||||||
|
let socket: WebSocket | undefined;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let stopped = false;
|
||||||
|
let attempts = 0;
|
||||||
|
let revoked = false;
|
||||||
|
const invalidate = () => {
|
||||||
|
if (stopped) return;
|
||||||
|
hint.current?.();
|
||||||
|
void queryClient.invalidateQueries({ predicate: (query) => {
|
||||||
|
const [path, options] = query.queryKey;
|
||||||
|
return Array.isArray(path) && path[0] === "chat" &&
|
||||||
|
!!options && typeof options === "object" && "input" in options &&
|
||||||
|
!!options.input && typeof options.input === "object" &&
|
||||||
|
"workspace" in options.input && options.input.workspace === workspace;
|
||||||
|
} });
|
||||||
|
};
|
||||||
|
async function connect() {
|
||||||
|
revoked = false;
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/chat/ticket", {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ workspace }),
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
if (
|
||||||
|
response.status === 401 ||
|
||||||
|
response.status === 403 ||
|
||||||
|
response.status === 404
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
throw new Error("Chat ticket unavailable");
|
||||||
|
}
|
||||||
|
const data: unknown = await response.json();
|
||||||
|
if (
|
||||||
|
!data ||
|
||||||
|
typeof data !== "object" ||
|
||||||
|
!("ticket" in data) ||
|
||||||
|
typeof data.ticket !== "string"
|
||||||
|
)
|
||||||
|
throw new Error("Invalid chat ticket");
|
||||||
|
if (stopped) return;
|
||||||
|
const url = new URL(
|
||||||
|
process.env.NEXT_PUBLIC_CHAT_SERVER_URL || getCollabUrl(),
|
||||||
|
);
|
||||||
|
if (!process.env.NEXT_PUBLIC_CHAT_SERVER_URL) url.port = "1235";
|
||||||
|
url.pathname = "/chat";
|
||||||
|
url.search = new URLSearchParams({ ticket: data.ticket }).toString();
|
||||||
|
socket = new WebSocket(url);
|
||||||
|
socket.onopen = () => {
|
||||||
|
attempts = 0;
|
||||||
|
invalidate(); // catch up after missed Redis notifications
|
||||||
|
for (const channelId of channels.split(","))
|
||||||
|
socket?.send(JSON.stringify({ type: "subscribe", channelId }));
|
||||||
|
};
|
||||||
|
socket.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const message: unknown = JSON.parse(String(event.data));
|
||||||
|
if (
|
||||||
|
message &&
|
||||||
|
typeof message === "object" &&
|
||||||
|
"type" in message &&
|
||||||
|
(message.type === "invalidate" ||
|
||||||
|
message.type === "revoked" ||
|
||||||
|
message.type === "subscribed")
|
||||||
|
) {
|
||||||
|
if (message.type === "revoked") revoked = true;
|
||||||
|
invalidate();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore malformed hints */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
socket.onclose = (event) => {
|
||||||
|
if (event.code === 1008 && revoked) {
|
||||||
|
invalidate();
|
||||||
|
}
|
||||||
|
if (!stopped)
|
||||||
|
timer = setTimeout(
|
||||||
|
() => {
|
||||||
|
void connect();
|
||||||
|
},
|
||||||
|
Math.min(30_000, 1000 * 2 ** Math.min(attempts++, 5)),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
if (!stopped)
|
||||||
|
timer = setTimeout(
|
||||||
|
() => {
|
||||||
|
void connect();
|
||||||
|
},
|
||||||
|
Math.min(30_000, 1000 * 2 ** Math.min(attempts++, 5)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void connect();
|
||||||
|
return () => {
|
||||||
|
stopped = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
socket?.close();
|
||||||
|
};
|
||||||
|
}, [workspace, channels, queryClient]);
|
||||||
|
}
|
||||||
|
|
@ -7,7 +7,9 @@
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"type-check": "tsc --noEmit"
|
"type-check": "tsc --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/openai": "^1",
|
"@ai-sdk/openai": "^1",
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,11 @@ import { convoysRouter } from "@/server/routers/convoys";
|
||||||
import { copilotRouter, opsCopilotRouter } from "@/server/routers/copilot";
|
import { copilotRouter, opsCopilotRouter } from "@/server/routers/copilot";
|
||||||
import { harnessesRouter } from "@/server/routers/harnesses";
|
import { harnessesRouter } from "@/server/routers/harnesses";
|
||||||
import { budgetsRouter } from "@/server/routers/budgets";
|
import { budgetsRouter } from "@/server/routers/budgets";
|
||||||
|
import { chatRouter } from "@/server/routers/chat";
|
||||||
|
|
||||||
export const appRouter = router({
|
export const appRouter = router({
|
||||||
health: healthRouter,
|
health: healthRouter,
|
||||||
|
chat: chatRouter,
|
||||||
workspaces: workspacesRouter,
|
workspaces: workspacesRouter,
|
||||||
types: typesRouter,
|
types: typesRouter,
|
||||||
objects: objectsRouter,
|
objects: objectsRouter,
|
||||||
|
|
|
||||||
116
apps/web/server/routers/chat.test.ts
Normal file
116
apps/web/server/routers/chat.test.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { db } from "@tasks/database";
|
||||||
|
import type { Session } from "next-auth";
|
||||||
|
import {
|
||||||
|
ChatMutationError,
|
||||||
|
mutateChatMessage,
|
||||||
|
createMessageMutationRunner,
|
||||||
|
chatMutationCatchUp,
|
||||||
|
} from "@tasks/database/chat";
|
||||||
|
import { resolveWorkspace } from "@/server/lib/resolve-workspace";
|
||||||
|
import { createCallerFactory } from "@/server/trpc";
|
||||||
|
import { chatRouter } from "./chat";
|
||||||
|
|
||||||
|
vi.mock("@tasks/database/chat", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("@tasks/database/chat")>()),
|
||||||
|
mutateChatMessage: vi.fn(),
|
||||||
|
chatMutationCatchUp: vi.fn(),
|
||||||
|
createMessageMutationRunner: vi.fn(() => ({ transaction: vi.fn() })),
|
||||||
|
}));
|
||||||
|
vi.mock("@/server/lib/resolve-workspace", () => ({
|
||||||
|
resolveWorkspace: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/auth", () => ({ auth: vi.fn() }));
|
||||||
|
|
||||||
|
const workspaceId = "11111111-1111-4111-8111-111111111111";
|
||||||
|
const channelId = "33333333-3333-4333-8333-333333333333";
|
||||||
|
const userId = "44444444-4444-4444-8444-444444444444";
|
||||||
|
const messageId = "77777777-7777-4777-8777-777777777777";
|
||||||
|
const clientMutationId = "66666666-6666-4666-8666-666666666666";
|
||||||
|
const caller = () =>
|
||||||
|
createCallerFactory(chatRouter)({
|
||||||
|
db,
|
||||||
|
session: { user: { id: userId } } as Session,
|
||||||
|
});
|
||||||
|
const command = {
|
||||||
|
type: "edit_message" as const,
|
||||||
|
workspaceId,
|
||||||
|
channelId,
|
||||||
|
context: { type: "workspace" as const, workspaceId },
|
||||||
|
messageId,
|
||||||
|
body: "corrected",
|
||||||
|
expectedRevision: 2,
|
||||||
|
clientMutationId,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(resolveWorkspace).mockResolvedValue({
|
||||||
|
id: workspaceId,
|
||||||
|
slug: "acme",
|
||||||
|
name: "Acme",
|
||||||
|
role: "member",
|
||||||
|
ownerUserId: userId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("chat message mutation procedures", () => {
|
||||||
|
it("delegates validated edit and delete with actor and exact command", async () => {
|
||||||
|
const result = {
|
||||||
|
changed: true,
|
||||||
|
result: { messageId, revision: 3, status: "edited" as const },
|
||||||
|
};
|
||||||
|
vi.mocked(mutateChatMessage).mockResolvedValue(result);
|
||||||
|
await expect(caller().editMessage({ workspace: workspaceId, command })).resolves.toEqual(result);
|
||||||
|
expect(mutateChatMessage).toHaveBeenCalledWith(expect.anything(), {
|
||||||
|
actorUserId: userId,
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mutation recovery procedure", () => {
|
||||||
|
const input = { workspace: "acme", channelId, after: "0", limit: 2 };
|
||||||
|
const result = {
|
||||||
|
mutations: [{ messageId, mutationSequence: "1", revision: 3, kind: "edited" as const }],
|
||||||
|
next: "1",
|
||||||
|
through: "3",
|
||||||
|
hasMore: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("passes the resolved workspace and session actor, and preserves the fixed high water", async () => {
|
||||||
|
vi.mocked(chatMutationCatchUp).mockResolvedValue(result as Awaited<ReturnType<typeof chatMutationCatchUp>>);
|
||||||
|
await expect(caller().mutationCatchUp(input)).resolves.toEqual(result);
|
||||||
|
expect(chatMutationCatchUp).toHaveBeenCalledWith(db, {
|
||||||
|
workspaceId,
|
||||||
|
actorUserId: userId,
|
||||||
|
channelId,
|
||||||
|
after: "0",
|
||||||
|
through: undefined,
|
||||||
|
limit: 2,
|
||||||
|
});
|
||||||
|
await caller().mutationCatchUp({ ...input, after: "1", through: "3" });
|
||||||
|
expect(chatMutationCatchUp).toHaveBeenLastCalledWith(db, {
|
||||||
|
workspaceId,
|
||||||
|
actorUserId: userId,
|
||||||
|
channelId,
|
||||||
|
after: "1",
|
||||||
|
through: "3",
|
||||||
|
limit: 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects malformed cursors and maps read denial without exposing a channel", async () => {
|
||||||
|
await expect(caller().mutationCatchUp({ ...input, after: "01" })).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||||
|
await expect(caller().mutationCatchUp({ ...input, through: "0", after: "1" })).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||||
|
expect(chatMutationCatchUp).not.toHaveBeenCalled();
|
||||||
|
vi.mocked(chatMutationCatchUp).mockRejectedValueOnce(new (await import("@tasks/database/chat")).ChatReadError());
|
||||||
|
await expect(caller().mutationCatchUp(input)).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires an authenticated workspace member", async () => {
|
||||||
|
vi.mocked(resolveWorkspace).mockRejectedValueOnce(new Error("not a member"));
|
||||||
|
await expect(caller().mutationCatchUp(input)).rejects.toThrow();
|
||||||
|
expect(chatMutationCatchUp).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
426
apps/web/server/routers/chat.ts
Normal file
426
apps/web/server/routers/chat.ts
Normal file
|
|
@ -0,0 +1,426 @@
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import {
|
||||||
|
chatChannelNameSchema,
|
||||||
|
chatClientMutationIdSchema,
|
||||||
|
chatCommandSchema,
|
||||||
|
chatCreateChannelInputSchema,
|
||||||
|
chatCreateDirectInputSchema,
|
||||||
|
type ChatCommand,
|
||||||
|
} from "@tasks/shared";
|
||||||
|
import {
|
||||||
|
addPrivateMember,
|
||||||
|
archiveChannel,
|
||||||
|
ChannelPersistenceError,
|
||||||
|
ChatPersistenceError,
|
||||||
|
ChatMutationError,
|
||||||
|
ChatReadError,
|
||||||
|
chatHistory,
|
||||||
|
chatThreadHistory,
|
||||||
|
chatCatchUp,
|
||||||
|
chatMutationCatchUp,
|
||||||
|
chatMarkChannelRead,
|
||||||
|
chatUnreadSummary,
|
||||||
|
chatSequenceSchema,
|
||||||
|
chatCursorSchema,
|
||||||
|
chatPageSizeSchema,
|
||||||
|
chatCatchUpSizeSchema,
|
||||||
|
createChannel,
|
||||||
|
createChannelTransactionRunner,
|
||||||
|
createChatTransactionRunner,
|
||||||
|
createDirectChannel,
|
||||||
|
listVisibleChannels,
|
||||||
|
createMessageMutationRunner,
|
||||||
|
mutateChatMessage,
|
||||||
|
removePrivateMember,
|
||||||
|
renameChannel,
|
||||||
|
sendChatMessage,
|
||||||
|
} from "@tasks/database/chat";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { router, workspaceProcedure } from "@/server/trpc";
|
||||||
|
|
||||||
|
const sendCommandSchema = chatCommandSchema.refine(
|
||||||
|
(command) => command.type === "send_message",
|
||||||
|
{ message: "Expected a send_message command" },
|
||||||
|
);
|
||||||
|
const mutationCommandSchema = chatCommandSchema.refine(
|
||||||
|
(command) =>
|
||||||
|
command.type === "edit_message" || command.type === "delete_message",
|
||||||
|
{ message: "Expected a message mutation" },
|
||||||
|
);
|
||||||
|
const mutationInputSchema = z
|
||||||
|
.object({
|
||||||
|
workspace: z.string().min(1),
|
||||||
|
command: mutationCommandSchema,
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
const inputSchema = z
|
||||||
|
.object({
|
||||||
|
workspace: z.string().min(1),
|
||||||
|
command: sendCommandSchema,
|
||||||
|
mentionedUserIds: z.array(z.string().uuid()).max(100).optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
function mapPersistenceError(error: unknown): never {
|
||||||
|
if (error instanceof ChatPersistenceError) {
|
||||||
|
if (error.code === "CHAT_FORBIDDEN") {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Chat channel not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (error.code === "CHAT_IDEMPOTENCY_CONFLICT") {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "CONFLICT",
|
||||||
|
message: "Message request conflicts with an existing request",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Invalid chat message request",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message: "Unable to send chat message",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaceInput = z.object({ workspace: z.string().min(1) }).strict();
|
||||||
|
const channelInput = workspaceInput
|
||||||
|
.extend({
|
||||||
|
channelId: z.string().uuid(),
|
||||||
|
clientMutationId: chatClientMutationIdSchema,
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
const runChannels = (
|
||||||
|
db: Parameters<typeof createChannelTransactionRunner>[0],
|
||||||
|
) => createChannelTransactionRunner(db);
|
||||||
|
async function channelCall<T>(operation: () => Promise<T>): Promise<T> {
|
||||||
|
try {
|
||||||
|
return await operation();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ChannelPersistenceError) {
|
||||||
|
if (error.code === "NOT_FOUND")
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Chat channel not found",
|
||||||
|
});
|
||||||
|
if (error.code === "CONFLICT")
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "CONFLICT",
|
||||||
|
message: "Channel conflicts with existing state",
|
||||||
|
});
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message:
|
||||||
|
error.code === "READ_ONLY"
|
||||||
|
? "Channel is archived"
|
||||||
|
: "Invalid channel request",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message: "Unable to update chat channel",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const readScope = workspaceInput
|
||||||
|
.extend({ channelId: z.string().uuid() })
|
||||||
|
.strict();
|
||||||
|
const catchUpInput = readScope
|
||||||
|
.extend({
|
||||||
|
after: chatCursorSchema,
|
||||||
|
through: chatCursorSchema.optional(),
|
||||||
|
limit: chatCatchUpSizeSchema.default(100),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.refine(
|
||||||
|
(input) =>
|
||||||
|
input.through === undefined ||
|
||||||
|
BigInt(input.after) <= BigInt(input.through),
|
||||||
|
{ path: ["through"], message: "After exceeds through" },
|
||||||
|
);
|
||||||
|
const pageInput = readScope
|
||||||
|
.extend({
|
||||||
|
before: chatSequenceSchema.optional(),
|
||||||
|
limit: chatPageSizeSchema.default(50),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
async function readCall<T>(operation: () => Promise<T>): Promise<T> {
|
||||||
|
try {
|
||||||
|
return await operation();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ChatReadError)
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Chat channel not found",
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mutationCall(
|
||||||
|
db: Parameters<typeof createMessageMutationRunner>[0],
|
||||||
|
actorUserId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
command: Extract<ChatCommand, { type: "edit_message" | "delete_message" }>,
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
command.workspaceId !== workspaceId ||
|
||||||
|
command.context.workspaceId !== workspaceId
|
||||||
|
)
|
||||||
|
throw new TRPCError({ code: "BAD_REQUEST", message: "Workspace mismatch" });
|
||||||
|
try {
|
||||||
|
return await mutateChatMessage(createMessageMutationRunner(db), {
|
||||||
|
actorUserId,
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ChatMutationError) {
|
||||||
|
if (error.code === "CHAT_FORBIDDEN" || error.code === "CHAT_NOT_FOUND")
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Chat message not found",
|
||||||
|
});
|
||||||
|
if (error.code === "CHAT_REVISION_CONFLICT")
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "CONFLICT",
|
||||||
|
message: "Message changed. Refresh before trying again.",
|
||||||
|
});
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "CONFLICT",
|
||||||
|
message: "Message request conflicts with an existing request",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message: "Unable to update chat message",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const chatRouter = router({
|
||||||
|
viewer: workspaceProcedure
|
||||||
|
.input(workspaceInput)
|
||||||
|
.query(({ ctx }) => ({ role: ctx.workspace.role })),
|
||||||
|
// History and reconnect endpoints use one workspace-resolved, membership-checked read seam.
|
||||||
|
history: workspaceProcedure.input(pageInput).query(({ ctx, input }) =>
|
||||||
|
readCall(() =>
|
||||||
|
chatHistory(ctx.db, {
|
||||||
|
workspaceId: ctx.workspace.id,
|
||||||
|
actorUserId: ctx.session.user.id,
|
||||||
|
channelId: input.channelId,
|
||||||
|
before: input.before,
|
||||||
|
limit: input.limit,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
threadHistory: workspaceProcedure
|
||||||
|
.input(pageInput.extend({ rootMessageId: z.string().uuid() }).strict())
|
||||||
|
.query(({ ctx, input }) =>
|
||||||
|
readCall(() =>
|
||||||
|
chatThreadHistory(ctx.db, {
|
||||||
|
workspaceId: ctx.workspace.id,
|
||||||
|
actorUserId: ctx.session.user.id,
|
||||||
|
channelId: input.channelId,
|
||||||
|
rootMessageId: input.rootMessageId,
|
||||||
|
before: input.before,
|
||||||
|
limit: input.limit,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
catchUp: workspaceProcedure
|
||||||
|
.input(catchUpInput)
|
||||||
|
.query(({ ctx, input }) =>
|
||||||
|
readCall(() =>
|
||||||
|
chatCatchUp(ctx.db, {
|
||||||
|
workspaceId: ctx.workspace.id,
|
||||||
|
actorUserId: ctx.session.user.id,
|
||||||
|
channelId: input.channelId,
|
||||||
|
after: input.after,
|
||||||
|
through: input.through,
|
||||||
|
limit: input.limit,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
mutationCatchUp: workspaceProcedure
|
||||||
|
.input(catchUpInput)
|
||||||
|
.query(({ ctx, input }) =>
|
||||||
|
readCall(() =>
|
||||||
|
chatMutationCatchUp(ctx.db, {
|
||||||
|
workspaceId: ctx.workspace.id,
|
||||||
|
actorUserId: ctx.session.user.id,
|
||||||
|
channelId: input.channelId,
|
||||||
|
after: input.after,
|
||||||
|
through: input.through,
|
||||||
|
limit: input.limit,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
markChannelRead: workspaceProcedure
|
||||||
|
.input(readScope.extend({ sequence: chatSequenceSchema }).strict())
|
||||||
|
.mutation(({ ctx, input }) =>
|
||||||
|
readCall(() =>
|
||||||
|
chatMarkChannelRead(ctx.db, {
|
||||||
|
workspaceId: ctx.workspace.id,
|
||||||
|
actorUserId: ctx.session.user.id,
|
||||||
|
channelId: input.channelId,
|
||||||
|
sequence: input.sequence,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
unreadSummary: workspaceProcedure.input(workspaceInput).query(({ ctx }) =>
|
||||||
|
readCall(() =>
|
||||||
|
chatUnreadSummary(ctx.db, {
|
||||||
|
workspaceId: ctx.workspace.id,
|
||||||
|
actorUserId: ctx.session.user.id,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
listChannels: workspaceProcedure.input(workspaceInput).query(({ ctx }) =>
|
||||||
|
channelCall(() =>
|
||||||
|
listVisibleChannels(runChannels(ctx.db), {
|
||||||
|
workspaceId: ctx.workspace.id,
|
||||||
|
actorUserId: ctx.session.user.id,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
createChannel: workspaceProcedure
|
||||||
|
.input(chatCreateChannelInputSchema)
|
||||||
|
.mutation(({ ctx, input }) =>
|
||||||
|
channelCall(() =>
|
||||||
|
createChannel(runChannels(ctx.db), {
|
||||||
|
workspaceId: ctx.workspace.id,
|
||||||
|
actorUserId: ctx.session.user.id,
|
||||||
|
kind: input.kind,
|
||||||
|
name: input.name,
|
||||||
|
clientMutationId: input.clientMutationId,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
renameChannel: workspaceProcedure
|
||||||
|
.input(channelInput.extend({ name: chatChannelNameSchema }).strict())
|
||||||
|
.mutation(({ ctx, input }) =>
|
||||||
|
channelCall(() =>
|
||||||
|
renameChannel(runChannels(ctx.db), {
|
||||||
|
workspaceId: ctx.workspace.id,
|
||||||
|
actorUserId: ctx.session.user.id,
|
||||||
|
channelId: input.channelId,
|
||||||
|
name: input.name,
|
||||||
|
clientMutationId: input.clientMutationId,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
archiveChannel: workspaceProcedure
|
||||||
|
.input(channelInput)
|
||||||
|
.mutation(({ ctx, input }) =>
|
||||||
|
channelCall(() =>
|
||||||
|
archiveChannel(runChannels(ctx.db), {
|
||||||
|
workspaceId: ctx.workspace.id,
|
||||||
|
actorUserId: ctx.session.user.id,
|
||||||
|
channelId: input.channelId,
|
||||||
|
clientMutationId: input.clientMutationId,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
addPrivateMember: workspaceProcedure
|
||||||
|
.input(channelInput.extend({ userId: z.string().uuid() }).strict())
|
||||||
|
.mutation(({ ctx, input }) =>
|
||||||
|
channelCall(() =>
|
||||||
|
addPrivateMember(runChannels(ctx.db), {
|
||||||
|
workspaceId: ctx.workspace.id,
|
||||||
|
actorUserId: ctx.session.user.id,
|
||||||
|
channelId: input.channelId,
|
||||||
|
userId: input.userId,
|
||||||
|
clientMutationId: input.clientMutationId,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
removePrivateMember: workspaceProcedure
|
||||||
|
.input(channelInput.extend({ userId: z.string().uuid() }).strict())
|
||||||
|
.mutation(({ ctx, input }) =>
|
||||||
|
channelCall(() =>
|
||||||
|
removePrivateMember(runChannels(ctx.db), {
|
||||||
|
workspaceId: ctx.workspace.id,
|
||||||
|
actorUserId: ctx.session.user.id,
|
||||||
|
channelId: input.channelId,
|
||||||
|
userId: input.userId,
|
||||||
|
clientMutationId: input.clientMutationId,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
createDirect: workspaceProcedure
|
||||||
|
.input(chatCreateDirectInputSchema)
|
||||||
|
.mutation(({ ctx, input }) =>
|
||||||
|
channelCall(() =>
|
||||||
|
createDirectChannel(runChannels(ctx.db), {
|
||||||
|
workspaceId: ctx.workspace.id,
|
||||||
|
actorUserId: ctx.session.user.id,
|
||||||
|
participantUserIds: input.participantUserIds,
|
||||||
|
clientMutationId: input.clientMutationId,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
editMessage: workspaceProcedure
|
||||||
|
.input(mutationInputSchema)
|
||||||
|
.mutation(({ ctx, input }) => {
|
||||||
|
if (input.command.type !== "edit_message")
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Expected edit_message command",
|
||||||
|
});
|
||||||
|
return mutationCall(
|
||||||
|
ctx.db,
|
||||||
|
ctx.session.user.id,
|
||||||
|
ctx.workspace.id,
|
||||||
|
input.command,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
deleteMessage: workspaceProcedure
|
||||||
|
.input(mutationInputSchema)
|
||||||
|
.mutation(({ ctx, input }) => {
|
||||||
|
if (input.command.type !== "delete_message")
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Expected delete_message command",
|
||||||
|
});
|
||||||
|
return mutationCall(
|
||||||
|
ctx.db,
|
||||||
|
ctx.session.user.id,
|
||||||
|
ctx.workspace.id,
|
||||||
|
input.command,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
send: workspaceProcedure
|
||||||
|
.input(inputSchema)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const command = {
|
||||||
|
...input.command,
|
||||||
|
workspaceId: ctx.workspace.id,
|
||||||
|
context: { type: "workspace" as const, workspaceId: ctx.workspace.id },
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await sendChatMessage(
|
||||||
|
createChatTransactionRunner(ctx.db),
|
||||||
|
{
|
||||||
|
actorUserId: ctx.session.user.id,
|
||||||
|
command,
|
||||||
|
mentionedUserIds: input.mentionedUserIds ?? [],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
created: result.created,
|
||||||
|
message: {
|
||||||
|
...result.message,
|
||||||
|
sequence: result.message.sequence.toString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
mapPersistenceError(error);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
});
|
||||||
15
apps/web/vitest.config.ts
Normal file
15
apps/web/vitest.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
"ai": "^4.3.16",
|
"ai": "^4.3.16",
|
||||||
"drizzle-orm": "^0.38.0",
|
"drizzle-orm": "^0.38.0",
|
||||||
"ioredis": "^5.4.2",
|
"ioredis": "^5.4.2",
|
||||||
|
"yaml": "^2.7.0",
|
||||||
"zod": "^3.24.0"
|
"zod": "^3.24.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
268
apps/worker/src/chat/outbox.test.ts
Normal file
268
apps/worker/src/chat/outbox.test.ts
Normal file
|
|
@ -0,0 +1,268 @@
|
||||||
|
import { PgDialect } from "drizzle-orm/pg-core";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
chatOutboxChannel,
|
||||||
|
createChatOutboxClaim,
|
||||||
|
publishChatOutboxBatch,
|
||||||
|
runChatOutboxPublisher,
|
||||||
|
type ChatOutboxRow,
|
||||||
|
type ClaimBatch,
|
||||||
|
} from "./outbox.js";
|
||||||
|
|
||||||
|
const workspaceId = "00000000-0000-4000-8000-000000000001";
|
||||||
|
const channelId = "00000000-0000-4000-8000-000000000002";
|
||||||
|
const row: ChatOutboxRow = {
|
||||||
|
id: "00000000-0000-4000-8000-000000000003",
|
||||||
|
workspaceId,
|
||||||
|
channelId,
|
||||||
|
eventType: "chat.read_state.changed.v1",
|
||||||
|
eventVersion: 1,
|
||||||
|
dedupeKey: "read:1",
|
||||||
|
payload: {
|
||||||
|
eventType: "chat.read_state.changed.v1",
|
||||||
|
eventVersion: 1,
|
||||||
|
workspaceId,
|
||||||
|
channelId,
|
||||||
|
userId: workspaceId,
|
||||||
|
sequence: "1",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
type Mark = (
|
||||||
|
id: string,
|
||||||
|
status: "published" | "failed",
|
||||||
|
error?: string,
|
||||||
|
) => Promise<void>;
|
||||||
|
function harness(rows: ChatOutboxRow[]) {
|
||||||
|
const changes: Array<{
|
||||||
|
id: string;
|
||||||
|
status: "published" | "failed";
|
||||||
|
error?: string;
|
||||||
|
}> = [];
|
||||||
|
const claim: ClaimBatch = vi.fn(async (run) =>
|
||||||
|
run(
|
||||||
|
rows,
|
||||||
|
async (id: string, status: "published" | "failed", error?: string) => {
|
||||||
|
changes.push({ id, status, error });
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const publish = vi.fn(async (_channel: string, _payload: string) => 1);
|
||||||
|
return { claim, publish, changes };
|
||||||
|
}
|
||||||
|
|
||||||
|
const flush = async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
};
|
||||||
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
|
describe("chat outbox publisher", () => {
|
||||||
|
it("claims a batch and publishes the committed envelope on a tenant/channel scoped topic", async () => {
|
||||||
|
const h = harness([row]);
|
||||||
|
expect(await publishChatOutboxBatch(h.claim, h.publish)).toEqual({
|
||||||
|
published: 1,
|
||||||
|
failed: 0,
|
||||||
|
});
|
||||||
|
expect(h.publish).toHaveBeenCalledWith(
|
||||||
|
chatOutboxChannel(workspaceId, channelId),
|
||||||
|
JSON.stringify({
|
||||||
|
...row.payload,
|
||||||
|
id: row.id,
|
||||||
|
dedupeKey: row.dedupeKey,
|
||||||
|
workspaceId,
|
||||||
|
channelId,
|
||||||
|
eventType: row.eventType,
|
||||||
|
eventVersion: row.eventVersion,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(h.changes).toEqual([
|
||||||
|
{ id: row.id, status: "published", error: undefined },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records a failed publish and continues processing other rows", async () => {
|
||||||
|
const second = { ...row, id: "00000000-0000-4000-8000-000000000004" };
|
||||||
|
const h = harness([row, second]);
|
||||||
|
h.publish.mockRejectedValueOnce(new Error("redis down"));
|
||||||
|
expect(await publishChatOutboxBatch(h.claim, h.publish)).toEqual({
|
||||||
|
published: 1,
|
||||||
|
failed: 1,
|
||||||
|
});
|
||||||
|
expect(h.changes).toEqual([
|
||||||
|
{ id: row.id, status: "failed", error: "redis down" },
|
||||||
|
{ id: second.id, status: "published", error: undefined },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not publish when claim fails", async () => {
|
||||||
|
const h = harness([]);
|
||||||
|
vi.mocked(h.claim).mockRejectedValueOnce(new Error("postgres down"));
|
||||||
|
await expect(publishChatOutboxBatch(h.claim, h.publish)).rejects.toThrow(
|
||||||
|
"postgres down",
|
||||||
|
);
|
||||||
|
expect(h.publish).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("times out a hung publish without marking published even if Redis later acknowledges", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
let acknowledge!: (count: number) => void;
|
||||||
|
const publish = vi.fn(
|
||||||
|
() =>
|
||||||
|
new Promise<number>((resolve) => {
|
||||||
|
acknowledge = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const h = harness([row]);
|
||||||
|
const batch = publishChatOutboxBatch(h.claim, publish, {
|
||||||
|
publishTimeoutMs: 100,
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
expect(await batch).toEqual({ published: 0, failed: 1 });
|
||||||
|
acknowledge(1);
|
||||||
|
await flush();
|
||||||
|
expect(h.changes).toEqual([
|
||||||
|
{
|
||||||
|
id: row.id,
|
||||||
|
status: "failed",
|
||||||
|
error: expect.stringContaining("timed out"),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops starting new publishes on abort and leaves the pending row retryable", async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const h = harness([
|
||||||
|
row,
|
||||||
|
{ ...row, id: "00000000-0000-4000-8000-000000000004" },
|
||||||
|
]);
|
||||||
|
const publish = vi.fn(() => new Promise<number>(() => {}));
|
||||||
|
const batch = publishChatOutboxBatch(h.claim, publish, {
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
controller.abort();
|
||||||
|
expect(await batch).toEqual({ published: 0, failed: 1 });
|
||||||
|
expect(publish).toHaveBeenCalledTimes(1);
|
||||||
|
expect(h.changes[0]).toMatchObject({ id: row.id, status: "failed" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a published notification pending after transaction rollback and redelivers with the same dedupe id", async () => {
|
||||||
|
const pending = new Set([row.id]);
|
||||||
|
let rollback = true;
|
||||||
|
const claim: ClaimBatch = async (run) => {
|
||||||
|
const staged = new Set<string>();
|
||||||
|
await run(pending.has(row.id) ? [row] : [], async (id, status) => {
|
||||||
|
if (status === "published") staged.add(id);
|
||||||
|
});
|
||||||
|
if (rollback) throw new Error("commit failed");
|
||||||
|
for (const id of staged) pending.delete(id);
|
||||||
|
};
|
||||||
|
const publish = vi.fn(async (_channel: string, _payload: string) => 1);
|
||||||
|
await expect(publishChatOutboxBatch(claim, publish)).rejects.toThrow(
|
||||||
|
"commit failed",
|
||||||
|
);
|
||||||
|
rollback = false;
|
||||||
|
expect(await publishChatOutboxBatch(claim, publish)).toEqual({
|
||||||
|
published: 1,
|
||||||
|
failed: 0,
|
||||||
|
});
|
||||||
|
expect(publish).toHaveBeenCalledTimes(2);
|
||||||
|
expect(JSON.parse(publish.mock.calls[0]![1]!)).toMatchObject({
|
||||||
|
id: row.id,
|
||||||
|
dedupeKey: row.dedupeKey,
|
||||||
|
});
|
||||||
|
expect(publish.mock.calls[1]).toEqual(publish.mock.calls[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds a skip-locked ordered claim and tenant/channel scoped update without a live DB", async () => {
|
||||||
|
const dialect = new PgDialect();
|
||||||
|
const queries: string[] = [];
|
||||||
|
const tx = {
|
||||||
|
select: vi.fn(() => ({
|
||||||
|
from: () => ({
|
||||||
|
where: () => ({
|
||||||
|
orderBy: (...columns: unknown[]) => {
|
||||||
|
queries.push(`order:${columns.length}`);
|
||||||
|
return {
|
||||||
|
limit: (size: number) => ({
|
||||||
|
for: (_mode: string, _options: unknown) => {
|
||||||
|
queries.push(
|
||||||
|
`limit:${size}:${_mode}:${JSON.stringify(_options)}`,
|
||||||
|
);
|
||||||
|
return Promise.resolve([row]);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
update: vi.fn(() => ({
|
||||||
|
set: () => ({
|
||||||
|
where: (condition: unknown) => {
|
||||||
|
queries.push(
|
||||||
|
dialect.sqlToQuery(
|
||||||
|
condition as Parameters<typeof dialect.sqlToQuery>[0],
|
||||||
|
).sql,
|
||||||
|
);
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
type FakeTx = typeof tx;
|
||||||
|
const db = {
|
||||||
|
transaction: async (run: (transaction: FakeTx) => Promise<void>) =>
|
||||||
|
run(tx),
|
||||||
|
};
|
||||||
|
await createChatOutboxClaim(db as never)(async (rows, mark) => {
|
||||||
|
expect(rows).toEqual([row]);
|
||||||
|
await mark(row.id, "published");
|
||||||
|
});
|
||||||
|
expect(queries.slice(0, 2)).toEqual([
|
||||||
|
"order:2",
|
||||||
|
'limit:50:update:{"skipLocked":true}',
|
||||||
|
]);
|
||||||
|
expect(queries[2]).toContain("workspace_id");
|
||||||
|
expect(queries[2]).toContain("channel_id");
|
||||||
|
expect(queries[2]).toContain("published_at");
|
||||||
|
await expect(
|
||||||
|
createChatOutboxClaim(db as never)(async (_rows, mark) => {
|
||||||
|
await mark("unclaimed", "published");
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("unclaimed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("paces retries after failures and exits promptly on abort during the delay", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const controller = new AbortController();
|
||||||
|
const claim = vi.fn(
|
||||||
|
async (run: (rows: ChatOutboxRow[], mark: Mark) => Promise<void>) => {
|
||||||
|
await run([row], async () => {});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const redis = {
|
||||||
|
publish: vi.fn(async () => {
|
||||||
|
throw new Error("redis down");
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const running = runChatOutboxPublisher(
|
||||||
|
{} as Parameters<typeof runChatOutboxPublisher>[0],
|
||||||
|
redis as never,
|
||||||
|
controller.signal,
|
||||||
|
claim,
|
||||||
|
);
|
||||||
|
await vi.advanceTimersByTimeAsync(900);
|
||||||
|
expect(redis.publish).toHaveBeenCalledTimes(1);
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
expect(redis.publish).toHaveBeenCalledTimes(2);
|
||||||
|
await vi.advanceTimersByTimeAsync(1900);
|
||||||
|
expect(redis.publish).toHaveBeenCalledTimes(2);
|
||||||
|
controller.abort();
|
||||||
|
await running;
|
||||||
|
expect(redis.publish).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
218
apps/worker/src/chat/outbox.ts
Normal file
218
apps/worker/src/chat/outbox.ts
Normal file
|
|
@ -0,0 +1,218 @@
|
||||||
|
import { and, asc, eq, isNull, sql } from "drizzle-orm";
|
||||||
|
import type IoRedis from "ioredis";
|
||||||
|
|
||||||
|
import type { db as dbInstance } from "@tasks/database";
|
||||||
|
import { chatOutbox } from "@tasks/database/schema";
|
||||||
|
|
||||||
|
/** Redis is a notification transport, not a durable history. Consumers replay from Postgres. */
|
||||||
|
export function chatOutboxChannel(
|
||||||
|
workspaceId: string,
|
||||||
|
channelId: string,
|
||||||
|
): string {
|
||||||
|
return `chat:v1:workspace:${workspaceId}:channel:${channelId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChatOutboxRow = Pick<
|
||||||
|
typeof chatOutbox.$inferSelect,
|
||||||
|
| "id"
|
||||||
|
| "workspaceId"
|
||||||
|
| "channelId"
|
||||||
|
| "eventType"
|
||||||
|
| "eventVersion"
|
||||||
|
| "dedupeKey"
|
||||||
|
| "payload"
|
||||||
|
>;
|
||||||
|
type Mark = (
|
||||||
|
id: string,
|
||||||
|
status: "published" | "failed",
|
||||||
|
error?: string,
|
||||||
|
) => Promise<void>;
|
||||||
|
export type ClaimBatch = (
|
||||||
|
run: (rows: ChatOutboxRow[], mark: Mark) => Promise<void>,
|
||||||
|
) => Promise<void>;
|
||||||
|
type Publish = (channel: string, payload: string) => Promise<number>;
|
||||||
|
|
||||||
|
const PUBLISH_TIMEOUT_MS = 5_000;
|
||||||
|
const RETRY_MIN_MS = 1_000;
|
||||||
|
const RETRY_MAX_MS = 30_000;
|
||||||
|
|
||||||
|
function abortError(): Error {
|
||||||
|
return new Error("Chat outbox publish aborted");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Do not disconnect the shared Redis client: it also handles orchestrator jobs. */
|
||||||
|
async function boundedPublish(
|
||||||
|
publish: Publish,
|
||||||
|
channel: string,
|
||||||
|
payload: string,
|
||||||
|
timeoutMs: number,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<void> {
|
||||||
|
if (signal?.aborted) throw abortError();
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let onAbort: (() => void) | undefined;
|
||||||
|
const deadline = new Promise<never>((_, reject) => {
|
||||||
|
timer = setTimeout(
|
||||||
|
() => reject(new Error("Chat outbox publish timed out")),
|
||||||
|
timeoutMs,
|
||||||
|
);
|
||||||
|
if (signal) {
|
||||||
|
onAbort = () => reject(abortError());
|
||||||
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
|
if (signal.aborted) onAbort();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
// A late Redis acknowledgement may mean duplicate delivery on retry. Keep the
|
||||||
|
// outbox row pending and let consumers deduplicate by id/dedupeKey.
|
||||||
|
await Promise.race([
|
||||||
|
Promise.resolve().then(() => publish(channel, payload)),
|
||||||
|
deadline,
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (onAbort) signal?.removeEventListener("abort", onAbort);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pause(ms: number, signal: AbortSignal): Promise<void> {
|
||||||
|
if (signal.aborted) return Promise.resolve();
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const finish = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
signal.removeEventListener("abort", finish);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
const timer = setTimeout(finish, ms);
|
||||||
|
signal.addEventListener("abort", finish, { once: true });
|
||||||
|
if (signal.aborted) finish();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep the row locks until Redis acknowledges the publish, so concurrent workers
|
||||||
|
* cannot send the same pending row. A crash between publish and commit can still
|
||||||
|
* redeliver it: consumers must deduplicate by id and replay by channel cursor.
|
||||||
|
*/
|
||||||
|
export async function publishChatOutboxBatch(
|
||||||
|
claim: ClaimBatch,
|
||||||
|
publish: Publish,
|
||||||
|
options: { publishTimeoutMs?: number; signal?: AbortSignal } = {},
|
||||||
|
): Promise<{ published: number; failed: number }> {
|
||||||
|
const result = { published: 0, failed: 0 };
|
||||||
|
await claim(async (rows, mark) => {
|
||||||
|
for (const row of rows) {
|
||||||
|
if (options.signal?.aborted) break;
|
||||||
|
let failure: unknown;
|
||||||
|
try {
|
||||||
|
await boundedPublish(
|
||||||
|
publish,
|
||||||
|
chatOutboxChannel(row.workspaceId, row.channelId),
|
||||||
|
JSON.stringify({
|
||||||
|
...row.payload,
|
||||||
|
id: row.id,
|
||||||
|
dedupeKey: row.dedupeKey,
|
||||||
|
workspaceId: row.workspaceId,
|
||||||
|
channelId: row.channelId,
|
||||||
|
eventType: row.eventType,
|
||||||
|
eventVersion: row.eventVersion,
|
||||||
|
}),
|
||||||
|
options.publishTimeoutMs ?? PUBLISH_TIMEOUT_MS,
|
||||||
|
options.signal,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
failure = error;
|
||||||
|
}
|
||||||
|
if (failure !== undefined) {
|
||||||
|
const message =
|
||||||
|
failure instanceof Error ? failure.message : String(failure);
|
||||||
|
// A failed status is still pending; it will be retried on the next tick.
|
||||||
|
await mark(row.id, "failed", message.slice(0, 2000));
|
||||||
|
result.failed++;
|
||||||
|
} else {
|
||||||
|
await mark(row.id, "published");
|
||||||
|
result.published++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createChatOutboxClaim(
|
||||||
|
db: typeof dbInstance,
|
||||||
|
batchSize = 50,
|
||||||
|
): ClaimBatch {
|
||||||
|
return async (run) => {
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
const rows = await tx
|
||||||
|
.select({
|
||||||
|
id: chatOutbox.id,
|
||||||
|
workspaceId: chatOutbox.workspaceId,
|
||||||
|
channelId: chatOutbox.channelId,
|
||||||
|
eventType: chatOutbox.eventType,
|
||||||
|
eventVersion: chatOutbox.eventVersion,
|
||||||
|
dedupeKey: chatOutbox.dedupeKey,
|
||||||
|
payload: chatOutbox.payload,
|
||||||
|
})
|
||||||
|
.from(chatOutbox)
|
||||||
|
.where(isNull(chatOutbox.publishedAt))
|
||||||
|
.orderBy(asc(chatOutbox.createdAt), asc(chatOutbox.id))
|
||||||
|
.limit(batchSize)
|
||||||
|
.for("update", { skipLocked: true });
|
||||||
|
|
||||||
|
await run(rows, async (id, status, error) => {
|
||||||
|
const row = rows.find((candidate) => candidate.id === id);
|
||||||
|
if (!row) throw new Error("Cannot mark an unclaimed chat outbox row");
|
||||||
|
await tx
|
||||||
|
.update(chatOutbox)
|
||||||
|
.set({
|
||||||
|
attempts: sql`${chatOutbox.attempts} + 1`,
|
||||||
|
publishedAt: status === "published" ? new Date() : null,
|
||||||
|
lastError:
|
||||||
|
status === "failed" ? (error ?? "Unknown publish failure") : null,
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(chatOutbox.id, id),
|
||||||
|
eq(chatOutbox.workspaceId, row.workspaceId),
|
||||||
|
eq(chatOutbox.channelId, row.channelId),
|
||||||
|
isNull(chatOutbox.publishedAt),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runChatOutboxPublisher(
|
||||||
|
db: typeof dbInstance,
|
||||||
|
redis: Pick<IoRedis, "publish">,
|
||||||
|
signal: AbortSignal,
|
||||||
|
claim: ClaimBatch = createChatOutboxClaim(db),
|
||||||
|
): Promise<void> {
|
||||||
|
let retryMs = RETRY_MIN_MS;
|
||||||
|
while (!signal.aborted) {
|
||||||
|
try {
|
||||||
|
const result = await publishChatOutboxBatch(
|
||||||
|
claim,
|
||||||
|
(channel, payload) => redis.publish(channel, payload),
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
if (signal.aborted) break;
|
||||||
|
if (result.failed > 0) {
|
||||||
|
console.error(
|
||||||
|
`[worker] chat outbox: ${result.failed} publish failures`,
|
||||||
|
);
|
||||||
|
await pause(retryMs, signal);
|
||||||
|
retryMs = Math.min(retryMs * 2, RETRY_MAX_MS);
|
||||||
|
} else {
|
||||||
|
retryMs = RETRY_MIN_MS;
|
||||||
|
await pause(result.published === 50 ? 0 : RETRY_MIN_MS, signal);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[worker] chat outbox tick failed:", error);
|
||||||
|
await pause(retryMs, signal);
|
||||||
|
retryMs = Math.min(retryMs * 2, RETRY_MAX_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -33,9 +33,14 @@ const envSchema = z.object({
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.transform((value) => value?.trim() || "/tmp/echodo-worker-worktrees"),
|
.transform((value) => value?.trim() || "/tmp/echodo-worker-worktrees"),
|
||||||
/** Session type: "minimal" (default, in-process) or "openhands" (agent-server). */
|
/**
|
||||||
|
* Session type:
|
||||||
|
* - minimal (default) — in-process tool loop
|
||||||
|
* - opencode — headless `opencode serve` on CT 116 (preferred harness)
|
||||||
|
* - openhands — legacy agent-server (optional profile; not the target path)
|
||||||
|
*/
|
||||||
WORKER_SESSION_TYPE: z
|
WORKER_SESSION_TYPE: z
|
||||||
.enum(["minimal", "openhands"])
|
.enum(["minimal", "opencode", "openhands"])
|
||||||
.optional()
|
.optional()
|
||||||
.default("minimal"),
|
.default("minimal"),
|
||||||
/** OpenHands agent-server URL. Docker: http://agent-server:8001, Local: http://localhost:18000 */
|
/** OpenHands agent-server URL. Docker: http://agent-server:8001, Local: http://localhost:18000 */
|
||||||
|
|
@ -53,6 +58,30 @@ const envSchema = z.object({
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.transform((value) => value?.trim() || ""),
|
.transform((value) => value?.trim() || ""),
|
||||||
|
/** OpenCode serve/web base URL. Docker: http://opencode:4096 */
|
||||||
|
OPENCODE_SERVER_URL: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((value) => value?.trim() || "http://opencode:4096"),
|
||||||
|
/** Must match OPENCODE_SERVER_PASSWORD on the opencode container when set. */
|
||||||
|
OPENCODE_SERVER_PASSWORD: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((value) => value?.trim() || ""),
|
||||||
|
OPENCODE_SERVER_USERNAME: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((value) => value?.trim() || "opencode"),
|
||||||
|
/** Provider id registered by @omniroute/opencode-plugin (default omniroute). */
|
||||||
|
OPENCODE_PROVIDER_ID: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((value) => value?.trim() || "omniroute"),
|
||||||
|
/** Workspace path OpenCode should treat as the project root (shared volume). */
|
||||||
|
WORKER_OPENCODE_WORKSPACE: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((value) => value?.trim() || "/workspace/repo"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type WorkerEnv = z.infer<typeof envSchema>;
|
export type WorkerEnv = z.infer<typeof envSchema>;
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import type {
|
||||||
WorkerJobContext,
|
WorkerJobContext,
|
||||||
} from "@tasks/shared/worker";
|
} from "@tasks/shared/worker";
|
||||||
import { createMinimalSession } from "../session/minimal-session.js";
|
import { createMinimalSession } from "../session/minimal-session.js";
|
||||||
|
import { createOpenCodeSession, type OpenCodeConfig } from "../session/opencode-session.js";
|
||||||
import { createOpenHandsSession, type OpenHandsConfig } from "../session/openhands-session.js";
|
import { createOpenHandsSession, type OpenHandsConfig } from "../session/openhands-session.js";
|
||||||
import { createRemoteAgUiExecutor } from "./remote-ag-ui.js";
|
import { createRemoteAgUiExecutor } from "./remote-ag-ui.js";
|
||||||
|
|
||||||
|
|
@ -82,6 +83,33 @@ function buildOpenHandsExecutor(config: OpenHandsConfig): WorkerExecutor {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildOpenCodeExecutor(config: OpenCodeConfig): WorkerExecutor {
|
||||||
|
return {
|
||||||
|
id: "opencode",
|
||||||
|
harnessType: "minimal" as HarnessType,
|
||||||
|
async run(ctx: WorkerJobContext, opts: WorkerExecutorOptions): Promise<{ summary: string; toolCalls: number; tokensTotal?: number; outcome?: "succeeded" | "failed" }> {
|
||||||
|
const session = await createOpenCodeSession(ctx, {
|
||||||
|
config,
|
||||||
|
resolvedModel: opts.resolvedModel as never,
|
||||||
|
roleId: ctx.roleId,
|
||||||
|
briefId: ctx.briefId,
|
||||||
|
briefTitle: opts.convoyTitle,
|
||||||
|
workflowPrompt: opts.workflowPrompt,
|
||||||
|
briefAcceptance: opts.briefAcceptance,
|
||||||
|
briefFiles: opts.briefFiles,
|
||||||
|
maxSteps: opts.maxSteps,
|
||||||
|
});
|
||||||
|
const convoyTitle = opts.convoyTitle ?? "unknown";
|
||||||
|
const result = await session.run(opts.buildPrompt(ctx, convoyTitle));
|
||||||
|
return {
|
||||||
|
summary: result.summary,
|
||||||
|
toolCalls: result.toolCalls,
|
||||||
|
tokensTotal: result.tokensTotal,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export type HarnessExecutorConfig = {
|
export type HarnessExecutorConfig = {
|
||||||
harnessType: HarnessType;
|
harnessType: HarnessType;
|
||||||
endpointUrl?: string | null;
|
endpointUrl?: string | null;
|
||||||
|
|
@ -117,3 +145,15 @@ export function createOpenHandsExecutor(
|
||||||
}
|
}
|
||||||
return buildOpenHandsExecutor({ agentServerUrl, sessionApiKey, repoRoot });
|
return buildOpenHandsExecutor({ agentServerUrl, sessionApiKey, repoRoot });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an executor for headless OpenCode (`opencode serve`).
|
||||||
|
* Password is optional (local/dev); production should set OPENCODE_SERVER_PASSWORD.
|
||||||
|
*/
|
||||||
|
export function createOpenCodeExecutor(config: OpenCodeConfig): WorkerExecutor | null {
|
||||||
|
if (!config.serverUrl) {
|
||||||
|
console.warn("[worker] OpenCode executor unavailable: missing OPENCODE_SERVER_URL");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return buildOpenCodeExecutor(config);
|
||||||
|
}
|
||||||
|
|
|
||||||
93
apps/worker/src/executor/opencode-executor.test.ts
Normal file
93
apps/worker/src/executor/opencode-executor.test.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { createOpenCodeExecutor } from "./index.js";
|
||||||
|
|
||||||
|
describe("createOpenCodeExecutor", () => {
|
||||||
|
it("returns null when serverUrl is empty", () => {
|
||||||
|
const exec = createOpenCodeExecutor({
|
||||||
|
serverUrl: "",
|
||||||
|
serverPassword: "",
|
||||||
|
serverUsername: "opencode",
|
||||||
|
repoRoot: "/workspace/repo",
|
||||||
|
providerId: "omniroute",
|
||||||
|
});
|
||||||
|
expect(exec).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an opencode executor with the expected id", () => {
|
||||||
|
const exec = createOpenCodeExecutor({
|
||||||
|
serverUrl: "http://opencode:4096",
|
||||||
|
serverPassword: "secret",
|
||||||
|
serverUsername: "opencode",
|
||||||
|
repoRoot: "/workspace/repo",
|
||||||
|
providerId: "omniroute",
|
||||||
|
});
|
||||||
|
expect(exec).not.toBeNull();
|
||||||
|
expect(exec?.id).toBe("opencode");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a session and posts a message with basic auth", async () => {
|
||||||
|
const fetchImpl = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
new Response(JSON.stringify({ id: "ses_1" }), { status: 200 }),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
info: { role: "assistant" },
|
||||||
|
parts: [{ type: "text", text: "done" }, { type: "tool", tool: "bash" }],
|
||||||
|
}),
|
||||||
|
{ status: 200 },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.stubGlobal("fetch", fetchImpl);
|
||||||
|
|
||||||
|
const exec = createOpenCodeExecutor({
|
||||||
|
serverUrl: "http://opencode:4096",
|
||||||
|
serverPassword: "secret",
|
||||||
|
serverUsername: "opencode",
|
||||||
|
repoRoot: "/workspace/repo",
|
||||||
|
providerId: "omniroute",
|
||||||
|
});
|
||||||
|
expect(exec).not.toBeNull();
|
||||||
|
if (!exec) return;
|
||||||
|
|
||||||
|
const result = await exec.run(
|
||||||
|
{
|
||||||
|
workspaceId: "w",
|
||||||
|
convoyId: "c",
|
||||||
|
roleId: "role-implementer",
|
||||||
|
briefId: "b",
|
||||||
|
repoRoot: "/workspace/repo",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
resolvedModel: {
|
||||||
|
modelId: "omniroute/auto/coding",
|
||||||
|
providerType: "openai",
|
||||||
|
alias: "auto/coding",
|
||||||
|
},
|
||||||
|
resolvedAlias: "auto/coding",
|
||||||
|
rolePromptPath: "/tmp/role.md",
|
||||||
|
maxSteps: 5,
|
||||||
|
buildPrompt: () => "Implement the thing",
|
||||||
|
convoyTitle: "test",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.summary).toBe("done");
|
||||||
|
expect(result.toolCalls).toBe(1);
|
||||||
|
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
||||||
|
const [, createInit] = fetchImpl.mock.calls[0]!;
|
||||||
|
expect((createInit as RequestInit).headers).toMatchObject({
|
||||||
|
Authorization: expect.stringMatching(/^Basic /),
|
||||||
|
});
|
||||||
|
const [msgUrl, msgInit] = fetchImpl.mock.calls[1]!;
|
||||||
|
expect(msgUrl).toBe("http://opencode:4096/session/ses_1/message");
|
||||||
|
const body = JSON.parse(String((msgInit as RequestInit).body));
|
||||||
|
expect(body.model).toEqual({ providerID: "omniroute", modelID: "auto/coding" });
|
||||||
|
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -91,6 +91,11 @@ const env = {
|
||||||
AGENT_SERVER_URL: "http://agent-server:8001",
|
AGENT_SERVER_URL: "http://agent-server:8001",
|
||||||
WORKER_OPENHANDS_WORKSPACE: "/workspace/repo",
|
WORKER_OPENHANDS_WORKSPACE: "/workspace/repo",
|
||||||
SESSION_API_KEY: "",
|
SESSION_API_KEY: "",
|
||||||
|
OPENCODE_SERVER_URL: "http://opencode:4096",
|
||||||
|
OPENCODE_SERVER_PASSWORD: "",
|
||||||
|
OPENCODE_SERVER_USERNAME: "opencode",
|
||||||
|
OPENCODE_PROVIDER_ID: "omniroute",
|
||||||
|
WORKER_OPENCODE_WORKSPACE: "/workspace/repo",
|
||||||
REDIS_URL: "redis://localhost:6379",
|
REDIS_URL: "redis://localhost:6379",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ import {
|
||||||
} from "./git/worktree.js";
|
} from "./git/worktree.js";
|
||||||
import { openDraftPR } from "./git/pr.js";
|
import { openDraftPR } from "./git/pr.js";
|
||||||
import { resolveHarnessForJob, getHarnessExecutor } from "./harness.js";
|
import { resolveHarnessForJob, getHarnessExecutor } from "./harness.js";
|
||||||
import { getExecutor, createOpenHandsExecutor, type HarnessExecutorConfig } from "./executor/index.js";
|
import { getExecutor, createOpenCodeExecutor, createOpenHandsExecutor, type HarnessExecutorConfig } from "./executor/index.js";
|
||||||
import { resolveHarnessForRoleDb } from "./db/harness.js";
|
import { resolveHarnessForRoleDb } from "./db/harness.js";
|
||||||
import { resolveRepoRoot } from "./lib/repo-root.js";
|
import { resolveRepoRoot } from "./lib/repo-root.js";
|
||||||
import {
|
import {
|
||||||
|
|
@ -255,10 +255,31 @@ export async function handleOrchestratorJob(
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
// Resolve the executor from the registry based on the harness type
|
// Resolve the executor from the registry based on the harness type.
|
||||||
// When WORKER_SESSION_TYPE=openhands, route to OpenHands agent-server executor
|
// WORKER_SESSION_TYPE=opencode → headless OpenCode (preferred).
|
||||||
|
// WORKER_SESSION_TYPE=openhands → legacy OpenHands agent-server.
|
||||||
let executor;
|
let executor;
|
||||||
if (env.WORKER_SESSION_TYPE === "openhands") {
|
if (env.WORKER_SESSION_TYPE === "opencode") {
|
||||||
|
const ocExecutor = createOpenCodeExecutor({
|
||||||
|
serverUrl: env.OPENCODE_SERVER_URL,
|
||||||
|
serverPassword: env.OPENCODE_SERVER_PASSWORD,
|
||||||
|
serverUsername: env.OPENCODE_SERVER_USERNAME,
|
||||||
|
repoRoot: env.WORKER_OPENCODE_WORKSPACE,
|
||||||
|
providerId: env.OPENCODE_PROVIDER_ID,
|
||||||
|
});
|
||||||
|
if (ocExecutor) {
|
||||||
|
executor = ocExecutor;
|
||||||
|
console.info(`[worker] using OpenCode executor server=${env.OPENCODE_SERVER_URL}`);
|
||||||
|
} else {
|
||||||
|
console.warn("[worker] OpenCode executor unavailable, falling back to minimal");
|
||||||
|
const harnessExecutorConfig: HarnessExecutorConfig = {
|
||||||
|
harnessType: harness.harnessType,
|
||||||
|
endpointUrl: harness.endpointUrl,
|
||||||
|
authEnvVar: harness.authEnvVar,
|
||||||
|
};
|
||||||
|
executor = getExecutor(harnessExecutorConfig);
|
||||||
|
}
|
||||||
|
} else if (env.WORKER_SESSION_TYPE === "openhands") {
|
||||||
const ohExecutor = createOpenHandsExecutor(
|
const ohExecutor = createOpenHandsExecutor(
|
||||||
env.AGENT_SERVER_URL,
|
env.AGENT_SERVER_URL,
|
||||||
env.SESSION_API_KEY,
|
env.SESSION_API_KEY,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { db } from "@tasks/database";
|
import { db } from "@tasks/database";
|
||||||
import IoRedis from "ioredis";
|
import IoRedis from "ioredis";
|
||||||
|
|
||||||
|
import { runChatOutboxPublisher } from "./chat/outbox.js";
|
||||||
import { loadEnv } from "./env.js";
|
import { loadEnv } from "./env.js";
|
||||||
import { handleOrchestratorJob } from "./handle-job.js";
|
import { handleOrchestratorJob } from "./handle-job.js";
|
||||||
import { dequeueJob } from "./queue/dequeue.js";
|
import { dequeueJob } from "./queue/dequeue.js";
|
||||||
|
|
@ -21,10 +22,15 @@ async function main(): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
console.info(`[worker] consuming ${env.WORKER_BLPOP_TIMEOUT_S}s BLPOP on orchestrator queue`);
|
console.info(`[worker] consuming ${env.WORKER_BLPOP_TIMEOUT_S}s BLPOP on orchestrator queue`);
|
||||||
|
const shutdown = new AbortController();
|
||||||
|
process.once("SIGTERM", () => shutdown.abort());
|
||||||
|
process.once("SIGINT", () => shutdown.abort());
|
||||||
|
const publisher = runChatOutboxPublisher(db, redis, shutdown.signal);
|
||||||
|
|
||||||
for (;;) {
|
while (!shutdown.signal.aborted) {
|
||||||
try {
|
try {
|
||||||
const job = await dequeueJob(redis, env.WORKER_BLPOP_TIMEOUT_S);
|
const job = await dequeueJob(redis, env.WORKER_BLPOP_TIMEOUT_S);
|
||||||
|
if (shutdown.signal.aborted) break;
|
||||||
if (!job) continue;
|
if (!job) continue;
|
||||||
console.info(`[worker] dequeued job=${job.id} role=${job.roleId} convoy=${job.convoyId}`);
|
console.info(`[worker] dequeued job=${job.id} role=${job.roleId} convoy=${job.convoyId}`);
|
||||||
await handleOrchestratorJob(db, redis, env, job);
|
await handleOrchestratorJob(db, redis, env, job);
|
||||||
|
|
@ -32,6 +38,8 @@ async function main(): Promise<void> {
|
||||||
console.error("[worker] loop error:", error);
|
console.error("[worker] loop error:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
await publisher;
|
||||||
|
await redis.quit();
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((error) => {
|
main().catch((error) => {
|
||||||
|
|
|
||||||
175
apps/worker/src/session/opencode-session.ts
Normal file
175
apps/worker/src/session/opencode-session.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
import type { ResolvedModel } from "@tasks/shared/model-routing";
|
||||||
|
import type { WorkerJobContext, WorkerSession } from "@tasks/shared/worker";
|
||||||
|
|
||||||
|
import { buildSkillFullContext, loadAgentSkills } from "./load-skills.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for a headless OpenCode server (`opencode serve` / `opencode web`).
|
||||||
|
* Auth is HTTP basic when OPENCODE_SERVER_PASSWORD is set on the server.
|
||||||
|
*/
|
||||||
|
export type OpenCodeConfig = {
|
||||||
|
serverUrl: string;
|
||||||
|
/** Basic-auth password; empty = no Authorization header. */
|
||||||
|
serverPassword: string;
|
||||||
|
/** Basic-auth username (OpenCode default: opencode). */
|
||||||
|
serverUsername: string;
|
||||||
|
/** Repo root to scan for .agents/skills/ and to describe in the prompt. */
|
||||||
|
repoRoot: string;
|
||||||
|
/** OpenCode provider id registered by the OmniRoute plugin (default omniroute). */
|
||||||
|
providerId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OpenCodeSessionOptions = {
|
||||||
|
config: OpenCodeConfig;
|
||||||
|
resolvedModel: ResolvedModel;
|
||||||
|
roleId: string;
|
||||||
|
briefId?: string;
|
||||||
|
briefTitle?: string;
|
||||||
|
workflowPrompt?: string;
|
||||||
|
briefAcceptance?: string;
|
||||||
|
briefFiles?: string[];
|
||||||
|
maxSteps?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SessionCreateResponse = { id: string; title?: string };
|
||||||
|
type MessageResponse = {
|
||||||
|
info?: { role?: string; error?: { name?: string; message?: string } };
|
||||||
|
parts?: Array<{ type?: string; text?: string; tool?: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function basicAuthHeader(username: string, password: string): string {
|
||||||
|
const token = Buffer.from(`${username}:${password}`, "utf8").toString("base64");
|
||||||
|
return `Basic ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripProviderPrefix(modelId: string, providerId: string): string {
|
||||||
|
const prefix = `${providerId}/`;
|
||||||
|
return modelId.startsWith(prefix) ? modelId.slice(prefix.length) : modelId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an OpenCode session that drives a remote `opencode serve` over HTTP.
|
||||||
|
* Prefer this over OpenHands on CT 116 — same OmniRoute catalog via the plugin,
|
||||||
|
* no Docker-in-Docker sandbox, one concurrent agent until RAM upgrade.
|
||||||
|
*/
|
||||||
|
export async function createOpenCodeSession(
|
||||||
|
context: WorkerJobContext,
|
||||||
|
options: OpenCodeSessionOptions,
|
||||||
|
): Promise<WorkerSession> {
|
||||||
|
const {
|
||||||
|
config,
|
||||||
|
resolvedModel,
|
||||||
|
roleId,
|
||||||
|
briefId,
|
||||||
|
briefTitle,
|
||||||
|
workflowPrompt,
|
||||||
|
briefAcceptance,
|
||||||
|
briefFiles,
|
||||||
|
} = options;
|
||||||
|
const apiBase = config.serverUrl.replace(/\/$/, "");
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/json",
|
||||||
|
};
|
||||||
|
if (config.serverPassword) {
|
||||||
|
headers.Authorization = basicAuthHeader(config.serverUsername, config.serverPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
let skillContext = "";
|
||||||
|
try {
|
||||||
|
const skills = await loadAgentSkills(config.repoRoot);
|
||||||
|
skillContext = buildSkillFullContext(skills);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[opencode] skill loading failed, continuing without skills:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
const systemParts: string[] = [
|
||||||
|
`You are an AI agent executing a brief for convoy "${context.convoyId}".`,
|
||||||
|
`Role: ${roleId}`,
|
||||||
|
briefId ? `Brief ID: ${briefId}` : "",
|
||||||
|
briefTitle ? `Brief: ${briefTitle}` : "",
|
||||||
|
`Working directory: ${config.repoRoot}`,
|
||||||
|
workflowPrompt ? `\n## Task\n${workflowPrompt}` : "",
|
||||||
|
briefAcceptance ? `\n## Acceptance criteria\n${briefAcceptance}` : "",
|
||||||
|
briefFiles?.length
|
||||||
|
? `\n## Files to examine\n${briefFiles.map((f) => `- ${f}`).join("\n")}`
|
||||||
|
: "",
|
||||||
|
skillContext || "",
|
||||||
|
"\n## Instructions\nUse the available tools to inspect and modify the repo. Run lint/type-check when appropriate. Finish with a concise summary of what changed.",
|
||||||
|
].filter(Boolean);
|
||||||
|
const systemPrompt = systemParts.join("\n");
|
||||||
|
|
||||||
|
const modelId = stripProviderPrefix(resolvedModel.modelId, config.providerId);
|
||||||
|
|
||||||
|
async function createSession(title: string): Promise<string> {
|
||||||
|
const res = await fetch(`${apiBase}/session`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ title }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`OpenCode session create failed (${res.status}): ${await res.text()}`);
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as SessionCreateResponse;
|
||||||
|
if (!data.id) throw new Error("OpenCode session create returned no id");
|
||||||
|
return data.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendPrompt(sessionId: string, prompt: string): Promise<{
|
||||||
|
summary: string;
|
||||||
|
toolCalls: number;
|
||||||
|
}> {
|
||||||
|
const res = await fetch(`${apiBase}/session/${sessionId}/message`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: { providerID: config.providerId, modelID: modelId },
|
||||||
|
system: systemPrompt,
|
||||||
|
parts: [{ type: "text", text: prompt }],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`OpenCode prompt failed (${res.status}): ${await res.text()}`);
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as MessageResponse;
|
||||||
|
if (data.info?.error) {
|
||||||
|
throw new Error(
|
||||||
|
`OpenCode error ${data.info.error.name ?? "unknown"}: ${data.info.error.message ?? ""}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const textParts = (data.parts ?? [])
|
||||||
|
.filter((p) => p.type === "text" && p.text)
|
||||||
|
.map((p) => p.text as string);
|
||||||
|
const toolCalls = (data.parts ?? []).filter((p) => p.type === "tool" || p.tool).length;
|
||||||
|
return {
|
||||||
|
summary: textParts.join("\n").trim() || "OpenCode session completed with no text summary.",
|
||||||
|
toolCalls,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
context,
|
||||||
|
async run(prompt: string) {
|
||||||
|
console.info(
|
||||||
|
`[opencode] session.run roleId=${roleId} brief=${briefId ?? "none"} model=${config.providerId}/${modelId}`,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const title = `convoy:${context.convoyId}:${roleId}:${briefId ?? "none"}`;
|
||||||
|
const sessionId = await createSession(title);
|
||||||
|
console.info(`[opencode] session started id=${sessionId}`);
|
||||||
|
const result = await sendPrompt(sessionId, prompt);
|
||||||
|
console.info(
|
||||||
|
`[opencode] session complete tools=${result.toolCalls} summary_len=${result.summary.length}`,
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
console.error(`[opencode] session failed:`, msg);
|
||||||
|
return {
|
||||||
|
summary: `OpenCode session failed: ${msg}`,
|
||||||
|
toolCalls: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -132,12 +132,14 @@ RUN rm -f apps/web/.env apps/web/.env.local apps/web/.env.production apps/web/.e
|
||||||
|
|
||||||
# NEXT_PUBLIC_* must be present at build time — Next.js inlines them into the client bundle.
|
# NEXT_PUBLIC_* must be present at build time — Next.js inlines them into the client bundle.
|
||||||
ARG NEXT_PUBLIC_COLLAB_SERVER_URL=ws://localhost:1234
|
ARG NEXT_PUBLIC_COLLAB_SERVER_URL=ws://localhost:1234
|
||||||
|
ARG NEXT_PUBLIC_CHAT_SERVER_URL=ws://localhost:1235
|
||||||
ARG NEXT_PUBLIC_UMAMI_SCRIPT=
|
ARG NEXT_PUBLIC_UMAMI_SCRIPT=
|
||||||
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
||||||
ARG NEXT_PUBLIC_LIBREDESK_URL=
|
ARG NEXT_PUBLIC_LIBREDESK_URL=
|
||||||
ARG NEXT_PUBLIC_LIBREDESK_WIDGET_ID=
|
ARG NEXT_PUBLIC_LIBREDESK_WIDGET_ID=
|
||||||
ARG NEXT_PUBLIC_DIRECTUS_URL=
|
ARG NEXT_PUBLIC_DIRECTUS_URL=
|
||||||
ENV NEXT_PUBLIC_COLLAB_SERVER_URL=${NEXT_PUBLIC_COLLAB_SERVER_URL}
|
ENV NEXT_PUBLIC_COLLAB_SERVER_URL=${NEXT_PUBLIC_COLLAB_SERVER_URL}
|
||||||
|
ENV NEXT_PUBLIC_CHAT_SERVER_URL=${NEXT_PUBLIC_CHAT_SERVER_URL}
|
||||||
ENV NEXT_PUBLIC_UMAMI_SCRIPT=${NEXT_PUBLIC_UMAMI_SCRIPT}
|
ENV NEXT_PUBLIC_UMAMI_SCRIPT=${NEXT_PUBLIC_UMAMI_SCRIPT}
|
||||||
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=${NEXT_PUBLIC_UMAMI_WEBSITE_ID}
|
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=${NEXT_PUBLIC_UMAMI_WEBSITE_ID}
|
||||||
ENV NEXT_PUBLIC_LIBREDESK_URL=${NEXT_PUBLIC_LIBREDESK_URL}
|
ENV NEXT_PUBLIC_LIBREDESK_URL=${NEXT_PUBLIC_LIBREDESK_URL}
|
||||||
|
|
@ -176,7 +178,7 @@ CMD ["node", "apps/web/server.js"]
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# collab — runtime image (Hocuspocus / Yjs websocket)
|
# collab — runtime image (Hocuspocus / Yjs and workspace Chat listeners)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
FROM node:${NODE_VERSION}-alpine AS collab
|
FROM node:${NODE_VERSION}-alpine AS collab
|
||||||
RUN addgroup --system --gid 1001 nodejs \
|
RUN addgroup --system --gid 1001 nodejs \
|
||||||
|
|
@ -195,10 +197,11 @@ COPY --from=collab-build --chown=collab:nodejs /app/apps/collab-server/node_modu
|
||||||
|
|
||||||
USER collab
|
USER collab
|
||||||
|
|
||||||
EXPOSE 1234
|
EXPOSE 1234 1235
|
||||||
|
|
||||||
|
# Listener liveness only; authenticated delivery is a separate acceptance gate.
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||||
CMD node -e "const p=+(process.env.PORT||1234);require('net').createConnection(p,'127.0.0.1').on('connect',()=>process.exit(0)).on('error',()=>process.exit(1))"
|
CMD node -e "let remaining=2;for(const p of [+(process.env.PORT||1234),+(process.env.CHAT_PORT||1235)]){const s=require('net').createConnection(p,'127.0.0.1');s.setTimeout(3000,()=>process.exit(1));s.on('connect',()=>{s.destroy();if(!--remaining)process.exit(0)}).on('error',()=>process.exit(1))}"
|
||||||
|
|
||||||
WORKDIR /app/apps/collab-server
|
WORKDIR /app/apps/collab-server
|
||||||
CMD ["node", "dist/index.mjs"]
|
CMD ["node", "dist/index.mjs"]
|
||||||
|
|
|
||||||
|
|
@ -12,3 +12,4 @@ services:
|
||||||
collab:
|
collab:
|
||||||
ports:
|
ports:
|
||||||
- "1234:1234"
|
- "1234:1234"
|
||||||
|
- "${CHAT_PORT:-1235}:${CHAT_PORT:-1235}"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
# Echodo production stack — shared CT 102 Postgres/Redis, multi-service build.
|
# Echodo production stack — shared CT 102 Postgres/Redis, multi-service build.
|
||||||
#
|
#
|
||||||
# Phase A (default): web, collab, mcp, orchestrator, worker
|
# Phase A (default): web, collab, mcp, orchestrator, worker
|
||||||
# Phase B (optional): OpenHands agent-server — enable with profile `agent-server`
|
# Phase B (optional coding harness):
|
||||||
|
# Preferred: OpenCode — COMPOSE_PROFILES=opencode, WORKER_SESSION_TYPE=opencode
|
||||||
|
# Legacy: OpenHands — COMPOSE_PROFILES=agent-server, WORKER_SESSION_TYPE=openhands
|
||||||
#
|
#
|
||||||
# Deploy targets:
|
# Deploy targets:
|
||||||
# - Dokploy (CT 112): compose file path `docker/docker-compose.prod.yml`, project dir = repo root
|
# - Dokploy (CT 112): compose file path `docker/docker-compose.prod.yml`, project dir = repo root
|
||||||
|
|
@ -10,13 +12,19 @@
|
||||||
# Domains (Dokploy UI or CT 100 Traefik):
|
# Domains (Dokploy UI or CT 100 Traefik):
|
||||||
# web → echodo.stillwell.cloud (port 3000)
|
# web → echodo.stillwell.cloud (port 3000)
|
||||||
# collab → collab.echodo.stillwell.cloud (port 1234)
|
# collab → collab.echodo.stillwell.cloud (port 1234)
|
||||||
|
# chat → echodo-chat.stillwell.cloud (collab service, CHAT_PORT default 1235)
|
||||||
|
# opencode (optional) → opencode.stillwell.cloud (port 4096)
|
||||||
#
|
#
|
||||||
# Build (required on ≤8 GB hosts — parallel export OOMs with exit 255):
|
# Build (required on ≤8 GB hosts — parallel export OOMs with exit 255):
|
||||||
# bash scripts/build-prod.sh
|
# bash scripts/build-prod.sh
|
||||||
# Dokploy → Advanced → Custom Build Command: bash scripts/build-prod.sh
|
# Dokploy → Advanced → Custom Build Command: bash scripts/build-prod.sh
|
||||||
#
|
#
|
||||||
# Phase B:
|
# Phase B OpenCode:
|
||||||
# docker compose -f docker/docker-compose.prod.yml --profile agent-server up -d
|
# export COMPOSE_PROFILES=opencode
|
||||||
|
# Set WORKER_SESSION_TYPE=opencode, OPENCODE_SERVER_PASSWORD, OMNIROUTE_API_KEY / BASE_URL.
|
||||||
|
#
|
||||||
|
# Phase B OpenHands (legacy):
|
||||||
|
# export COMPOSE_PROFILES=agent-server
|
||||||
# Set WORKER_SESSION_TYPE=openhands and matching SESSION_API_KEY / LOCAL_BACKEND_API_KEY.
|
# Set WORKER_SESSION_TYPE=openhands and matching SESSION_API_KEY / LOCAL_BACKEND_API_KEY.
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
|
@ -27,6 +35,7 @@ services:
|
||||||
target: web
|
target: web
|
||||||
args:
|
args:
|
||||||
NEXT_PUBLIC_COLLAB_SERVER_URL: ${NEXT_PUBLIC_COLLAB_SERVER_URL:-ws://localhost:1234}
|
NEXT_PUBLIC_COLLAB_SERVER_URL: ${NEXT_PUBLIC_COLLAB_SERVER_URL:-ws://localhost:1234}
|
||||||
|
NEXT_PUBLIC_CHAT_SERVER_URL: ${NEXT_PUBLIC_CHAT_SERVER_URL:-ws://localhost:1235}
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT: ${NEXT_PUBLIC_UMAMI_SCRIPT:-}
|
NEXT_PUBLIC_UMAMI_SCRIPT: ${NEXT_PUBLIC_UMAMI_SCRIPT:-}
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${NEXT_PUBLIC_UMAMI_WEBSITE_ID:-}
|
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${NEXT_PUBLIC_UMAMI_WEBSITE_ID:-}
|
||||||
NEXT_PUBLIC_LIBREDESK_URL: ${NEXT_PUBLIC_LIBREDESK_URL:-}
|
NEXT_PUBLIC_LIBREDESK_URL: ${NEXT_PUBLIC_LIBREDESK_URL:-}
|
||||||
|
|
@ -41,6 +50,7 @@ services:
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=${DATABASE_URL}
|
- DATABASE_URL=${DATABASE_URL}
|
||||||
- REDIS_URL=${REDIS_URL}
|
- REDIS_URL=${REDIS_URL}
|
||||||
|
- CHAT_TICKET_SECRET=${CHAT_TICKET_SECRET:?Set a shared Chat signing secret of at least 32 characters}
|
||||||
- NEXTAUTH_URL=${NEXTAUTH_URL}
|
- NEXTAUTH_URL=${NEXTAUTH_URL}
|
||||||
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
|
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
|
||||||
- AUTH_SECRET=${AUTH_SECRET}
|
- AUTH_SECRET=${AUTH_SECRET}
|
||||||
|
|
@ -93,13 +103,16 @@ services:
|
||||||
- DATABASE_URL=${DATABASE_URL}
|
- DATABASE_URL=${DATABASE_URL}
|
||||||
- REDIS_URL=${REDIS_URL}
|
- REDIS_URL=${REDIS_URL}
|
||||||
- PORT=1234
|
- PORT=1234
|
||||||
|
- CHAT_PORT=${CHAT_PORT:-1235}
|
||||||
|
- CHAT_TICKET_SECRET=${CHAT_TICKET_SECRET:?Set a shared Chat signing secret of at least 32 characters}
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
expose:
|
expose:
|
||||||
- "1234"
|
- "1234"
|
||||||
|
- "${CHAT_PORT:-1235}"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test:
|
||||||
- CMD-SHELL
|
- CMD-SHELL
|
||||||
- 'node -e "const p=+(process.env.PORT||1234);require(''net'').createConnection(p,''127.0.0.1'').on(''connect'',()=>process.exit(0)).on(''error'',()=>process.exit(1))"'
|
- 'node -e "let remaining=2;for(const p of [+(process.env.PORT||1234),+(process.env.CHAT_PORT||1235)]){const s=require(''net'').createConnection(p,''127.0.0.1'');s.setTimeout(3000,()=>process.exit(1));s.on(''connect'',()=>{s.destroy();if(!--remaining)process.exit(0)}).on(''error'',()=>process.exit(1))}"'
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
@ -179,6 +192,11 @@ services:
|
||||||
- WORKER_OPENHANDS_WORKSPACE=${WORKER_OPENHANDS_WORKSPACE:-/workspace/repo}
|
- WORKER_OPENHANDS_WORKSPACE=${WORKER_OPENHANDS_WORKSPACE:-/workspace/repo}
|
||||||
- AGENT_SERVER_URL=${AGENT_SERVER_URL:-http://agent-server:8001}
|
- AGENT_SERVER_URL=${AGENT_SERVER_URL:-http://agent-server:8001}
|
||||||
- SESSION_API_KEY=${SESSION_API_KEY:-}
|
- SESSION_API_KEY=${SESSION_API_KEY:-}
|
||||||
|
- OPENCODE_SERVER_URL=${OPENCODE_SERVER_URL:-http://opencode:4096}
|
||||||
|
- OPENCODE_SERVER_PASSWORD=${OPENCODE_SERVER_PASSWORD:-}
|
||||||
|
- OPENCODE_SERVER_USERNAME=${OPENCODE_SERVER_USERNAME:-opencode}
|
||||||
|
- OPENCODE_PROVIDER_ID=${OPENCODE_PROVIDER_ID:-omniroute}
|
||||||
|
- WORKER_OPENCODE_WORKSPACE=${WORKER_OPENCODE_WORKSPACE:-/workspace/repo}
|
||||||
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
||||||
- OPENAI_BASE_URL=${OPENAI_BASE_URL}
|
- OPENAI_BASE_URL=${OPENAI_BASE_URL}
|
||||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
||||||
|
|
@ -200,6 +218,62 @@ services:
|
||||||
networks:
|
networks:
|
||||||
- echodo
|
- echodo
|
||||||
|
|
||||||
|
opencode:
|
||||||
|
profiles: ["opencode"]
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/opencode/Dockerfile
|
||||||
|
image: submatter-opencode:tooling
|
||||||
|
container_name: submatter-opencode
|
||||||
|
restart: unless-stopped
|
||||||
|
working_dir: /workspace/repo
|
||||||
|
entrypoint: ["/bin/sh", "/config/entrypoint.sh"]
|
||||||
|
command:
|
||||||
|
[
|
||||||
|
"opencode",
|
||||||
|
"serve",
|
||||||
|
"--hostname",
|
||||||
|
"0.0.0.0",
|
||||||
|
"--port",
|
||||||
|
"4096",
|
||||||
|
]
|
||||||
|
environment:
|
||||||
|
- HOME=/data
|
||||||
|
- XDG_CONFIG_HOME=/data/.config
|
||||||
|
- XDG_DATA_HOME=/data/.local/share
|
||||||
|
- OPENCODE_SERVER_PASSWORD=${OPENCODE_SERVER_PASSWORD}
|
||||||
|
- OPENCODE_SERVER_USERNAME=${OPENCODE_SERVER_USERNAME:-opencode}
|
||||||
|
- OMNIROUTE_API_KEY=${OMNIROUTE_API_KEY}
|
||||||
|
- FORGEJO_TOKEN=${FORGEJO_TOKEN:-}
|
||||||
|
- GITHUB_TOKEN=${GITHUB_TOKEN:-}
|
||||||
|
- GIT_USER_NAME=${GIT_USER_NAME:-opencode}
|
||||||
|
- GIT_USER_EMAIL=${GIT_USER_EMAIL:-opencode@stillwell.cloud}
|
||||||
|
volumes:
|
||||||
|
- echodo-workspace:/workspace
|
||||||
|
- opencode-data:/data
|
||||||
|
- ./docker/opencode/opencode.json:/config/opencode.json:ro
|
||||||
|
- ./docker/opencode/entrypoint.sh:/config/entrypoint.sh:ro
|
||||||
|
ports:
|
||||||
|
- "4096:4096"
|
||||||
|
healthcheck:
|
||||||
|
# BusyBox wget has no --user/--password; use Authorization header.
|
||||||
|
# $$ escapes so Compose does not interpolate AUTH at parse time.
|
||||||
|
test:
|
||||||
|
- CMD-SHELL
|
||||||
|
- 'AUTH=$$(printf "%s" "$${OPENCODE_SERVER_USERNAME:-opencode}:$${OPENCODE_SERVER_PASSWORD}" | base64 | tr -d "\n"); wget -qO- --timeout=3 --header="Authorization: Basic $$AUTH" http://127.0.0.1:4096/global/health >/dev/null || exit 1'
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 45s
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
# Next.js builds need headroom beyond the bare serve binary.
|
||||||
|
memory: 3G
|
||||||
|
cpus: "2"
|
||||||
|
networks:
|
||||||
|
- echodo
|
||||||
|
|
||||||
agent-server:
|
agent-server:
|
||||||
profiles: ["agent-server"]
|
profiles: ["agent-server"]
|
||||||
image: ghcr.io/open-hands/agent-server:0.19.0
|
image: ghcr.io/open-hands/agent-server:0.19.0
|
||||||
|
|
@ -237,7 +311,20 @@ services:
|
||||||
- echodo
|
- echodo
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
# Backed by ZFS bind mounts on CT 116 (apps/submatter/* → /mnt/*).
|
||||||
|
# Repos, worktrees, and builds grow here — not on the 32G local-lvm root.
|
||||||
echodo-workspace:
|
echodo-workspace:
|
||||||
|
driver: local
|
||||||
|
driver_opts:
|
||||||
|
type: none
|
||||||
|
o: bind
|
||||||
|
device: /mnt/workspace
|
||||||
|
opencode-data:
|
||||||
|
driver: local
|
||||||
|
driver_opts:
|
||||||
|
type: none
|
||||||
|
o: bind
|
||||||
|
device: /mnt/opencode-data
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
echodo:
|
echodo:
|
||||||
|
|
|
||||||
31
docker/opencode/Dockerfile
Normal file
31
docker/opencode/Dockerfile
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
# OpenCode serve image + host tooling for interactive / worker agent sessions.
|
||||||
|
# Official image is Alpine and ships only the `opencode` binary.
|
||||||
|
FROM ghcr.io/anomalyco/opencode:latest
|
||||||
|
|
||||||
|
USER root
|
||||||
|
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
bash \
|
||||||
|
ca-certificates \
|
||||||
|
curl \
|
||||||
|
git \
|
||||||
|
github-cli \
|
||||||
|
openssh-client \
|
||||||
|
ripgrep \
|
||||||
|
python3 \
|
||||||
|
make \
|
||||||
|
g++ \
|
||||||
|
libc6-compat \
|
||||||
|
nodejs \
|
||||||
|
npm \
|
||||||
|
&& curl -fsSL -o /usr/local/bin/pnpm \
|
||||||
|
https://github.com/pnpm/pnpm/releases/download/v9.15.9/pnpm-linuxstatic-x64 \
|
||||||
|
&& chmod +x /usr/local/bin/pnpm \
|
||||||
|
&& npm install -g tsx \
|
||||||
|
&& rm -rf /root/.npm/_cacache /tmp/*
|
||||||
|
|
||||||
|
ENV GIT_CONFIG_COUNT=1 \
|
||||||
|
GIT_CONFIG_KEY_0=safe.directory \
|
||||||
|
GIT_CONFIG_VALUE_0=*
|
||||||
|
|
||||||
|
WORKDIR /workspace
|
||||||
69
docker/opencode/README.md
Normal file
69
docker/opencode/README.md
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
# OpenCode on CT 116 (Submatter)
|
||||||
|
|
||||||
|
Headless coding harness for Submatter. Prefer this over OpenHands.
|
||||||
|
|
||||||
|
Runs `opencode serve` on `:4096` (HTTP API for the worker). Attach a TUI with
|
||||||
|
`opencode attach http://…:4096`, or use the browser via Traefik once Authentik
|
||||||
|
is happy. The image has no desktop browser — do not use `opencode web` in
|
||||||
|
compose without an `xdg-open` stub (entrypoint provides one if you switch back).
|
||||||
|
|
||||||
|
## Tooling image
|
||||||
|
|
||||||
|
`docker/opencode/Dockerfile` extends `ghcr.io/anomalyco/opencode:latest` with:
|
||||||
|
|
||||||
|
- `git`, `openssh-client`, `gh`
|
||||||
|
- Node.js + `npm` + `pnpm` + `tsx`
|
||||||
|
- `bash`, `curl`, `ripgrep`, `python3`, `make`, `g++` (native module builds)
|
||||||
|
|
||||||
|
Forgejo HTTPS auth is seeded from `FORGEJO_TOKEN` into `~/.git-credentials` at
|
||||||
|
container start. Projects live under `/workspace/<repo>` on the shared volume.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export COMPOSE_PROFILES=opencode
|
||||||
|
docker compose --project-directory /opt/submatter \
|
||||||
|
-f docker/docker-compose.prod.yml --env-file .env \
|
||||||
|
build opencode
|
||||||
|
bash scripts/deploy-prod.sh up
|
||||||
|
```
|
||||||
|
|
||||||
|
## Enable
|
||||||
|
|
||||||
|
On CT 116 `/opt/submatter/.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
COMPOSE_PROFILES=opencode
|
||||||
|
WORKER_SESSION_TYPE=opencode
|
||||||
|
OPENCODE_SERVER_PASSWORD=<openssl rand -hex 24>
|
||||||
|
OPENCODE_SERVER_URL=http://opencode:4096
|
||||||
|
OMNIROUTE_BASE_URL=https://omniroute.stillwell.cloud/v1
|
||||||
|
OMNIROUTE_API_KEY=<gateway inference key>
|
||||||
|
FORGEJO_TOKEN=<forgejo pat with repo scope>
|
||||||
|
```
|
||||||
|
|
||||||
|
Then:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export COMPOSE_PROFILES=opencode
|
||||||
|
bash scripts/deploy-prod.sh up
|
||||||
|
# First time / after worker harness code lands:
|
||||||
|
# bash scripts/deploy-prod.sh build
|
||||||
|
```
|
||||||
|
|
||||||
|
## URLs
|
||||||
|
|
||||||
|
| Surface | URL |
|
||||||
|
|---------|-----|
|
||||||
|
| Traefik | `https://opencode.stillwell.cloud` (Authentik + basic auth) |
|
||||||
|
| Worker API | `http://opencode:4096` on the `echodo` Docker network |
|
||||||
|
| LAN health | `curl -u opencode:$OPENCODE_SERVER_PASSWORD http://192.168.68.116:4096/global/health` |
|
||||||
|
|
||||||
|
## Auth
|
||||||
|
|
||||||
|
- Browser: Authentik via Traefik (`authentik-auth`) **and** OpenCode basic auth.
|
||||||
|
- Worker: same basic auth via `OPENCODE_SERVER_PASSWORD`.
|
||||||
|
- Models: `@omniroute/opencode-plugin` + `OMNIROUTE_API_KEY` (seeded into `auth.json` by `entrypoint.sh`).
|
||||||
|
- Git: `FORGEJO_TOKEN` → HTTPS credential helper for `forgejo.stillwell.cloud`.
|
||||||
|
|
||||||
|
## RAM
|
||||||
|
|
||||||
|
Container capped at 3 GB (Next.js builds). Keep one concurrent agent until host RAM → 64 GB.
|
||||||
49
docker/opencode/entrypoint.sh
Normal file
49
docker/opencode/entrypoint.sh
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
#!/bin/sh
|
||||||
|
# Seed OmniRoute auth for headless OpenCode, then start serve/web.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
CONFIG_HOME="${XDG_CONFIG_HOME:-${HOME:-/data}/.config}"
|
||||||
|
DATA_HOME="${XDG_DATA_HOME:-${HOME:-/data}/.local/share}"
|
||||||
|
mkdir -p "$CONFIG_HOME/opencode" "$DATA_HOME/opencode" /data/bin "$HOME"
|
||||||
|
|
||||||
|
# Container has no desktop — stub xdg-open so `opencode web` does not exit.
|
||||||
|
if [ ! -x /data/bin/xdg-open ]; then
|
||||||
|
printf '#!/bin/sh\nexit 0\n' > /data/bin/xdg-open
|
||||||
|
chmod +x /data/bin/xdg-open
|
||||||
|
fi
|
||||||
|
export PATH="/data/bin:${PATH}"
|
||||||
|
|
||||||
|
if [ -f /config/opencode.json ]; then
|
||||||
|
cp /config/opencode.json "$CONFIG_HOME/opencode/opencode.json"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "${OMNIROUTE_API_KEY:-}" ]; then
|
||||||
|
# Keys must not contain double-quotes; OmniRoute keys are hex/base64-safe.
|
||||||
|
printf '{"omniroute":{"type":"api","key":"%s"}}\n' "$OMNIROUTE_API_KEY" \
|
||||||
|
> "$DATA_HOME/opencode/auth.json"
|
||||||
|
chmod 600 "$DATA_HOME/opencode/auth.json" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Git identity + Forgejo HTTPS auth for clone/push from agent sessions.
|
||||||
|
if command -v git >/dev/null 2>&1; then
|
||||||
|
git config --global --add safe.directory '*'
|
||||||
|
if [ -n "${GIT_AUTHOR_NAME:-}" ]; then
|
||||||
|
git config --global user.name "$GIT_AUTHOR_NAME"
|
||||||
|
else
|
||||||
|
git config --global user.name "${GIT_USER_NAME:-opencode}"
|
||||||
|
fi
|
||||||
|
if [ -n "${GIT_AUTHOR_EMAIL:-}" ]; then
|
||||||
|
git config --global user.email "$GIT_AUTHOR_EMAIL"
|
||||||
|
else
|
||||||
|
git config --global user.email "${GIT_USER_EMAIL:-opencode@stillwell.cloud}"
|
||||||
|
fi
|
||||||
|
if [ -n "${FORGEJO_TOKEN:-}" ]; then
|
||||||
|
git config --global credential.helper store
|
||||||
|
# oauth2:<token> is Forgejo's HTTPS token username convention.
|
||||||
|
printf 'https://oauth2:%s@forgejo.stillwell.cloud\n' "$FORGEJO_TOKEN" \
|
||||||
|
> "$HOME/.git-credentials"
|
||||||
|
chmod 600 "$HOME/.git-credentials"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
16
docker/opencode/opencode.json
Normal file
16
docker/opencode/opencode.json
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/config.json",
|
||||||
|
"plugin": [
|
||||||
|
[
|
||||||
|
"@omniroute/opencode-plugin",
|
||||||
|
{
|
||||||
|
"providerId": "omniroute",
|
||||||
|
"baseURL": "http://192.168.68.108:20128"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"server": {
|
||||||
|
"port": 4096,
|
||||||
|
"hostname": "0.0.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,15 +1,23 @@
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -eu
|
# When a remote harness shares a workspace volume, seed it from the deploy
|
||||||
|
# snapshot baked into the image (/app/repo).
|
||||||
|
set -u
|
||||||
|
|
||||||
# When OpenHands is enabled, seed the shared workspace volume from the deploy
|
session_type="${WORKER_SESSION_TYPE:-minimal}"
|
||||||
# snapshot baked into the image (/app/repo). Agent-server reads the same path.
|
if [ "$session_type" = "openhands" ] || [ "$session_type" = "opencode" ]; then
|
||||||
if [ "${WORKER_SESSION_TYPE:-minimal}" = "openhands" ]; then
|
if [ "$session_type" = "opencode" ]; then
|
||||||
target="${WORKER_OPENHANDS_WORKSPACE:-/workspace/repo}"
|
target="${WORKER_OPENCODE_WORKSPACE:-/workspace/repo}"
|
||||||
mkdir -p "$(dirname "$target")"
|
else
|
||||||
|
target="${WORKER_OPENHANDS_WORKSPACE:-/workspace/repo}"
|
||||||
|
fi
|
||||||
if [ ! -f "$target/pnpm-workspace.yaml" ]; then
|
if [ ! -f "$target/pnpm-workspace.yaml" ]; then
|
||||||
echo "[worker-entrypoint] seeding OpenHands workspace at ${target}"
|
echo "[worker-entrypoint] seeding ${session_type} workspace at ${target}"
|
||||||
mkdir -p "$target"
|
mkdir -p "$target" || true
|
||||||
cp -a /app/repo/. "$target/"
|
# Volume may be root-owned from an earlier helper container — best-effort seed.
|
||||||
|
if ! cp -a /app/repo/. "$target/" 2>/tmp/worker-seed.err; then
|
||||||
|
echo "[worker-entrypoint] WARN: workspace seed failed (continuing with /app/repo):"
|
||||||
|
cat /tmp/worker-seed.err 2>/dev/null || true
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
|
||||||
71
docs/Current Progress.md
Normal file
71
docs/Current Progress.md
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
# Workspace Chat — Current Progress
|
||||||
|
|
||||||
|
Updated: 2026-10-30 (dedicated Chat hostname migration pending rebuild).
|
||||||
|
|
||||||
|
## Hostname correction — pending deployment
|
||||||
|
|
||||||
|
`chat.stillwell.cloud` belongs to OpenMausBot on CT 117, not Workspace Chat. The operator reports routing to that app has been restored and `echodo-chat.stillwell.cloud` is already an alias on the Workspace Chat Traefik rule.
|
||||||
|
|
||||||
|
The Workspace Chat endpoint is now **`wss://echodo-chat.stillwell.cloud`** (socket path `/chat`). Set `NEXT_PUBLIC_CHAT_SERVER_URL` to that value in CT 116's `/opt/submatter/.env`, rebuild the web image, and recreate web as documented in `docs/deployment-production.md`. Repo examples now use only the dedicated hostname. Deployment and browser verification of the new endpoint are not yet confirmed; a push alone will not update the host's `.env`.
|
||||||
|
|
||||||
|
## Previous go-live evidence (before hostname correction)
|
||||||
|
|
||||||
|
The following records the previous deployment, not verification of the new hostname. Operator reported **14/14 acceptance checks passed**. Operator-confirmed CT 102 `echodo` ledger: **24 entries through `0023`**, with required columns/table verified and all `chat_*` tables owned by `echodo`. An earlier copy of this document said `0022–0024`; no `0024` migration exists in this checkout, so that statement is not treated as verified.
|
||||||
|
|
||||||
|
The deployment evidence below was supplied by the operator and is present in this workspace copy. It was not independently rerun in this reconciliation session; read-only access through PVE stopped at unknown SSH host-key verification.
|
||||||
|
|
||||||
|
## Deployed
|
||||||
|
|
||||||
|
- `CHAT_TICKET_SECRET` generated on CT 116 (≥32), shared by web + collab (sha256 match). Not logged.
|
||||||
|
- `CHAT_PORT=1235`; collab dual listeners 1234 (Hocuspocus) + 1235 (Chat).
|
||||||
|
- Traefik `echodo-chat` → `192.168.68.116:1235` for `chat.echodo.stillwell.cloud` and `chat.stillwell.cloud`.
|
||||||
|
- Public WSS URL baked into web: `wss://chat.stillwell.cloud` (nested `*.echodo.stillwell.cloud` Universal SSL fails publicly).
|
||||||
|
- Compose validated; web/collab/worker rebuilt/recreated; opencode-v2 preserved.
|
||||||
|
|
||||||
|
## Runtime fixes applied during go-live
|
||||||
|
|
||||||
|
1. Ticket Origin check used `request.url` origin (`https://0.0.0.0:3000`) behind Docker — now prefers `x-forwarded-host` / `host` + `x-forwarded-proto`.
|
||||||
|
2. `channel-drizzle-adapter` `row()` spread full DB columns into strict lifecycle receipt schema — project ChannelRecord fields only.
|
||||||
|
3. `redactCreatedOutbox` `jsonb_build_object(...)` params caused `could not determine data type of parameter $1` — set plain JSON payload instead.
|
||||||
|
4. Empty `AUTH_DEV_PASSWORD` blocked credentials acceptance — set on CT 116 (not logged).
|
||||||
|
|
||||||
|
Source under `/opt/submatter` (+ opencode workspace copy) updated for (1–3). Image chunks patched in place for immediate runtime; rebuild web on next deploy to bake permanently.
|
||||||
|
|
||||||
|
## Source reconciliation (2026-10-29)
|
||||||
|
|
||||||
|
- Channel projection and plain-JSON redaction fixes were already present locally; preserved and covered with adapter regression tests.
|
||||||
|
- Local ticket handling now pins the expected origin to configured `AUTH_URL` (or `NEXTAUTH_URL`), not caller-supplied forwarded headers. Production fails closed without a valid configured URL. This is a hardened replacement for the deployed forwarded-header hot patch, not a byte-for-byte copy. Deploy and retest ticket issuance before considering this replacement live.
|
||||||
|
- Canonical browser endpoint is `wss://chat.stillwell.cloud` (socket path `/chat`). The Traefik example retains both hostname routes.
|
||||||
|
- Next deploy must rebuild web from reconciled source; image hot patches are not durable. Re-run ticket issuance, cross-origin rejection and the acceptance suite afterward. No production changes, rebuild, commit or push were performed during this reconciliation.
|
||||||
|
- **Security follow-up:** `AUTH_DEV_PASSWORD` is a shared credentials password for existing user emails, not a per-user password. Remove it after acceptance unless the operator explicitly intends this login mechanism; verify SSO access first and recreate web when removing the environment value. Awaiting operator decision; not changed remotely.
|
||||||
|
- Remaining release gates: full local checks, disposable-Postgres coverage, final review and convoy lifecycle reconciliation. The reported live suite does not prove multi-instance fan-out or Redis outage behavior.
|
||||||
|
|
||||||
|
## Main deployment preparation
|
||||||
|
|
||||||
|
The operator approved sending the reconciled branch to `main` for real-build testing. The branch includes the earlier foundation commit and its AI-shell/backlog changes. The main push triggers `.forgejo/workflows/deploy-homelab.yml` when the runner and deployment secrets are configured; a push alone does not prove deployment success. The deploy script no longer removes orphan containers, preserving separately managed services such as `opencode-v2`. Six static deployment checks cover the configuration and this safety guard.
|
||||||
|
|
||||||
|
## Local validation after reconciliation
|
||||||
|
|
||||||
|
- `pnpm lint`: passed with existing warnings.
|
||||||
|
- `pnpm type-check --concurrency=1`: passed.
|
||||||
|
- `pnpm test`: ticket-origin regressions (11), channel adapter tests (10) and outbox adapter test (1) passed. Full suite remains blocked by the worker remote-AG-UI pending-response polling test timing out after 5000 ms. PostgreSQL-gated tests remain skipped without a disposable test database.
|
||||||
|
- `pnpm build --concurrency=1`: passed with a longer command allowance after the first attempt timed out during build tracing. This is a local build, not a Docker image rebuild or deployment.
|
||||||
|
- `git diff --check`: passed.
|
||||||
|
- Security review of this slice: ticket origin is pinned to server configuration; forwarded-header spoofing, missing/malformed origins and workspace denial are covered. Redaction remains workspace/channel/message/create-event scoped. Production shared-password sign-in remains an operational security concern until explicitly resolved. No secrets were read or printed.
|
||||||
|
|
||||||
|
## Acceptance evidence (2026-10-29)
|
||||||
|
|
||||||
|
Two signed-in members (`me@randallstillwell.com` + `chat-acceptance-b@stillwell.local`) against workspace `randall-stillwell`:
|
||||||
|
|
||||||
|
| Gate | Result |
|
||||||
|
|------|--------|
|
||||||
|
| Secret shared web/collab | Pass |
|
||||||
|
| Compose validate + ports | Pass |
|
||||||
|
| Public WSS reject without ticket | Pass (connection fails) |
|
||||||
|
| Dual WS subscribe via `wss://chat.stillwell.cloud/chat` | Pass |
|
||||||
|
| Send + Redis invalidate hint | Pass |
|
||||||
|
| Reply | Pass |
|
||||||
|
| Reconnect catch-up | Pass |
|
||||||
|
| Unread mark + summary | Pass |
|
||||||
|
| Edit / delete + mutation catch-up | Pass (after redact fix) |
|
||||||
|
| Membership revocation (hint + history denied) | Pass |
|
||||||
|
|
@ -34,7 +34,7 @@ External: Postgres/Redis on **CT 102**, TLS on **CT 100**, OmniRoute on **CT 108
|
||||||
| File | Use |
|
| File | Use |
|
||||||
|------|-----|
|
|------|-----|
|
||||||
| `docker/docker-compose.prod.yml` | Core stack |
|
| `docker/docker-compose.prod.yml` | Core stack |
|
||||||
| `docker/docker-compose.prod.standalone.yml` | Publish `:3000` / `:1234` for CT 100 Traefik |
|
| `docker/docker-compose.prod.standalone.yml` | Publish `:3000` / `:1234` / `:1235` for CT 100 Traefik |
|
||||||
| `scripts/build-prod.sh` | Serialized image build |
|
| `scripts/build-prod.sh` | Serialized image build |
|
||||||
| `scripts/deploy-prod.sh` | Build + up + health wait |
|
| `scripts/deploy-prod.sh` | Build + up + health wait |
|
||||||
|
|
||||||
|
|
@ -65,6 +65,9 @@ docker compose -f docker/docker-compose.prod.yml \
|
||||||
| `AUTH_AUTHENTIK_*` | | Authentik OIDC app `echodo` |
|
| `AUTH_AUTHENTIK_*` | | Authentik OIDC app `echodo` |
|
||||||
| `COLLAB_SERVER_URL` | `wss://collab.echodo.stillwell.cloud` | Server-side |
|
| `COLLAB_SERVER_URL` | `wss://collab.echodo.stillwell.cloud` | Server-side |
|
||||||
| `NEXT_PUBLIC_COLLAB_SERVER_URL` | `wss://collab.echodo.stillwell.cloud` | **Build arg** — requires image rebuild |
|
| `NEXT_PUBLIC_COLLAB_SERVER_URL` | `wss://collab.echodo.stillwell.cloud` | **Build arg** — requires image rebuild |
|
||||||
|
| `NEXT_PUBLIC_CHAT_SERVER_URL` | `wss://echodo-chat.stillwell.cloud` | **Build arg** — requires web image rebuild; provision DNS/TLS first |
|
||||||
|
| `CHAT_TICKET_SECRET` | Generate with `openssl rand -base64 32` | Same runtime secret for web and collab; at least 32 characters; never a build arg |
|
||||||
|
| `CHAT_PORT` | `1235` | Second listener inside collab; cannot equal Hocuspocus port `1234` |
|
||||||
| `OMNIROUTE_BASE_URL` | `https://omniroute.stillwell.cloud/v1` | Web + worker |
|
| `OMNIROUTE_BASE_URL` | `https://omniroute.stillwell.cloud/v1` | Web + worker |
|
||||||
| `OMNIROUTE_API_KEY` | (your key) | + register in Settings → AI → Providers |
|
| `OMNIROUTE_API_KEY` | (your key) | + register in Settings → AI → Providers |
|
||||||
| `FORGEJO_TOKEN` / `GITHUB_TOKEN` | PAT values | Referenced by name in Connected repos settings |
|
| `FORGEJO_TOKEN` / `GITHUB_TOKEN` | PAT values | Referenced by name in Connected repos settings |
|
||||||
|
|
@ -74,6 +77,42 @@ Changing any `NEXT_PUBLIC_*` value requires `bash scripts/deploy-prod.sh build`,
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Workspace Chat integration gate
|
||||||
|
|
||||||
|
Chat runs inside the existing `collab` service on a **separate listener** from Hocuspocus. The worker publishes metadata-only Redis hints; Postgres history and mutation catch-up remain authoritative. Team Chat does not invoke AI agents, and object contexts are not enabled by this deployment.
|
||||||
|
|
||||||
|
1. Set the Chat variables above in `/opt/submatter/.env`. Keep the signing secret server-side; Compose requires a nonempty value, and the application requires at least 32 characters. A missing/short secret disables the listener and makes the dual-listener health check fail.
|
||||||
|
2. Provision `echodo-chat.stillwell.cloud` DNS and TLS on CT 100 (canonical browser endpoint). `chat.stillwell.cloud` belongs to another app: never include it in the Workspace Chat router. Apply the `echodo-chat` router/service from [`traefik-ct100.example.yml`](../proxmox/ct116/traefik-ct100.example.yml). Traefik supports WebSocket upgrades without a separate service process. If `CHAT_PORT` changes, update the proxy backend port too. Restrict host backend ports to the trusted proxy network.
|
||||||
|
|
||||||
|
For the hostname migration, set `NEXT_PUBLIC_CHAT_SERVER_URL=wss://echodo-chat.stillwell.cloud` in CT 116's `/opt/submatter/.env`, then run from `/opt/submatter`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --project-directory /opt/submatter -f docker/docker-compose.prod.yml -f docker/docker-compose.prod.standalone.yml build web
|
||||||
|
docker compose --project-directory /opt/submatter -f docker/docker-compose.prod.yml -f docker/docker-compose.prod.standalone.yml up -d --no-deps web
|
||||||
|
```
|
||||||
|
|
||||||
|
A git push or container restart alone does not change the baked browser URL. Test Workspace Chat inside `https://echodo.stillwell.cloud`; its WebSocket should connect to `wss://echodo-chat.stillwell.cloud/chat`. The WSS endpoint is not a separate chat UI.
|
||||||
|
3. Before rolling out, verify the database target is CT 102 **`echodo`** and inspect `drizzle.__drizzle_migrations`. The operator reports 24 ledger entries through `0023`, verified Chat schema, and ownership of all `chat_*` tables transferred to `echodo`. Do not reapply these migrations; inspect the ledger for any future pending entries. Compare the ledger with `packages/database/migrations/meta/_journal.json`; apply only pending journaled migrations using `pnpm --filter @tasks/database db:migrate` in a checkout with the real `DATABASE_URL` exported securely. Do not print the URL, use `db:push`, or replay historical migrations manually. Take a backup first.
|
||||||
|
4. Verify `0022` added `chat_messages.send_intent_fingerprint` and positive `revision`; verify `0023` added `chat_message_mutations`, its tenant/channel/message foreign keys and indexes, and `chat_channels.next_mutation_sequence`. Do not infer applied schema from TypeScript files. The historical `0017` correction is already local; do not replay it on an initialized database.
|
||||||
|
5. Validate Compose on the deployment host without printing resolved secrets:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --project-directory /opt/submatter --env-file /opt/submatter/.env \
|
||||||
|
-f docker/docker-compose.prod.yml -f docker/docker-compose.prod.standalone.yml config --quiet
|
||||||
|
bash scripts/deploy-prod.sh build
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Confirm both collab listeners and worker health. The TCP health check verifies listener liveness only, **not** Redis delivery, database authorization, or end-to-end readiness. An ordinary HTTP request to the Chat listener returns 404; use an authenticated browser WebSocket session for acceptance.
|
||||||
|
7. Test two signed-in workspace members: send/reply/edit/delete, unread acknowledgement, reconnect catch-up, offline edits/deletes, and duplicate retries. Revoke private/direct membership while connected and verify subsequent hints/history are denied and cached content is cleared; admins must not bypass membership. Verify another workspace cannot obtain history or subscribe. Restart collab/worker and test Redis outage recovery without message loss.
|
||||||
|
|
||||||
|
Run PostgreSQL integration tests only against a disposable test database, not production. DNS, TLS, migrations, Redis/WebSocket delivery, and two-browser acceptance are independent release gates; passing unit tests does not satisfy them. See [Current Progress](Current%20Progress.md) for the current evidence and blockers.
|
||||||
|
|
||||||
|
### Post-go-live reconciliation
|
||||||
|
|
||||||
|
The operator reports Chat live with 14/14 acceptance checks; see [Current Progress](Current%20Progress.md) for provenance and scope. Rebuild web on the next deploy to replace hot-patched image chunks with source-built code. The reconciled ticket route uses `AUTH_URL` or `NEXTAUTH_URL` as its trusted public origin; set it to the HTTPS **web app** origin, not the WSS endpoint. It ignores forwarded headers for this decision and fails closed without valid production configuration. Restrict direct backend access to the proxy network regardless.
|
||||||
|
|
||||||
|
`AUTH_DEV_PASSWORD` was enabled for acceptance. This shared password can authenticate existing email identities; it is not individual-user password authentication. Confirm SSO works, then remove it and recreate web if it was temporary. Do not leave this enabled solely because acceptance needed it. Re-run ticket origin/authentication and live Chat acceptance after the source-built deployment.
|
||||||
|
|
||||||
## Auto-deploy on push
|
## Auto-deploy on push
|
||||||
|
|
||||||
**CI** (every PR): `.github/workflows/ci.yml` — lint, type-check, test on GitHub-hosted or PR runner.
|
**CI** (every PR): `.github/workflows/ci.yml` — lint, type-check, test on GitHub-hosted or PR runner.
|
||||||
|
|
@ -117,7 +156,26 @@ If Forgejo is canonical git and you want push deploy without Actions:
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase B — OpenHands (optional)
|
## Phase B — OpenCode (preferred)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export COMPOSE_PROFILES=opencode
|
||||||
|
# Set in .env: WORKER_SESSION_TYPE=opencode, OPENCODE_SERVER_PASSWORD, OMNIROUTE_*
|
||||||
|
bash scripts/deploy-prod.sh build # first time / worker code change
|
||||||
|
# or: bash scripts/deploy-prod.sh up
|
||||||
|
```
|
||||||
|
|
||||||
|
| Variable | Notes |
|
||||||
|
|----------|-------|
|
||||||
|
| `OPENCODE_SERVER_PASSWORD` | `openssl rand -hex 24` — Traefik still fronts Authentik |
|
||||||
|
| `WORKER_SESSION_TYPE` | `opencode` |
|
||||||
|
| `OPENCODE_SERVER_URL` | `http://opencode:4096` (Docker DNS) |
|
||||||
|
| `OMNIROUTE_BASE_URL` | `https://omniroute.stillwell.cloud/v1` or LAN `http://192.168.68.108:20128/v1` |
|
||||||
|
| `OMNIROUTE_API_KEY` | OmniRoute bearer key |
|
||||||
|
|
||||||
|
Web UI: `https://opencode.stillwell.cloud`. Details: `docker/opencode/README.md`.
|
||||||
|
|
||||||
|
### Legacy — OpenHands (optional)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export COMPOSE_PROFILES=agent-server
|
export COMPOSE_PROFILES=agent-server
|
||||||
|
|
@ -129,11 +187,8 @@ bash scripts/deploy-prod.sh build
|
||||||
| `LOCAL_BACKEND_API_KEY` | `openssl rand -hex 32` |
|
| `LOCAL_BACKEND_API_KEY` | `openssl rand -hex 32` |
|
||||||
| `SESSION_API_KEY` | Same as above (worker) |
|
| `SESSION_API_KEY` | Same as above (worker) |
|
||||||
| `WORKER_SESSION_TYPE` | `openhands` |
|
| `WORKER_SESSION_TYPE` | `openhands` |
|
||||||
| `OMNIROUTE_BASE_URL` | `https://omniroute.stillwell.cloud/v1` |
|
|
||||||
| `OMNIROUTE_API_KEY` | OmniRoute bearer key |
|
|
||||||
|
|
||||||
Requires Docker socket on CT 116 (`nesting=1` on LXC).
|
|
||||||
|
|
||||||
|
Requires Docker socket on CT 116 (`nesting=1` on LXC). Prefer OpenCode unless debugging the old path.
|
||||||
---
|
---
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
"dev": "turbo dev",
|
"dev": "turbo dev",
|
||||||
"lint": "turbo lint",
|
"lint": "turbo lint",
|
||||||
"type-check": "turbo type-check",
|
"type-check": "turbo type-check",
|
||||||
"test": "turbo test",
|
"test": "node --test scripts/chat-deployment.test.mjs && turbo test",
|
||||||
"db:generate": "turbo db:generate --filter=@tasks/database",
|
"db:generate": "turbo db:generate --filter=@tasks/database",
|
||||||
"db:migrate": "turbo db:migrate --filter=@tasks/database",
|
"db:migrate": "turbo db:migrate --filter=@tasks/database",
|
||||||
"db:push": "turbo db:push --filter=@tasks/database",
|
"db:push": "turbo db:push --filter=@tasks/database",
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,3 @@
|
||||||
CREATE TABLE "convoy_operator_messages" (
|
|
||||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
||||||
"workspace_id" uuid NOT NULL,
|
|
||||||
"convoy_id" uuid NOT NULL,
|
|
||||||
"brief_id" uuid,
|
|
||||||
"agent_run_id" uuid,
|
|
||||||
"delivery" varchar(20) NOT NULL,
|
|
||||||
"body" text NOT NULL,
|
|
||||||
"author_user_id" uuid NOT NULL,
|
|
||||||
"delivered_at" timestamp with time zone,
|
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "completion_artifacts" (
|
CREATE TABLE "completion_artifacts" (
|
||||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
"workspace_id" uuid NOT NULL,
|
"workspace_id" uuid NOT NULL,
|
||||||
|
|
@ -21,15 +8,9 @@ CREATE TABLE "completion_artifacts" (
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
ALTER TABLE "convoy_operator_messages" ADD CONSTRAINT "convoy_operator_messages_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "convoy_operator_messages" ADD CONSTRAINT "convoy_operator_messages_convoy_id_objects_id_fk" FOREIGN KEY ("convoy_id") REFERENCES "public"."objects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "convoy_operator_messages" ADD CONSTRAINT "convoy_operator_messages_brief_id_objects_id_fk" FOREIGN KEY ("brief_id") REFERENCES "public"."objects"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "convoy_operator_messages" ADD CONSTRAINT "convoy_operator_messages_agent_run_id_agent_runs_id_fk" FOREIGN KEY ("agent_run_id") REFERENCES "public"."agent_runs"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "convoy_operator_messages" ADD CONSTRAINT "convoy_operator_messages_author_user_id_users_id_fk" FOREIGN KEY ("author_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "completion_artifacts" ADD CONSTRAINT "completion_artifacts_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
ALTER TABLE "completion_artifacts" ADD CONSTRAINT "completion_artifacts_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
ALTER TABLE "completion_artifacts" ADD CONSTRAINT "completion_artifacts_convoy_id_objects_id_fk" FOREIGN KEY ("convoy_id") REFERENCES "public"."objects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
ALTER TABLE "completion_artifacts" ADD CONSTRAINT "completion_artifacts_convoy_id_objects_id_fk" FOREIGN KEY ("convoy_id") REFERENCES "public"."objects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
ALTER TABLE "completion_artifacts" ADD CONSTRAINT "completion_artifacts_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
ALTER TABLE "completion_artifacts" ADD CONSTRAINT "completion_artifacts_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
CREATE INDEX "convoy_operator_messages_workspace_id_convoy_id_delivered_at_idx" ON "convoy_operator_messages" USING btree ("workspace_id","convoy_id","delivered_at");--> statement-breakpoint
|
|
||||||
CREATE INDEX "completion_artifacts_workspace_id_idx" ON "completion_artifacts" USING btree ("workspace_id");--> statement-breakpoint
|
CREATE INDEX "completion_artifacts_workspace_id_idx" ON "completion_artifacts" USING btree ("workspace_id");--> statement-breakpoint
|
||||||
CREATE INDEX "completion_artifacts_convoy_id_idx" ON "completion_artifacts" USING btree ("convoy_id");--> statement-breakpoint
|
CREATE INDEX "completion_artifacts_convoy_id_idx" ON "completion_artifacts" USING btree ("convoy_id");--> statement-breakpoint
|
||||||
CREATE INDEX "completion_artifacts_created_at_idx" ON "completion_artifacts" USING btree ("created_at");
|
CREATE INDEX "completion_artifacts_created_at_idx" ON "completion_artifacts" USING btree ("created_at");
|
||||||
26
packages/database/migrations/0018_early_risque.sql
Normal file
26
packages/database/migrations/0018_early_risque.sql
Normal 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);
|
||||||
109
packages/database/migrations/0019_petite_monster_badoon.sql
Normal file
109
packages/database/migrations/0019_petite_monster_badoon.sql
Normal 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");
|
||||||
19
packages/database/migrations/0020_demonic_hammerhead.sql
Normal file
19
packages/database/migrations/0020_demonic_hammerhead.sql
Normal 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;
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
ALTER TABLE "chat_messages" ADD COLUMN "send_intent_fingerprint" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "chat_messages" ADD COLUMN "revision" integer DEFAULT 1 NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "chat_messages" ADD CONSTRAINT "chat_messages_revision_check" CHECK ("chat_messages"."revision" > 0);
|
||||||
18
packages/database/migrations/0023_thick_shinko_yamashiro.sql
Normal file
18
packages/database/migrations/0023_thick_shinko_yamashiro.sql
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
CREATE TABLE "chat_message_mutations" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"workspace_id" uuid NOT NULL,
|
||||||
|
"channel_id" uuid NOT NULL,
|
||||||
|
"message_id" uuid NOT NULL,
|
||||||
|
"mutation_sequence" bigint NOT NULL,
|
||||||
|
"revision" integer NOT NULL,
|
||||||
|
"kind" varchar(16) NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "chat_message_mutations_channel_sequence_unique" UNIQUE("workspace_id","channel_id","mutation_sequence"),
|
||||||
|
CONSTRAINT "chat_message_mutations_revision_check" CHECK ("chat_message_mutations"."revision" > 1)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "chat_channels" ADD COLUMN "next_mutation_sequence" bigint DEFAULT 1 NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "chat_message_mutations" ADD CONSTRAINT "chat_message_mutations_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "chat_message_mutations" ADD CONSTRAINT "chat_message_mutations_channel_fk" FOREIGN KEY ("workspace_id","channel_id") REFERENCES "public"."chat_channels"("workspace_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "chat_message_mutations" ADD CONSTRAINT "chat_message_mutations_message_fk" FOREIGN KEY ("workspace_id","message_id") REFERENCES "public"."chat_messages"("workspace_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "chat_message_mutations_workspace_id_idx" ON "chat_message_mutations" USING btree ("workspace_id");
|
||||||
4782
packages/database/migrations/meta/0018_snapshot.json
Normal file
4782
packages/database/migrations/meta/0018_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
5683
packages/database/migrations/meta/0019_snapshot.json
Normal file
5683
packages/database/migrations/meta/0019_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
5814
packages/database/migrations/meta/0020_snapshot.json
Normal file
5814
packages/database/migrations/meta/0020_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
5831
packages/database/migrations/meta/0022_snapshot.json
Normal file
5831
packages/database/migrations/meta/0022_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
5976
packages/database/migrations/meta/0023_snapshot.json
Normal file
5976
packages/database/migrations/meta/0023_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -127,6 +127,48 @@
|
||||||
"when": 1787698170280,
|
"when": 1787698170280,
|
||||||
"tag": "0017_lively_squadron_supreme",
|
"tag": "0017_lively_squadron_supreme",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 18,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1792737772691,
|
||||||
|
"tag": "0018_early_risque",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 19,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1792763738904,
|
||||||
|
"tag": "0019_petite_monster_badoon",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 20,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1792766022303,
|
||||||
|
"tag": "0020_demonic_hammerhead",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 21,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1792766022304,
|
||||||
|
"tag": "0021_chat_direct_membership_immutability",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 22,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1793053728379,
|
||||||
|
"tag": "0022_skinny_masked_marvel",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 23,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1793053797739,
|
||||||
|
"tag": "0023_thick_shinko_yamashiro",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
"./schema": "./src/schema/index.ts",
|
"./schema": "./src/schema/index.ts",
|
||||||
"./client": "./src/client.ts",
|
"./client": "./src/client.ts",
|
||||||
|
"./chat": "./src/chat/index.ts",
|
||||||
"./markdown-backlog": "./src/markdown-backlog/index.ts",
|
"./markdown-backlog": "./src/markdown-backlog/index.ts",
|
||||||
"./model-routing": "./src/model-routing/index.ts"
|
"./model-routing": "./src/model-routing/index.ts"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
206
packages/database/src/chat/channel-drizzle-adapter.test.ts
Normal file
206
packages/database/src/chat/channel-drizzle-adapter.test.ts
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { createChannelTransactionRunner } from "./channel-drizzle-adapter";
|
||||||
|
|
||||||
|
type ChannelDatabase = NonNullable<
|
||||||
|
Parameters<typeof createChannelTransactionRunner>[0]
|
||||||
|
>;
|
||||||
|
type DatabaseTransaction = Parameters<
|
||||||
|
Parameters<ChannelDatabase["transaction"]>[0]
|
||||||
|
>[0];
|
||||||
|
type TransactionCallback = (tx: DatabaseTransaction) => Promise<unknown>;
|
||||||
|
|
||||||
|
function fakeDatabase(
|
||||||
|
transaction: (callback: TransactionCallback) => Promise<unknown>,
|
||||||
|
): ChannelDatabase {
|
||||||
|
return { transaction } as unknown as ChannelDatabase;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fakeTx = {} as DatabaseTransaction;
|
||||||
|
|
||||||
|
function pgError(code: string): Error & { code: string } {
|
||||||
|
return Object.assign(new Error(`PostgreSQL ${code}`), { code });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("createChannelTransactionRunner", () => {
|
||||||
|
it("projects only ChannelRecord fields from database rows for strict receipts", async () => {
|
||||||
|
const expected = {
|
||||||
|
id: "10000000-0000-4000-8000-000000000001",
|
||||||
|
workspaceId: "20000000-0000-4000-8000-000000000001",
|
||||||
|
kind: "public",
|
||||||
|
name: "general",
|
||||||
|
ownerUserId: "30000000-0000-4000-8000-000000000001",
|
||||||
|
directKey: null,
|
||||||
|
archivedAt: null,
|
||||||
|
};
|
||||||
|
const records = [
|
||||||
|
{
|
||||||
|
...expected,
|
||||||
|
nextSequence: 42n,
|
||||||
|
nextMutationSequence: 9n,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const tx = {
|
||||||
|
select: () => ({
|
||||||
|
from: () => ({
|
||||||
|
where: () => ({
|
||||||
|
for: async () => records,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
} as unknown as DatabaseTransaction;
|
||||||
|
const result = await createChannelTransactionRunner(
|
||||||
|
fakeDatabase(async (run) => run(tx)),
|
||||||
|
)((adapter) => adapter.lockChannel(expected.workspaceId, expected.id));
|
||||||
|
expect(result).toEqual(expected);
|
||||||
|
expect(() => JSON.stringify(result)).not.toThrow();
|
||||||
|
});
|
||||||
|
it.each(["40001", "40P01"])(
|
||||||
|
"replays the callback in a fresh transaction after %s",
|
||||||
|
async (code) => {
|
||||||
|
const transaction = vi.fn(async (callback: TransactionCallback) =>
|
||||||
|
callback(fakeTx),
|
||||||
|
);
|
||||||
|
const run = vi.fn(async () => {
|
||||||
|
if (run.mock.calls.length === 1) throw pgError(code);
|
||||||
|
return "committed";
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
createChannelTransactionRunner(fakeDatabase(transaction))(run),
|
||||||
|
).resolves.toBe("committed");
|
||||||
|
expect(transaction).toHaveBeenCalledTimes(2);
|
||||||
|
expect(run).toHaveBeenCalledTimes(2);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("replays the callback when a wrapped deadlock rejects after the callback returns", async () => {
|
||||||
|
const transaction = vi.fn(async (callback: TransactionCallback) => {
|
||||||
|
const result = await callback(fakeTx);
|
||||||
|
if (transaction.mock.calls.length === 1) {
|
||||||
|
throw new Error("transaction failed", { cause: pgError("40P01") });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
const run = vi.fn(async () => "committed");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
createChannelTransactionRunner(fakeDatabase(transaction))(run),
|
||||||
|
).resolves.toBe("committed");
|
||||||
|
expect(transaction).toHaveBeenCalledTimes(2);
|
||||||
|
expect(run).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rethrows the original wrapped serialization error after three attempts", async () => {
|
||||||
|
const original = new Error("transaction failed", {
|
||||||
|
cause: new Error("driver failed", { cause: pgError("40001") }),
|
||||||
|
});
|
||||||
|
const transaction = vi.fn(async (callback: TransactionCallback) => {
|
||||||
|
await callback(fakeTx);
|
||||||
|
throw original;
|
||||||
|
});
|
||||||
|
const run = vi.fn(async () => "not committed");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
createChannelTransactionRunner(fakeDatabase(transaction))(run),
|
||||||
|
).rejects.toBe(original);
|
||||||
|
expect(transaction).toHaveBeenCalledTimes(3);
|
||||||
|
expect(run).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not retry unrelated failures", async () => {
|
||||||
|
const original = new Error("invalid input", { cause: pgError("23505") });
|
||||||
|
const transaction = vi.fn(async (callback: TransactionCallback) =>
|
||||||
|
callback(fakeTx),
|
||||||
|
);
|
||||||
|
const run = vi.fn(async () => {
|
||||||
|
throw original;
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
createChannelTransactionRunner(fakeDatabase(transaction))(run),
|
||||||
|
).rejects.toBe(original);
|
||||||
|
expect(transaction).toHaveBeenCalledTimes(1);
|
||||||
|
expect(run).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("channel membership persistence", () => {
|
||||||
|
it("does not mark a no-op insert as changed", async () => {
|
||||||
|
const returning = vi.fn().mockResolvedValue([]);
|
||||||
|
const tx = {
|
||||||
|
insert: vi.fn().mockReturnValue({
|
||||||
|
values: vi.fn().mockReturnValue({
|
||||||
|
onConflictDoNothing: vi.fn().mockReturnValue({ returning }),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const database = {
|
||||||
|
transaction: async (fn: (transaction: typeof tx) => Promise<boolean>) =>
|
||||||
|
fn(tx),
|
||||||
|
};
|
||||||
|
await expect(
|
||||||
|
createChannelTransactionRunner(
|
||||||
|
database as unknown as Parameters<
|
||||||
|
typeof createChannelTransactionRunner
|
||||||
|
>[0],
|
||||||
|
)((adapter) => adapter.addMember("workspace", "channel", "member")),
|
||||||
|
).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mark a no-op delete as changed", async () => {
|
||||||
|
const returning = vi.fn().mockResolvedValue([]);
|
||||||
|
const tx = {
|
||||||
|
delete: vi
|
||||||
|
.fn()
|
||||||
|
.mockReturnValue({ where: vi.fn().mockReturnValue({ returning }) }),
|
||||||
|
};
|
||||||
|
const database = {
|
||||||
|
transaction: async (fn: (transaction: typeof tx) => Promise<boolean>) =>
|
||||||
|
fn(tx),
|
||||||
|
};
|
||||||
|
await expect(
|
||||||
|
createChannelTransactionRunner(
|
||||||
|
database as unknown as Parameters<
|
||||||
|
typeof createChannelTransactionRunner
|
||||||
|
>[0],
|
||||||
|
)((adapter) => adapter.removeMember("workspace", "channel", "member")),
|
||||||
|
).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports inserted members using returned rows rather than driver command metadata", async () => {
|
||||||
|
const returning = vi.fn().mockResolvedValue([{ userId: "member" }]);
|
||||||
|
const onConflictDoNothing = vi.fn().mockReturnValue({ returning });
|
||||||
|
const values = vi.fn().mockReturnValue({ onConflictDoNothing });
|
||||||
|
const tx = { insert: vi.fn().mockReturnValue({ values }) };
|
||||||
|
const database = {
|
||||||
|
transaction: async (fn: (transaction: typeof tx) => Promise<boolean>) =>
|
||||||
|
fn(tx),
|
||||||
|
};
|
||||||
|
const changed = await createChannelTransactionRunner(
|
||||||
|
database as unknown as Parameters<
|
||||||
|
typeof createChannelTransactionRunner
|
||||||
|
>[0],
|
||||||
|
)((adapter) => adapter.addMember("workspace", "channel", "member"));
|
||||||
|
expect(changed).toBe(true);
|
||||||
|
expect(returning).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports deleted members using returned rows rather than driver command metadata", async () => {
|
||||||
|
const returning = vi.fn().mockResolvedValue([{ userId: "member" }]);
|
||||||
|
const where = vi.fn().mockReturnValue({ returning });
|
||||||
|
const tx = { delete: vi.fn().mockReturnValue({ where }) };
|
||||||
|
const database = {
|
||||||
|
transaction: async (fn: (transaction: typeof tx) => Promise<boolean>) =>
|
||||||
|
fn(tx),
|
||||||
|
};
|
||||||
|
const changed = await createChannelTransactionRunner(
|
||||||
|
database as unknown as Parameters<
|
||||||
|
typeof createChannelTransactionRunner
|
||||||
|
>[0],
|
||||||
|
)((adapter) => adapter.removeMember("workspace", "channel", "member"));
|
||||||
|
expect(changed).toBe(true);
|
||||||
|
expect(returning).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
348
packages/database/src/chat/channel-drizzle-adapter.ts
Normal file
348
packages/database/src/chat/channel-drizzle-adapter.ts
Normal file
|
|
@ -0,0 +1,348 @@
|
||||||
|
import { and, asc, eq, inArray, isNull, or, sql } from "drizzle-orm";
|
||||||
|
import { chatChannelLifecycleEventV1Schema } from "@tasks/shared";
|
||||||
|
import { db as defaultDb } from "../client";
|
||||||
|
import {
|
||||||
|
chatChannelMembers,
|
||||||
|
chatChannels,
|
||||||
|
chatMutationReceipts,
|
||||||
|
chatOutbox,
|
||||||
|
workspaceMembers,
|
||||||
|
} from "../schema";
|
||||||
|
import {
|
||||||
|
ChannelPersistenceError,
|
||||||
|
type ChannelRunner,
|
||||||
|
type ChannelTransaction,
|
||||||
|
} from "./manage-channels";
|
||||||
|
type Tx = Parameters<Parameters<typeof defaultDb.transaction>[0]>[0];
|
||||||
|
const row = (c: typeof chatChannels.$inferSelect) => ({
|
||||||
|
id: c.id,
|
||||||
|
workspaceId: c.workspaceId,
|
||||||
|
kind: c.kind,
|
||||||
|
name: c.name,
|
||||||
|
ownerUserId: c.ownerUserId,
|
||||||
|
directKey: c.directKey,
|
||||||
|
archivedAt: c.archivedAt,
|
||||||
|
});
|
||||||
|
function translate(error: unknown): never {
|
||||||
|
const record =
|
||||||
|
typeof error === "object" && error
|
||||||
|
? (error as { code?: string; constraint?: string })
|
||||||
|
: {};
|
||||||
|
if (
|
||||||
|
record.constraint === "chat_direct_membership_immutable" ||
|
||||||
|
record.constraint === "chat_direct_channel_identity_immutable"
|
||||||
|
)
|
||||||
|
throw new ChannelPersistenceError("READ_ONLY");
|
||||||
|
if (
|
||||||
|
record.constraint === "chat_direct_channel_transition_valid" ||
|
||||||
|
record.constraint === "chat_direct_channel_participants_valid"
|
||||||
|
)
|
||||||
|
throw new ChannelPersistenceError("INVALID");
|
||||||
|
if (
|
||||||
|
record.constraint ===
|
||||||
|
"chat_mutation_receipts_workspace_id_actor_user_id_client_mutation_id_pk" ||
|
||||||
|
record.constraint === "chat_channels_workspace_id_direct_key_unique"
|
||||||
|
)
|
||||||
|
throw new ChannelPersistenceError("CONFLICT");
|
||||||
|
if (
|
||||||
|
record.constraint === "chat_channel_members_workspace_member_fk" ||
|
||||||
|
record.constraint === "chat_channels_owner_member_fk"
|
||||||
|
)
|
||||||
|
throw new ChannelPersistenceError("NOT_FOUND");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
function adapter(tx: Tx): ChannelTransaction {
|
||||||
|
return {
|
||||||
|
async loadActor(workspaceId, userId) {
|
||||||
|
const [m] = await tx
|
||||||
|
.select({
|
||||||
|
userId: workspaceMembers.userId,
|
||||||
|
role: workspaceMembers.role,
|
||||||
|
})
|
||||||
|
.from(workspaceMembers)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(workspaceMembers.workspaceId, workspaceId),
|
||||||
|
eq(workspaceMembers.userId, userId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
return m ?? null;
|
||||||
|
},
|
||||||
|
async listVisible(workspaceId, actorUserId) {
|
||||||
|
return (
|
||||||
|
await tx
|
||||||
|
.selectDistinct({ channel: chatChannels })
|
||||||
|
.from(chatChannels)
|
||||||
|
.leftJoin(
|
||||||
|
chatChannelMembers,
|
||||||
|
and(
|
||||||
|
eq(chatChannelMembers.workspaceId, workspaceId),
|
||||||
|
eq(chatChannelMembers.channelId, chatChannels.id),
|
||||||
|
eq(chatChannelMembers.userId, actorUserId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(chatChannels.workspaceId, workspaceId),
|
||||||
|
or(
|
||||||
|
eq(chatChannels.kind, "public"),
|
||||||
|
eq(chatChannelMembers.userId, actorUserId),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(asc(chatChannels.createdAt))
|
||||||
|
).map((x) => row(x.channel));
|
||||||
|
},
|
||||||
|
async lockChannel(workspaceId, channelId) {
|
||||||
|
const [c] = await tx
|
||||||
|
.select()
|
||||||
|
.from(chatChannels)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(chatChannels.workspaceId, workspaceId),
|
||||||
|
eq(chatChannels.id, channelId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.for("update");
|
||||||
|
return c ? row(c) : null;
|
||||||
|
},
|
||||||
|
async isChannelMember(workspaceId, channelId, userId) {
|
||||||
|
const [m] = await tx
|
||||||
|
.select({ userId: chatChannelMembers.userId })
|
||||||
|
.from(chatChannelMembers)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(chatChannelMembers.workspaceId, workspaceId),
|
||||||
|
eq(chatChannelMembers.channelId, channelId),
|
||||||
|
eq(chatChannelMembers.userId, userId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
return Boolean(m);
|
||||||
|
},
|
||||||
|
async areWorkspaceMembers(workspaceId, userIds) {
|
||||||
|
if (!userIds.length) return false;
|
||||||
|
const found = await tx
|
||||||
|
.select({ userId: workspaceMembers.userId })
|
||||||
|
.from(workspaceMembers)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(workspaceMembers.workspaceId, workspaceId),
|
||||||
|
inArray(workspaceMembers.userId, userIds),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return found.length === new Set(userIds).size;
|
||||||
|
},
|
||||||
|
async insertChannel(input) {
|
||||||
|
try {
|
||||||
|
const [c] = await tx.insert(chatChannels).values(input).returning();
|
||||||
|
return row(c!);
|
||||||
|
} catch (e) {
|
||||||
|
translate(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async renameActiveChannel(workspaceId, channelId, name) {
|
||||||
|
const [c] = await tx
|
||||||
|
.update(chatChannels)
|
||||||
|
.set({ name, updatedAt: new Date() })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(chatChannels.workspaceId, workspaceId),
|
||||||
|
eq(chatChannels.id, channelId),
|
||||||
|
isNull(chatChannels.archivedAt),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
return c ? row(c) : null;
|
||||||
|
},
|
||||||
|
async archiveActiveChannel(workspaceId, channelId) {
|
||||||
|
const [c] = await tx
|
||||||
|
.update(chatChannels)
|
||||||
|
.set({ archivedAt: new Date(), updatedAt: new Date() })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(chatChannels.workspaceId, workspaceId),
|
||||||
|
eq(chatChannels.id, channelId),
|
||||||
|
isNull(chatChannels.archivedAt),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
return c ? row(c) : null;
|
||||||
|
},
|
||||||
|
async addMember(workspaceId, channelId, userId) {
|
||||||
|
try {
|
||||||
|
const inserted = await tx
|
||||||
|
.insert(chatChannelMembers)
|
||||||
|
.values({ workspaceId, channelId, userId })
|
||||||
|
.onConflictDoNothing()
|
||||||
|
.returning({ userId: chatChannelMembers.userId });
|
||||||
|
return inserted.length > 0;
|
||||||
|
} catch (e) {
|
||||||
|
translate(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async removeMember(workspaceId, channelId, userId) {
|
||||||
|
const deleted = await tx
|
||||||
|
.delete(chatChannelMembers)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(chatChannelMembers.workspaceId, workspaceId),
|
||||||
|
eq(chatChannelMembers.channelId, channelId),
|
||||||
|
eq(chatChannelMembers.userId, userId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning({ userId: chatChannelMembers.userId });
|
||||||
|
return deleted.length > 0;
|
||||||
|
},
|
||||||
|
async lockDirect(workspaceId, directKey) {
|
||||||
|
await tx.execute(
|
||||||
|
sql`select pg_advisory_xact_lock(hashtextextended(${`${workspaceId}:${directKey}`},0))`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
async findDirect(workspaceId, directKey) {
|
||||||
|
const [c] = await tx
|
||||||
|
.select()
|
||||||
|
.from(chatChannels)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(chatChannels.workspaceId, workspaceId),
|
||||||
|
eq(chatChannels.directKey, directKey),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
return c ? row(c) : null;
|
||||||
|
},
|
||||||
|
async insertDirect(input) {
|
||||||
|
try {
|
||||||
|
const [c] = await tx
|
||||||
|
.insert(chatChannels)
|
||||||
|
.values({
|
||||||
|
...input,
|
||||||
|
kind: "private",
|
||||||
|
name: "__direct_staging__",
|
||||||
|
directKey: null,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
return c ? row(c) : null;
|
||||||
|
} catch (e) {
|
||||||
|
translate(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async addInitialDirectMembers(workspaceId, channelId, userIds) {
|
||||||
|
await tx
|
||||||
|
.insert(chatChannelMembers)
|
||||||
|
.values(userIds.map((userId) => ({ workspaceId, channelId, userId })));
|
||||||
|
},
|
||||||
|
async finalizeDirect(workspaceId, channelId, directKey) {
|
||||||
|
const [channel] = await tx
|
||||||
|
.update(chatChannels)
|
||||||
|
.set({ kind: "direct", name: null, directKey, updatedAt: new Date() })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(chatChannels.workspaceId, workspaceId),
|
||||||
|
eq(chatChannels.id, channelId),
|
||||||
|
eq(chatChannels.kind, "private"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
if (!channel) throw new ChannelPersistenceError("CONFLICT");
|
||||||
|
return row(channel);
|
||||||
|
},
|
||||||
|
async listMemberIds(workspaceId, channelId) {
|
||||||
|
const rows = await tx
|
||||||
|
.select({ userId: chatChannelMembers.userId })
|
||||||
|
.from(chatChannelMembers)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(chatChannelMembers.workspaceId, workspaceId),
|
||||||
|
eq(chatChannelMembers.channelId, channelId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(asc(chatChannelMembers.userId));
|
||||||
|
return rows.map((x) => x.userId);
|
||||||
|
},
|
||||||
|
async lockMutation(scope) {
|
||||||
|
await tx.execute(
|
||||||
|
sql`select pg_advisory_xact_lock(hashtextextended(${`${scope.workspaceId}:${scope.actorUserId}:${scope.clientMutationId}`}, 0))`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
async loadReceipt(scope) {
|
||||||
|
const [receipt] = await tx
|
||||||
|
.select()
|
||||||
|
.from(chatMutationReceipts)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(chatMutationReceipts.workspaceId, scope.workspaceId),
|
||||||
|
eq(chatMutationReceipts.actorUserId, scope.actorUserId),
|
||||||
|
eq(chatMutationReceipts.clientMutationId, scope.clientMutationId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
return receipt
|
||||||
|
? {
|
||||||
|
operation: receipt.operation,
|
||||||
|
fingerprint: receipt.fingerprint,
|
||||||
|
result: receipt.result,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
},
|
||||||
|
async saveReceipt(scope, operation, fingerprint, result) {
|
||||||
|
try {
|
||||||
|
await tx.insert(chatMutationReceipts).values({
|
||||||
|
...scope,
|
||||||
|
operation,
|
||||||
|
fingerprint,
|
||||||
|
result: result as Record<string, unknown>,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
translate(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async emit(value) {
|
||||||
|
const e = chatChannelLifecycleEventV1Schema.parse(value);
|
||||||
|
const subject = "subjectUserId" in e ? e.subjectUserId : "channel";
|
||||||
|
await tx.insert(chatOutbox).values({
|
||||||
|
workspaceId: e.workspaceId,
|
||||||
|
channelId: e.channelId,
|
||||||
|
eventType: e.eventType,
|
||||||
|
eventVersion: e.eventVersion,
|
||||||
|
dedupeKey: `${e.eventType}:${e.channelId}:${subject}:${e.clientMutationId}`,
|
||||||
|
payload: e,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function isRetryableTransactionError(error: unknown): boolean {
|
||||||
|
const seen = new Set<unknown>();
|
||||||
|
let current = error;
|
||||||
|
while (
|
||||||
|
current !== null &&
|
||||||
|
typeof current === "object" &&
|
||||||
|
!seen.has(current)
|
||||||
|
) {
|
||||||
|
seen.add(current);
|
||||||
|
if (
|
||||||
|
"code" in current &&
|
||||||
|
(current.code === "40001" || current.code === "40P01")
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
current = "cause" in current ? current.cause : undefined;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createChannelTransactionRunner(
|
||||||
|
database: typeof defaultDb = defaultDb,
|
||||||
|
): ChannelRunner {
|
||||||
|
return async (fn) => {
|
||||||
|
for (let attempt = 1; ; attempt++) {
|
||||||
|
try {
|
||||||
|
return await database.transaction((tx) => fn(adapter(tx)));
|
||||||
|
} catch (error) {
|
||||||
|
if (attempt >= 3 || !isRetryableTransactionError(error)) throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
139
packages/database/src/chat/drizzle-adapter.test.ts
Normal file
139
packages/database/src/chat/drizzle-adapter.test.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createChatTransactionRunner,
|
||||||
|
type ChatDatabase,
|
||||||
|
} from "./drizzle-adapter";
|
||||||
|
|
||||||
|
type DatabaseTransaction = Parameters<
|
||||||
|
Parameters<ChatDatabase["transaction"]>[0]
|
||||||
|
>[0];
|
||||||
|
type TransactionCallback = (tx: DatabaseTransaction) => Promise<unknown>;
|
||||||
|
|
||||||
|
// These fakes exercise the transaction boundary, not a live PostgreSQL connection.
|
||||||
|
function fakeDatabase(
|
||||||
|
transaction: (callback: TransactionCallback) => Promise<unknown>,
|
||||||
|
): ChatDatabase {
|
||||||
|
return { transaction } as unknown as ChatDatabase;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fakeTx = {} as DatabaseTransaction;
|
||||||
|
|
||||||
|
function pgError(code: string): Error & { code: string } {
|
||||||
|
return Object.assign(new Error(`PostgreSQL ${code}`), { code });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("createChatTransactionRunner", () => {
|
||||||
|
it.each(["40001", "40P01"])(
|
||||||
|
"restarts the entire callback in a fresh transaction after %s",
|
||||||
|
async (code) => {
|
||||||
|
const transaction = vi.fn(async (callback: TransactionCallback) =>
|
||||||
|
callback(fakeTx),
|
||||||
|
);
|
||||||
|
const run = vi.fn(async () => {
|
||||||
|
if (run.mock.calls.length === 1) throw pgError(code);
|
||||||
|
return "committed";
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
createChatTransactionRunner(fakeDatabase(transaction)).transaction(run),
|
||||||
|
).resolves.toBe("committed");
|
||||||
|
expect(transaction).toHaveBeenCalledTimes(2);
|
||||||
|
expect(run).toHaveBeenCalledTimes(2);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("replays the callback when the transaction rejects after it returns", async () => {
|
||||||
|
const transaction = vi.fn(async (callback: TransactionCallback) => {
|
||||||
|
const result = await callback(fakeTx);
|
||||||
|
if (transaction.mock.calls.length === 1) throw pgError("40001");
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
const run = vi.fn(async () => "committed");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
createChatTransactionRunner(fakeDatabase(transaction)).transaction(run),
|
||||||
|
).resolves.toBe("committed");
|
||||||
|
expect(transaction).toHaveBeenCalledTimes(2);
|
||||||
|
expect(run).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries a serialization failure wrapped in a cause", async () => {
|
||||||
|
const transaction = vi.fn(async (callback: TransactionCallback) =>
|
||||||
|
callback(fakeTx),
|
||||||
|
);
|
||||||
|
const run = vi.fn(async () => {
|
||||||
|
if (run.mock.calls.length === 1)
|
||||||
|
throw new Error("query failed", { cause: pgError("40001") });
|
||||||
|
return 42;
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
createChatTransactionRunner(fakeDatabase(transaction)).transaction(run),
|
||||||
|
).resolves.toBe(42);
|
||||||
|
expect(transaction).toHaveBeenCalledTimes(2);
|
||||||
|
expect(run).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops after three total attempts and rethrows the last error unchanged", async () => {
|
||||||
|
const failures = [pgError("40001"), pgError("40P01"), pgError("40001")];
|
||||||
|
const transaction = vi.fn(async (callback: TransactionCallback) =>
|
||||||
|
callback(fakeTx),
|
||||||
|
);
|
||||||
|
const run = vi.fn(async () => {
|
||||||
|
throw failures[run.mock.calls.length - 1];
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
createChatTransactionRunner(fakeDatabase(transaction)).transaction(run),
|
||||||
|
).rejects.toBe(failures[2]);
|
||||||
|
expect(transaction).toHaveBeenCalledTimes(3);
|
||||||
|
expect(run).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
new Error("unrelated"),
|
||||||
|
pgError("23505"),
|
||||||
|
pgError("CHAT_FORBIDDEN"),
|
||||||
|
])("does not retry unrelated or domain failures: %s", async (failure) => {
|
||||||
|
const transaction = vi.fn(async (callback: TransactionCallback) =>
|
||||||
|
callback(fakeTx),
|
||||||
|
);
|
||||||
|
const run = vi.fn(async () => {
|
||||||
|
throw failure;
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
createChatTransactionRunner(fakeDatabase(transaction)).transaction(run),
|
||||||
|
).rejects.toBe(failure);
|
||||||
|
expect(transaction).toHaveBeenCalledTimes(1);
|
||||||
|
expect(run).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replays the whole callback after a rolled-back attempt, not just the failed operation", async () => {
|
||||||
|
const committed: string[] = [];
|
||||||
|
let attempts = 0;
|
||||||
|
let staged: string[] = [];
|
||||||
|
const transaction = vi.fn(async (callback: TransactionCallback) => {
|
||||||
|
staged = [];
|
||||||
|
attempts++;
|
||||||
|
// A fake rollback model: failed attempts never append staged writes.
|
||||||
|
const result = await callback(fakeTx);
|
||||||
|
committed.push(...staged);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
const run = vi.fn(async () => {
|
||||||
|
staged.push(`first-${attempts}`);
|
||||||
|
if (attempts === 1) throw pgError("40P01");
|
||||||
|
staged.push(`second-${attempts}`);
|
||||||
|
return "done";
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
createChatTransactionRunner(fakeDatabase(transaction)).transaction(run),
|
||||||
|
).resolves.toBe("done");
|
||||||
|
expect(committed).toEqual(["first-2", "second-2"]);
|
||||||
|
expect(run).toHaveBeenCalledTimes(2);
|
||||||
|
expect(transaction).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
245
packages/database/src/chat/drizzle-adapter.ts
Normal file
245
packages/database/src/chat/drizzle-adapter.ts
Normal file
|
|
@ -0,0 +1,245 @@
|
||||||
|
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||||
|
|
||||||
|
import { db as defaultDb } from "../client";
|
||||||
|
import {
|
||||||
|
chatChannelMembers,
|
||||||
|
chatChannels,
|
||||||
|
chatMessageMentions,
|
||||||
|
chatMessages,
|
||||||
|
chatOutbox,
|
||||||
|
workspaceMembers,
|
||||||
|
} from "../schema";
|
||||||
|
import {
|
||||||
|
sendChatMessage,
|
||||||
|
type ChatPersistenceTransaction,
|
||||||
|
type ChatTransactionRunner,
|
||||||
|
} from "./send-message";
|
||||||
|
|
||||||
|
export type ChatDatabase = typeof defaultDb;
|
||||||
|
type DatabaseTransaction = Parameters<
|
||||||
|
Parameters<ChatDatabase["transaction"]>[0]
|
||||||
|
>[0];
|
||||||
|
|
||||||
|
export function transactionAdapter(
|
||||||
|
tx: DatabaseTransaction,
|
||||||
|
): ChatPersistenceTransaction {
|
||||||
|
return {
|
||||||
|
async claimIdempotency(scope) {
|
||||||
|
const lockKey = `${scope.workspaceId}:${scope.channelId}:${scope.clientIdempotencyKey}`;
|
||||||
|
// A 64-bit hash collision only adds serialization; it cannot merge keys
|
||||||
|
// because the following lookup still compares every original scope field.
|
||||||
|
await tx.execute(
|
||||||
|
sql`select pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))`,
|
||||||
|
);
|
||||||
|
const [message] = await tx
|
||||||
|
.select()
|
||||||
|
.from(chatMessages)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(chatMessages.workspaceId, scope.workspaceId),
|
||||||
|
eq(chatMessages.channelId, scope.channelId),
|
||||||
|
eq(chatMessages.clientIdempotencyKey, scope.clientIdempotencyKey),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
return message
|
||||||
|
? {
|
||||||
|
id: message.id,
|
||||||
|
workspaceId: message.workspaceId,
|
||||||
|
channelId: message.channelId,
|
||||||
|
sequence: message.sequence,
|
||||||
|
body: message.body,
|
||||||
|
parentMessageId: message.parentMessageId,
|
||||||
|
authorUserId: message.authorUserId!,
|
||||||
|
clientIdempotencyKey: message.clientIdempotencyKey,
|
||||||
|
sendIntentFingerprint: message.sendIntentFingerprint,
|
||||||
|
status: message.status,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
},
|
||||||
|
async loadMessageMentions(scope) {
|
||||||
|
const rows = await tx
|
||||||
|
.select({ userId: chatMessageMentions.userId })
|
||||||
|
.from(chatMessageMentions)
|
||||||
|
.innerJoin(
|
||||||
|
chatMessages,
|
||||||
|
and(
|
||||||
|
eq(chatMessages.workspaceId, chatMessageMentions.workspaceId),
|
||||||
|
eq(chatMessages.id, chatMessageMentions.messageId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(chatMessageMentions.workspaceId, scope.workspaceId),
|
||||||
|
eq(chatMessageMentions.messageId, scope.messageId),
|
||||||
|
eq(chatMessages.workspaceId, scope.workspaceId),
|
||||||
|
eq(chatMessages.channelId, scope.channelId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return rows.map(({ userId }) => userId);
|
||||||
|
},
|
||||||
|
async loadSendFacts(scope) {
|
||||||
|
const [member] = await tx
|
||||||
|
.select({ role: workspaceMembers.role })
|
||||||
|
.from(workspaceMembers)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(workspaceMembers.workspaceId, scope.workspaceId),
|
||||||
|
eq(workspaceMembers.userId, scope.actorUserId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
const [channel] = await tx
|
||||||
|
.select()
|
||||||
|
.from(chatChannels)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(chatChannels.workspaceId, scope.workspaceId),
|
||||||
|
eq(chatChannels.id, scope.channelId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (!member || !channel) return null;
|
||||||
|
const [channelMember] = await tx
|
||||||
|
.select({ userId: chatChannelMembers.userId })
|
||||||
|
.from(chatChannelMembers)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(chatChannelMembers.workspaceId, scope.workspaceId),
|
||||||
|
eq(chatChannelMembers.channelId, scope.channelId),
|
||||||
|
eq(chatChannelMembers.userId, scope.actorUserId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
const role = member.role;
|
||||||
|
if (
|
||||||
|
!(["owner", "admin", "member", "guest"] as const).includes(
|
||||||
|
role as never,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
return {
|
||||||
|
actor: {
|
||||||
|
userId: scope.actorUserId,
|
||||||
|
workspaceId: scope.workspaceId,
|
||||||
|
activeWorkspaceMember: true,
|
||||||
|
role: role as "owner" | "admin" | "member" | "guest",
|
||||||
|
},
|
||||||
|
channel: {
|
||||||
|
id: channel.id,
|
||||||
|
workspaceId: channel.workspaceId,
|
||||||
|
kind: channel.kind,
|
||||||
|
archived: channel.archivedAt !== null,
|
||||||
|
activeMember: Boolean(channelMember),
|
||||||
|
ownerUserId: channel.ownerUserId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async loadParent(scope) {
|
||||||
|
const [parent] = await tx
|
||||||
|
.select({
|
||||||
|
workspaceId: chatMessages.workspaceId,
|
||||||
|
channelId: chatMessages.channelId,
|
||||||
|
parentMessageId: chatMessages.parentMessageId,
|
||||||
|
})
|
||||||
|
.from(chatMessages)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(chatMessages.workspaceId, scope.workspaceId),
|
||||||
|
eq(chatMessages.channelId, scope.channelId),
|
||||||
|
eq(chatMessages.id, scope.messageId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
return parent ?? null;
|
||||||
|
},
|
||||||
|
async validateMentionMembers(scope) {
|
||||||
|
if (!scope.userIds.length) return [];
|
||||||
|
const rows = await tx
|
||||||
|
.select({ userId: workspaceMembers.userId })
|
||||||
|
.from(workspaceMembers)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(workspaceMembers.workspaceId, scope.workspaceId),
|
||||||
|
inArray(workspaceMembers.userId, scope.userIds),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return rows.map(({ userId }) => userId);
|
||||||
|
},
|
||||||
|
async allocateSequence(scope) {
|
||||||
|
const [row] = await tx
|
||||||
|
.update(chatChannels)
|
||||||
|
.set({ nextSequence: sql`${chatChannels.nextSequence} + 1` })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(chatChannels.workspaceId, scope.workspaceId),
|
||||||
|
eq(chatChannels.id, scope.channelId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning({ sequence: sql<bigint>`${chatChannels.nextSequence} - 1` });
|
||||||
|
if (!row)
|
||||||
|
throw new Error("Channel disappeared during message transaction");
|
||||||
|
return row.sequence;
|
||||||
|
},
|
||||||
|
async insertMessage(values) {
|
||||||
|
const [message] = await tx
|
||||||
|
.insert(chatMessages)
|
||||||
|
.values({
|
||||||
|
...values,
|
||||||
|
authorType: "human",
|
||||||
|
authorAgentId: null,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
if (!message) throw new Error("Message insert returned no row");
|
||||||
|
return { ...values, id: message.id, sequence: message.sequence };
|
||||||
|
},
|
||||||
|
async insertMentions(values) {
|
||||||
|
await tx.insert(chatMessageMentions).values(
|
||||||
|
values.userIds.map((userId) => ({
|
||||||
|
workspaceId: values.workspaceId,
|
||||||
|
messageId: values.messageId,
|
||||||
|
userId,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
async insertOutbox(values) {
|
||||||
|
await tx.insert(chatOutbox).values(values);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function retryable(error: unknown): boolean {
|
||||||
|
if (!error || typeof error !== "object") return false;
|
||||||
|
const candidate = error as { code?: unknown; cause?: unknown };
|
||||||
|
return (
|
||||||
|
candidate.code === "40001" ||
|
||||||
|
candidate.code === "40P01" ||
|
||||||
|
(candidate.cause !== undefined && retryable(candidate.cause))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createChatTransactionRunner(
|
||||||
|
database: ChatDatabase = defaultDb,
|
||||||
|
): ChatTransactionRunner {
|
||||||
|
return {
|
||||||
|
async transaction(run) {
|
||||||
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
|
try {
|
||||||
|
return await database.transaction((tx) =>
|
||||||
|
run(transactionAdapter(tx)),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (attempt === 2 || !retryable(error)) throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("Unreachable transaction retry state");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendChatMessageWithDb(
|
||||||
|
input: Parameters<typeof sendChatMessage>[1],
|
||||||
|
database: ChatDatabase = defaultDb,
|
||||||
|
) {
|
||||||
|
return sendChatMessage(createChatTransactionRunner(database), input);
|
||||||
|
}
|
||||||
7
packages/database/src/chat/index.ts
Normal file
7
packages/database/src/chat/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
export * from "./channel-drizzle-adapter";
|
||||||
|
export * from "./drizzle-adapter";
|
||||||
|
export * from "./manage-channels";
|
||||||
|
export * from "./mutate-message";
|
||||||
|
export * from "./message-drizzle-adapter";
|
||||||
|
export * from "./read-history";
|
||||||
|
export * from "./send-message";
|
||||||
297
packages/database/src/chat/manage-channels.test.ts
Normal file
297
packages/database/src/chat/manage-channels.test.ts
Normal file
|
|
@ -0,0 +1,297 @@
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
addPrivateMember,
|
||||||
|
createChannel,
|
||||||
|
createDirectChannel,
|
||||||
|
removePrivateMember,
|
||||||
|
renameChannel,
|
||||||
|
type ChannelRecord,
|
||||||
|
type ChannelTransaction,
|
||||||
|
} from "./manage-channels";
|
||||||
|
const workspaceId = "11111111-1111-4111-8111-111111111111",
|
||||||
|
actorUserId = "22222222-2222-4222-8222-222222222222",
|
||||||
|
target = "33333333-3333-4333-8333-333333333333",
|
||||||
|
channelId = "44444444-4444-4444-8444-444444444444",
|
||||||
|
clientMutationId = "55555555-5555-4555-8555-555555555555";
|
||||||
|
const channel = (patch: Partial<ChannelRecord> = {}): ChannelRecord => ({
|
||||||
|
id: channelId,
|
||||||
|
workspaceId,
|
||||||
|
kind: "private",
|
||||||
|
name: "private",
|
||||||
|
ownerUserId: actorUserId,
|
||||||
|
directKey: null,
|
||||||
|
archivedAt: null,
|
||||||
|
...patch,
|
||||||
|
});
|
||||||
|
function seam(patch: Partial<ChannelTransaction> = {}) {
|
||||||
|
const events: unknown[] = [];
|
||||||
|
const tx: ChannelTransaction = {
|
||||||
|
loadActor: async () => ({ userId: actorUserId, role: "member" }),
|
||||||
|
listVisible: async () => [],
|
||||||
|
lockChannel: async () => channel(),
|
||||||
|
isChannelMember: async () => true,
|
||||||
|
areWorkspaceMembers: async () => true,
|
||||||
|
insertChannel: async () => channel(),
|
||||||
|
renameActiveChannel: async (_w, _c, name) => channel({ name }),
|
||||||
|
archiveActiveChannel: async () => channel({ archivedAt: new Date() }),
|
||||||
|
addMember: async () => true,
|
||||||
|
removeMember: async () => true,
|
||||||
|
lockDirect: async () => {},
|
||||||
|
findDirect: async () => null,
|
||||||
|
insertDirect: async (input) =>
|
||||||
|
channel({ kind: "direct", name: null, directKey: input.directKey }),
|
||||||
|
addInitialDirectMembers: async () => {},
|
||||||
|
finalizeDirect: async (_w, _c, directKey) =>
|
||||||
|
channel({ kind: "direct", name: null, directKey }),
|
||||||
|
listMemberIds: async () => [],
|
||||||
|
lockMutation: async () => {},
|
||||||
|
loadReceipt: async () => null,
|
||||||
|
saveReceipt: async () => {},
|
||||||
|
emit: async (e) => {
|
||||||
|
events.push(e);
|
||||||
|
},
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
run: async <T>(fn: (tx: ChannelTransaction) => Promise<T>) => fn(tx),
|
||||||
|
events,
|
||||||
|
tx,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const scope = { workspaceId, actorUserId, clientMutationId };
|
||||||
|
describe("channel lifecycle orchestration", () => {
|
||||||
|
it("does not emit for idempotent membership add/remove", async () => {
|
||||||
|
const s = seam({
|
||||||
|
addMember: async () => false,
|
||||||
|
removeMember: async () => false,
|
||||||
|
});
|
||||||
|
await addPrivateMember(s.run, { ...scope, channelId, userId: target });
|
||||||
|
await removePrivateMember(s.run, { ...scope, channelId, userId: target });
|
||||||
|
expect(s.events).toEqual([]);
|
||||||
|
});
|
||||||
|
it("emits membership events only when add and remove change rows", async () => {
|
||||||
|
const s = seam();
|
||||||
|
await addPrivateMember(s.run, { ...scope, channelId, userId: target });
|
||||||
|
await removePrivateMember(s.run, { ...scope, channelId, userId: target });
|
||||||
|
expect(s.events).toMatchObject([
|
||||||
|
{ eventType: "chat.channel.member.added.v1", subjectUserId: target },
|
||||||
|
{ eventType: "chat.channel.member.removed.v1", subjectUserId: target },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
it("protects the private owner", async () => {
|
||||||
|
const s = seam();
|
||||||
|
await expect(
|
||||||
|
removePrivateMember(s.run, { ...scope, channelId, userId: actorUserId }),
|
||||||
|
).rejects.toMatchObject({ code: "INVALID" });
|
||||||
|
});
|
||||||
|
it("gives workspace admins no private membership bypass", async () => {
|
||||||
|
const s = seam({
|
||||||
|
loadActor: async () => ({ userId: actorUserId, role: "admin" }),
|
||||||
|
isChannelMember: async () => false,
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
addPrivateMember(s.run, { ...scope, channelId, userId: target }),
|
||||||
|
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||||
|
});
|
||||||
|
it("denies mutation after archive", async () => {
|
||||||
|
const s = seam({
|
||||||
|
lockChannel: async () => channel({ archivedAt: new Date() }),
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
renameChannel(s.run, { ...scope, channelId, name: "next" }),
|
||||||
|
).rejects.toMatchObject({ code: "READ_ONLY" });
|
||||||
|
});
|
||||||
|
it("canonicalizes and deduplicates direct participants and orders events", async () => {
|
||||||
|
let inserted: string[] = [];
|
||||||
|
const s = seam({
|
||||||
|
addInitialDirectMembers: async (_w, _c, ids) => {
|
||||||
|
inserted = ids;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await createDirectChannel(s.run, {
|
||||||
|
...scope,
|
||||||
|
participantUserIds: [target, actorUserId, target],
|
||||||
|
});
|
||||||
|
expect(inserted).toEqual([actorUserId, target].sort());
|
||||||
|
expect(s.events).toHaveLength(3);
|
||||||
|
});
|
||||||
|
it("requires actor and two distinct participants", async () => {
|
||||||
|
const s = seam();
|
||||||
|
await expect(
|
||||||
|
createDirectChannel(s.run, {
|
||||||
|
...scope,
|
||||||
|
participantUserIds: [target, target],
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: "INVALID" });
|
||||||
|
});
|
||||||
|
it("rejects an existing direct with a mismatched exact set", async () => {
|
||||||
|
const s = seam({
|
||||||
|
findDirect: async () =>
|
||||||
|
channel({ kind: "direct", name: null, directKey: "x" }),
|
||||||
|
listMemberIds: async () => [actorUserId],
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
createDirectChannel(s.run, {
|
||||||
|
...scope,
|
||||||
|
participantUserIds: [actorUserId, target],
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: "CONFLICT" });
|
||||||
|
});
|
||||||
|
it("denies replay of a private channel receipt after membership revocation", async () => {
|
||||||
|
const original = channel();
|
||||||
|
const s = seam({
|
||||||
|
loadReceipt: async () => ({
|
||||||
|
operation: "create_channel",
|
||||||
|
fingerprint: createHash("sha256")
|
||||||
|
.update(JSON.stringify({ kind: "private", name: "private" }))
|
||||||
|
.digest("hex"),
|
||||||
|
result: { operation: "create_channel", channel: original },
|
||||||
|
}),
|
||||||
|
isChannelMember: async () => false,
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
createChannel(s.run, { ...scope, kind: "private", name: "private" }),
|
||||||
|
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||||
|
expect(s.events).toEqual([]);
|
||||||
|
});
|
||||||
|
it("denies replay of a private member receipt after membership revocation", async () => {
|
||||||
|
const s = seam({
|
||||||
|
loadReceipt: async () => ({
|
||||||
|
operation: "add_private_member",
|
||||||
|
fingerprint: createHash("sha256")
|
||||||
|
.update(JSON.stringify({ channelId, userId: target }))
|
||||||
|
.digest("hex"),
|
||||||
|
result: {
|
||||||
|
operation: "add_private_member",
|
||||||
|
member: { channelId, userId: target, changed: true },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
isChannelMember: async () => false,
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
addPrivateMember(s.run, { ...scope, channelId, userId: target }),
|
||||||
|
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||||
|
});
|
||||||
|
it("does not disclose a saved remove-self result after leaving a private channel", async () => {
|
||||||
|
const s = seam({
|
||||||
|
lockChannel: async () => channel({ ownerUserId: target }),
|
||||||
|
loadReceipt: async () => ({
|
||||||
|
operation: "remove_private_member",
|
||||||
|
fingerprint: createHash("sha256")
|
||||||
|
.update(JSON.stringify({ channelId, userId: actorUserId }))
|
||||||
|
.digest("hex"),
|
||||||
|
result: {
|
||||||
|
operation: "remove_private_member",
|
||||||
|
member: { channelId, userId: actorUserId, changed: true },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
isChannelMember: async () => false,
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
removePrivateMember(s.run, { ...scope, channelId, userId: actorUserId }),
|
||||||
|
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||||
|
});
|
||||||
|
it("allows public receipt replay without channel membership", async () => {
|
||||||
|
const original = channel({ kind: "public" });
|
||||||
|
const s = seam({
|
||||||
|
lockChannel: async () => original,
|
||||||
|
isChannelMember: async () => false,
|
||||||
|
loadReceipt: async () => ({
|
||||||
|
operation: "create_channel",
|
||||||
|
fingerprint: createHash("sha256")
|
||||||
|
.update(JSON.stringify({ kind: "public", name: "private" }))
|
||||||
|
.digest("hex"),
|
||||||
|
result: { operation: "create_channel", channel: original },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
createChannel(s.run, { ...scope, kind: "public", name: "private" }),
|
||||||
|
).resolves.toMatchObject({ id: channelId });
|
||||||
|
});
|
||||||
|
it("denies replay of a direct channel receipt after membership revocation", async () => {
|
||||||
|
const participants = [actorUserId, target].sort();
|
||||||
|
const original = channel({ kind: "direct", name: null, directKey: "key" });
|
||||||
|
const s = seam({
|
||||||
|
lockChannel: async () => original,
|
||||||
|
isChannelMember: async () => false,
|
||||||
|
loadReceipt: async () => ({
|
||||||
|
operation: "create_direct",
|
||||||
|
fingerprint: createHash("sha256")
|
||||||
|
.update(JSON.stringify({ participantUserIds: participants }))
|
||||||
|
.digest("hex"),
|
||||||
|
result: { operation: "create_direct", channel: original },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
createDirectChannel(s.run, {
|
||||||
|
...scope,
|
||||||
|
participantUserIds: participants,
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||||
|
});
|
||||||
|
it("replays readable archived channels without rechecking write permissions", async () => {
|
||||||
|
const archived = channel({ archivedAt: new Date("2026-01-01T00:00:00Z") });
|
||||||
|
const s = seam({
|
||||||
|
lockChannel: async () => archived,
|
||||||
|
loadReceipt: async () => ({
|
||||||
|
operation: "rename_channel",
|
||||||
|
fingerprint: createHash("sha256")
|
||||||
|
.update(JSON.stringify({ channelId, name: "next" }))
|
||||||
|
.digest("hex"),
|
||||||
|
result: {
|
||||||
|
operation: "rename_channel",
|
||||||
|
channel: {
|
||||||
|
...archived,
|
||||||
|
archivedAt: archived.archivedAt?.toISOString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
renameChannel(s.run, { ...scope, channelId, name: "next" }),
|
||||||
|
).resolves.toMatchObject({ id: channelId, name: "private" });
|
||||||
|
expect(s.events).toEqual([]);
|
||||||
|
});
|
||||||
|
it("replays a matching receipt without state or event calls", async () => {
|
||||||
|
const original = channel();
|
||||||
|
const insert = vi.fn();
|
||||||
|
const fingerprint = createHash("sha256")
|
||||||
|
.update(JSON.stringify({ kind: "public", name: "private" }))
|
||||||
|
.digest("hex");
|
||||||
|
const s = seam({
|
||||||
|
loadReceipt: async () => ({
|
||||||
|
operation: "create_channel",
|
||||||
|
fingerprint,
|
||||||
|
result: {
|
||||||
|
operation: "create_channel",
|
||||||
|
channel: { ...original, archivedAt: null },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
insertChannel: insert,
|
||||||
|
});
|
||||||
|
const result = await createChannel(s.run, {
|
||||||
|
...scope,
|
||||||
|
kind: "public",
|
||||||
|
name: "private",
|
||||||
|
});
|
||||||
|
expect(result.id).toBe(channelId);
|
||||||
|
expect(insert).not.toHaveBeenCalled();
|
||||||
|
expect(s.events).toEqual([]);
|
||||||
|
});
|
||||||
|
it("rejects receipt intent collisions before state calls", async () => {
|
||||||
|
const lockChannel = vi.fn();
|
||||||
|
const s = seam({
|
||||||
|
loadReceipt: async () => ({
|
||||||
|
operation: "archive_channel",
|
||||||
|
fingerprint: "different",
|
||||||
|
result: {},
|
||||||
|
}),
|
||||||
|
lockChannel,
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
renameChannel(s.run, { ...scope, channelId, name: "next" }),
|
||||||
|
).rejects.toMatchObject({ code: "CONFLICT" });
|
||||||
|
expect(lockChannel).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
479
packages/database/src/chat/manage-channels.ts
Normal file
479
packages/database/src/chat/manage-channels.ts
Normal file
|
|
@ -0,0 +1,479 @@
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import {
|
||||||
|
chatChannelLifecycleEventV1Schema,
|
||||||
|
chatChannelNameSchema,
|
||||||
|
chatLifecycleReceiptResultSchema,
|
||||||
|
type ChatChannelLifecycleEventV1,
|
||||||
|
type ChatLifecycleReceiptResult,
|
||||||
|
} from "@tasks/shared";
|
||||||
|
|
||||||
|
export type ChannelKind = "public" | "private" | "direct";
|
||||||
|
export type ChannelRecord = {
|
||||||
|
id: string;
|
||||||
|
workspaceId: string;
|
||||||
|
kind: ChannelKind;
|
||||||
|
name: string | null;
|
||||||
|
ownerUserId: string;
|
||||||
|
directKey: string | null;
|
||||||
|
archivedAt: Date | null;
|
||||||
|
};
|
||||||
|
export type WorkspaceActor = { userId: string; role: string };
|
||||||
|
export class ChannelPersistenceError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly code: "NOT_FOUND" | "INVALID" | "READ_ONLY" | "CONFLICT",
|
||||||
|
) {
|
||||||
|
super(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export type MutationScope = {
|
||||||
|
workspaceId: string;
|
||||||
|
actorUserId: string;
|
||||||
|
clientMutationId: string;
|
||||||
|
};
|
||||||
|
export type ChannelTransaction = {
|
||||||
|
loadActor(
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<WorkspaceActor | null>;
|
||||||
|
listVisible(
|
||||||
|
workspaceId: string,
|
||||||
|
actorUserId: string,
|
||||||
|
): Promise<ChannelRecord[]>;
|
||||||
|
lockChannel(
|
||||||
|
workspaceId: string,
|
||||||
|
channelId: string,
|
||||||
|
): Promise<ChannelRecord | null>;
|
||||||
|
isChannelMember(
|
||||||
|
workspaceId: string,
|
||||||
|
channelId: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<boolean>;
|
||||||
|
areWorkspaceMembers(workspaceId: string, userIds: string[]): Promise<boolean>;
|
||||||
|
insertChannel(
|
||||||
|
input: Omit<ChannelRecord, "id" | "archivedAt">,
|
||||||
|
): Promise<ChannelRecord>;
|
||||||
|
renameActiveChannel(
|
||||||
|
workspaceId: string,
|
||||||
|
channelId: string,
|
||||||
|
name: string,
|
||||||
|
): Promise<ChannelRecord | null>;
|
||||||
|
archiveActiveChannel(
|
||||||
|
workspaceId: string,
|
||||||
|
channelId: string,
|
||||||
|
): Promise<ChannelRecord | null>;
|
||||||
|
addMember(
|
||||||
|
workspaceId: string,
|
||||||
|
channelId: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<boolean>;
|
||||||
|
removeMember(
|
||||||
|
workspaceId: string,
|
||||||
|
channelId: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<boolean>;
|
||||||
|
lockDirect(workspaceId: string, directKey: string): Promise<void>;
|
||||||
|
findDirect(
|
||||||
|
workspaceId: string,
|
||||||
|
directKey: string,
|
||||||
|
): Promise<ChannelRecord | null>;
|
||||||
|
insertDirect(
|
||||||
|
input: Omit<ChannelRecord, "id" | "archivedAt">,
|
||||||
|
): Promise<ChannelRecord | null>;
|
||||||
|
addInitialDirectMembers(
|
||||||
|
workspaceId: string,
|
||||||
|
channelId: string,
|
||||||
|
userIds: string[],
|
||||||
|
): Promise<void>;
|
||||||
|
finalizeDirect(
|
||||||
|
workspaceId: string,
|
||||||
|
channelId: string,
|
||||||
|
directKey: string,
|
||||||
|
): Promise<ChannelRecord>;
|
||||||
|
listMemberIds(workspaceId: string, channelId: string): Promise<string[]>;
|
||||||
|
lockMutation(scope: MutationScope): Promise<void>;
|
||||||
|
loadReceipt(scope: MutationScope): Promise<{
|
||||||
|
operation: string;
|
||||||
|
fingerprint: string;
|
||||||
|
result: unknown;
|
||||||
|
} | null>;
|
||||||
|
saveReceipt(
|
||||||
|
scope: MutationScope,
|
||||||
|
operation: string,
|
||||||
|
fingerprint: string,
|
||||||
|
result: unknown,
|
||||||
|
): Promise<void>;
|
||||||
|
emit(event: ChatChannelLifecycleEventV1): Promise<void>;
|
||||||
|
};
|
||||||
|
export type ChannelRunner = <T>(
|
||||||
|
fn: (tx: ChannelTransaction) => Promise<T>,
|
||||||
|
) => Promise<T>;
|
||||||
|
const isAdmin = (role: string) => role === "admin" || role === "owner";
|
||||||
|
async function requireActor(tx: ChannelTransaction, scope: MutationScope) {
|
||||||
|
const actor = await tx.loadActor(scope.workspaceId, scope.actorUserId);
|
||||||
|
if (!actor) throw new ChannelPersistenceError("NOT_FOUND");
|
||||||
|
return actor;
|
||||||
|
}
|
||||||
|
async function lockMutable(
|
||||||
|
tx: ChannelTransaction,
|
||||||
|
scope: MutationScope,
|
||||||
|
channelId: string,
|
||||||
|
) {
|
||||||
|
const [actor, channel] = await Promise.all([
|
||||||
|
requireActor(tx, scope),
|
||||||
|
tx.lockChannel(scope.workspaceId, channelId),
|
||||||
|
]);
|
||||||
|
if (!channel) throw new ChannelPersistenceError("NOT_FOUND");
|
||||||
|
if (channel.archivedAt) throw new ChannelPersistenceError("READ_ONLY");
|
||||||
|
return { actor, channel };
|
||||||
|
}
|
||||||
|
async function requirePrivateManager(
|
||||||
|
tx: ChannelTransaction,
|
||||||
|
scope: MutationScope,
|
||||||
|
channelId: string,
|
||||||
|
) {
|
||||||
|
const facts = await lockMutable(tx, scope, channelId);
|
||||||
|
if (
|
||||||
|
facts.channel.kind !== "private" ||
|
||||||
|
!(await tx.isChannelMember(
|
||||||
|
scope.workspaceId,
|
||||||
|
channelId,
|
||||||
|
scope.actorUserId,
|
||||||
|
)) ||
|
||||||
|
(facts.channel.ownerUserId !== scope.actorUserId &&
|
||||||
|
!isAdmin(facts.actor.role))
|
||||||
|
)
|
||||||
|
throw new ChannelPersistenceError("NOT_FOUND");
|
||||||
|
return facts.channel;
|
||||||
|
}
|
||||||
|
function parseEvent(event: ChatChannelLifecycleEventV1) {
|
||||||
|
return chatChannelLifecycleEventV1Schema.parse(event);
|
||||||
|
}
|
||||||
|
async function withReceipt(
|
||||||
|
tx: ChannelTransaction,
|
||||||
|
scope: MutationScope,
|
||||||
|
operation: ChatLifecycleReceiptResult["operation"],
|
||||||
|
intent: unknown,
|
||||||
|
execute: () => Promise<ChatLifecycleReceiptResult>,
|
||||||
|
): Promise<ChatLifecycleReceiptResult> {
|
||||||
|
const fingerprint = createHash("sha256")
|
||||||
|
.update(JSON.stringify(intent))
|
||||||
|
.digest("hex");
|
||||||
|
await tx.lockMutation(scope);
|
||||||
|
const receipt = await tx.loadReceipt(scope);
|
||||||
|
if (receipt) {
|
||||||
|
if (receipt.operation !== operation || receipt.fingerprint !== fingerprint)
|
||||||
|
throw new ChannelPersistenceError("CONFLICT");
|
||||||
|
const result = chatLifecycleReceiptResultSchema.parse(receipt.result);
|
||||||
|
const channelId =
|
||||||
|
"channel" in result ? result.channel.id : result.member.channelId;
|
||||||
|
const channel = await tx.lockChannel(scope.workspaceId, channelId);
|
||||||
|
if (
|
||||||
|
!channel ||
|
||||||
|
("channel" in result &&
|
||||||
|
result.channel.workspaceId !== scope.workspaceId) ||
|
||||||
|
(channel.kind !== "public" &&
|
||||||
|
!(await tx.isChannelMember(
|
||||||
|
scope.workspaceId,
|
||||||
|
channelId,
|
||||||
|
scope.actorUserId,
|
||||||
|
)))
|
||||||
|
)
|
||||||
|
throw new ChannelPersistenceError("NOT_FOUND");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
const result = chatLifecycleReceiptResultSchema.parse(await execute());
|
||||||
|
if (result.operation !== operation)
|
||||||
|
throw new ChannelPersistenceError("CONFLICT");
|
||||||
|
await tx.saveReceipt(scope, operation, fingerprint, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
function channelResult(channel: ChannelRecord) {
|
||||||
|
return { ...channel, archivedAt: channel.archivedAt?.toISOString() ?? null };
|
||||||
|
}
|
||||||
|
function resultChannel(result: ChatLifecycleReceiptResult): ChannelRecord {
|
||||||
|
if (!("channel" in result)) throw new ChannelPersistenceError("CONFLICT");
|
||||||
|
return {
|
||||||
|
...result.channel,
|
||||||
|
archivedAt: result.channel.archivedAt
|
||||||
|
? new Date(result.channel.archivedAt)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function eventBase(scope: MutationScope, channelId: string) {
|
||||||
|
return {
|
||||||
|
eventVersion: 1 as const,
|
||||||
|
workspaceId: scope.workspaceId,
|
||||||
|
channelId,
|
||||||
|
actorUserId: scope.actorUserId,
|
||||||
|
clientMutationId: scope.clientMutationId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listVisibleChannels = (
|
||||||
|
run: ChannelRunner,
|
||||||
|
scope: Omit<MutationScope, "clientMutationId">,
|
||||||
|
) =>
|
||||||
|
run(async (tx) => {
|
||||||
|
if (!(await tx.loadActor(scope.workspaceId, scope.actorUserId)))
|
||||||
|
throw new ChannelPersistenceError("NOT_FOUND");
|
||||||
|
return tx.listVisible(scope.workspaceId, scope.actorUserId);
|
||||||
|
});
|
||||||
|
export const createChannel = (
|
||||||
|
run: ChannelRunner,
|
||||||
|
scope: MutationScope & { kind: "public" | "private"; name: string },
|
||||||
|
) =>
|
||||||
|
run(async (tx) => {
|
||||||
|
await requireActor(tx, scope);
|
||||||
|
const name = chatChannelNameSchema.parse(scope.name);
|
||||||
|
const result = await withReceipt(
|
||||||
|
tx,
|
||||||
|
scope,
|
||||||
|
"create_channel",
|
||||||
|
{ kind: scope.kind, name },
|
||||||
|
async () => {
|
||||||
|
const channel = await tx.insertChannel({
|
||||||
|
workspaceId: scope.workspaceId,
|
||||||
|
kind: scope.kind,
|
||||||
|
name,
|
||||||
|
ownerUserId: scope.actorUserId,
|
||||||
|
directKey: null,
|
||||||
|
});
|
||||||
|
if (scope.kind === "private")
|
||||||
|
await tx.addMember(scope.workspaceId, channel.id, scope.actorUserId);
|
||||||
|
await tx.emit(
|
||||||
|
parseEvent({
|
||||||
|
...eventBase(scope, channel.id),
|
||||||
|
eventType: "chat.channel.created.v1",
|
||||||
|
kind: scope.kind,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (scope.kind === "private")
|
||||||
|
await tx.emit(
|
||||||
|
parseEvent({
|
||||||
|
...eventBase(scope, channel.id),
|
||||||
|
eventType: "chat.channel.member.added.v1",
|
||||||
|
subjectUserId: scope.actorUserId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return { operation: "create_channel", channel: channelResult(channel) };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return resultChannel(result);
|
||||||
|
});
|
||||||
|
export const renameChannel = (
|
||||||
|
run: ChannelRunner,
|
||||||
|
scope: MutationScope & { channelId: string; name: string },
|
||||||
|
) =>
|
||||||
|
run(async (tx) => {
|
||||||
|
await requireActor(tx, scope);
|
||||||
|
const name = chatChannelNameSchema.parse(scope.name);
|
||||||
|
const result = await withReceipt(
|
||||||
|
tx,
|
||||||
|
scope,
|
||||||
|
"rename_channel",
|
||||||
|
{ channelId: scope.channelId, name },
|
||||||
|
async () => {
|
||||||
|
const { actor, channel } = await lockMutable(
|
||||||
|
tx,
|
||||||
|
scope,
|
||||||
|
scope.channelId,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
channel.kind === "direct" ||
|
||||||
|
(channel.ownerUserId !== scope.actorUserId && !isAdmin(actor.role)) ||
|
||||||
|
(channel.kind === "private" &&
|
||||||
|
!(await tx.isChannelMember(
|
||||||
|
scope.workspaceId,
|
||||||
|
channel.id,
|
||||||
|
scope.actorUserId,
|
||||||
|
)))
|
||||||
|
)
|
||||||
|
throw new ChannelPersistenceError("NOT_FOUND");
|
||||||
|
let updated = channel;
|
||||||
|
if (channel.name !== name) {
|
||||||
|
const changed = await tx.renameActiveChannel(
|
||||||
|
scope.workspaceId,
|
||||||
|
channel.id,
|
||||||
|
name,
|
||||||
|
);
|
||||||
|
if (!changed) throw new ChannelPersistenceError("READ_ONLY");
|
||||||
|
updated = changed;
|
||||||
|
await tx.emit(
|
||||||
|
parseEvent({
|
||||||
|
...eventBase(scope, channel.id),
|
||||||
|
eventType: "chat.channel.renamed.v1",
|
||||||
|
name,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { operation: "rename_channel", channel: channelResult(updated) };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return resultChannel(result);
|
||||||
|
});
|
||||||
|
export const archiveChannel = (
|
||||||
|
run: ChannelRunner,
|
||||||
|
scope: MutationScope & { channelId: string },
|
||||||
|
) =>
|
||||||
|
run(async (tx) => {
|
||||||
|
await requireActor(tx, scope);
|
||||||
|
const result = await withReceipt(
|
||||||
|
tx,
|
||||||
|
scope,
|
||||||
|
"archive_channel",
|
||||||
|
{ channelId: scope.channelId },
|
||||||
|
async () => {
|
||||||
|
const { actor, channel } = await lockMutable(
|
||||||
|
tx,
|
||||||
|
scope,
|
||||||
|
scope.channelId,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
channel.kind === "direct" ||
|
||||||
|
(channel.ownerUserId !== scope.actorUserId && !isAdmin(actor.role)) ||
|
||||||
|
(channel.kind === "private" &&
|
||||||
|
!(await tx.isChannelMember(
|
||||||
|
scope.workspaceId,
|
||||||
|
channel.id,
|
||||||
|
scope.actorUserId,
|
||||||
|
)))
|
||||||
|
)
|
||||||
|
throw new ChannelPersistenceError("NOT_FOUND");
|
||||||
|
const updated = await tx.archiveActiveChannel(
|
||||||
|
scope.workspaceId,
|
||||||
|
channel.id,
|
||||||
|
);
|
||||||
|
if (!updated) throw new ChannelPersistenceError("READ_ONLY");
|
||||||
|
await tx.emit(
|
||||||
|
parseEvent({
|
||||||
|
...eventBase(scope, channel.id),
|
||||||
|
eventType: "chat.channel.archived.v1",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
operation: "archive_channel",
|
||||||
|
channel: channelResult(updated),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return resultChannel(result);
|
||||||
|
});
|
||||||
|
async function memberMutation(
|
||||||
|
tx: ChannelTransaction,
|
||||||
|
scope: MutationScope & { channelId: string; userId: string },
|
||||||
|
action: "add_private_member" | "remove_private_member",
|
||||||
|
) {
|
||||||
|
await requireActor(tx, scope);
|
||||||
|
return withReceipt(
|
||||||
|
tx,
|
||||||
|
scope,
|
||||||
|
action,
|
||||||
|
{ channelId: scope.channelId, userId: scope.userId },
|
||||||
|
async () => {
|
||||||
|
const channel = await requirePrivateManager(tx, scope, scope.channelId);
|
||||||
|
if (
|
||||||
|
action === "add_private_member" &&
|
||||||
|
!(await tx.areWorkspaceMembers(scope.workspaceId, [scope.userId]))
|
||||||
|
)
|
||||||
|
throw new ChannelPersistenceError("NOT_FOUND");
|
||||||
|
if (
|
||||||
|
action === "remove_private_member" &&
|
||||||
|
channel.ownerUserId === scope.userId
|
||||||
|
)
|
||||||
|
throw new ChannelPersistenceError("INVALID");
|
||||||
|
const changed =
|
||||||
|
action === "add_private_member"
|
||||||
|
? await tx.addMember(scope.workspaceId, channel.id, scope.userId)
|
||||||
|
: await tx.removeMember(scope.workspaceId, channel.id, scope.userId);
|
||||||
|
if (changed)
|
||||||
|
await tx.emit(
|
||||||
|
parseEvent({
|
||||||
|
...eventBase(scope, channel.id),
|
||||||
|
eventType:
|
||||||
|
action === "add_private_member"
|
||||||
|
? "chat.channel.member.added.v1"
|
||||||
|
: "chat.channel.member.removed.v1",
|
||||||
|
subjectUserId: scope.userId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
operation: action,
|
||||||
|
member: { channelId: channel.id, userId: scope.userId, changed },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export const addPrivateMember = (
|
||||||
|
run: ChannelRunner,
|
||||||
|
scope: MutationScope & { channelId: string; userId: string },
|
||||||
|
) => run((tx) => memberMutation(tx, scope, "add_private_member"));
|
||||||
|
export const removePrivateMember = (
|
||||||
|
run: ChannelRunner,
|
||||||
|
scope: MutationScope & { channelId: string; userId: string },
|
||||||
|
) => run((tx) => memberMutation(tx, scope, "remove_private_member"));
|
||||||
|
export const createDirectChannel = (
|
||||||
|
run: ChannelRunner,
|
||||||
|
scope: MutationScope & { participantUserIds: string[] },
|
||||||
|
) =>
|
||||||
|
run(async (tx) => {
|
||||||
|
await requireActor(tx, scope);
|
||||||
|
const ids = [...new Set(scope.participantUserIds)].sort();
|
||||||
|
const result = await withReceipt(
|
||||||
|
tx,
|
||||||
|
scope,
|
||||||
|
"create_direct",
|
||||||
|
{ participantUserIds: ids },
|
||||||
|
async () => {
|
||||||
|
if (
|
||||||
|
ids.length < 2 ||
|
||||||
|
!ids.includes(scope.actorUserId) ||
|
||||||
|
!(await tx.areWorkspaceMembers(scope.workspaceId, ids))
|
||||||
|
)
|
||||||
|
throw new ChannelPersistenceError("INVALID");
|
||||||
|
const key = createHash("md5").update(ids.join(":")).digest("hex");
|
||||||
|
await tx.lockDirect(scope.workspaceId, key);
|
||||||
|
let channel = await tx.findDirect(scope.workspaceId, key);
|
||||||
|
if (channel) {
|
||||||
|
const existing = (
|
||||||
|
await tx.listMemberIds(scope.workspaceId, channel.id)
|
||||||
|
).sort();
|
||||||
|
if (
|
||||||
|
existing.length !== ids.length ||
|
||||||
|
existing.some((id, i) => id !== ids[i])
|
||||||
|
)
|
||||||
|
throw new ChannelPersistenceError("CONFLICT");
|
||||||
|
return {
|
||||||
|
operation: "create_direct",
|
||||||
|
channel: channelResult(channel),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
channel = await tx.insertDirect({
|
||||||
|
workspaceId: scope.workspaceId,
|
||||||
|
kind: "direct",
|
||||||
|
name: null,
|
||||||
|
ownerUserId: scope.actorUserId,
|
||||||
|
directKey: key,
|
||||||
|
});
|
||||||
|
if (!channel) throw new ChannelPersistenceError("CONFLICT");
|
||||||
|
await tx.addInitialDirectMembers(scope.workspaceId, channel.id, ids);
|
||||||
|
channel = await tx.finalizeDirect(scope.workspaceId, channel.id, key);
|
||||||
|
await tx.emit(
|
||||||
|
parseEvent({
|
||||||
|
...eventBase(scope, channel.id),
|
||||||
|
eventType: "chat.channel.created.v1",
|
||||||
|
kind: "direct",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
for (const id of ids)
|
||||||
|
await tx.emit(
|
||||||
|
parseEvent({
|
||||||
|
...eventBase(scope, channel.id),
|
||||||
|
eventType: "chat.channel.member.added.v1",
|
||||||
|
subjectUserId: id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return { operation: "create_direct", channel: channelResult(channel) };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return resultChannel(result);
|
||||||
|
});
|
||||||
53
packages/database/src/chat/message-drizzle-adapter.test.ts
Normal file
53
packages/database/src/chat/message-drizzle-adapter.test.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { PgDialect } from "drizzle-orm/pg-core";
|
||||||
|
import { chatOutbox } from "../schema";
|
||||||
|
import { createMessageMutationRunner } from "./message-drizzle-adapter";
|
||||||
|
|
||||||
|
type Database = NonNullable<Parameters<typeof createMessageMutationRunner>[0]>;
|
||||||
|
type Tx = Parameters<Parameters<Database["transaction"]>[0]>[0];
|
||||||
|
|
||||||
|
describe("created outbox redaction", () => {
|
||||||
|
it("binds plain JSON metadata and scopes redaction to the workspace/channel/message/create event", async () => {
|
||||||
|
const where = vi.fn(async () => undefined);
|
||||||
|
const set = vi.fn(() => ({ where }));
|
||||||
|
const update = vi.fn(() => ({ set }));
|
||||||
|
const tx = { update } as unknown as Tx;
|
||||||
|
const database = {
|
||||||
|
transaction: async (run: (tx: Tx) => Promise<unknown>) => run(tx),
|
||||||
|
} as unknown as Database;
|
||||||
|
const scope = {
|
||||||
|
workspaceId: "10000000-0000-4000-8000-000000000001",
|
||||||
|
channelId: "20000000-0000-4000-8000-000000000001",
|
||||||
|
messageId: "30000000-0000-4000-8000-000000000001",
|
||||||
|
};
|
||||||
|
await createMessageMutationRunner(database).transaction((adapter) =>
|
||||||
|
adapter.redactCreatedOutbox(scope),
|
||||||
|
);
|
||||||
|
expect(update).toHaveBeenCalledWith(chatOutbox);
|
||||||
|
expect(set).toHaveBeenCalledWith({
|
||||||
|
payload: {
|
||||||
|
eventType: "chat.message.created.v1",
|
||||||
|
eventVersion: 1,
|
||||||
|
...scope,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const condition = (
|
||||||
|
where.mock.calls as unknown as [[Parameters<PgDialect["sqlToQuery"]>[0]]]
|
||||||
|
)[0][0];
|
||||||
|
const query = new PgDialect().sqlToQuery(condition);
|
||||||
|
expect(query.params).toEqual([
|
||||||
|
scope.workspaceId,
|
||||||
|
scope.channelId,
|
||||||
|
scope.messageId,
|
||||||
|
"chat.message.created.v1",
|
||||||
|
]);
|
||||||
|
for (const column of [
|
||||||
|
"workspace_id",
|
||||||
|
"channel_id",
|
||||||
|
"message_id",
|
||||||
|
"event_type",
|
||||||
|
]) {
|
||||||
|
expect(query.sql).toContain(`"${column}"`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
253
packages/database/src/chat/message-drizzle-adapter.ts
Normal file
253
packages/database/src/chat/message-drizzle-adapter.ts
Normal file
|
|
@ -0,0 +1,253 @@
|
||||||
|
import { chatMessageMutationEventV1Schema } from "@tasks/shared";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { and, eq, sql } from "drizzle-orm";
|
||||||
|
|
||||||
|
import { db as defaultDb } from "../client";
|
||||||
|
import {
|
||||||
|
chatChannels,
|
||||||
|
chatMessageMutations,
|
||||||
|
chatMessageMentions,
|
||||||
|
chatMessages,
|
||||||
|
chatMutationReceipts,
|
||||||
|
chatOutbox,
|
||||||
|
} from "../schema";
|
||||||
|
import { transactionAdapter } from "./drizzle-adapter";
|
||||||
|
import {
|
||||||
|
mutateChatMessage,
|
||||||
|
type MessageMutationRunner,
|
||||||
|
type MessageMutationTransaction,
|
||||||
|
} from "./mutate-message";
|
||||||
|
|
||||||
|
// Reuse the send adapter's tenant-scoped membership lookup; mutations use the same channel policy.
|
||||||
|
type Db = typeof defaultDb;
|
||||||
|
type Tx = Parameters<Parameters<Db["transaction"]>[0]>[0];
|
||||||
|
function adapter(tx: Tx): MessageMutationTransaction {
|
||||||
|
return {
|
||||||
|
async claim(scope) {
|
||||||
|
const lockKey = `${scope.workspaceId}:${scope.actorUserId}:${scope.clientMutationId}`;
|
||||||
|
await tx.execute(
|
||||||
|
sql`select pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))`,
|
||||||
|
);
|
||||||
|
const [receipt] = await tx
|
||||||
|
.select()
|
||||||
|
.from(chatMutationReceipts)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(chatMutationReceipts.workspaceId, scope.workspaceId),
|
||||||
|
eq(chatMutationReceipts.actorUserId, scope.actorUserId),
|
||||||
|
eq(chatMutationReceipts.clientMutationId, scope.clientMutationId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
return receipt ?? null;
|
||||||
|
},
|
||||||
|
async facts(scope) {
|
||||||
|
// This is deliberately the same member/channel lookup as send, not a role-based shortcut.
|
||||||
|
return transactionAdapter(tx).loadSendFacts(scope);
|
||||||
|
},
|
||||||
|
async lockMessage(scope) {
|
||||||
|
const [message] = await tx
|
||||||
|
.select({
|
||||||
|
id: chatMessages.id,
|
||||||
|
workspaceId: chatMessages.workspaceId,
|
||||||
|
channelId: chatMessages.channelId,
|
||||||
|
authorUserId: chatMessages.authorUserId,
|
||||||
|
status: chatMessages.status,
|
||||||
|
revision: chatMessages.revision,
|
||||||
|
})
|
||||||
|
.from(chatMessages)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(chatMessages.workspaceId, scope.workspaceId),
|
||||||
|
eq(chatMessages.channelId, scope.channelId),
|
||||||
|
eq(chatMessages.id, scope.messageId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.for("update")
|
||||||
|
.limit(1);
|
||||||
|
return message ?? null;
|
||||||
|
},
|
||||||
|
async pinLegacySendIntent(scope) {
|
||||||
|
const [message] = await tx
|
||||||
|
.select({
|
||||||
|
body: chatMessages.body,
|
||||||
|
authorUserId: chatMessages.authorUserId,
|
||||||
|
parentMessageId: chatMessages.parentMessageId,
|
||||||
|
sendIntentFingerprint: chatMessages.sendIntentFingerprint,
|
||||||
|
})
|
||||||
|
.from(chatMessages)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(chatMessages.workspaceId, scope.workspaceId),
|
||||||
|
eq(chatMessages.channelId, scope.channelId),
|
||||||
|
eq(chatMessages.id, scope.messageId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (!message || message.sendIntentFingerprint) return;
|
||||||
|
const mentions = await tx
|
||||||
|
.select({ userId: chatMessageMentions.userId })
|
||||||
|
.from(chatMessageMentions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(chatMessageMentions.workspaceId, scope.workspaceId),
|
||||||
|
eq(chatMessageMentions.messageId, scope.messageId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const fingerprint = createHash("sha256")
|
||||||
|
.update(
|
||||||
|
JSON.stringify([
|
||||||
|
scope.workspaceId,
|
||||||
|
scope.channelId,
|
||||||
|
message.authorUserId,
|
||||||
|
message.body,
|
||||||
|
message.parentMessageId,
|
||||||
|
mentions.map((row) => row.userId).sort(),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.digest("hex");
|
||||||
|
await tx
|
||||||
|
.update(chatMessages)
|
||||||
|
.set({ sendIntentFingerprint: fingerprint })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(chatMessages.workspaceId, scope.workspaceId),
|
||||||
|
eq(chatMessages.channelId, scope.channelId),
|
||||||
|
eq(chatMessages.id, scope.messageId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
async apply(scope) {
|
||||||
|
const [updated] = await tx
|
||||||
|
.update(chatMessages)
|
||||||
|
.set(
|
||||||
|
scope.type === "edit_message"
|
||||||
|
? {
|
||||||
|
body: scope.body!,
|
||||||
|
status: "edited",
|
||||||
|
editedAt: new Date(),
|
||||||
|
revision: scope.revision,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
body: "",
|
||||||
|
status: "deleted",
|
||||||
|
deletedAt: new Date(),
|
||||||
|
revision: scope.revision,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(chatMessages.workspaceId, scope.workspaceId),
|
||||||
|
eq(chatMessages.channelId, scope.channelId),
|
||||||
|
eq(chatMessages.id, scope.messageId),
|
||||||
|
eq(chatMessages.revision, scope.revision - 1),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning({ id: chatMessages.id });
|
||||||
|
if (!updated) throw new Error("Concurrent message update");
|
||||||
|
const [channel] = await tx
|
||||||
|
.update(chatChannels)
|
||||||
|
.set({
|
||||||
|
nextMutationSequence: sql`${chatChannels.nextMutationSequence} + 1`,
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(chatChannels.workspaceId, scope.workspaceId),
|
||||||
|
eq(chatChannels.id, scope.channelId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning({ nextMutationSequence: chatChannels.nextMutationSequence });
|
||||||
|
if (!channel) throw new Error("Channel disappeared during mutation");
|
||||||
|
const status = scope.type === "edit_message" ? "edited" : "deleted";
|
||||||
|
await tx.insert(chatMessageMutations).values({
|
||||||
|
workspaceId: scope.workspaceId,
|
||||||
|
channelId: scope.channelId,
|
||||||
|
messageId: scope.messageId,
|
||||||
|
mutationSequence: channel.nextMutationSequence - 1n,
|
||||||
|
revision: scope.revision,
|
||||||
|
kind: status,
|
||||||
|
});
|
||||||
|
return { messageId: updated.id, revision: scope.revision, status };
|
||||||
|
},
|
||||||
|
async redactCreatedOutbox(scope) {
|
||||||
|
// Historical create payloads contain the original body. Strip them before publishing
|
||||||
|
// a mutation so a delayed worker cannot replay stale content after edit/delete.
|
||||||
|
await tx
|
||||||
|
.update(chatOutbox)
|
||||||
|
.set({
|
||||||
|
// Plain JSON — avoid jsonb_build_object() params (Postgres: could not determine data type of parameter $1).
|
||||||
|
payload: {
|
||||||
|
eventType: "chat.message.created.v1",
|
||||||
|
eventVersion: 1,
|
||||||
|
workspaceId: scope.workspaceId,
|
||||||
|
channelId: scope.channelId,
|
||||||
|
messageId: scope.messageId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(chatOutbox.workspaceId, scope.workspaceId),
|
||||||
|
eq(chatOutbox.channelId, scope.channelId),
|
||||||
|
eq(chatOutbox.messageId, scope.messageId),
|
||||||
|
eq(chatOutbox.eventType, "chat.message.created.v1"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
async insertOutbox(scope) {
|
||||||
|
await tx.insert(chatOutbox).values({
|
||||||
|
workspaceId: scope.workspaceId,
|
||||||
|
channelId: scope.channelId,
|
||||||
|
messageId: scope.messageId,
|
||||||
|
eventVersion: 1,
|
||||||
|
eventType: scope.eventType,
|
||||||
|
dedupeKey: `mutation:${scope.actorUserId}:${scope.clientMutationId}`,
|
||||||
|
payload: chatMessageMutationEventV1Schema.parse({
|
||||||
|
eventType: scope.eventType,
|
||||||
|
eventVersion: 1,
|
||||||
|
workspaceId: scope.workspaceId,
|
||||||
|
channelId: scope.channelId,
|
||||||
|
messageId: scope.messageId,
|
||||||
|
revision: scope.result.revision,
|
||||||
|
status: scope.result.status,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async saveReceipt(scope) {
|
||||||
|
await tx.insert(chatMutationReceipts).values({ ...scope });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function retryable(error: unknown): boolean {
|
||||||
|
if (!error || typeof error !== "object") return false;
|
||||||
|
const e = error as { code?: unknown; cause?: unknown };
|
||||||
|
return (
|
||||||
|
e.code === "40001" ||
|
||||||
|
e.code === "40P01" ||
|
||||||
|
(e.cause !== undefined && retryable(e.cause))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export function createMessageMutationRunner(
|
||||||
|
database: Db = defaultDb,
|
||||||
|
): MessageMutationRunner {
|
||||||
|
return {
|
||||||
|
async transaction(run) {
|
||||||
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
|
try {
|
||||||
|
return await database.transaction((tx) => run(adapter(tx)), {
|
||||||
|
isolationLevel: "repeatable read",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (attempt === 2 || !retryable(error)) throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("Unreachable retry state");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export async function mutateChatMessageWithDb(
|
||||||
|
input: Parameters<typeof mutateChatMessage>[1],
|
||||||
|
database: Db = defaultDb,
|
||||||
|
) {
|
||||||
|
return mutateChatMessage(createMessageMutationRunner(database), input);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue