plans: scaffold daily-driver-finish, saas-hardening, agent-coordination
Three new plan trees that fill in the gaps surfaced during repo review. Together they map out what remains between the current scaffold-with-stubs state and a daily-usable, multitenant, agent-coordinated app. * Plan-daily-driver-finish (P0): turn stubs into real data. Five tasks covering the lint/shared-types breakage, hardcoded dashboard mocks, AI-page setTimeout placeholder, post-signin landing decision, and a cross-browser collab smoke test against the deployed Hocuspocus instance. * Plan-multitenant-saas-hardening (P1): everything multitenant needs beyond what Plan-multitenant-cursor-sync already covers. Invites and role management, soft-delete + append-only audit log, rate limits on the auth + mutation hot paths, and a Vitest + GitHub Actions test foundation so PRs can't ship red. * Plan-agent-coordination (P2): the layer that makes a Task-*.md runnable, not just readable. Adds workflow_prompt with task -> epic -> plan inheritance, an agent_runs table for auditable sessions, and two new MCP tools (claim_task / complete_task) that replace the freeform update_object composition agents do today. Includes an intentionally-deferred Epic-optional-orchestrator that captures the Symphony-shaped runner as a decision point rather than an immediate build. Each task is bead-scale (one focused Cursor session) with explicit in-scope, out-of-scope, and anti-goal sections so a future agent can pick up a single Task-*.md and start without scrollback context. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
875b1cfc87
commit
778fe1d321
22 changed files with 1649 additions and 0 deletions
|
|
@ -0,0 +1,54 @@
|
||||||
|
---
|
||||||
|
kind: epic
|
||||||
|
slug: mcp-claim-complete
|
||||||
|
title: MCP claim_task and complete_task — atomic lifecycle for agents
|
||||||
|
plan_slug: agent-coordination
|
||||||
|
status: ready
|
||||||
|
priority: P2
|
||||||
|
tenant_id: global
|
||||||
|
cursor_epic_id: null
|
||||||
|
updated_at: "2026-06-01"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Epic objective
|
||||||
|
|
||||||
|
Add two MCP tools that give a coding agent a shaped lifecycle for a task: `claim_task` (status flip + open `agent_runs` row + return effective workflow prompt) and `complete_task` (status flip + close `agent_runs` row with outcome + tokens).
|
||||||
|
|
||||||
|
These replace the current freeform composition where an agent has to call `update_object` repeatedly and pray.
|
||||||
|
|
||||||
|
## In scope / out of scope
|
||||||
|
|
||||||
|
**In scope**
|
||||||
|
|
||||||
|
- New MCP tools `claim_task` and `complete_task` with zod schemas.
|
||||||
|
- Idempotency rules (don't double-claim).
|
||||||
|
- Integration with the `agent_runs` table from the previous epic.
|
||||||
|
|
||||||
|
**Out of scope**
|
||||||
|
|
||||||
|
- A long-running orchestrator (deferred epic).
|
||||||
|
- A `pause_task` / `resume_task` tool. Resist; you can model that with two `complete_task` calls (`cancelled` + a new claim) until evidence demands it.
|
||||||
|
|
||||||
|
## Related tasks
|
||||||
|
|
||||||
|
| Task | Link |
|
||||||
|
|------|------|
|
||||||
|
| MCP claim_task tool | `./Task-mcp-claim-task-tool.md` |
|
||||||
|
| MCP complete_task tool | `./Task-mcp-complete-task-tool.md` |
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Depends on: `Epic-task-as-runnable-unit` (needs `workflow_prompt` and `agent_runs`).
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] An agent can call `claim_task({ workspace, backlogItemId })` and receive the effective workflow prompt + a `runId`.
|
||||||
|
- [ ] An agent can call `complete_task({ runId, outcome, tokens, notes })` to finalize.
|
||||||
|
- [ ] Double-claim of the same task by the same actor returns the existing open run; by a different actor returns an explicit "already claimed by X" error.
|
||||||
|
|
||||||
|
## Proposed timeline
|
||||||
|
|
||||||
|
| Phase | Window | Notes |
|
||||||
|
|-------|--------|-------|
|
||||||
|
| claim_task | Day 1 | Tool + idempotency. |
|
||||||
|
| complete_task | Day 2 | Tool + run finalization. |
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
---
|
||||||
|
kind: task
|
||||||
|
slug: mcp-claim-task-tool
|
||||||
|
title: MCP claim_task tool — open an agent_runs row and return the prompt
|
||||||
|
plan_slug: agent-coordination
|
||||||
|
epic_slug: mcp-claim-complete
|
||||||
|
status: ready
|
||||||
|
priority: P2
|
||||||
|
tenant_id: global
|
||||||
|
owner: unassigned
|
||||||
|
cursor_todo_id: null
|
||||||
|
updated_at: "2026-06-01"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task summary
|
||||||
|
|
||||||
|
A new MCP tool `claim_task` that an agent calls at session start. It flips the backlog item to `in_progress`, opens an `agent_runs` row, and returns the effective workflow prompt + run id.
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
### Contract
|
||||||
|
|
||||||
|
Register in `apps/mcp-server/src/tools/claim-task.ts` (mirror the structure of `create-object.ts`).
|
||||||
|
|
||||||
|
Input zod schema:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
workspace: string, // slug or UUID, resolved via resolveWorkspaceHandle
|
||||||
|
backlogItemId: string, // uuid
|
||||||
|
actorUserId?: string, // optional — defaults to null ("system / dev session")
|
||||||
|
notes?: string, // short opener text the agent can attach to the run
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
1. Resolve the workspace handle (existing helper).
|
||||||
|
2. Look up the backlog item; verify it belongs to the resolved workspace. 404 otherwise.
|
||||||
|
3. Check for an existing open `agent_runs` row (`finished_at IS NULL`) for this `backlog_item_id`:
|
||||||
|
- If one exists *with the same `actor_user_id`*: return it (idempotent re-claim). Refresh its `notes` if provided.
|
||||||
|
- If one exists *with a different actor* (or actor is null on both sides): return error `ALREADY_CLAIMED` with the existing actor id (or "anonymous").
|
||||||
|
4. Insert a new `agent_runs` row: `workspace_id`, `backlog_item_id`, `actor_user_id`, `started_at=now()`, `notes`.
|
||||||
|
5. Update the backlog item: `status='in_progress'` (only if currently `ready` or `draft`; leave alone if already `in_progress` or `done`).
|
||||||
|
6. Compute and return:
|
||||||
|
- `runId`: the new `agent_runs.id`
|
||||||
|
- `workflowPrompt`: result of `resolveWorkflowPrompt`
|
||||||
|
- `backlogItem`: a minimal snapshot (`title`, `body_markdown`, `status`)
|
||||||
|
|
||||||
|
### Output shape
|
||||||
|
|
||||||
|
Use the existing `toolOk` / `toolErr` helpers in `apps/mcp-server/src/tools/tool-result.ts`. Return as structured tool output so the in-session model can pattern-match.
|
||||||
|
|
||||||
|
### Audit log
|
||||||
|
|
||||||
|
Write an `audit_log` row (`action: "task.claimed"`, `target_type: "agent_run"`, `target_id: runId`, `metadata: { backlog_item_id, prior_status }`). Assumes `audit_log` from `Plan-multitenant-saas-hardening` has landed; if not, file a follow-up to add audit writes when it does.
|
||||||
|
|
||||||
|
### Anti-goals
|
||||||
|
|
||||||
|
- No re-fetching from external systems. The tool only touches Echodo's DB.
|
||||||
|
- No "soft lock" mechanic (timeout-based claims). If a run goes stale, the operator (or `complete_task`) ends it explicitly.
|
||||||
|
|
||||||
|
## Subtasks
|
||||||
|
|
||||||
|
- [ ] Create `apps/mcp-server/src/tools/claim-task.ts`.
|
||||||
|
- [ ] Register in `apps/mcp-server/src/tools/index.ts`.
|
||||||
|
- [ ] Implement the 6-step behavior with proper zod validation.
|
||||||
|
- [ ] Audit log write (or follow-up task if `audit_log` isn't in yet).
|
||||||
|
- [ ] Verify by running the MCP server locally and calling the tool with a stub agent.
|
||||||
|
|
||||||
|
## Owner or assignee
|
||||||
|
|
||||||
|
Unassigned
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
ready
|
||||||
|
|
||||||
|
## Estimation
|
||||||
|
|
||||||
|
M
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Successful claim returns `runId`, `workflowPrompt`, and `backlogItem`.
|
||||||
|
- [ ] Re-claim by same actor returns the same `runId`.
|
||||||
|
- [ ] Claim by different actor errors with `ALREADY_CLAIMED`.
|
||||||
|
- [ ] Backlog item transitions to `in_progress` only if previously `ready` or `draft`.
|
||||||
|
|
||||||
|
## Links to related Epic / Plan
|
||||||
|
|
||||||
|
- Epic: `./Epic-mcp-claim-complete.md`
|
||||||
|
- Plan: `../Plan-agent-coordination.md`
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
---
|
||||||
|
kind: task
|
||||||
|
slug: mcp-complete-task-tool
|
||||||
|
title: MCP complete_task tool — close an agent_runs row and finalize status
|
||||||
|
plan_slug: agent-coordination
|
||||||
|
epic_slug: mcp-claim-complete
|
||||||
|
status: ready
|
||||||
|
priority: P2
|
||||||
|
tenant_id: global
|
||||||
|
owner: unassigned
|
||||||
|
cursor_todo_id: null
|
||||||
|
updated_at: "2026-06-01"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task summary
|
||||||
|
|
||||||
|
A new MCP tool `complete_task` that an agent calls at session end. It closes the `agent_runs` row with an outcome and token totals, and optionally flips the backlog item to `done` (or another terminal status).
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
### Contract
|
||||||
|
|
||||||
|
Register in `apps/mcp-server/src/tools/complete-task.ts`.
|
||||||
|
|
||||||
|
Input zod schema:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
runId: string, // uuid of the agent_runs row to close
|
||||||
|
outcome: "succeeded" | "failed" | "cancelled" | "stalled",
|
||||||
|
tokensInput?: number,
|
||||||
|
tokensOutput?: number,
|
||||||
|
tokensTotal?: number,
|
||||||
|
notes?: string, // freeform summary, capped at ~2000 chars
|
||||||
|
error?: string, // optional failure message
|
||||||
|
finalStatus?: "done" | "blocked" | "ready" | "in_progress" | "cancelled",
|
||||||
|
// optional override for the backlog item's status
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
1. Look up the run; verify it's still open (`finished_at IS NULL`). If already closed, error `RUN_ALREADY_FINISHED`.
|
||||||
|
2. Resolve the backlog item and confirm its workspace matches the run's workspace.
|
||||||
|
3. Update the run row: `finished_at=now()`, `outcome`, tokens, `notes`, `error`.
|
||||||
|
4. Compute the final backlog status:
|
||||||
|
- If `finalStatus` provided: use it (validate it's a legal `markdown_backlog_items.status` value).
|
||||||
|
- Else if `outcome === "succeeded"`: set status to `done`.
|
||||||
|
- Else if `outcome === "failed"`: set status to `blocked`.
|
||||||
|
- Else: leave status as-is.
|
||||||
|
5. Write `audit_log` row (`action: "task.completed"`, `metadata: { outcome, final_status }`).
|
||||||
|
6. Return `{ runId, finishedAt, finalStatus, tokensTotal }`.
|
||||||
|
|
||||||
|
### Token semantics
|
||||||
|
|
||||||
|
Take the agent at its word for `tokensTotal` — don't recompute from input+output. This matches Symphony's "prefer absolute thread totals" rule (`SPEC.md` §13.5) and avoids double-counting when models report cumulative totals natively.
|
||||||
|
|
||||||
|
If `tokensTotal` is omitted but `tokensInput` and `tokensOutput` are provided, compute total as `input + output` and store. If all three are present and inconsistent, prefer `tokensTotal` and don't error.
|
||||||
|
|
||||||
|
### Idempotency
|
||||||
|
|
||||||
|
Closing an already-closed run is an error, not a silent no-op. The agent should know it tried to close something twice. If the operator wants to amend a closed run, they can do it via a future tRPC procedure — not through this tool.
|
||||||
|
|
||||||
|
### Anti-goals
|
||||||
|
|
||||||
|
- No streaming updates. This tool runs once at session end.
|
||||||
|
- No "extend" or "renew" semantics. A long session that the agent thinks is still going should keep its run open by *not* calling `complete_task`. Stall detection is the orchestrator's job (deferred).
|
||||||
|
|
||||||
|
## Subtasks
|
||||||
|
|
||||||
|
- [ ] Create `apps/mcp-server/src/tools/complete-task.ts`.
|
||||||
|
- [ ] Register in `apps/mcp-server/src/tools/index.ts`.
|
||||||
|
- [ ] Implement the 6-step behavior with zod validation.
|
||||||
|
- [ ] Audit log write.
|
||||||
|
- [ ] Verify with a manual end-to-end loop: `claim_task` → do nothing → `complete_task` and confirm the run row is closed.
|
||||||
|
|
||||||
|
## Owner or assignee
|
||||||
|
|
||||||
|
Unassigned
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
ready
|
||||||
|
|
||||||
|
## Estimation
|
||||||
|
|
||||||
|
M
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Successful close updates `finished_at`, `outcome`, tokens, and (when appropriate) the backlog item's status.
|
||||||
|
- [ ] Double-close errors with `RUN_ALREADY_FINISHED`.
|
||||||
|
- [ ] Token inconsistency is resolved by preferring `tokensTotal`.
|
||||||
|
|
||||||
|
## Links to related Epic / Plan
|
||||||
|
|
||||||
|
- Epic: `./Epic-mcp-claim-complete.md`
|
||||||
|
- Plan: `../Plan-agent-coordination.md`
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
---
|
||||||
|
kind: epic
|
||||||
|
slug: optional-orchestrator
|
||||||
|
title: Optional orchestrator — deferred until manual usage proves the shape
|
||||||
|
plan_slug: agent-coordination
|
||||||
|
status: draft
|
||||||
|
priority: P3
|
||||||
|
tenant_id: global
|
||||||
|
cursor_epic_id: null
|
||||||
|
updated_at: "2026-06-01"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Epic objective
|
||||||
|
|
||||||
|
**Deferred.** A Symphony-style orchestrator that polls `markdown_backlog_items` for ready tasks, spawns a coding agent per task in an isolated workspace directory, and writes runs back through the MCP tools from `Epic-mcp-claim-complete`.
|
||||||
|
|
||||||
|
This epic is intentionally left without tasks. It represents the decision point at M4 in the plan: *after 10+ real runs through `claim_task` / `complete_task`, decide whether building an orchestrator is worth it.*
|
||||||
|
|
||||||
|
## Why this is deferred
|
||||||
|
|
||||||
|
Building an orchestrator before the manual abstractions are proven is the classic premature-automation trap. Symphony itself only earns its complexity because of high-volume Linear-driven workflows. For a single operator or a small tenant base, you can get most of the value by:
|
||||||
|
|
||||||
|
- Operator starts a Cursor session in the target repo.
|
||||||
|
- Operator (or in-session agent) calls `claim_task` via the MCP tool.
|
||||||
|
- Work happens.
|
||||||
|
- Agent calls `complete_task`.
|
||||||
|
|
||||||
|
If, after using that loop for a while, the friction is "I want this to happen on a cron / on issue creation / overnight in the background," *then* this epic earns its tasks.
|
||||||
|
|
||||||
|
## Open questions to revisit
|
||||||
|
|
||||||
|
- Do we want a TypeScript orchestrator inside `apps/orchestrator/`, or do we want to make Echodo a Symphony-compatible tracker and run Symphony itself against it?
|
||||||
|
- Per-task workspace lifecycle: git worktree vs full clone? Hooks for setup/teardown?
|
||||||
|
- How does the orchestrator authenticate to a tenant? (Service account? Tenant-issued token?)
|
||||||
|
- Multitenancy is the load-bearing constraint Symphony doesn't have; how do we cap concurrent runs per workspace, not just per orchestrator process?
|
||||||
|
|
||||||
|
When this epic graduates from `draft`, the first task should be a *design doc* (not code) that picks answers to those questions.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
draft (deferred until M4 decision in `Plan-agent-coordination`).
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- Plan: `../Plan-agent-coordination.md`
|
||||||
|
- Reference: [openai/symphony SPEC.md](https://github.com/openai/symphony/blob/main/SPEC.md) — particularly §7 (orchestrator state machine) and §13 (observability).
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
---
|
||||||
|
kind: epic
|
||||||
|
slug: task-as-runnable-unit
|
||||||
|
title: Task as runnable unit — workflow prompts and a runs table
|
||||||
|
plan_slug: agent-coordination
|
||||||
|
status: ready
|
||||||
|
priority: P2
|
||||||
|
tenant_id: global
|
||||||
|
cursor_epic_id: null
|
||||||
|
updated_at: "2026-06-01"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Epic objective
|
||||||
|
|
||||||
|
Every backlog item can carry a workflow prompt; every agent session against a task gets recorded in a `runs` table. These two changes are the foundation that `claim_task` / `complete_task` build on.
|
||||||
|
|
||||||
|
## In scope / out of scope
|
||||||
|
|
||||||
|
**In scope**
|
||||||
|
|
||||||
|
- Schema additions for workflow prompts.
|
||||||
|
- New `runs` table + tRPC router.
|
||||||
|
- Minimal UI for editing the prompt and viewing recent runs.
|
||||||
|
|
||||||
|
**Out of scope**
|
||||||
|
|
||||||
|
- The actual MCP tools (next epic).
|
||||||
|
- Orchestrator process (deferred epic).
|
||||||
|
|
||||||
|
## Related tasks
|
||||||
|
|
||||||
|
| Task | Link |
|
||||||
|
|------|------|
|
||||||
|
| Add workflow prompt to backlog items | `./Task-add-workflow-prompt-to-backlog-items.md` |
|
||||||
|
| Runs table and tRPC router | `./Task-runs-table-and-trpc-router.md` |
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Depends on: `Plan-daily-driver-finish` (don't extend stubs).
|
||||||
|
- Blocks: `Epic-mcp-claim-complete`.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] A task row can store an optional workflow prompt that overrides the epic/plan default.
|
||||||
|
- [ ] A `runs` row captures `started_at`, `finished_at`, `actor_user_id`, `outcome`, `tokens_input`, `tokens_output`, and `notes`.
|
||||||
|
- [ ] Minimal UI lets an operator read a task's effective prompt and recent runs.
|
||||||
|
|
||||||
|
## Proposed timeline
|
||||||
|
|
||||||
|
| Phase | Window | Notes |
|
||||||
|
|-------|--------|-------|
|
||||||
|
| Schema + procedures | Day 1 | Columns, table, tRPC router. |
|
||||||
|
| UI surfacing | Day 2 | Inline prompt editor, recent-runs panel. |
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
---
|
||||||
|
kind: task
|
||||||
|
slug: add-workflow-prompt-to-backlog-items
|
||||||
|
title: Add workflow_prompt column to markdown_backlog_items, with inheritance
|
||||||
|
plan_slug: agent-coordination
|
||||||
|
epic_slug: task-as-runnable-unit
|
||||||
|
status: ready
|
||||||
|
priority: P2
|
||||||
|
tenant_id: global
|
||||||
|
owner: unassigned
|
||||||
|
cursor_todo_id: null
|
||||||
|
updated_at: "2026-06-01"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task summary
|
||||||
|
|
||||||
|
Backlog items need a place to store a per-item agent prompt. Add a `workflow_prompt` text column to `markdown_backlog_items`, parse it from frontmatter, and define an inheritance chain (task → epic → plan → workspace default).
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
### Frontmatter contract
|
||||||
|
|
||||||
|
Add an optional `agent_prompt:` field to the YAML frontmatter of Plan/Epic/Task templates. It's a multiline string (use `|` block scalar in YAML). Example:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
agent_prompt: |
|
||||||
|
You are working on a focused implementation task in a multitenant TypeScript monorepo.
|
||||||
|
Read the task body in full before writing any code.
|
||||||
|
Validate every external input with zod.
|
||||||
|
Run pnpm lint && pnpm type-check before declaring done.
|
||||||
|
```
|
||||||
|
|
||||||
|
Update `docs/templates/{plan,epic,task}-template.md` to include the field (commented-out for tasks, since most tasks will inherit).
|
||||||
|
|
||||||
|
### Schema
|
||||||
|
|
||||||
|
Add to `markdown_backlog_items`:
|
||||||
|
|
||||||
|
- `workflow_prompt` text null
|
||||||
|
|
||||||
|
No new index needed; this column is rarely filtered on.
|
||||||
|
|
||||||
|
### Parser
|
||||||
|
|
||||||
|
In `packages/database/src/markdown-backlog/parse.ts`, extract `agent_prompt` from the parsed frontmatter into `workflow_prompt` on the upserted row. Use the existing zod schema for backlog frontmatter — extend it; don't fork.
|
||||||
|
|
||||||
|
### Inheritance helper
|
||||||
|
|
||||||
|
A new function `resolveWorkflowPrompt(db, { workspaceId, backlogItemId })` that walks task → epic → plan and returns the first non-null `workflow_prompt`. If none exist, return a built-in default string (lives as a constant in `packages/database/src/markdown-backlog/`). Workspace-level overrides can come later.
|
||||||
|
|
||||||
|
Don't store the resolved prompt anywhere — always compute on read. Caching is premature; this is a small lookup.
|
||||||
|
|
||||||
|
### Minimal UI
|
||||||
|
|
||||||
|
Add a "Workflow prompt" section to the task detail panel (`apps/web/components/panels/object-detail.tsx` or wherever the backlog-item detail lives). Show:
|
||||||
|
|
||||||
|
- "Effective prompt" — the result of `resolveWorkflowPrompt` (read-only).
|
||||||
|
- "Override" — a textarea bound to the item's own `workflow_prompt`. Empty = inherit.
|
||||||
|
|
||||||
|
Use a tRPC procedure `backlog.updateWorkflowPrompt({ backlogItemId, workflowPrompt })` that re-renders the panel on success.
|
||||||
|
|
||||||
|
### Anti-goals
|
||||||
|
|
||||||
|
- Don't write a template engine for the prompt. Symphony uses Liquid; we don't need that yet. A plain string is fine.
|
||||||
|
- Don't store prompts in `objects`. The agent flow is anchored on the markdown backlog, not the generic objects table.
|
||||||
|
|
||||||
|
## Subtasks
|
||||||
|
|
||||||
|
- [ ] Extend backlog frontmatter zod schema with `agent_prompt`.
|
||||||
|
- [ ] Add `workflow_prompt` column + migration.
|
||||||
|
- [ ] Parse and persist in `parse.ts`.
|
||||||
|
- [ ] Implement `resolveWorkflowPrompt` with the inheritance walk.
|
||||||
|
- [ ] Update Plan/Epic/Task templates in `docs/templates/`.
|
||||||
|
- [ ] Add UI panel section.
|
||||||
|
|
||||||
|
## Owner or assignee
|
||||||
|
|
||||||
|
Unassigned
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
ready
|
||||||
|
|
||||||
|
## Estimation
|
||||||
|
|
||||||
|
M
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] A task with no override falls back to its epic's prompt; an epic with no override falls back to its plan; a plan with no override falls back to the built-in default.
|
||||||
|
- [ ] Setting `agent_prompt:` in frontmatter and re-importing populates `workflow_prompt`.
|
||||||
|
- [ ] UI shows effective prompt and override box.
|
||||||
|
|
||||||
|
## Links to related Epic / Plan
|
||||||
|
|
||||||
|
- Epic: `./Epic-task-as-runnable-unit.md`
|
||||||
|
- Plan: `../Plan-agent-coordination.md`
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
---
|
||||||
|
kind: task
|
||||||
|
slug: runs-table-and-trpc-router
|
||||||
|
title: Runs table, tRPC router, and minimal recent-runs UI
|
||||||
|
plan_slug: agent-coordination
|
||||||
|
epic_slug: task-as-runnable-unit
|
||||||
|
status: ready
|
||||||
|
priority: P2
|
||||||
|
tenant_id: global
|
||||||
|
owner: unassigned
|
||||||
|
cursor_todo_id: null
|
||||||
|
updated_at: "2026-06-01"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task summary
|
||||||
|
|
||||||
|
Every agent session against a task gets a `runs` row. Add the table, a tRPC router for reads, and a minimal UI to see recent runs.
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
### Schema
|
||||||
|
|
||||||
|
New table `agent_runs`:
|
||||||
|
|
||||||
|
- `id` uuid pk
|
||||||
|
- `workspace_id` uuid not null (indexed)
|
||||||
|
- `backlog_item_id` uuid not null references `markdown_backlog_items.id` on delete cascade
|
||||||
|
- `actor_user_id` uuid null references `users.id` (null = anonymous / dev session)
|
||||||
|
- `started_at` timestamptz default now (not null)
|
||||||
|
- `finished_at` timestamptz null
|
||||||
|
- `outcome` varchar null — one of `succeeded`, `failed`, `cancelled`, `stalled` (matches Symphony's normalized vocabulary)
|
||||||
|
- `error` text null
|
||||||
|
- `tokens_input` integer null
|
||||||
|
- `tokens_output` integer null
|
||||||
|
- `tokens_total` integer null
|
||||||
|
- `notes` text null — short string the agent (or operator) can write to summarize the run
|
||||||
|
- `metadata` jsonb null — extensible; keep small
|
||||||
|
- Indexes: `(workspace_id, started_at desc)` for the recent-runs query, `(backlog_item_id, started_at desc)` for the per-task history.
|
||||||
|
|
||||||
|
### tRPC router
|
||||||
|
|
||||||
|
New `apps/web/server/routers/runs.ts`:
|
||||||
|
|
||||||
|
- `runs.listRecent({ workspaceSlug, limit?: number })` — workspace-scoped, default limit 25.
|
||||||
|
- `runs.listForTask({ workspaceSlug, backlogItemId })` — workspace-scoped + verify the item belongs to the workspace.
|
||||||
|
- `runs.summary({ workspaceSlug })` — counts by `outcome` for the last 7 days, plus aggregate `tokens_total`. Mirror the shape of Symphony's `GET /api/v1/state` snapshot but adapted to our domain.
|
||||||
|
|
||||||
|
### UI
|
||||||
|
|
||||||
|
1. **Workspace settings**: new route `/[workspaceSlug]/settings/runs` showing the last 25 runs, paginated, with task title, started, finished, outcome, token total. Filter by outcome.
|
||||||
|
2. **Task detail panel**: a "Recent runs" sub-section listing the last 5 runs for the current task.
|
||||||
|
3. **Workspace home (after `Task-wire-workspace-home-dashboard`)**: add a small "Agent runs this week" card sourced from `runs.summary`.
|
||||||
|
|
||||||
|
### What this task does NOT do
|
||||||
|
|
||||||
|
- It does *not* implement `claim_task` / `complete_task` (next epic). Those are the *write path* for these rows. This task is read-side + storage only.
|
||||||
|
- It does not implement an orchestrator. Rows can be inserted manually for testing.
|
||||||
|
|
||||||
|
### Token accounting note
|
||||||
|
|
||||||
|
Mirror Symphony's lesson: prefer absolute thread totals (`tokens_total`) over per-call deltas, and avoid double-counting. Since this table is written by the MCP tools (next epic), the agent reports a single `tokens_total` at close-out time, not incremental deltas.
|
||||||
|
|
||||||
|
## Subtasks
|
||||||
|
|
||||||
|
- [ ] Schema + migration for `agent_runs`.
|
||||||
|
- [ ] tRPC router with the 3 procedures.
|
||||||
|
- [ ] Settings runs view with pagination + outcome filter.
|
||||||
|
- [ ] Task detail panel "Recent runs" section.
|
||||||
|
- [ ] Hook the workspace-home "Agent runs this week" card (skip if the home dashboard task hasn't landed yet — file a follow-up).
|
||||||
|
|
||||||
|
## Owner or assignee
|
||||||
|
|
||||||
|
Unassigned
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
ready
|
||||||
|
|
||||||
|
## Estimation
|
||||||
|
|
||||||
|
M
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Inserting a synthetic `agent_runs` row makes it appear in all three UI surfaces.
|
||||||
|
- [ ] All queries filter by `workspace_id` and verify membership.
|
||||||
|
- [ ] Outcome filter works on the settings view.
|
||||||
|
- [ ] Pagination doesn't show >25 rows per page.
|
||||||
|
|
||||||
|
## Links to related Epic / Plan
|
||||||
|
|
||||||
|
- Epic: `./Epic-task-as-runnable-unit.md`
|
||||||
|
- Plan: `../Plan-agent-coordination.md`
|
||||||
71
plans/Plan-agent-coordination/Plan-agent-coordination.md
Normal file
71
plans/Plan-agent-coordination/Plan-agent-coordination.md
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
---
|
||||||
|
kind: plan
|
||||||
|
slug: agent-coordination
|
||||||
|
title: Agent coordination — make tasks runnable, not just readable
|
||||||
|
status: ready
|
||||||
|
priority: P2
|
||||||
|
tenant_id: global
|
||||||
|
cursor_plan_id: null
|
||||||
|
updated_at: "2026-06-01"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Plan overview
|
||||||
|
|
||||||
|
Echodo already exposes objects to coding agents via the MCP server. What it doesn't yet do is make a `Task-*.md` *runnable* — a Cursor (or Codex, or Claude) agent that picks up a task should be able to claim it, work it, and complete it with auditable state changes, without freeform composition through `update_object`.
|
||||||
|
|
||||||
|
This plan is the **agent-coordination layer**: workflow prompts on tasks, a `runs` table that records every agent session, and two new MCP tools (`claim_task` / `complete_task`) that give agents a primitive shaped for the actual workflow.
|
||||||
|
|
||||||
|
Inspired by OpenAI's [Symphony](https://github.com/openai/symphony) orchestrator state machine, but **multitenant from day one** (Symphony has no concept of tenants) and *not* spawning agent processes itself. Echodo here is the *coordination plane*; the agent process still runs in your local Cursor session or wherever you launch it.
|
||||||
|
|
||||||
|
## Objectives and success criteria
|
||||||
|
|
||||||
|
- Every backlog item can carry a workflow prompt (per-task or inherited from epic/plan) that an agent uses as its first-message context.
|
||||||
|
- A `runs` table records every agent session against a task: `started_at`, `finished_at`, `outcome`, `actor`, `token_usage`, and a short `notes` field.
|
||||||
|
- Two new MCP tools — `claim_task` and `complete_task` — make the lifecycle explicit and atomic, instead of a sequence of `update_object` calls.
|
||||||
|
- A `runs` summary surface (tRPC + minimal UI) lets you see "what did agents do this week?" without grepping logs.
|
||||||
|
|
||||||
|
## Scope and boundaries
|
||||||
|
|
||||||
|
**In scope**
|
||||||
|
|
||||||
|
- New columns / table for workflow prompts and runs.
|
||||||
|
- Two new MCP tools.
|
||||||
|
- tRPC `runs` router for the read side.
|
||||||
|
- Minimal UI: a "Recent agent runs" panel on the task detail view or workspace home.
|
||||||
|
|
||||||
|
**Out of scope (this plan)**
|
||||||
|
|
||||||
|
- An actual orchestrator daemon that polls and spawns agents. Punted to a separate epic (`Epic-optional-orchestrator`), explicitly *deferred* until manual usage of `claim_task` / `complete_task` proves the abstractions are right.
|
||||||
|
- Workflow templates per-epic / per-plan beyond simple inheritance. Don't gold-plate.
|
||||||
|
- Cross-tenant agent coordination. Not happening.
|
||||||
|
|
||||||
|
## Cross-team collaborators
|
||||||
|
|
||||||
|
| Name / group | Role |
|
||||||
|
|--------------|------|
|
||||||
|
| Solo operator (you) | Everything. |
|
||||||
|
|
||||||
|
## Epics
|
||||||
|
|
||||||
|
| Epic | Link |
|
||||||
|
|------|------|
|
||||||
|
| Task as runnable unit | `./Epic-task-as-runnable-unit/Epic-task-as-runnable-unit.md` |
|
||||||
|
| MCP claim/complete | `./Epic-mcp-claim-complete/Epic-mcp-claim-complete.md` |
|
||||||
|
| Optional orchestrator (deferred) | `./Epic-optional-orchestrator/Epic-optional-orchestrator.md` |
|
||||||
|
|
||||||
|
## Metrics and milestones
|
||||||
|
|
||||||
|
| Milestone | Target | Metric |
|
||||||
|
|-----------|--------|--------|
|
||||||
|
| Runnable tasks | M1 | A task carries a workflow prompt; the MCP `claim_task` tool returns it. |
|
||||||
|
| Auditable runs | M2 | Every session writes a `runs` row with `actor_user_id`, timings, and outcome. |
|
||||||
|
| Snapshot surface | M3 | `GET /[workspaceSlug]/settings/runs` (or similar) lists recent runs paginated. |
|
||||||
|
| Operator's choice | M4 | After 10+ real runs, decide whether the optional orchestrator epic is worth building. Update the deferred epic with a yes/no decision. |
|
||||||
|
|
||||||
|
## Risks and mitigations
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|------|------------|
|
||||||
|
| Workflow prompts drift from task body | Render them together; don't make the prompt a hidden field. Make it editable inline in the task detail panel. |
|
||||||
|
| Runs table grows fast | Partial index on `workspace_id, status` for the "active runs" query. Roll up to a daily aggregate later if needed. |
|
||||||
|
| MCP tool surface creep | Two tools only. Resist adding `pause_task`, `block_task`, `comment_on_task` until you've felt the absence. |
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
---
|
||||||
|
kind: epic
|
||||||
|
slug: shipping-the-shell
|
||||||
|
title: Shipping the shell — wire stubs to real data, fix repo guardrails
|
||||||
|
plan_slug: daily-driver-finish
|
||||||
|
status: in_progress
|
||||||
|
priority: P0
|
||||||
|
tenant_id: global
|
||||||
|
cursor_epic_id: null
|
||||||
|
updated_at: "2026-06-01"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Epic objective
|
||||||
|
|
||||||
|
Every page in `apps/web/app/(app)/[workspaceSlug]/` should serve real data from the existing tRPC routers, and `pnpm lint && pnpm type-check` should pass from a clean clone. Today both are partially broken (hardcoded mocks on the home page, `setTimeout` on the AI page, `next lint` interactive prompt, Node-API leak in shared).
|
||||||
|
|
||||||
|
## In scope / out of scope
|
||||||
|
|
||||||
|
**In scope**
|
||||||
|
|
||||||
|
- Wire `[workspaceSlug]/page.tsx` to tRPC.
|
||||||
|
- Wire `[workspaceSlug]/ai/page.tsx` to `routers/ai.ts` + `packages/ai`.
|
||||||
|
- Decide and implement the post-sign-in landing route.
|
||||||
|
- Smoke-test the collab editor against the deployed Hocuspocus instance.
|
||||||
|
- Fix `pnpm lint` (Next 16 ESLint CLI migration).
|
||||||
|
- Fix `pnpm type-check` (`@tasks/shared/utils/id.ts` Node API leak).
|
||||||
|
|
||||||
|
**Out of scope**
|
||||||
|
|
||||||
|
- New feature work. This epic is *finishing*, not extending.
|
||||||
|
|
||||||
|
## Related tasks
|
||||||
|
|
||||||
|
| Task | Link |
|
||||||
|
|------|------|
|
||||||
|
| Fix lint + shared types | `./Task-fix-lint-and-shared-types.md` |
|
||||||
|
| Wire workspace home dashboard | `./Task-wire-workspace-home-dashboard.md` |
|
||||||
|
| Wire AI chat to tRPC | `./Task-wire-ai-chat-to-trpc.md` |
|
||||||
|
| Pick workspace landing route | `./Task-pick-workspace-landing-route.md` |
|
||||||
|
| Smoke-test collab editor | `./Task-smoke-test-collab-editor.md` |
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Depends on: nothing. Auth + workspace provisioning landed in `875b1cf`.
|
||||||
|
- Blocks: `Plan-multitenant-saas-hardening` (don't harden surfaces that are still stubs).
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] `pnpm lint && pnpm type-check` exits 0 from a clean clone.
|
||||||
|
- [ ] No hardcoded mock data in any `(app)/[workspaceSlug]/*` page.
|
||||||
|
- [ ] AI chat page sends and receives messages through `routers/ai.ts`.
|
||||||
|
- [ ] Post-sign-in lands a user on a page that shows real state.
|
||||||
|
- [ ] Two browsers editing the same TipTap doc on the deployed Hocuspocus instance stay in sync for at least one full edit session.
|
||||||
|
|
||||||
|
## Proposed timeline
|
||||||
|
|
||||||
|
| Phase | Window | Notes |
|
||||||
|
|-------|--------|-------|
|
||||||
|
| Repo green | Day 1 | Lint + shared types fix |
|
||||||
|
| Stubs to data | Day 2 | Home + AI + landing route |
|
||||||
|
| Collab verify | Day 3 | Cross-browser smoke test |
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
---
|
||||||
|
kind: task
|
||||||
|
slug: fix-lint-and-shared-types
|
||||||
|
title: Fix pnpm lint (Next 16 deprecation) and packages/shared Node-API leak
|
||||||
|
plan_slug: daily-driver-finish
|
||||||
|
epic_slug: shipping-the-shell
|
||||||
|
status: ready
|
||||||
|
priority: P0
|
||||||
|
tenant_id: global
|
||||||
|
owner: unassigned
|
||||||
|
cursor_todo_id: null
|
||||||
|
updated_at: "2026-06-01"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task summary
|
||||||
|
|
||||||
|
`pnpm lint` and `pnpm type-check` both fail from a clean clone today, for two independent pre-existing reasons. Fix both in one task so the repo is green before any further work.
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
**Lint failure**: `apps/web` runs `next lint`, which Next.js 16 deprecates and which prompts interactively for an ESLint config when it doesn't find one. The prompt is what's actually failing. Run the official codemod:
|
||||||
|
|
||||||
|
```
|
||||||
|
cd apps/web && npx @next/codemod@canary next-lint-to-eslint-cli .
|
||||||
|
```
|
||||||
|
|
||||||
|
Then update `apps/web/package.json`'s `lint` script to invoke `eslint` directly. Keep the rule set as close as possible to whatever was active before; this is a transport change, not a policy change.
|
||||||
|
|
||||||
|
**Type-check failure**: `packages/shared/src/utils/id.ts` imports `randomUUID` from Node's `crypto` module:
|
||||||
|
|
||||||
|
```10:5:packages/shared/src/utils/id.ts
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
|
||||||
|
export function generateId(): string {
|
||||||
|
return randomUUID();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`packages/shared` is forbidden from depending on Node-specific APIs (see `AGENTS.md` §4 and `.cursor/rules/repo-overview.mdc`) because anything in shared has to be importable from the browser bundle. Replace with the Web Crypto API, which is available in Node 20+ and all modern browsers:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function generateId(): string {
|
||||||
|
return globalThis.crypto.randomUUID();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify by grepping for other Node imports in `packages/shared/` while you're in there (`node:fs`, `node:path`, `process.*`). If you find any others, fix them in this same task — the rule was clearly being relaxed.
|
||||||
|
|
||||||
|
## Subtasks
|
||||||
|
|
||||||
|
- [ ] Run the Next.js codemod and verify `apps/web/eslint.config.{js,mjs}` is created.
|
||||||
|
- [ ] Update `apps/web/package.json` `lint` script.
|
||||||
|
- [ ] Replace `crypto.randomUUID` with `globalThis.crypto.randomUUID` in `packages/shared/src/utils/id.ts`.
|
||||||
|
- [ ] Grep `packages/shared/src/` for other Node-only imports and fix.
|
||||||
|
- [ ] Verify `pnpm lint && pnpm type-check` exits 0.
|
||||||
|
|
||||||
|
## Owner or assignee
|
||||||
|
|
||||||
|
Unassigned
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
ready
|
||||||
|
|
||||||
|
## Estimation
|
||||||
|
|
||||||
|
S
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] `pnpm lint` exits 0 non-interactively.
|
||||||
|
- [ ] `pnpm type-check` exits 0.
|
||||||
|
- [ ] No Node-only imports remain in `packages/shared/src/`.
|
||||||
|
|
||||||
|
## Links to related Epic / Plan
|
||||||
|
|
||||||
|
- Epic: `./Epic-shipping-the-shell.md`
|
||||||
|
- Plan: `../Plan-daily-driver-finish.md`
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
---
|
||||||
|
kind: task
|
||||||
|
slug: pick-workspace-landing-route
|
||||||
|
title: Decide and implement the post-sign-in landing route
|
||||||
|
plan_slug: daily-driver-finish
|
||||||
|
epic_slug: shipping-the-shell
|
||||||
|
status: ready
|
||||||
|
priority: P1
|
||||||
|
tenant_id: global
|
||||||
|
owner: unassigned
|
||||||
|
cursor_todo_id: null
|
||||||
|
updated_at: "2026-06-01"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task summary
|
||||||
|
|
||||||
|
After sign-in, where should a user land? Today they land on `[workspaceSlug]/page.tsx`, which is a mostly-decorative dashboard. Make a deliberate decision and implement it.
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
### The decision
|
||||||
|
|
||||||
|
Three reasonable defaults:
|
||||||
|
|
||||||
|
1. **Workspace home** (current) — only good once `Task-wire-workspace-home-dashboard` lands. Until then, it's a mockup.
|
||||||
|
2. **Planner** — `[workspaceSlug]/planner`, the most "active" view. Lots of tenants will prefer this.
|
||||||
|
3. **Last visited surface** — store `last_visited_path` on the user (or in `localStorage`) and redirect there. Best UX but requires a column and a tiny middleware.
|
||||||
|
|
||||||
|
Recommendation: **(1) workspace home, but only after** `Task-wire-workspace-home-dashboard` has landed. If that task isn't done yet, ship a redirect to `(2) /planner` as the interim default and remove the redirect once the home page is real.
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
1. The redirect target for an authenticated user with no specific URL is decided in `apps/web/middleware.ts` (if it exists) or via the NextAuth `pages.signIn` and the post-sign-in `callbackUrl`.
|
||||||
|
2. Verify `signIn(provider, { callbackUrl: "/" })` lands somewhere sensible. The current top-level page should redirect to the user's first workspace's slug. Check `apps/web/app/page.tsx` (the unauth root) and `apps/web/app/(app)/page.tsx` (the auth root).
|
||||||
|
3. The redirect must use `workspace_members` to find a workspace the user belongs to. Don't trust the slug from a query param without verifying membership.
|
||||||
|
|
||||||
|
### Anti-goals
|
||||||
|
|
||||||
|
- Don't introduce a "switcher" workflow as part of this task. If users have multiple workspaces today, just pick the first one (oldest membership) and land them there.
|
||||||
|
- Don't add new DB columns for `last_visited_path` unless you're committing to do the full implementation. The simple redirect is fine for now.
|
||||||
|
|
||||||
|
## Subtasks
|
||||||
|
|
||||||
|
- [ ] Read `apps/web/middleware.ts` and `apps/web/app/(app)/layout.tsx` to find the existing landing logic.
|
||||||
|
- [ ] Decide: workspace home or planner. Document the choice in this task before implementing.
|
||||||
|
- [ ] Implement the redirect, scoped through `workspace_members`.
|
||||||
|
- [ ] Verify with a fresh OAuth sign-in that the user lands somewhere useful.
|
||||||
|
|
||||||
|
## Owner or assignee
|
||||||
|
|
||||||
|
Unassigned
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
ready
|
||||||
|
|
||||||
|
## Estimation
|
||||||
|
|
||||||
|
S
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] A fresh sign-in lands on a page that shows real state, not a mockup.
|
||||||
|
- [ ] Landing logic resolves workspace via `workspace_members` (no URL-trust).
|
||||||
|
- [ ] Users with zero workspaces don't 404 — they hit an onboarding flow or the workspace-creation dialog. (If neither exists, file a follow-up task and gate this acceptance criterion on it.)
|
||||||
|
|
||||||
|
## Links to related Epic / Plan
|
||||||
|
|
||||||
|
- Epic: `./Epic-shipping-the-shell.md`
|
||||||
|
- Plan: `../Plan-daily-driver-finish.md`
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
---
|
||||||
|
kind: task
|
||||||
|
slug: smoke-test-collab-editor
|
||||||
|
title: Smoke-test TipTap + Yjs + Hocuspocus across two browsers on deployed stack
|
||||||
|
plan_slug: daily-driver-finish
|
||||||
|
epic_slug: shipping-the-shell
|
||||||
|
status: ready
|
||||||
|
priority: P1
|
||||||
|
tenant_id: global
|
||||||
|
owner: unassigned
|
||||||
|
cursor_todo_id: null
|
||||||
|
updated_at: "2026-06-01"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task summary
|
||||||
|
|
||||||
|
The TipTap + Yjs + Hocuspocus stack works in dev. Before declaring Echodo daily-usable, verify two browsers on the deployed collab service can edit the same document without drift, disconnect, or auth issues.
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
This is a verification task, not a feature task. It has two parts: build a checklist, run the checklist, document the result.
|
||||||
|
|
||||||
|
### Pre-flight
|
||||||
|
|
||||||
|
- Deploy must be up. CT 102 (Postgres/Redis), CT 107 (Coolify web), collab service running and reachable.
|
||||||
|
- A test workspace must exist with at least one document object.
|
||||||
|
- Two browsers with different sign-ins (use credentials provider for both — fastest path).
|
||||||
|
|
||||||
|
### Checklist
|
||||||
|
|
||||||
|
1. Browser A opens the document. Verify cursor appears and TipTap loads.
|
||||||
|
2. Browser B opens the same document. Verify B sees A's content.
|
||||||
|
3. A types a paragraph. B should see it within ~200ms.
|
||||||
|
4. B types in a different paragraph simultaneously. Both should converge without lost characters.
|
||||||
|
5. A goes offline (devtools network → offline), types, comes back online. Yjs should reconcile A's offline edits.
|
||||||
|
6. Kill the Hocuspocus process; clients should reconnect when it comes back (verify in `apps/collab-server/src/index.ts` logs).
|
||||||
|
7. Close both browsers. Reopen. Verify content persisted in Postgres (Hocuspocus database extension).
|
||||||
|
|
||||||
|
### Output
|
||||||
|
|
||||||
|
Write the result up as a comment block in `apps/collab-server/README.md` (create if missing) — a one-screen "verified on $DATE against $COMMIT_SHA, here's what works" note. Future operators need to know whether this stack is trusted or risky.
|
||||||
|
|
||||||
|
### If you find a regression
|
||||||
|
|
||||||
|
Don't fix it in this task. File a follow-up task in the same epic and flag the issue clearly. This task is verification; fixes are scoped separately so the verification has a clean before/after.
|
||||||
|
|
||||||
|
## Subtasks
|
||||||
|
|
||||||
|
- [ ] Verify deploy is live on CT 107 with collab server reachable.
|
||||||
|
- [ ] Run the 7-step checklist above.
|
||||||
|
- [ ] Write the result block in `apps/collab-server/README.md`.
|
||||||
|
- [ ] File any regression follow-ups as new tasks under this epic.
|
||||||
|
|
||||||
|
## Owner or assignee
|
||||||
|
|
||||||
|
Unassigned
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
ready
|
||||||
|
|
||||||
|
## Estimation
|
||||||
|
|
||||||
|
S
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] All 7 checklist items pass, or any failures are filed as separate tasks.
|
||||||
|
- [ ] Result block exists in `apps/collab-server/README.md` with date and commit SHA.
|
||||||
|
|
||||||
|
## Links to related Epic / Plan
|
||||||
|
|
||||||
|
- Epic: `./Epic-shipping-the-shell.md`
|
||||||
|
- Plan: `../Plan-daily-driver-finish.md`
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
---
|
||||||
|
kind: task
|
||||||
|
slug: wire-ai-chat-to-trpc
|
||||||
|
title: Replace AI chat setTimeout placeholder with a real provider call
|
||||||
|
plan_slug: daily-driver-finish
|
||||||
|
epic_slug: shipping-the-shell
|
||||||
|
status: ready
|
||||||
|
priority: P0
|
||||||
|
tenant_id: global
|
||||||
|
owner: unassigned
|
||||||
|
cursor_todo_id: null
|
||||||
|
updated_at: "2026-06-01"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task summary
|
||||||
|
|
||||||
|
`apps/web/app/(app)/[workspaceSlug]/ai/page.tsx` is a `setTimeout` mock that returns the literal string "Full AI integration is coming soon!". Replace with a real call through `apps/web/server/routers/ai.ts` and `packages/ai`.
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
The page already manages local chat state — message list, input ref, auto-scroll, loading state. All it's missing is the network call.
|
||||||
|
|
||||||
|
### Approach
|
||||||
|
|
||||||
|
1. **Inspect `routers/ai.ts`** to see what procedures exist. If there's already a `chat` / `complete` procedure, use it. Otherwise add one:
|
||||||
|
- Input: `{ messages: Array<{ role: "user" | "assistant" | "system", content: string }>, workspaceSlug: string }`
|
||||||
|
- Output: streaming text (use the Vercel AI SDK's `streamText` from `@tasks/ai`).
|
||||||
|
- Wrap with `workspaceProcedure` so workspace membership is checked.
|
||||||
|
|
||||||
|
2. **Use streaming, not request/response**. The Vercel AI SDK's `useChat` hook is the natural fit, but it expects a `/api/chat` HTTP endpoint, not tRPC. Two options:
|
||||||
|
- (a) Add a Next.js route handler at `apps/web/app/api/chat/route.ts` that calls into the same provider abstraction in `packages/ai`. Keep auth in the route handler (`auth()` from `apps/web/lib/auth.ts`). Use `useChat()` on the client.
|
||||||
|
- (b) Stream through tRPC v11's subscription support. More work; only choose this if you specifically want a single API style.
|
||||||
|
- Recommendation: (a). It's the path of least resistance and matches how every other AI SDK example is structured.
|
||||||
|
|
||||||
|
3. **Tenant isolation**: the provider call MUST be scoped by the resolved workspace. Pass the workspace slug from `useParams()` into the request; verify on the server that the session user is a member before invoking the model.
|
||||||
|
|
||||||
|
4. **Provider selection** comes from env (`packages/ai` already supports this — OpenAI, Anthropic, or Ollama on CT 108 via `OPENAI_BASE_URL`). Don't hardcode a provider in the route handler.
|
||||||
|
|
||||||
|
5. **Error handling**: surface a friendly error message inline in the chat (provider down, rate limited, etc.) rather than crashing the page. Show "AI is unavailable" if no provider env is set.
|
||||||
|
|
||||||
|
6. **Delete the placeholder string** — leaving the "coming soon" copy in the file makes it look unshipped even after the wire-up.
|
||||||
|
|
||||||
|
### Out of scope
|
||||||
|
|
||||||
|
- Tool-calling, retrieval, or letting the AI mutate workspace objects. That's `Plan-agent-coordination` work.
|
||||||
|
- Conversation persistence (storing chats in DB). Keep messages in component state for now; persistence is a follow-up.
|
||||||
|
|
||||||
|
## Subtasks
|
||||||
|
|
||||||
|
- [ ] Audit `apps/web/server/routers/ai.ts` and `packages/ai/src/` for existing primitives.
|
||||||
|
- [ ] Add `apps/web/app/api/chat/route.ts` route handler with `auth()` check and `workspace_id` scoping.
|
||||||
|
- [ ] Replace the `setTimeout` block in `[workspaceSlug]/ai/page.tsx` with `useChat()` from `@ai-sdk/react`.
|
||||||
|
- [ ] Add inline error state and unavailable state.
|
||||||
|
- [ ] Verify against at least one provider (whichever is configured in your `.env`).
|
||||||
|
|
||||||
|
## Owner or assignee
|
||||||
|
|
||||||
|
Unassigned
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
ready
|
||||||
|
|
||||||
|
## Estimation
|
||||||
|
|
||||||
|
M
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] No `setTimeout` mock remains in `[workspaceSlug]/ai/page.tsx`.
|
||||||
|
- [ ] Sending a message produces a streamed response from a real provider.
|
||||||
|
- [ ] Server route validates session and workspace membership before calling the provider.
|
||||||
|
- [ ] Error and unavailable states render gracefully.
|
||||||
|
|
||||||
|
## Links to related Epic / Plan
|
||||||
|
|
||||||
|
- Epic: `./Epic-shipping-the-shell.md`
|
||||||
|
- Plan: `../Plan-daily-driver-finish.md`
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
---
|
||||||
|
kind: task
|
||||||
|
slug: wire-workspace-home-dashboard
|
||||||
|
title: Replace hardcoded dashboard mocks with real tRPC queries
|
||||||
|
plan_slug: daily-driver-finish
|
||||||
|
epic_slug: shipping-the-shell
|
||||||
|
status: ready
|
||||||
|
priority: P0
|
||||||
|
tenant_id: global
|
||||||
|
owner: unassigned
|
||||||
|
cursor_todo_id: null
|
||||||
|
updated_at: "2026-06-01"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task summary
|
||||||
|
|
||||||
|
`apps/web/app/(app)/[workspaceSlug]/page.tsx` currently renders a hardcoded `stats` array (`"24"`, `"8"`, `"12"`) and a hardcoded `recent` list. Replace both with real tRPC calls so the home page reflects actual workspace state.
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
The page is a Client Component (`"use client"`). It already pulls `currentWorkspace` from `useWorkspaceStore`. What it needs:
|
||||||
|
|
||||||
|
1. **Stats card**: replace the three `stats` entries with counts derived from `objects` filtered by workspace. Suggested:
|
||||||
|
- "Open tasks" — count of `objects` where `type = 'task'` and `status` is not in a "done" set, scoped by workspace_id.
|
||||||
|
- "Due this week" — same scope, additionally filtered by `due_at <= now + 7 days`. If `objects` doesn't have a due-date column yet, drop this card rather than fake it.
|
||||||
|
- "Lists" / "Projects" — count of `objects` where `type` is in `('project', 'space', 'group')`.
|
||||||
|
|
||||||
|
2. **Recent list**: replace the static `recent` array with the 5 most-recently-updated objects in the workspace. Use the existing `objects` router. If no suitable procedure exists, add one as `objects.listRecent({ limit: 5 })` — workspace-scoped via `workspaceProcedure` in `apps/web/server/trpc.ts`.
|
||||||
|
|
||||||
|
3. **Loading + empty states**: use the same `Card` skeleton pattern already in `apps/web/app/(app)/[workspaceSlug]/teams/page.tsx` (`MemberCardSkeleton`). When the workspace has no objects, render a friendly empty state with a "Create your first project" CTA that opens the existing `CreateObjectDialog`.
|
||||||
|
|
||||||
|
### Constraints
|
||||||
|
|
||||||
|
- Don't add a new column to `objects` unless you also add a Drizzle migration. Prefer to drop a card rather than schema-creep this task.
|
||||||
|
- All queries MUST filter by `workspace_id`. Use `workspaceProcedure`, never a raw procedure that pulls the slug from the URL without re-checking.
|
||||||
|
- No raw `fetch` against `/api/trpc` — use `api.objects.<...>.useQuery()` via `@trpc/react-query`.
|
||||||
|
|
||||||
|
## Subtasks
|
||||||
|
|
||||||
|
- [ ] Audit `apps/web/server/routers/objects.ts` for existing `count` / `listRecent` procedures.
|
||||||
|
- [ ] Add the missing procedure(s) if needed, with zod inputs and workspace scoping.
|
||||||
|
- [ ] Replace `stats` and `recent` arrays in `[workspaceSlug]/page.tsx` with `api.objects.*.useQuery()` calls.
|
||||||
|
- [ ] Add a skeleton state and an empty state.
|
||||||
|
- [ ] Wire the empty-state CTA to `CreateObjectDialog`.
|
||||||
|
|
||||||
|
## Owner or assignee
|
||||||
|
|
||||||
|
Unassigned
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
ready
|
||||||
|
|
||||||
|
## Estimation
|
||||||
|
|
||||||
|
M
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] No hardcoded numbers or hardcoded titles remain in `apps/web/app/(app)/[workspaceSlug]/page.tsx`.
|
||||||
|
- [ ] Loading state renders without a flash of zeros.
|
||||||
|
- [ ] Empty state for a brand-new workspace renders a CTA.
|
||||||
|
- [ ] All queries filter by `workspace_id`.
|
||||||
|
|
||||||
|
## Links to related Epic / Plan
|
||||||
|
|
||||||
|
- Epic: `./Epic-shipping-the-shell.md`
|
||||||
|
- Plan: `../Plan-daily-driver-finish.md`
|
||||||
56
plans/Plan-daily-driver-finish/Plan-daily-driver-finish.md
Normal file
56
plans/Plan-daily-driver-finish/Plan-daily-driver-finish.md
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
---
|
||||||
|
kind: plan
|
||||||
|
slug: daily-driver-finish
|
||||||
|
title: Daily-driver finish — turn the scaffold into an app you'd actually open
|
||||||
|
status: in_progress
|
||||||
|
priority: P0
|
||||||
|
tenant_id: global
|
||||||
|
cursor_plan_id: null
|
||||||
|
updated_at: "2026-06-01"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Plan overview
|
||||||
|
|
||||||
|
Echodo's web app is feature-rich on the surface (workspaces, planner, docs, forms, whiteboards, AI page, MCP server, collab editor) but several of those surfaces are stubs or hardcoded mockups. This plan finishes the **personal task manager** layer: the smallest set of changes that turns `pnpm dev` into something you'd open on a Monday morning instead of a demo.
|
||||||
|
|
||||||
|
## Objectives and success criteria
|
||||||
|
|
||||||
|
- Workspace home page shows real data (open tasks, recent items) sourced from tRPC, not hardcoded constants.
|
||||||
|
- AI chat page calls the existing `routers/ai.ts` and `packages/ai` provider instead of a `setTimeout` placeholder.
|
||||||
|
- New users land on a useful surface after sign-in instead of a decorative dashboard.
|
||||||
|
- Collab editor (TipTap + Yjs + Hocuspocus) has been smoke-tested end-to-end on the deployed stack with two browsers.
|
||||||
|
- `pnpm lint` and `pnpm type-check` both run clean from a fresh clone (currently both fail for pre-existing infra reasons).
|
||||||
|
|
||||||
|
## Scope and boundaries
|
||||||
|
|
||||||
|
**In scope**
|
||||||
|
|
||||||
|
- Dashboard data binding, AI chat wiring, landing-route decision, collab smoke test.
|
||||||
|
- Repo cleanup: `next lint` deprecation, `@tasks/shared` Node-API leak in `utils/id.ts`.
|
||||||
|
|
||||||
|
**Out of scope (this plan)**
|
||||||
|
|
||||||
|
- Multitenant invite flows, audit log, rate limiting (see `Plan-multitenant-saas-hardening`).
|
||||||
|
- Agent orchestration / runs table (see `Plan-agent-coordination`).
|
||||||
|
- New product features beyond what's already scaffolded.
|
||||||
|
|
||||||
|
## Epics
|
||||||
|
|
||||||
|
| Epic | Link |
|
||||||
|
|------|------|
|
||||||
|
| Shipping the shell | `./Epic-shipping-the-shell/Epic-shipping-the-shell.md` |
|
||||||
|
|
||||||
|
## Metrics and milestones
|
||||||
|
|
||||||
|
| Milestone | Target | Metric |
|
||||||
|
|-----------|--------|--------|
|
||||||
|
| Repo green | M1 | `pnpm lint && pnpm type-check` exits 0 from a clean clone |
|
||||||
|
| Daily-usable | M2 | Home + AI + landing route all serve real data, no hardcoded mocks left in user-facing pages |
|
||||||
|
| Two-browser collab verified | M3 | Two browsers editing the same TipTap doc on the deployed Hocuspocus instance without drift |
|
||||||
|
|
||||||
|
## Risks and mitigations
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|------|------------|
|
||||||
|
| `next lint` migration churn | Migrate to ESLint CLI via the official Next codemod; keep the same rule set so behavior stays stable. |
|
||||||
|
| Hocuspocus auth mismatch in prod | Smoke test specifically against the deployed collab service, not localhost-only, before declaring M3 done. |
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
---
|
||||||
|
kind: epic
|
||||||
|
slug: tenant-lifecycle
|
||||||
|
title: Tenant lifecycle — invites, roles, soft-delete, audit, rate-limits
|
||||||
|
plan_slug: multitenant-saas-hardening
|
||||||
|
status: ready
|
||||||
|
priority: P1
|
||||||
|
tenant_id: global
|
||||||
|
cursor_epic_id: null
|
||||||
|
updated_at: "2026-06-01"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Epic objective
|
||||||
|
|
||||||
|
Cover the operational edges of running a multitenant app: who's in the workspace, what they did, can we recover from a mistake, and can we cap abuse.
|
||||||
|
|
||||||
|
## In scope / out of scope
|
||||||
|
|
||||||
|
**In scope**
|
||||||
|
|
||||||
|
- Invite + role management (`workspace_members` already exists; add invite flow on top).
|
||||||
|
- Append-only `audit_log` table with write hooks.
|
||||||
|
- Soft-delete on workspaces and core tenant-scoped tables.
|
||||||
|
- Rate-limiting middleware on auth + mutation routes.
|
||||||
|
|
||||||
|
**Out of scope**
|
||||||
|
|
||||||
|
- SSO group sync (later).
|
||||||
|
- Per-tenant billing (separate plan).
|
||||||
|
|
||||||
|
## Related tasks
|
||||||
|
|
||||||
|
| Task | Link |
|
||||||
|
|------|------|
|
||||||
|
| Workspace invites and roles | `./Task-workspace-invites-and-roles.md` |
|
||||||
|
| Workspace soft-delete and audit | `./Task-workspace-soft-delete-and-audit.md` |
|
||||||
|
| Rate limit and abuse guardrails | `./Task-rate-limit-and-abuse-guardrails.md` |
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Depends on: `Plan-daily-driver-finish` (don't harden surfaces that are still stubs).
|
||||||
|
- Blocks: charging real money, onboarding non-trusted tenants.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] An owner can invite an email; the recipient lands in the workspace after sign-in.
|
||||||
|
- [ ] Every tenant-scoped mutation produces an `audit_log` row.
|
||||||
|
- [ ] An archived workspace stops serving its data through tRPC but is restorable for at least 30 days.
|
||||||
|
- [ ] Credentials sign-in is rate-limited at the route handler level.
|
||||||
|
|
||||||
|
## Proposed timeline
|
||||||
|
|
||||||
|
| Phase | Window | Notes |
|
||||||
|
|-------|--------|-------|
|
||||||
|
| Invites | Week 1 | Owner can add a teammate end-to-end. |
|
||||||
|
| Audit + soft-delete | Week 2 | Schema + middleware. |
|
||||||
|
| Guardrails | Week 3 | Rate limits + abuse signals. |
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
---
|
||||||
|
kind: task
|
||||||
|
slug: rate-limit-and-abuse-guardrails
|
||||||
|
title: Rate-limit credentials sign-in and high-impact mutations
|
||||||
|
plan_slug: multitenant-saas-hardening
|
||||||
|
epic_slug: tenant-lifecycle
|
||||||
|
status: ready
|
||||||
|
priority: P2
|
||||||
|
tenant_id: global
|
||||||
|
owner: unassigned
|
||||||
|
cursor_todo_id: null
|
||||||
|
updated_at: "2026-06-01"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task summary
|
||||||
|
|
||||||
|
Add per-IP rate limits to credentials sign-in and to a small set of high-impact mutation endpoints (invite, archive, create-workspace). The goal is to cap brute-force and obvious abuse, not to be a full WAF.
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
### Storage
|
||||||
|
|
||||||
|
Use the existing Redis instance on CT 102 (already used by Hocuspocus for awareness). Add a thin client in `packages/shared/src/rate-limit/` — token-bucket or sliding-window, your choice; the contract is `consume(key: string, opts: { capacity, refillPerSecond }): Promise<{ ok: boolean, remaining: number, retryAfterMs: number }>`.
|
||||||
|
|
||||||
|
If you don't want a new shared lib, `@upstash/ratelimit` is a fine drop-in even though we're not on Upstash — it works against any Redis URL. Don't add a new database for this.
|
||||||
|
|
||||||
|
### Where to apply
|
||||||
|
|
||||||
|
1. **Credentials sign-in** (`apps/web/app/api/auth/[...nextauth]/route.ts`, or the `authorize` callback): key by IP (`x-forwarded-for`, falling back to remote addr). 5 attempts / 60 seconds, then 429.
|
||||||
|
2. **Invite create**: key by `actor_user_id`. 30 invites / hour. Workspace-scoped is fine too.
|
||||||
|
3. **Workspace create**: key by `actor_user_id`. 5 workspaces / hour.
|
||||||
|
4. **Archive/restore**: key by `(workspace_id, actor_user_id)`. 20 / hour. Cheap insurance against a script flipping state in a loop.
|
||||||
|
|
||||||
|
### IP extraction
|
||||||
|
|
||||||
|
Use the same helper everywhere; don't recompute in every route. Common gotcha: `x-forwarded-for` is a comma-separated list when there are multiple proxies. Take the first entry. If the deployed stack is behind Cloudflare, `cf-connecting-ip` is more reliable — add that as a higher-priority source if present.
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
When a limit trips, return `429 Too Many Requests` with a `Retry-After` header in seconds and a tRPC `TOO_MANY_REQUESTS` error code. The client should surface a friendly message ("try again in ~30 seconds"), not a stack trace.
|
||||||
|
|
||||||
|
### Audit logging
|
||||||
|
|
||||||
|
Every limit trip writes an `audit_log` row (`action: "rate_limit.tripped"`, `metadata: { route, key_kind, retry_after_ms }`). This is how you discover whether anyone's hitting the limits in practice; without it the limits are silent.
|
||||||
|
|
||||||
|
### Anti-goals
|
||||||
|
|
||||||
|
- Don't add a CAPTCHA. If brute-forcing becomes a real problem, add Cloudflare in front of the deploy.
|
||||||
|
- Don't write a generic rate-limit middleware that wraps every tRPC procedure. The set of high-value endpoints is short; explicit is better than universal.
|
||||||
|
|
||||||
|
## Subtasks
|
||||||
|
|
||||||
|
- [ ] Pick the Redis client and rate-limit lib (or hand-roll).
|
||||||
|
- [ ] Add IP extraction helper.
|
||||||
|
- [ ] Apply limits to the 4 routes above.
|
||||||
|
- [ ] Wire `audit_log` writes on limit trips.
|
||||||
|
- [ ] Verify by scripting 20 credentials sign-in attempts against a dev deploy.
|
||||||
|
|
||||||
|
## Owner or assignee
|
||||||
|
|
||||||
|
Unassigned
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
ready
|
||||||
|
|
||||||
|
## Estimation
|
||||||
|
|
||||||
|
M
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Brute-forcing credentials sign-in trips at attempt 6 within 60 seconds.
|
||||||
|
- [ ] Invite-spam attempt trips at invite 31 within an hour.
|
||||||
|
- [ ] Every trip produces an `audit_log` row.
|
||||||
|
- [ ] No global tRPC middleware — limits are applied per route.
|
||||||
|
|
||||||
|
## Links to related Epic / Plan
|
||||||
|
|
||||||
|
- Epic: `./Epic-tenant-lifecycle.md`
|
||||||
|
- Plan: `../Plan-multitenant-saas-hardening.md`
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
---
|
||||||
|
kind: task
|
||||||
|
slug: workspace-invites-and-roles
|
||||||
|
title: Workspace invites, accept flow, and role management
|
||||||
|
plan_slug: multitenant-saas-hardening
|
||||||
|
epic_slug: tenant-lifecycle
|
||||||
|
status: ready
|
||||||
|
priority: P1
|
||||||
|
tenant_id: global
|
||||||
|
owner: unassigned
|
||||||
|
cursor_todo_id: null
|
||||||
|
updated_at: "2026-06-01"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task summary
|
||||||
|
|
||||||
|
Owners can invite an email to a workspace, the recipient accepts via a link (or via a "pending invites" UI on first sign-in), and lands in the workspace as a member. Owners and admins can change roles and remove members.
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
`workspace_members` already exists. This task adds the *invite* layer on top.
|
||||||
|
|
||||||
|
### Schema additions
|
||||||
|
|
||||||
|
New table `workspace_invites`:
|
||||||
|
|
||||||
|
- `id` uuid pk
|
||||||
|
- `workspace_id` uuid not null, references `workspaces.id` on delete cascade, indexed
|
||||||
|
- `email` varchar not null (store lowercase — match the case-insensitive convention in migration 0004)
|
||||||
|
- `role` varchar not null (`owner` | `admin` | `member`)
|
||||||
|
- `invited_by_user_id` uuid not null references `users.id`
|
||||||
|
- `token` varchar not null unique (random 32+ bytes, base64url)
|
||||||
|
- `expires_at` timestamptz not null (default `now() + interval '14 days'`)
|
||||||
|
- `accepted_at` timestamptz null
|
||||||
|
- `revoked_at` timestamptz null
|
||||||
|
- `created_at` timestamptz default now
|
||||||
|
- Unique partial index on `(workspace_id, lower(email)) where accepted_at is null and revoked_at is null` — prevents two open invites for the same email.
|
||||||
|
|
||||||
|
### tRPC procedures
|
||||||
|
|
||||||
|
In a new router `apps/web/server/routers/invites.ts`:
|
||||||
|
|
||||||
|
- `invites.create({ workspaceSlug, email, role })` — admin/owner only. Generates `token`, sends an invite email (later — for now just return the accept URL so an operator can paste it). Idempotent: if there's an open invite for that email/workspace, return it.
|
||||||
|
- `invites.list({ workspaceSlug })` — admin/owner only. Lists pending invites.
|
||||||
|
- `invites.revoke({ inviteId })` — admin/owner only. Sets `revoked_at`.
|
||||||
|
- `invites.accept({ token })` — *public* procedure (no workspace scope). Validates token, requires authenticated session, inserts `workspace_members` row, sets `accepted_at`.
|
||||||
|
|
||||||
|
### Membership procedures
|
||||||
|
|
||||||
|
Extend the existing `workspaces` router (`apps/web/server/routers/workspaces.ts`):
|
||||||
|
|
||||||
|
- `workspaces.listMembers({ workspaceSlug })` — already exists per the teams page; verify.
|
||||||
|
- `workspaces.updateMemberRole({ workspaceSlug, userId, role })` — admin/owner only.
|
||||||
|
- `workspaces.removeMember({ workspaceSlug, userId })` — admin/owner only. Can't remove the last owner; raise `BAD_REQUEST` if attempted.
|
||||||
|
|
||||||
|
### UI
|
||||||
|
|
||||||
|
Extend `apps/web/app/(app)/[workspaceSlug]/teams/page.tsx`:
|
||||||
|
|
||||||
|
- Add "Invite teammate" button → dialog with email + role select.
|
||||||
|
- Show pending invites in a separate section with "Copy invite link" and "Revoke".
|
||||||
|
- Per-member kebab menu: change role, remove. Hide for the current user; hide remove for the last owner.
|
||||||
|
|
||||||
|
Add a new route `apps/web/app/invite/[token]/page.tsx`:
|
||||||
|
|
||||||
|
- If not signed in, send to `/sign-in?callbackUrl=/invite/<token>`.
|
||||||
|
- If signed in, call `invites.accept` and redirect to the workspace.
|
||||||
|
|
||||||
|
### Email (optional first pass)
|
||||||
|
|
||||||
|
Don't block on actual email sending. Return the accept URL from `invites.create` and let the operator paste it. Add a follow-up task ("send invite emails via Resend/Postmark") once a provider is chosen.
|
||||||
|
|
||||||
|
## Subtasks
|
||||||
|
|
||||||
|
- [ ] Add `workspace_invites` schema in `packages/database/src/schema/workspaces.ts` (or a new file).
|
||||||
|
- [ ] Generate and commit the migration via `pnpm db:generate`.
|
||||||
|
- [ ] Add `apps/web/server/routers/invites.ts` and wire into `root.ts`.
|
||||||
|
- [ ] Add `updateMemberRole` and `removeMember` procedures.
|
||||||
|
- [ ] Add invite dialog and pending-invites section to teams page.
|
||||||
|
- [ ] Add `/invite/[token]` accept route.
|
||||||
|
- [ ] Verify end-to-end: owner A invites email B, B signs up with that email, lands in the workspace as member.
|
||||||
|
|
||||||
|
## Owner or assignee
|
||||||
|
|
||||||
|
Unassigned
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
ready
|
||||||
|
|
||||||
|
## Estimation
|
||||||
|
|
||||||
|
L
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Invite flow works end-to-end without email (copy-paste URL).
|
||||||
|
- [ ] Cannot remove the last owner.
|
||||||
|
- [ ] Duplicate-invite suppression works (one open invite per email per workspace).
|
||||||
|
- [ ] Accept route 404s for revoked / expired tokens.
|
||||||
|
|
||||||
|
## Links to related Epic / Plan
|
||||||
|
|
||||||
|
- Epic: `./Epic-tenant-lifecycle.md`
|
||||||
|
- Plan: `../Plan-multitenant-saas-hardening.md`
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
---
|
||||||
|
kind: task
|
||||||
|
slug: workspace-soft-delete-and-audit
|
||||||
|
title: Workspace soft-delete (archive/restore) and append-only audit log
|
||||||
|
plan_slug: multitenant-saas-hardening
|
||||||
|
epic_slug: tenant-lifecycle
|
||||||
|
status: ready
|
||||||
|
priority: P1
|
||||||
|
tenant_id: global
|
||||||
|
owner: unassigned
|
||||||
|
cursor_todo_id: null
|
||||||
|
updated_at: "2026-06-01"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task summary
|
||||||
|
|
||||||
|
Two related changes shipped together because they share the "we need a paper trail" motivation: soft-delete for workspaces (and the major tenant-scoped tables) and an append-only `audit_log` that records who did what.
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
### Soft-delete
|
||||||
|
|
||||||
|
Add `archived_at timestamptz null` to:
|
||||||
|
|
||||||
|
- `workspaces`
|
||||||
|
- `objects`
|
||||||
|
- `markdown_backlog_items`
|
||||||
|
|
||||||
|
For each, add a partial index `where archived_at is null` on the same columns currently indexed (so the "active rows" filter stays fast).
|
||||||
|
|
||||||
|
**Read-side convention**: every workspace-scoped tRPC procedure adds `archived_at IS NULL` to its `where` clause by default. Add an `includeArchived: boolean` optional input to list-procedures that opt-in to showing archived rows (settings → archive view).
|
||||||
|
|
||||||
|
**Write-side**:
|
||||||
|
|
||||||
|
- `workspaces.archive({ workspaceSlug })` — owner only. Sets `archived_at`. Cascades to a *background job* that flips `archived_at` on all `objects` and `markdown_backlog_items` for that workspace. (For now, do it inline in the same transaction; revisit if it ever blocks.)
|
||||||
|
- `workspaces.restore({ workspaceSlug })` — owner only. Sets `archived_at = null` and cascades the unset.
|
||||||
|
|
||||||
|
Don't hard-delete anything via the UI yet. Hard-delete is a separate task and a separate set of risks.
|
||||||
|
|
||||||
|
### Audit log
|
||||||
|
|
||||||
|
New table `audit_log`:
|
||||||
|
|
||||||
|
- `id` uuid pk
|
||||||
|
- `workspace_id` uuid not null (indexed)
|
||||||
|
- `actor_user_id` uuid null (null = system actor, e.g. markdown importer)
|
||||||
|
- `action` varchar not null (e.g. `object.create`, `object.update`, `member.invite`, `workspace.archive`)
|
||||||
|
- `target_type` varchar not null (e.g. `object`, `workspace`, `workspace_member`)
|
||||||
|
- `target_id` uuid null (nullable because some actions don't target a single row)
|
||||||
|
- `metadata` jsonb null (small structured payload — keep it small, don't dump full row state here)
|
||||||
|
- `created_at` timestamptz default now (indexed `(workspace_id, created_at desc)`)
|
||||||
|
|
||||||
|
**Write path**: a tiny helper `recordAudit(db, { workspaceId, actorUserId, action, targetType, targetId, metadata })`. Call from every mutation procedure. Don't auto-instrument via Drizzle middleware — be explicit so it's grep-able which mutations are audited and which aren't.
|
||||||
|
|
||||||
|
**Read view**: minimal — `apps/web/app/(app)/[workspaceSlug]/settings/audit/page.tsx` (new route) with a paginated table. Owner-only. Keep the UI dumb; this is a debugging surface, not a product feature.
|
||||||
|
|
||||||
|
### Anti-goals
|
||||||
|
|
||||||
|
- Don't try to write to the audit log from inside a non-procedure context (cron, importer) without an explicit `actor_user_id = null` or a synthetic "system" user. The point of the column is "who did this, for accountability" — fudging it defeats the purpose.
|
||||||
|
- Don't add row-level retention policies in this task. The table can grow; we'll partition or roll up later.
|
||||||
|
|
||||||
|
## Subtasks
|
||||||
|
|
||||||
|
- [ ] Add `archived_at` to the 3 tables + partial indexes.
|
||||||
|
- [ ] Add `audit_log` schema.
|
||||||
|
- [ ] Generate and commit migration.
|
||||||
|
- [ ] Add `archive` and `restore` procedures to the workspaces router.
|
||||||
|
- [ ] Implement `recordAudit` helper and call from every mutation in `objects`, `workspaces`, `invites`, and the markdown backlog import path.
|
||||||
|
- [ ] Add `archived_at IS NULL` to every existing list-procedure (audit existing routers).
|
||||||
|
- [ ] Add `/[workspaceSlug]/settings/audit` page (owner-only, paginated).
|
||||||
|
|
||||||
|
## Owner or assignee
|
||||||
|
|
||||||
|
Unassigned
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
ready
|
||||||
|
|
||||||
|
## Estimation
|
||||||
|
|
||||||
|
L
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Archived workspace stops appearing in the workspace switcher.
|
||||||
|
- [ ] Restoring an archived workspace makes its objects visible again.
|
||||||
|
- [ ] At least one `audit_log` row is written for every mutation in `objects` and `workspaces`.
|
||||||
|
- [ ] Audit view renders paginated rows scoped to the current workspace.
|
||||||
|
|
||||||
|
## Links to related Epic / Plan
|
||||||
|
|
||||||
|
- Epic: `./Epic-tenant-lifecycle.md`
|
||||||
|
- Plan: `../Plan-multitenant-saas-hardening.md`
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
---
|
||||||
|
kind: epic
|
||||||
|
slug: test-foundation
|
||||||
|
title: Test foundation — Vitest bootstrap and CI gate
|
||||||
|
plan_slug: multitenant-saas-hardening
|
||||||
|
status: ready
|
||||||
|
priority: P1
|
||||||
|
tenant_id: global
|
||||||
|
cursor_epic_id: null
|
||||||
|
updated_at: "2026-06-01"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Epic objective
|
||||||
|
|
||||||
|
Get the repo to a place where `pnpm test` is a real command and a PR can't merge with broken lint/types/tests. Today there is no test runner (`AGENTS.md` is explicit about this) and no CI workflow.
|
||||||
|
|
||||||
|
## In scope / out of scope
|
||||||
|
|
||||||
|
**In scope**
|
||||||
|
|
||||||
|
- Add Vitest to the workspaces that need it most: `packages/database`, `packages/shared`, `packages/ai`.
|
||||||
|
- Add a `pnpm test` script wired through Turborepo so cached test runs work.
|
||||||
|
- Add `.github/workflows/ci.yml` running `pnpm lint && pnpm type-check && pnpm test` on PRs.
|
||||||
|
|
||||||
|
**Out of scope**
|
||||||
|
|
||||||
|
- Testing React components. Component testing in Next.js + React 19 + RSC is its own rabbit hole; defer until there's a real motivating bug.
|
||||||
|
- End-to-end (Playwright) tests. Same reason.
|
||||||
|
|
||||||
|
## Related tasks
|
||||||
|
|
||||||
|
| Task | Link |
|
||||||
|
|------|------|
|
||||||
|
| Bootstrap Vitest and GitHub Actions CI | `./Task-bootstrap-vitest-and-ci.md` |
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Depends on: `Task-fix-lint-and-shared-types` from `Plan-daily-driver-finish` (CI needs lint + type-check to be green).
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] `pnpm test` runs Vitest across at least 3 packages.
|
||||||
|
- [ ] CI workflow gates PRs on lint + type-check + test.
|
||||||
|
- [ ] At least 3 real tests exist (one in each chosen package) that catch a real regression if their target code is broken.
|
||||||
|
|
||||||
|
## Proposed timeline
|
||||||
|
|
||||||
|
| Phase | Window | Notes |
|
||||||
|
|-------|--------|-------|
|
||||||
|
| Vitest + first tests | Day 1 | Set up runner, write seed tests. |
|
||||||
|
| CI workflow | Day 2 | Pin Node 20, run lint/type/test on PR. |
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
---
|
||||||
|
kind: task
|
||||||
|
slug: bootstrap-vitest-and-ci
|
||||||
|
title: Bootstrap Vitest in 3 packages and add GitHub Actions CI
|
||||||
|
plan_slug: multitenant-saas-hardening
|
||||||
|
epic_slug: test-foundation
|
||||||
|
status: ready
|
||||||
|
priority: P1
|
||||||
|
tenant_id: global
|
||||||
|
owner: unassigned
|
||||||
|
cursor_todo_id: null
|
||||||
|
updated_at: "2026-06-01"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task summary
|
||||||
|
|
||||||
|
Add Vitest to `packages/database`, `packages/shared`, and `packages/ai`. Write one real test in each. Wire `pnpm test` through Turborepo. Add `.github/workflows/ci.yml`.
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
### Vitest setup
|
||||||
|
|
||||||
|
Install at the workspace root:
|
||||||
|
|
||||||
|
```
|
||||||
|
pnpm -w add -D vitest @vitest/coverage-v8
|
||||||
|
```
|
||||||
|
|
||||||
|
In each target package, add `vitest.config.ts` (minimal, ESM) and a `test` script in `package.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"scripts": {
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then in `turbo.json`, add a `test` pipeline entry that depends on `build` only where strictly needed (probably not for these packages):
|
||||||
|
|
||||||
|
```json
|
||||||
|
"test": {
|
||||||
|
"dependsOn": ["^build"],
|
||||||
|
"outputs": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `pnpm test` to the root `package.json` scripts as `turbo test`.
|
||||||
|
|
||||||
|
### First tests (don't fake them)
|
||||||
|
|
||||||
|
- `packages/database`: pick one of the markdown-backlog helpers (`parseBacklogMarkdown` in `src/markdown-backlog/parse.ts`). Write a test that parses a known-good Plan-template fixture and asserts the fields. Then write a test for a malformed file (non-map front matter) and assert it throws the documented error. This is high-value: the importer's parsing rules are load-bearing.
|
||||||
|
- `packages/shared`: pick a zod schema and verify happy + sad paths. If `generateId` was just rewritten to use Web Crypto, add a test that asserts the output looks like a UUID v4 (regex match). Cheap, but it catches future "let's import randomUUID from somewhere again" regressions.
|
||||||
|
- `packages/ai`: harder because of provider env. Mock the provider in the test (Vercel AI SDK has `MockLanguageModel` patterns). Verify your prompt-assembly helper produces the right message array given a known input.
|
||||||
|
|
||||||
|
### GitHub Actions
|
||||||
|
|
||||||
|
Create `.github/workflows/ci.yml`:
|
||||||
|
|
||||||
|
- Runs on `pull_request` and `push` to `main`.
|
||||||
|
- Matrix: just Node 20 for now.
|
||||||
|
- Steps: checkout, setup-node, setup-pnpm, `pnpm install --frozen-lockfile`, `pnpm lint`, `pnpm type-check`, `pnpm test`.
|
||||||
|
- Cache pnpm store via `actions/cache`. Don't bother with Turbo Remote Cache — local Turbo caching inside the runner is enough at this scale.
|
||||||
|
|
||||||
|
Pin pnpm to 9.x (same as local; `AGENTS.md` says pnpm 9). Don't use the corepack auto-detect mode for now — explicit versions are more reproducible.
|
||||||
|
|
||||||
|
### Anti-goals
|
||||||
|
|
||||||
|
- No React Testing Library. No JSDOM environment. Adding browser-shape tests is its own project.
|
||||||
|
- No coverage thresholds in v1. Get tests running first; threshold-policing comes when there's enough surface to police.
|
||||||
|
|
||||||
|
## Subtasks
|
||||||
|
|
||||||
|
- [ ] Install Vitest at the workspace root.
|
||||||
|
- [ ] Add `vitest.config.ts` + `test` script to the 3 packages.
|
||||||
|
- [ ] Write 3 real tests (one per package) that fail if their target is broken.
|
||||||
|
- [ ] Wire `turbo test` and root `pnpm test`.
|
||||||
|
- [ ] Add `.github/workflows/ci.yml`.
|
||||||
|
- [ ] Push to a branch, open a PR, verify CI runs.
|
||||||
|
|
||||||
|
## Owner or assignee
|
||||||
|
|
||||||
|
Unassigned
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
ready
|
||||||
|
|
||||||
|
## Estimation
|
||||||
|
|
||||||
|
M
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] `pnpm test` exits 0 from a clean clone.
|
||||||
|
- [ ] CI runs on PR and gates merge on success.
|
||||||
|
- [ ] Each of the 3 tests fails when its target is mutated (verify by intentionally breaking each one).
|
||||||
|
|
||||||
|
## Links to related Epic / Plan
|
||||||
|
|
||||||
|
- Epic: `./Epic-test-foundation.md`
|
||||||
|
- Plan: `../Plan-multitenant-saas-hardening.md`
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
---
|
||||||
|
kind: plan
|
||||||
|
slug: multitenant-saas-hardening
|
||||||
|
title: Multitenant SaaS hardening — invites, audit, guardrails, test foundation
|
||||||
|
status: ready
|
||||||
|
priority: P1
|
||||||
|
tenant_id: global
|
||||||
|
cursor_plan_id: null
|
||||||
|
updated_at: "2026-06-01"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Plan overview
|
||||||
|
|
||||||
|
`Plan-multitenant-cursor-sync` covers the *Cursor sync layer* slice of multitenancy. This plan covers the rest: the operational hardening needed before Echodo can be safely used as a real multitenant SaaS rather than a homelab single-tenant app.
|
||||||
|
|
||||||
|
The unifying theme is **tenant blast-radius** — what's the worst thing a tenant (or a bug operating on behalf of a tenant) can do, and how do we cap it? Today the answer is "we don't really know," because there's no invite flow, no audit log, no rate limit, and no test runner.
|
||||||
|
|
||||||
|
## Objectives and success criteria
|
||||||
|
|
||||||
|
- A workspace owner can invite, role-change, and remove members from the app UI.
|
||||||
|
- Every mutation against a tenant-scoped table is recorded in an append-only audit log with actor user id, action, and target.
|
||||||
|
- Workspaces support soft-delete (archive) without losing data, and restore.
|
||||||
|
- Public mutation endpoints have rate limits that survive a brute-force attempt against credentials sign-in.
|
||||||
|
- `pnpm test` runs Vitest across the workspaces that have tests, and a minimal GitHub Actions CI runs lint + type-check + test on PRs.
|
||||||
|
|
||||||
|
## Scope and boundaries
|
||||||
|
|
||||||
|
**In scope**
|
||||||
|
|
||||||
|
- Invite + role management (DB + UI + tRPC).
|
||||||
|
- Audit log table, write path, minimal read view.
|
||||||
|
- Workspace soft-delete and restore.
|
||||||
|
- Rate-limiting middleware on critical mutations.
|
||||||
|
- Vitest bootstrap + GitHub Actions CI.
|
||||||
|
|
||||||
|
**Out of scope (this plan)**
|
||||||
|
|
||||||
|
- Billing / Stripe (a separate plan when there's actual revenue to charge for).
|
||||||
|
- SSO group-based role mapping (Authentik groups → workspace roles). Defer until manual invites are in.
|
||||||
|
- Full SOC2 audit posture. The audit log here is a foundation, not a compliance artifact.
|
||||||
|
|
||||||
|
## Cross-team collaborators
|
||||||
|
|
||||||
|
| Name / group | Role |
|
||||||
|
|--------------|------|
|
||||||
|
| Solo operator (you) | Everything. |
|
||||||
|
|
||||||
|
## Epics
|
||||||
|
|
||||||
|
| Epic | Link |
|
||||||
|
|------|------|
|
||||||
|
| Tenant lifecycle | `./Epic-tenant-lifecycle/Epic-tenant-lifecycle.md` |
|
||||||
|
| Test foundation | `./Epic-test-foundation/Epic-test-foundation.md` |
|
||||||
|
|
||||||
|
## Metrics and milestones
|
||||||
|
|
||||||
|
| Milestone | Target | Metric |
|
||||||
|
|-----------|--------|--------|
|
||||||
|
| Invites shipped | M1 | Owner can invite a new email and the recipient can accept and land in the workspace. |
|
||||||
|
| Audit visibility | M2 | Every mutation on `objects` / `workspaces` / `workspace_members` produces an audit row with `actor_user_id`. |
|
||||||
|
| Guardrails live | M3 | Brute-forcing credentials sign-in is rate-limited at the route handler. |
|
||||||
|
| CI green on PRs | M4 | A PR with `pnpm lint && pnpm type-check && pnpm test` failing cannot merge. |
|
||||||
|
|
||||||
|
## Risks and mitigations
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|------|------------|
|
||||||
|
| Soft-delete semantics get confused with hard-delete | One column (`archived_at` timestamp); every workspace-scoped query adds `archived_at IS NULL` by default. No new "is_deleted" boolean. |
|
||||||
|
| Audit log becomes a write-amplification problem | Single append-only table with a partial index on `workspace_id, created_at`. Don't denormalize until you measure. |
|
||||||
|
| Vitest adoption stalls | Start with `packages/database` and `packages/shared` only. Don't try to test React components in v1. |
|
||||||
Loading…
Reference in a new issue