Compare commits
41 commits
491d196384
...
9bde5960bf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bde5960bf | ||
|
|
4efaf6c0c8 | ||
|
|
3fc7d01585 | ||
|
|
f6028615e8 | ||
|
|
3810cd9dab | ||
|
|
2ed38fa1f2 | ||
|
|
e3af2c33e6 | ||
|
|
9ad114b2f5 | ||
|
|
02b1ae610a | ||
|
|
6f139d6af2 | ||
|
|
6e17936b7f | ||
|
|
86155455a7 | ||
|
|
1c27c88b14 | ||
|
|
38d91d4749 | ||
|
|
6ccb060bea | ||
|
|
d1b2b9695e | ||
|
|
35def387ae | ||
|
|
2f6adf4382 | ||
|
|
b513b3b04f | ||
|
|
e8074dc5fc | ||
|
|
3e33cab2d6 | ||
|
|
8e1ab90fb1 | ||
|
|
5bade93b07 | ||
|
|
8b01c1b8dd | ||
|
|
5025ac5266 | ||
|
|
ea70716fc6 | ||
|
|
4bb553c15f | ||
|
|
9405da8a45 | ||
|
|
2a9be5d4e1 | ||
|
|
ce4e5b7c29 | ||
|
|
0604d66008 | ||
|
|
30c4e533e8 | ||
|
|
76dacff58d | ||
|
|
2d309e8a75 | ||
|
|
863d2d9d44 | ||
|
|
a6898617b5 | ||
|
|
dfe73b3647 | ||
|
|
f04122fd04 | ||
|
|
fde698f439 | ||
|
|
cda4958b17 | ||
|
|
e256380671 |
301 changed files with 53703 additions and 262 deletions
297
.agents/skills/backlog-sync/SKILL.md
Normal file
297
.agents/skills/backlog-sync/SKILL.md
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
---
|
||||
name: backlog-sync
|
||||
description: Work with the plans/ markdown backlog - understand the strict layout (Plan/Epic/Task hierarchy), parse frontmatter, sync to markdown_backlog_items, and maintain the DB ↔ .md contract. Use when creating or modifying backlog items, understanding the import process, or working with the markdown-backlog package.
|
||||
---
|
||||
|
||||
# Backlog Sync
|
||||
|
||||
The `plans/` directory is the **source of truth** for product work. It's a strict markdown hierarchy parsed into the `markdown_backlog_items` database table. This skill teaches you the layout rules, frontmatter contract, and how the markdown ↔ DB sync works.
|
||||
|
||||
## When to Activate
|
||||
|
||||
Use this skill when:
|
||||
- Creating or editing files under `plans/`
|
||||
- Working with `markdown_backlog_items` or `cursor_sync_mappings` tables
|
||||
- Running or debugging the markdown import (`pnpm --filter @tasks/database watch:markdown-backlog`)
|
||||
- Understanding how `agent_prompt` inheritance works (plan → epic → task)
|
||||
- Troubleshooting backlog sync issues
|
||||
- Implementing features that read/write backlog data
|
||||
|
||||
## Core Rules
|
||||
|
||||
### 1. Directory Structure is Strict
|
||||
|
||||
The parser depends on this **exact** layout. Do not improvise.
|
||||
|
||||
```
|
||||
plans/
|
||||
Plan-<slug>/
|
||||
Plan-<slug>.md ← Plan overview
|
||||
Epic-<slug>/
|
||||
Epic-<slug>.md ← Epic definition
|
||||
Task-<slug>.md ← Executable work item
|
||||
Task-<another>.md
|
||||
```
|
||||
|
||||
**Key constraints:**
|
||||
- Slugs are lowercase-hyphenated (`feature-x`, not `Feature_X` or `featureX`)
|
||||
- Slugs are stable — renaming breaks Cursor sync mappings
|
||||
- One task per file
|
||||
- File names must match the pattern: `Plan-<slug>.md`, `Epic-<slug>.md`, `Task-<slug>.md`
|
||||
|
||||
### 2. Frontmatter Contract
|
||||
|
||||
Every Plan/Epic/Task markdown file must start with YAML frontmatter. The parser extracts these fields:
|
||||
|
||||
**Required across all kinds:**
|
||||
|
||||
```yaml
|
||||
---
|
||||
kind: plan | epic | task
|
||||
slug: matches-the-filename-slug
|
||||
title: Human-readable title
|
||||
status: draft | ready | in_progress | blocked | done | cancelled
|
||||
priority: P0 | P1 | P2 | P3
|
||||
tenant_id: UUID or "global"
|
||||
updated_at: "YYYY-MM-DD"
|
||||
---
|
||||
```
|
||||
|
||||
**Plan-specific additions:**
|
||||
|
||||
```yaml
|
||||
cursor_plan_id: null # populated by Cursor sync
|
||||
```
|
||||
|
||||
**Epic-specific additions:**
|
||||
|
||||
```yaml
|
||||
plan_slug: parent-plan-slug
|
||||
cursor_epic_id: null
|
||||
```
|
||||
|
||||
**Task-specific additions:**
|
||||
|
||||
```yaml
|
||||
plan_slug: parent-plan-slug
|
||||
epic_slug: parent-epic-slug
|
||||
owner: unassigned | github-username
|
||||
cursor_todo_id: null
|
||||
agent_prompt: |
|
||||
Optional multi-line prompt for agents working on this task.
|
||||
Inherits from epic → plan if not set here.
|
||||
```
|
||||
|
||||
**What the parser does:**
|
||||
- Extracts frontmatter via regex: `/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/`
|
||||
- Parses YAML (tolerates syntax errors → empty object)
|
||||
- Infers `kind` from filename if frontmatter omits it (`Task-*.md` → `task`)
|
||||
- Infers `slug` from filename if frontmatter omits it (strips `Plan-`/`Epic-`/`Task-` prefix)
|
||||
- Infers `plan_slug` from path if frontmatter omits it (`plans/Plan-foo/...` → `foo`)
|
||||
- Infers `epic_slug` from path for tasks if frontmatter omits it (`Epic-bar/Task-x.md` → `bar`)
|
||||
- Computes `contentHash` (SHA-256 of full file) for idempotency
|
||||
- Stores `workflowPrompt` from `agent_prompt:` frontmatter field
|
||||
|
||||
### 3. Agent Prompt Inheritance
|
||||
|
||||
Tasks can define `agent_prompt:` in frontmatter. If missing, the system inherits:
|
||||
|
||||
```
|
||||
Task (no agent_prompt) → Epic (check agent_prompt) → Plan (check agent_prompt) → Default
|
||||
```
|
||||
|
||||
The `resolveWorkflowPrompt` function in `packages/database/src/markdown-backlog/resolve-prompt.ts` implements this cascade. When an agent claims a task via MCP `claim_task`, the resolved prompt is returned as context.
|
||||
|
||||
**Default prompt** (when nothing is set):
|
||||
|
||||
```
|
||||
You are an AI assistant helping implement a software task.
|
||||
Follow the acceptance criteria and constraints in the task description.
|
||||
Run pnpm lint && pnpm type-check after code changes.
|
||||
```
|
||||
|
||||
### 4. Database Sync Flow
|
||||
|
||||
**Import (Markdown → DB):**
|
||||
|
||||
1. Run `pnpm --filter @tasks/database watch:markdown-backlog`
|
||||
2. Scanner walks `plans/` for `*.md` files matching the pattern
|
||||
3. Parser extracts frontmatter + body + contentHash
|
||||
4. Syncer upserts rows to `markdown_backlog_items` by `(workspace_id, repo_path)` unique key
|
||||
5. Hierarchical `parent_id` links are computed (task → epic → plan)
|
||||
|
||||
**Key columns in `markdown_backlog_items`:**
|
||||
|
||||
| Column | Source |
|
||||
|--------|--------|
|
||||
| `workspace_id` | Env var `MARKDOWN_BACKLOG_WORKSPACE_ID` |
|
||||
| `repo_path` | Relative posix path: `plans/Plan-foo/Epic-bar/Task-baz.md` |
|
||||
| `kind` | Frontmatter `kind:` or inferred from filename |
|
||||
| `slug` | Frontmatter `slug:` or inferred from filename |
|
||||
| `title` | Frontmatter `title:` or first `# Heading` or filename |
|
||||
| `body` | Markdown body after frontmatter |
|
||||
| `content_hash` | SHA-256 of full file |
|
||||
| `parent_id` | Computed by matching `plan_slug`/`epic_slug` to parent rows |
|
||||
| `workflow_prompt` | Frontmatter `agent_prompt:` (null if not set) |
|
||||
|
||||
**Export (DB → Markdown):**
|
||||
|
||||
Not yet fully automated. The `exportBacklogItemToMarkdown` function can generate markdown from a DB row, but writes are manual or triggered via tRPC `backlog.createPlan/createEpic/createTask`.
|
||||
|
||||
### 5. Don't Break the Contract
|
||||
|
||||
**What NOT to do:**
|
||||
|
||||
- Don't nest tasks under other tasks (only Plan → Epic → Task)
|
||||
- Don't put secrets or API keys in frontmatter (files are committed)
|
||||
- Don't hand-edit `cursor_plan_id`, `cursor_epic_id`, `cursor_todo_id` (Cursor owns these)
|
||||
- Don't rename slugs without understanding the Cursor sync impact (breaks mappings)
|
||||
- Don't mix `snake_case` and `camelCase` in frontmatter keys (parser expects `plan_slug`, `epic_slug`, `agent_prompt`)
|
||||
- Don't create files outside the `plans/Plan-*/` structure (parser ignores them)
|
||||
|
||||
**What TO do:**
|
||||
|
||||
- Match existing style (see `docs/templates/plan-template.md`, `epic-template.md`, `task-template.md`)
|
||||
- Keep tasks **bead-scale** (one agent session can complete or materially advance)
|
||||
- Cross-link with relative paths: tasks link to epic via `./Epic-<slug>.md`, to plan via `../Plan-<slug>.md`
|
||||
- Use `tenant_id: global` for repo-wide work that's not tenant-specific
|
||||
- Run `pnpm --filter @tasks/database watch:markdown-backlog` after adding/editing files to sync
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Creating a New Task
|
||||
|
||||
1. Navigate to the Epic folder: `plans/Plan-<plan>/Epic-<epic>/`
|
||||
2. Create `Task-<slug>.md` (lowercase-hyphenated slug)
|
||||
3. Copy frontmatter template from `docs/templates/task-template.md`
|
||||
4. Fill required fields: `kind: task`, `slug`, `title`, `status: draft`, `priority`, `plan_slug`, `epic_slug`, `tenant_id`, `updated_at`
|
||||
5. Write task body (## Description, ## Acceptance criteria, etc.)
|
||||
6. Run `pnpm --filter @tasks/database watch:markdown-backlog` to import
|
||||
7. Verify row exists: `SELECT * FROM markdown_backlog_items WHERE slug = '<slug>'`
|
||||
|
||||
### Adding an Agent Prompt to a Task
|
||||
|
||||
If the task needs custom context for agents:
|
||||
|
||||
```yaml
|
||||
---
|
||||
kind: task
|
||||
slug: implement-feature-x
|
||||
# ... other required fields ...
|
||||
agent_prompt: |
|
||||
This task extends the existing authentication system.
|
||||
Key constraints:
|
||||
- Use NextAuth v5 (not v4)
|
||||
- Follow patterns in apps/web/lib/auth.ts
|
||||
- Add tests for new session logic
|
||||
---
|
||||
```
|
||||
|
||||
When an agent calls MCP `claim_task` with this task's `backlogItemId`, the prompt is returned and injected into the session context.
|
||||
|
||||
### Checking Sync Status
|
||||
|
||||
```bash
|
||||
# Watch mode (auto-imports on file change)
|
||||
pnpm --filter @tasks/database watch:markdown-backlog
|
||||
|
||||
# One-shot import
|
||||
MARKDOWN_BACKLOG_WORKSPACE_ID=<uuid> node packages/database/dist/markdown-backlog-cli.js
|
||||
```
|
||||
|
||||
**What it logs:**
|
||||
- Created: new rows inserted
|
||||
- Updated: contentHash changed
|
||||
- Skipped: contentHash matches (idempotent)
|
||||
- Errors: parse failures, missing parent refs
|
||||
|
||||
### Understanding the Hierarchy via SQL
|
||||
|
||||
```sql
|
||||
-- Get full tree for a plan
|
||||
WITH RECURSIVE tree AS (
|
||||
SELECT id, kind, slug, title, parent_id, 0 AS depth
|
||||
FROM markdown_backlog_items
|
||||
WHERE kind = 'plan' AND slug = 'automation-foundation'
|
||||
UNION ALL
|
||||
SELECT c.id, c.kind, c.slug, c.title, c.parent_id, p.depth + 1
|
||||
FROM markdown_backlog_items c
|
||||
JOIN tree p ON c.parent_id = p.id
|
||||
)
|
||||
SELECT kind, slug, title, depth FROM tree ORDER BY depth, slug;
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: Valid Task Frontmatter
|
||||
|
||||
```yaml
|
||||
---
|
||||
kind: task
|
||||
slug: db-convoy-events-migration
|
||||
title: Drizzle convoy_events table and migration
|
||||
plan_slug: automation-foundation
|
||||
epic_slug: convoy-lifecycle-mcp
|
||||
status: done
|
||||
priority: P1
|
||||
tenant_id: global
|
||||
owner: unassigned
|
||||
cursor_todo_id: null
|
||||
updated_at: "2026-08-12"
|
||||
---
|
||||
|
||||
# Task summary
|
||||
|
||||
Add Drizzle schema + migration for convoy_events...
|
||||
```
|
||||
|
||||
### Example 2: Task with Agent Prompt
|
||||
|
||||
```yaml
|
||||
---
|
||||
kind: task
|
||||
slug: trpc-backlog-tree
|
||||
title: tRPC backlog.list / tree / get for markdown_backlog_items
|
||||
plan_slug: automation-foundation
|
||||
epic_slug: plans-roadmap-ui
|
||||
status: ready
|
||||
priority: P1
|
||||
tenant_id: global
|
||||
owner: unassigned
|
||||
cursor_todo_id: null
|
||||
updated_at: "2026-08-12"
|
||||
agent_prompt: |
|
||||
Extend apps/web backlog router: list/tree/get for markdown_backlog_items.
|
||||
Always filter by workspace_id. Zod inputs. No Plans UI yet — API only.
|
||||
Reuse existing schema. claim_task / complete_task; lint + type-check + test.
|
||||
---
|
||||
```
|
||||
|
||||
### Example 3: Debugging Import Issues
|
||||
|
||||
**Problem:** Task not appearing in DB after markdown edit.
|
||||
|
||||
**Diagnosis:**
|
||||
1. Check file structure: `ls plans/Plan-<plan>/Epic-<epic>/Task-<slug>.md`
|
||||
2. Validate frontmatter YAML syntax (common issue: unbalanced quotes, missing colons)
|
||||
3. Check watcher logs: `pnpm --filter @tasks/database watch:markdown-backlog` (look for parse errors)
|
||||
4. Query DB: `SELECT * FROM markdown_backlog_items WHERE slug = '<slug>' AND workspace_id = '<uuid>'`
|
||||
5. Check `content_hash`: if it matches, sync is idempotent (no change detected)
|
||||
|
||||
**Fix:** Correct frontmatter YAML, re-run import, verify row updated.
|
||||
|
||||
## References
|
||||
|
||||
- **Parser source:** `packages/database/src/markdown-backlog/parse.ts`
|
||||
- **Sync logic:** `packages/database/src/markdown-backlog/sync.ts`
|
||||
- **Prompt resolution:** `packages/database/src/markdown-backlog/resolve-prompt.ts`
|
||||
- **Templates:** `docs/templates/plan-template.md`, `epic-template.md`, `task-template.md`
|
||||
- **Glossary:** `docs/Glossary.md` (backlog hierarchy, sync vocabulary)
|
||||
- **Cursor sync notes:** `config/CursorSync.md` (push/pull behavior, mappings)
|
||||
- **Schema:** `packages/database/src/schema/markdown_backlog_items.ts`
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `convoy-lifecycle` — when backlog tasks map to convoy briefs
|
||||
- `model-routing` — how agent_prompt + model_policy interact
|
||||
- `code-review-two-axis` — reviewing changes to backlog sync code
|
||||
243
.agents/skills/code-review-two-axis/SKILL.md
Normal file
243
.agents/skills/code-review-two-axis/SKILL.md
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
---
|
||||
name: code-review-two-axis
|
||||
description: Review code changes along two axes: Standards (does code follow repo conventions?) and Spec (does it match the originating issue/requirement?). Uses parallel sub-agents to keep concerns separate, then aggregates findings. Use when role-reviewer runs, a human requests PR review, or you're asked to review "changes since X".
|
||||
---
|
||||
|
||||
# Code Review: Two-Axis Method
|
||||
|
||||
A rigorous but practical code review method that separates **Standards** from **Spec**, runs them as parallel sub-agents, and aggregates findings. Adapted from the approach in [mattpocock/skills](https://github.com/mattpocock/skills/blob/main/skills/engineering/code-review/SKILL.md) for Echodo's convoy pipeline.
|
||||
|
||||
## When to Activate
|
||||
|
||||
Use this skill when:
|
||||
- **Role-reviewer** is triggered during convoy `pr_review` gate
|
||||
- A human requests a PR review in Mission Control
|
||||
- Someone asks "review the changes since commit X" or "review branch Y"
|
||||
- You're asked to evaluate if code meets the acceptance criteria from the originating brief
|
||||
|
||||
## Core Method
|
||||
|
||||
### Two Axes
|
||||
|
||||
**1. Standards (Does the code follow repo conventions?)**
|
||||
- Follows `AGENTS.md` and codebase style
|
||||
- Uses existing patterns (file locations, naming, imports)
|
||||
- Passes linter/formatter checks
|
||||
- No obvious code smells (from Fowler's refactoring book)
|
||||
|
||||
**2. Spec (Does the code match what was asked for?)**
|
||||
- Implements acceptance criteria from the originating brief
|
||||
- Matches the description in `.convoys/<slug>/brief-*.md`
|
||||
- Addresses the issue/story/task it claims to solve
|
||||
- No missing functionality or edge cases
|
||||
|
||||
### Why Two Separate?
|
||||
|
||||
- **Separation of concerns:** A change might be well-written (Standards) but not solve the problem (Spec), or vice versa
|
||||
- **Clearer feedback:** "This is good code but doesn't match the spec" vs "This matches the spec but violates our style"
|
||||
- **Parallel sub-agents:** Run both in parallel to avoid context pollution between concerns
|
||||
|
||||
## The Process
|
||||
|
||||
### Step 1: Pin the Fixed Point
|
||||
|
||||
Whatever the user specifies as the comparison base:
|
||||
- Commit SHA: `abc1234`
|
||||
- Branch: `feature-x`
|
||||
- Tag: `v1.2.0`
|
||||
- Relative: `HEAD~5`, `main`
|
||||
|
||||
**Capture:**
|
||||
- Diff command: `git diff <fixed-point>...HEAD` (three-dot for merge-base comparison)
|
||||
- Commit log: `git log <fixed-point>..HEAD --oneline`
|
||||
|
||||
**Validate:**
|
||||
- `git rev-parse <fixed-point>` must resolve
|
||||
- Diff must be non-empty
|
||||
|
||||
### Step 2: Identify the Spec Source
|
||||
|
||||
Find what the change is supposed to implement, in this order:
|
||||
|
||||
1. **Issue references in commits:** `#123`, `Closes #45`, etc.
|
||||
2. **Brief path:** User passed `.convoys/<slug>/brief-N-*.md`
|
||||
3. **Matching spec file:** Look in `docs/`, `specs/`, `.scratch/` for files matching branch name
|
||||
4. **Ask the user:** If none found, ask where the spec is
|
||||
|
||||
### Step 3: Identify the Standards Sources
|
||||
|
||||
- `AGENTS.md` at repo root
|
||||
- `.cursor/rules/*.mdc` files (if not migrated to `.agents/skills/`)
|
||||
- `docs/` for architecture decisions
|
||||
- Existing code patterns in the same module
|
||||
- **Fowler code smells baseline** (below)
|
||||
|
||||
### Step 4: Run Both Sub-Agents in Parallel
|
||||
|
||||
Run **Standards agent** and **Spec agent** as separate conversations with their own context. Each receives:
|
||||
- The diff
|
||||
- Its specific criteria
|
||||
- Output format instructions
|
||||
|
||||
**Standards sub-agent receives:**
|
||||
- Diff
|
||||
- Standards sources (AGENTS.md, rules, patterns)
|
||||
- Code smell baseline
|
||||
- Instruction: "Review ONLY for standards violations"
|
||||
|
||||
**Spec sub-agent receives:**
|
||||
- Diff
|
||||
- Brief/spec source
|
||||
- Acceptance criteria from originating task
|
||||
- Instruction: "Review ONLY for spec conformance"
|
||||
|
||||
### Step 5: Aggregate Findings
|
||||
|
||||
After both sub-agents complete, merge their reports:
|
||||
|
||||
```markdown
|
||||
# Code Review: <convoy/branch>
|
||||
|
||||
## Summary
|
||||
<Combined 2-3 sentence summary>
|
||||
|
||||
## Standards Findings
|
||||
<Issues from Standards axis, grouped by severity>
|
||||
|
||||
## Spec Findings
|
||||
<Issues from Spec axis, grouped by severity>
|
||||
|
||||
## Recommendations
|
||||
<Combined recommendations, prioritized>
|
||||
|
||||
## Verdict
|
||||
[ ] Approve - meets standards and spec
|
||||
[ ] Approve with comments - minor issues, not blocking
|
||||
[ ] Request changes - significant issues must be addressed
|
||||
[ ] Block - blocker-level issues (security, correctness)
|
||||
```
|
||||
|
||||
## Code Smell Baseline (Standards)
|
||||
|
||||
Even if repo docs say nothing, apply this baseline. **Repo rules always override.**
|
||||
|
||||
### The 11 Smells
|
||||
|
||||
| Smell | What It Is | How to Fix |
|
||||
|-------|------------|------------|
|
||||
| **Mysterious Name** | Function/variable/type name doesn't reveal purpose | Rename to something descriptive |
|
||||
| **Duplicated Code** | Same logic in multiple files/hunks | Extract to shared function |
|
||||
| **Feature Envy** | Method reaches into another object's data more than its own | Move method to the data it envys |
|
||||
| **Data Clumps** | Same fields/params travel together | Bundle into a type |
|
||||
| **Primitive Obsession** | Primitives standing in for domain concepts | Create small types |
|
||||
| **Repeated Switches** | Same switch/if-cascade repeats | Use polymorphism or map |
|
||||
| **Shotgun Surgery** | One logical change forces edits in many files | Gather related changes together |
|
||||
| **Divergent Change** | One file edited for unrelated reasons | Split by concern |
|
||||
| **Speculative Generality** | Abstraction/params added for imagined future needs | Delete until real need appears |
|
||||
| **Message Chains** | Long `a.b().c().d()` navigation | Hide behind method on first object |
|
||||
| **Middle Man** | Class/function that mostly just delegates | Call target directly |
|
||||
| **Refused Bequest** | Subclass ignores most of what it inherits | Use composition, not inheritance |
|
||||
|
||||
## Severity Levels
|
||||
|
||||
| Level | Meaning | Action |
|
||||
|-------|---------|--------|
|
||||
| **Blocker** | Security vulnerability, correctness bug, data loss risk | Must fix before merge |
|
||||
| **Major** | Significant issue affecting functionality or maintainability | Should fix before merge |
|
||||
| **Minor** | Nits, style issues, minor improvements | Fix before merge or follow-up |
|
||||
| **Nit** | Style preference, whitespace, naming convention | Optional, can defer |
|
||||
|
||||
## Categories
|
||||
|
||||
| Category | What It Covers |
|
||||
|----------|----------------|
|
||||
| **Correctness** | Logic errors, bugs, edge cases not handled |
|
||||
| **Security** | Vulnerabilities, input validation, auth issues |
|
||||
| **Performance** | Inefficiency, unnecessary work, memory issues |
|
||||
| **Readability** | Clarity, naming, documentation |
|
||||
| **Style** | Formatting, lint violations (let linter handle these) |
|
||||
| **Test-Coverage** | Missing tests for new functionality |
|
||||
|
||||
## Example Review Output
|
||||
|
||||
### Input
|
||||
|
||||
- **Convoy:** `feature-add-auth-endpoint`
|
||||
- **Fixed point:** `main`
|
||||
- **Brief:** `.convoys/feature-add-auth-endpoint/brief-1-implement.md`
|
||||
- **Acceptance criteria from brief:**
|
||||
- [ ] Route at `apps/web/app/api/auth/register/route.ts`
|
||||
- [ ] zod schema validates email/password
|
||||
- [ ] Database insert creates user row
|
||||
- [ ] Returns 201 on success, 400 on validation error
|
||||
|
||||
### Output
|
||||
|
||||
```markdown
|
||||
# Code Review: feature-add-auth-endpoint
|
||||
|
||||
## Summary
|
||||
Implements POST /api/auth/register endpoint with zod validation and database insert. Route file correctly placed. Meets most acceptance criteria. One validation edge case and missing test coverage identified.
|
||||
|
||||
## Standards Findings
|
||||
|
||||
### Minor
|
||||
- **[style]** `apps/web/app/api/auth/register/route.ts:15` — Consider extracting validation schema to `lib/validations/auth.ts` for reusability
|
||||
- **[readability]** `apps/web/app/api/auth/register/route.ts:8` — `createUser` function name could be more descriptive: `createUserFromCredentials`
|
||||
|
||||
### Nit
|
||||
- **[style]** Missing blank line between imports
|
||||
|
||||
## Spec Findings
|
||||
|
||||
### Major
|
||||
- **[correctness]** `apps/web/app/api/auth/register/route.ts:28` — Returns 500 on database error instead of appropriate 4xx. Should return 409 if email already exists.
|
||||
|
||||
### Minor
|
||||
- **[test-coverage]** No test file created for this route. Brief AC didn't explicitly require tests but Echodo standard is to add tests.
|
||||
|
||||
## Recommendations
|
||||
|
||||
1. **High priority:** Handle duplicate email (409) and database errors (500 → 500, but logged)
|
||||
2. **Medium:** Consider extracting validation schema
|
||||
3. **Low:** Add minimal test coverage
|
||||
|
||||
## Verdict
|
||||
|
||||
[ ] Approve
|
||||
[X] Approve with comments - one major issue should be addressed
|
||||
[ ] Request changes
|
||||
[ ] Block
|
||||
|
||||
## Metadata
|
||||
- **Reviewed by:** role-reviewer (qwen-coder-mlx)
|
||||
- **Model:** qwen-coder-mlx
|
||||
- **Files reviewed:** 1
|
||||
- **Lines changed:** ~45
|
||||
```
|
||||
|
||||
## Running as Role-Reviewer
|
||||
|
||||
When triggered as `role-reviewer` in a convoy:
|
||||
|
||||
1. **Find the brief:** Look at convoy's current brief in `.convoys/<slug>/brief-*.md`
|
||||
2. **Extract AC:** Get acceptance criteria from brief body
|
||||
3. **Pin base:** Use `main` as default fixed point, or ask if different
|
||||
4. **Run parallel agents:** Standards + Spec in parallel
|
||||
5. **Aggregate:** Merge into final review per format above
|
||||
6. **Log:** Call `log_convoy_event` with event type `role_complete`, actor `role-reviewer`
|
||||
7. **Transition:** If blocker/major issues, transition convoy back to `in_progress`. If minor/nits, transition to `pr_review`.
|
||||
|
||||
## Interaction with Other Skills
|
||||
|
||||
- **backlog-sync:** When review involves changes to markdown import logic
|
||||
- **convoy-lifecycle:** When logging role_complete and transitioning status
|
||||
- **model-routing:** When determining which model runs the review
|
||||
- **tdd-red-green:** When suggesting tests that should exist
|
||||
|
||||
## References
|
||||
|
||||
- **Original skill:** https://github.com/mattpocock/skills/blob/main/skills/engineering/code-review/SKILL.md
|
||||
- **Fowler Refactoring:** Chapter 3 - "Bad Smells in Code"
|
||||
- **AGENTS.md:** Root of repo for Echodo-specific standards
|
||||
- **Echodo patterns:** Use existing code in `apps/web/`, `packages/` as standards reference
|
||||
440
.agents/skills/convoy-lifecycle/SKILL.md
Normal file
440
.agents/skills/convoy-lifecycle/SKILL.md
Normal file
|
|
@ -0,0 +1,440 @@
|
|||
---
|
||||
name: convoy-lifecycle
|
||||
description: Work with the convoy system - understand status transitions, gate actors, MCP tools, and checkpoint/resume. Use when creating/managing convoys, transitioning status, logging events, or implementing the worker harness.
|
||||
---
|
||||
|
||||
# Convoy Lifecycle
|
||||
|
||||
Convoys are the pipeline execution model for Echodo. A convoy groups related briefs into a cohesive unit with a success metric, model policy, and human gates. This skill teaches you the status machine, actor rules, MCP tools, and how the orchestrator drives the flow.
|
||||
|
||||
## When to Activate
|
||||
|
||||
Use this skill when:
|
||||
- Creating or managing `.convoys/*.md` files
|
||||
- Transitioning convoy status (draft → ready → merged, etc.)
|
||||
- Understanding gate actors and what they can/cannot do
|
||||
- Implementing the worker harness (Epic 7)
|
||||
- Logging events to `convoy_events` or `agent_runs`
|
||||
- Checking checkpoint/resume behavior
|
||||
- Debugging convoy state issues
|
||||
|
||||
## Core Rules
|
||||
|
||||
### 1. Status Machine (The Contract)
|
||||
|
||||
Convoys have a strict status lifecycle. Transitions are enforced by the server.
|
||||
|
||||
**Code flow (from → to):**
|
||||
|
||||
```
|
||||
draft ──→ convoy_review ──→ ready ──→ in_progress ──→ pr_review ──→ merged ──→ released
|
||||
↑ │ ↑
|
||||
│ │ │
|
||||
│ └──→ blocked └──→ blocked
|
||||
│ ↑
|
||||
└──→ cancelled └──→ cancelled
|
||||
```
|
||||
|
||||
**Non-code variant (docs/SOPs):**
|
||||
|
||||
```
|
||||
draft ──→ convoy_review ──→ ready ──→ in_progress ──→ doc_review ──→ published ──→ released
|
||||
↑ │ ↑
|
||||
│ └──→ blocked └──→ blocked
|
||||
│
|
||||
└──→ cancelled
|
||||
```
|
||||
|
||||
**Terminal states:** `released`, `cancelled`
|
||||
|
||||
**Exceptional state:** `blocked` (can resume from most states)
|
||||
|
||||
### 2. Actor Rules (Who Can Do What)
|
||||
|
||||
The `ALLOWED_TRANSITIONS` table in `packages/shared/src/convoy-lifecycle/state-machine.ts` defines explicit actor permissions. **Pipeline roles cannot perform gate transitions.**
|
||||
|
||||
| Actor | Can Do |
|
||||
|-------|--------|
|
||||
| `role-architect` | Submit draft to convoy_review |
|
||||
| `role-conductor` | Cancel convoy, unblock from blocked |
|
||||
| `role-implementer` | Submit ready to in_progress, move pr_review back to in_progress, block/unblock |
|
||||
| `human-gate-1` | Approve/reject convoy_review → ready, block/unblock, cancel |
|
||||
| `human-gate-2` | Approve pr_review → merged, doc_review → published, block/unblock, cancel |
|
||||
| `human-gate-3` | Approve merged → released |
|
||||
| `doc-approver` | Alias for human-gate-2 on doc flows |
|
||||
| `automation` | Orchestrator-driven transitions (checkpoint resume, stall recovery) |
|
||||
|
||||
**Key principle:** `role-architect` ≠ human-gate-1. The conductor/architect roles plan and kick off; humans approve and release.
|
||||
|
||||
### 3. Gate Actors are Not Pipeline Roles
|
||||
|
||||
This is a common mistake. Do NOT let pipeline roles perform gates:
|
||||
|
||||
- ❌ `role-implementer` cannot approve PR merge (must be `human-gate-2`)
|
||||
- ❌ `role-architect` cannot release to production (must be `human-gate-3`)
|
||||
- ❌ `role-conductor` cannot approve convoy_review (must be `human-gate-1`)
|
||||
|
||||
**Exception:** The orchestrator runs as `automation` actor for internal transitions (checkpoint resume, stall recovery).
|
||||
|
||||
### 4. Reason Requirement
|
||||
|
||||
Some transitions require a `reason` string (e.g., blocking, cancelling, unblocking):
|
||||
|
||||
```typescript
|
||||
// From state-machine.ts
|
||||
{ from: "blocked", to: "ready", actors: ["human-gate-1", "role-conductor"], requiresReason: true }
|
||||
```
|
||||
|
||||
When calling MCP `transition_convoy_status` for these, pass `reason: "blocking for redesign"`.
|
||||
|
||||
### 5. File-Based Convoys (`.convoys/*.md`)
|
||||
|
||||
Convoys are defined in markdown with YAML frontmatter. Location: `.convoys/<slug>.md`.
|
||||
|
||||
**Frontmatter structure:**
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: <slug>
|
||||
classification: server-only | web | full-stack
|
||||
success_metric: One sentence explaining what "done" looks like
|
||||
skip:
|
||||
- ia # skip IA review
|
||||
- ux # skip UX review
|
||||
- design
|
||||
- a11y
|
||||
- visual
|
||||
- smoke
|
||||
status: draft | in_progress | merged | shipped | abandoned
|
||||
created: YYYY-MM-DD
|
||||
model_policy:
|
||||
roles:
|
||||
role-conductor: claude-planning
|
||||
role-architect: claude-planning
|
||||
role-implementer: qwen-coder
|
||||
role-reviewer: qwen-coder
|
||||
escalate_to: claude-planning
|
||||
---
|
||||
```
|
||||
|
||||
**Body sections:**
|
||||
- **Why:** Problem statement and motivation
|
||||
- **Scope:** What's in/out
|
||||
- **Roles invoked:** Who runs which phases
|
||||
- **Todos:** Brief mapping
|
||||
- **Architecture:** Technical notes
|
||||
|
||||
**Briefs live in:** `.convoys/<slug>/brief-<N>-<title>.md`
|
||||
|
||||
### 6. Checkpoint/Resume (Worker Feature)
|
||||
|
||||
When a worker job runs, it can checkpoint state to `worker_checkpoints`:
|
||||
|
||||
```typescript
|
||||
// Checkpoint payload (example from docs/worker.md)
|
||||
{
|
||||
toolHistory: [{ tool: "read_file", input: { path: "..." }, output: "..." }],
|
||||
status: "in_progress",
|
||||
lastCheckpointAt: "2026-08-12T10:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Resume behavior:**
|
||||
- Worker kills → orchestrator detects stall (`ORCHESTRATOR_STALL_MINUTES` default 120)
|
||||
- Next dequeue reads checkpoint for same job → resumes from where it left off
|
||||
- `toolHistory` enables context replay without re-running everything
|
||||
|
||||
### 7. Events Table (`convoy_events`)
|
||||
|
||||
Every transition and role completion is logged to `convoy_events`:
|
||||
|
||||
```sql
|
||||
-- Example log entries
|
||||
| id | convoy_slug | event_type | actor | metadata |
|
||||
|----|-------------|-----------------|---------------|-------------------------------|
|
||||
| 1 | feature-x | status_change | role-architect| { from: "draft", to: "ready" }|
|
||||
| 2 | feature-x | role_complete | role-architect| { brief: "brief-1", ... } |
|
||||
| 3 | feature-x | role_complete | role-implementer| { brief: "brief-2", model: ...}|
|
||||
| 4 | feature-x | status_change | human-gate-2 | { from: "pr_review", to: "merged" } |
|
||||
```
|
||||
|
||||
**Event types:**
|
||||
- `status_change` — transition from one convoy status to another
|
||||
- `role_complete` — a brief finished with metrics (model, tokens, duration)
|
||||
- `stall_recovered` — orchestrator resumed from checkpoint
|
||||
- `gate_action` — human approved/requested changes
|
||||
|
||||
## MCP Tools
|
||||
|
||||
### create_convoy
|
||||
|
||||
**Purpose:** Create a new convoy at `draft` status with metadata.
|
||||
|
||||
**Signature:**
|
||||
```typescript
|
||||
create_convoy({
|
||||
workspace: string, // Workspace UUID or slug
|
||||
slug: string, // Convoy slug (kebab-case)
|
||||
title: string,
|
||||
successMetric: string, // Frontmatter success_metric
|
||||
classification: string, // server-only | web | full-stack
|
||||
skip?: string[], // Optional: ["ia", "ux", "design"]
|
||||
modelPolicy?: { // Optional: set model routes
|
||||
roles: Record<string, string>, // role -> alias
|
||||
escalateTo?: string,
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**Returns:** Convoy object with ID, slug, current status (`draft`), created_at.
|
||||
|
||||
**Example usage:**
|
||||
```
|
||||
"Create a convoy called 'test-omniroute-integration' with success metric 'OmniRoute routes all 5 roles successfully'. Classification: server-only. Skip IA, UX, design."
|
||||
```
|
||||
|
||||
### transition_convoy_status
|
||||
|
||||
**Purpose:** Move convoy between statuses. Enforces actor rules + reason requirements.
|
||||
|
||||
**Signature:**
|
||||
```typescript
|
||||
transition_convoy_status({
|
||||
workspace: string,
|
||||
convoySlug: string,
|
||||
fromStatus: string,
|
||||
toStatus: string,
|
||||
actor: string, // role-architect, human-gate-1, etc.
|
||||
reason?: string, // Required for block/unblock/cancel
|
||||
})
|
||||
```
|
||||
|
||||
**Returns:** Updated convoy status, event log entry.
|
||||
|
||||
**Example usage:**
|
||||
```
|
||||
"Transition convoy 'test-e2e-autonomous' from 'ready' to 'in_progress'. Actor: role-implementer."
|
||||
```
|
||||
|
||||
### log_convoy_event
|
||||
|
||||
**Purpose:** Append a role completion or custom event to `convoy_events`.
|
||||
|
||||
**Signature:**
|
||||
```typescript
|
||||
log_convoy_event({
|
||||
workspace: string,
|
||||
convoySlug: string,
|
||||
eventType: string, // role_complete, gate_action, etc.
|
||||
actor: string, // Who performed the event
|
||||
metadata: Record<string, unknown>, // Additional context
|
||||
})
|
||||
```
|
||||
|
||||
**Example usage:**
|
||||
```
|
||||
"Log role_complete for convoy 'test-e2e-autonomous', actor: role-implementer, brief: brief-2-implement-feature, model: qwen-coder, tokens: 12345"
|
||||
```
|
||||
|
||||
### query_manifest_status
|
||||
|
||||
**Purpose:** Get convoy status, briefs, and recent events for Mission Control UI.
|
||||
|
||||
**Signature:**
|
||||
```typescript
|
||||
query_manifest_status({
|
||||
workspace: string,
|
||||
convoySlug?: string, // Optional: filter to one convoy
|
||||
})
|
||||
```
|
||||
|
||||
**Returns:** Convoy object with status, briefs (with status), recent events.
|
||||
|
||||
### reconcile_from_files
|
||||
|
||||
**Purpose:** Import `.convoys/*.md` into the DB and report pending sync outbox.
|
||||
|
||||
**Signature:**
|
||||
```typescript
|
||||
reconcile_from_files({
|
||||
repoPath?: string, // Optional: scan specific path
|
||||
})
|
||||
```
|
||||
|
||||
**Returns:** Summary of created/updated convoys, any sync errors.
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Creating a Convoy
|
||||
|
||||
1. **Define the convoy file:** `.convoys/feature-x.md`
|
||||
2. **Write frontmatter:** name, classification, success_metric, model_policy
|
||||
3. **Write body:** Why, Scope, Roles, Todos
|
||||
4. **Create briefs:** `.convoys/feature-x/brief-1-*.md` for each task
|
||||
5. **Import to DB:** `reconcile_from_files` or `create_convoy` MCP
|
||||
6. **Start work:** `transition_convoy_status` to `convoy_review`
|
||||
|
||||
### Running Through Gates
|
||||
|
||||
1. **Draft → Convoy Review:** `role-architect` submits (or `create_convoy` with status)
|
||||
2. **Convoy Review:** `human-gate-1` approves (→ ready) or rejects (→ draft)
|
||||
3. **Ready → In Progress:** `role-implementer` starts work
|
||||
4. **In Progress → PR Review:** `role-implementer` submits code
|
||||
5. **PR Review:** `human-gate-2` approves (→ merged) or requests changes (→ in_progress)
|
||||
6. **Merged → Released:** `human-gate-3` promotes to production
|
||||
|
||||
### Logging Role Completion
|
||||
|
||||
When an agent finishes a brief:
|
||||
|
||||
```typescript
|
||||
// In worker, after complete_task MCP
|
||||
await log_convoy_event({
|
||||
workspace: workspaceId,
|
||||
convoySlug: convoy.slug,
|
||||
eventType: "role_complete",
|
||||
actor: roleId, // e.g., "role-implementer"
|
||||
metadata: {
|
||||
briefSlug: brief.slug,
|
||||
model: modelSpec.alias, // e.g., "qwen-coder"
|
||||
modelTier: tier, // "coding" or "coding-pro"
|
||||
tokensIn: tokens.input,
|
||||
tokensOut: tokens.output,
|
||||
durationMs: duration,
|
||||
outcome: "success" | "failure",
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Handling Blocks
|
||||
|
||||
To block a convoy (any gate actor or role-implementer):
|
||||
|
||||
```typescript
|
||||
await transition_convoy_status({
|
||||
workspace: workspaceId,
|
||||
convoySlug: "feature-x",
|
||||
fromStatus: "in_progress",
|
||||
toStatus: "blocked",
|
||||
actor: "human-gate-2", // or role-implementer
|
||||
reason: "Design changed - need API redesign before continuing"
|
||||
});
|
||||
```
|
||||
|
||||
To unblock:
|
||||
|
||||
```typescript
|
||||
await transition_convoy_status({
|
||||
workspace: workspaceId,
|
||||
convoySlug: "feature-x",
|
||||
fromStatus: "blocked",
|
||||
toStatus: "in_progress",
|
||||
actor: "human-gate-2", // or role-conductor
|
||||
reason: "API redesign complete"
|
||||
});
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: Simple Code Convoy
|
||||
|
||||
**`.convoys/add-auth-endpoint.md`:**
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: add-auth-endpoint
|
||||
classification: server-only
|
||||
success_metric: POST /api/auth/register returns 201 and creates user row
|
||||
skip:
|
||||
- ia
|
||||
- ux
|
||||
- design
|
||||
status: draft
|
||||
created: 2026-08-12
|
||||
model_policy:
|
||||
roles:
|
||||
role-architect: claude-planning
|
||||
role-implementer: qwen-coder
|
||||
role-reviewer: qwen-coder
|
||||
---
|
||||
```
|
||||
|
||||
**Brief:** `.convoys/add-auth-endpoint/brief-1-implement.md`
|
||||
|
||||
```markdown
|
||||
## Description
|
||||
|
||||
Implement the POST /api/auth/register endpoint.
|
||||
|
||||
## Scope
|
||||
|
||||
- Create route handler at apps/web/app/api/auth/register/route.ts
|
||||
- Add validation with zod
|
||||
- Call database insert
|
||||
- Return 201 on success, 400 on validation error
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Route file exists at correct path
|
||||
- [ ] zod schema validates email/password
|
||||
- [ ] Database insert creates user row
|
||||
- [ ] pnpm lint && pnpm type-check pass
|
||||
- [ ] Brief is done
|
||||
```
|
||||
|
||||
### Example 2: Doc-Only Convoy
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: write-sops
|
||||
classification: server-only
|
||||
success_metric: 5 SOP documents are published under docs/sops/
|
||||
skip:
|
||||
- ia
|
||||
- ux
|
||||
- design
|
||||
- visual
|
||||
- smoke
|
||||
status: draft
|
||||
created: 2026-08-12
|
||||
model_policy:
|
||||
roles:
|
||||
role-doc-writer: auto/coding
|
||||
---
|
||||
|
||||
Use the template at docs/templates/doc-sop.md to write SOPs for:
|
||||
- How to run a conveyor
|
||||
- How to add a new model provider
|
||||
- How to configure OmniRoute
|
||||
- How to add a new harness
|
||||
```
|
||||
|
||||
### Example 3: Status Transition Flow
|
||||
|
||||
```
|
||||
1. Architect submits: transition(ready → convoy_review) by role-architect
|
||||
2. Gate approves: transition(convoy_review → ready) by human-gate-1
|
||||
3. Implementer starts: transition(ready → in_progress) by role-implementer
|
||||
4. Implementer submits: transition(in_progress → pr_review) by role-implementer
|
||||
5. Gate merges: transition(pr_review → merged) by human-gate-2
|
||||
6. Gate releases: transition(merged → released) by human-gate-3
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- **State machine:** `packages/shared/src/convoy-lifecycle/state-machine.ts`
|
||||
- **Schemas:** `packages/shared/src/convoy-lifecycle/schemas.ts`
|
||||
- **Kanban view:** `packages/shared/src/convoy-lifecycle/kanban.ts`
|
||||
- **Tests:** `packages/shared/src/convoy-lifecycle/state-machine.test.ts`
|
||||
- **MCP tools:** `apps/mcp-server/src/tools/create-convoy.ts`, `transition_convoy_status.ts`, `log_convoy_event.ts`
|
||||
- **Worker docs:** `docs/worker.md`
|
||||
- **Orchestrator docs:** `docs/orchestrator.md`
|
||||
- **Convoy lifecycle doc:** `docs/convoy-lifecycle.md`
|
||||
- **Example convoys:** `.convoys/second-brain-os-v01.md`, `.convoys/polish-dag-tiers-cost.md`
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `backlog-sync` — when briefs map to markdown_backlog_items
|
||||
- `model-routing` — how model_policy aliases resolve via omniroute
|
||||
- `openhands-worker` — worker implementation that uses this lifecycle
|
||||
- `code-review-two-axis` — what role-reviewer does during pr_review
|
||||
463
.agents/skills/model-routing/SKILL.md
Normal file
463
.agents/skills/model-routing/SKILL.md
Normal file
|
|
@ -0,0 +1,463 @@
|
|||
---
|
||||
name: model-routing
|
||||
description: Route pipeline roles to AI models via workspace registry and OmniRoute profiles. Understand resolution order, convoy overrides, tier mapping, and OmniRoute integration. Use when configuring models, debugging route failures, or working with model_policy.
|
||||
---
|
||||
|
||||
# Model Routing
|
||||
|
||||
Echodo's model routing system connects **pipeline roles** (conductor, architect, implementer, reviewer) to **AI models** via a workspace-scoped registry with layered overrides. The system integrates with OmniRoute for dynamic model selection across 5 profiles (`auto`, `auto/coding`, `auto/coding-pro`, `auto/reasoning`, `auto/reasoning-pro`). This skill teaches you how routes resolve, where to configure them, and how the orchestrator dispatches work.
|
||||
|
||||
## When to Activate
|
||||
|
||||
Use this skill when:
|
||||
- Configuring model providers or role routes in Settings → AI
|
||||
- Understanding `model_policy` in `.convoys/*.md`
|
||||
- Debugging model resolution failures
|
||||
- Integrating OmniRoute (cloud or local)
|
||||
- Logging model metadata in `agent_runs` or `convoy_events`
|
||||
- Adding new pipeline roles that need model routing
|
||||
- Testing provider connectivity (`testProvider`)
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### 1. Four-Layer Resolution Order (First Match Wins)
|
||||
|
||||
**Priority from highest to lowest:**
|
||||
|
||||
```
|
||||
1. Brief override ──→ recommended_model in .convoys/<slug>/brief-<N>-*.md
|
||||
(Direct per-task model selection)
|
||||
|
||||
2. Convoy override ──→ model_policy.roles in .convoys/<slug>.md
|
||||
(Per-convoy role→alias mapping)
|
||||
|
||||
3. Workspace route ──→ role_model_routes row in DB
|
||||
(Settings → AI → Role Routing table)
|
||||
|
||||
4. Pipeline template ──→ default role mapping
|
||||
(Shipped seed data for new workspaces)
|
||||
```
|
||||
|
||||
**Example:** A task has `recommended_model: qwen-coder-mlx`, its convoy has `model_policy.roles: { role-implementer: claude-planning }`, and the workspace route maps `role-implementer → qwen-coder`. The system picks **`qwen-coder-mlx`** (brief override wins).
|
||||
|
||||
### 2. Registry Components
|
||||
|
||||
| Component | Table | Purpose |
|
||||
|-----------|-------|---------|
|
||||
| **Model Provider** | `model_providers` | Concrete model config: API endpoint, auth, params |
|
||||
| **Role Route** | `role_model_routes` | Which alias serves which role |
|
||||
| **Convoy Override** | `.convoys/*.md` frontmatter | Per-convoy role mapping |
|
||||
| **Brief Override** | `.convoys/<slug>/brief-*.md` frontmatter | Per-task specific model |
|
||||
|
||||
### 3. OmniRoute Integration
|
||||
|
||||
**OmniRoute profiles** map to Echodo roles:
|
||||
|
||||
| OmniRoute Profile | Echodo Role | Use Case |
|
||||
|-------------------|-------------|----------|
|
||||
| `auto/reasoning` | `role-conductor`, `role-architect` | Planning, classification, design |
|
||||
| `auto/reasoning-pro` | `role-architect` (escalation), `gate-copilot` | Complex reasoning, escalation path |
|
||||
| `auto/coding` | `role-implementer`, `role-doc-writer` | Code/docs implementation |
|
||||
| `auto/coding-pro` | `role-reviewer`, `role-implementer` (escalation) | Code review, complex refactoring |
|
||||
| `auto` | `chat-default`, fallback | General purpose, fastest |
|
||||
|
||||
**How it works:**
|
||||
1. Echodo resolves alias via `resolveModel(workspace, roleId)`
|
||||
2. Returns `ResolvedModel` with provider type, model ID, base URL, env var name
|
||||
3. If provider is `omni_route` type (or base URL points to OmniRoute), OmniRoute resolves the alias to a live endpoint
|
||||
4. Worker uses resolved endpoint for inference
|
||||
|
||||
### 4. The `ResolvedModel` Contract
|
||||
|
||||
```typescript
|
||||
// Runtime structure (not persisted)
|
||||
type ResolvedModel = {
|
||||
alias: string; // "qwen-coder-mlx"
|
||||
providerType: ProviderType; // "openai_compatible"
|
||||
modelId: string; // "mlx-community/Qwen2.5-Coder-32B-Instruct-4bit"
|
||||
baseUrl?: string; // "http://192.168.1.42:8000/v1"
|
||||
apiKey: string; // From process.env[apiKeyEnvVar]
|
||||
extraHeaders?: Record<string, string>;
|
||||
defaultParams?: { temperature?: number; maxTokens?: number };
|
||||
tier?: "premium" | "fast" | "local" | "auto";
|
||||
};
|
||||
```
|
||||
|
||||
**Key invariants:**
|
||||
- Never store API keys in DB → only `apiKeyEnvVar` names
|
||||
- Worker must have env var populated at runtime
|
||||
- `baseUrl` required for `openai_compatible` provider type
|
||||
- `tier` is advisory (cost/performance grouping)
|
||||
|
||||
## Model Provider Types
|
||||
|
||||
### Provider Type Matrix
|
||||
|
||||
| Type | Client | API Format | Example Alias | Use For |
|
||||
|------|--------|------------|---------------|---------|
|
||||
| `anthropic` | `@ai-sdk/anthropic` | Anthropic Messages API | `claude-planning` | Planning, reasoning, complex tasks |
|
||||
| `openai` | `@ai-sdk/openai` | OpenAI Chat Completion | `gpt-4o-mini` | General, chat |
|
||||
| `google` | `@ai-sdk/google` | Gemini REST API | `gemini-flash` | Multimodal, reasoning |
|
||||
| `openai_compatible` | `@ai-sdk/openai` + `baseURL` | OpenAI-compatible | `qwen-coder-mlx`, `nim-llama-70b` | Local inference, NIM |
|
||||
| `azure_openai` | `@ai-sdk/openai` + Azure config | Azure OpenAI | `azure-gpt4` | Enterprise Azure deployments |
|
||||
| `omni_route` | Custom client | OmniRoute API | `auto/coding` | Dynamic routing across 929 models |
|
||||
|
||||
### Local Inference Setup Examples
|
||||
|
||||
**Ollama (Mac):**
|
||||
```yaml
|
||||
alias: qwen-coder
|
||||
provider_type: openai_compatible
|
||||
model_id: qwen2.5-coder:32b
|
||||
base_url: http://192.168.1.42:11434/v1
|
||||
api_key_env_var: OLLAMA_API_KEY # placeholder, any non-empty string
|
||||
metadata: { tier: local, runtime: ollama }
|
||||
```
|
||||
|
||||
**MLX (Apple Silicon via mlx-openai-server):**
|
||||
```yaml
|
||||
alias: qwen-coder-mlx
|
||||
provider_type: openai_compatible
|
||||
model_id: mlx-community/Qwen2.5-Coder-32B-Instruct-4bit
|
||||
base_url: http://192.168.1.42:8000/v1
|
||||
api_key_env_var: MLX_API_KEY
|
||||
metadata: { tier: local, runtime: mlx }
|
||||
```
|
||||
|
||||
**Parallel implementers:** Use `mlx-openai-server` (port 8000) over `mlx_lm.server` (8080) for better batching when orchestrator fans out to multiple implementers.
|
||||
|
||||
**OmniRoute Cloud:**
|
||||
```yaml
|
||||
alias: auto/coding
|
||||
provider_type: omni_route
|
||||
model_id: auto/coding
|
||||
base_url: https://omniroute-cloud.example.com/v1
|
||||
api_key_env_var: OMNIROUTE_API_KEY
|
||||
metadata: { tier: auto }
|
||||
```
|
||||
|
||||
## How to Configure
|
||||
|
||||
### 1. Create Provider (Settings → AI → Providers)
|
||||
|
||||
```typescript
|
||||
// Example: Add local MLX provider
|
||||
{
|
||||
alias: "qwen-coder-mlx",
|
||||
display_name: "Qwen2.5 Coder MLX",
|
||||
provider_type: "openai_compatible",
|
||||
model_id: "mlx-community/Qwen2.5-Coder-32B-Instruct-4bit",
|
||||
base_url: "http://192.168.1.42:8000/v1",
|
||||
api_key_env_var: "MLX_API_KEY",
|
||||
metadata: { tier: "local", runtime: "mlx" }
|
||||
}
|
||||
```
|
||||
|
||||
**Test:** Click "Test" button in UI. It sends a small token request to verify connectivity.
|
||||
|
||||
### 2. Map Role to Alias (Settings → AI → Role Routing)
|
||||
|
||||
```typescript
|
||||
// Example: Route implementers to local MLX
|
||||
{
|
||||
role_id: "role-implementer",
|
||||
provider_alias_id: "qwen-coder-mlx-id"
|
||||
}
|
||||
```
|
||||
|
||||
**Supported role IDs:**
|
||||
- `role-conductor`, `role-architect`, `role-implementer`, `role-reviewer`
|
||||
- `role-ia-architect`, `role-ux-reviewer`, `role-design-system-auditor`, `role-a11y-auditor`, `role-doc-writer`
|
||||
- `gate-copilot`, `chat-default`
|
||||
- Custom roles (string IDs, no migration needed)
|
||||
|
||||
### 3. Convoy Override (`.convoys/*.md`)
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: feature-x
|
||||
model_policy:
|
||||
roles:
|
||||
role-conductor: claude-planning
|
||||
role-architect: claude-planning
|
||||
role-implementer: qwen-coder-mlx
|
||||
role-reviewer: qwen-coder-mlx
|
||||
escalate_to: claude-planning
|
||||
---
|
||||
```
|
||||
|
||||
### 4. Brief Override (`.convoys/<slug>/brief-*.md`)
|
||||
|
||||
```yaml
|
||||
---
|
||||
brief: implement-feature
|
||||
recommended_model: qwen-coder-mlx
|
||||
model_tier: local
|
||||
---
|
||||
```
|
||||
|
||||
## Resolution API
|
||||
|
||||
### `resolveModel(workspaceId, roleId, context?)`
|
||||
|
||||
```typescript
|
||||
// In packages/shared/src/orchestrator/resolve.ts
|
||||
function resolveModel(input: {
|
||||
workspaceId: string;
|
||||
roleId: string;
|
||||
briefContext?: { recommended_model?: string };
|
||||
convoyContext?: { model_policy?: Record<string, string> };
|
||||
}): ResolvedModel
|
||||
```
|
||||
|
||||
**Implementation steps:**
|
||||
1. If `briefContext?.recommended_model` exists, use that alias
|
||||
2. Else if `convoyContext?.model_policy?.[roleId]` exists, use that alias
|
||||
3. Else lookup `role_model_routes` row for `(workspaceId, roleId)`
|
||||
4. Else use default mapping (pipeline template)
|
||||
5. Load provider config for alias from `model_providers`
|
||||
6. Validate provider is enabled
|
||||
7. Read API key from env var
|
||||
8. Return `ResolvedModel`
|
||||
|
||||
### Orchestrator Usage
|
||||
|
||||
```typescript
|
||||
// When dispatching a job
|
||||
const resolved = await resolveModel({
|
||||
workspaceId: convoy.workspace_id,
|
||||
roleId: brief.role_id,
|
||||
briefContext: { recommended_model: brief.recommended_model },
|
||||
convoyContext: { model_policy: convoy.model_policy }
|
||||
});
|
||||
|
||||
// Job payload includes model spec
|
||||
await enqueueJob({
|
||||
roleId: brief.role_id,
|
||||
briefSlug: brief.slug,
|
||||
modelSpec: resolved,
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
## Escalation Logic
|
||||
|
||||
When `model_policy.escalate_to` is set or workspace policy defines escalation:
|
||||
|
||||
```typescript
|
||||
// After N implementer failures (default 2)
|
||||
if (attempts >= 2 && convoy.model_policy?.escalate_to) {
|
||||
const escalated = await resolveModel({
|
||||
workspaceId: convoy.workspace_id,
|
||||
roleId: roleId,
|
||||
convoyContext: { model_policy: { [roleId]: convoy.model_policy.escalate_to } }
|
||||
});
|
||||
// Re-dispatch with escalated model
|
||||
}
|
||||
```
|
||||
|
||||
## Logging Model Metadata
|
||||
|
||||
**`agent_runs` columns:**
|
||||
|
||||
| Column | Source | Example |
|
||||
|--------|--------|---------|
|
||||
| `model_alias` | `ResolvedModel.alias` | `qwen-coder-mlx` |
|
||||
| `provider_type` | `ResolvedModel.providerType` | `openai_compatible` |
|
||||
| `model_id` | `ResolvedModel.modelId` | `mlx-community/Qwen2.5-Coder-32B-Instruct-4bit` |
|
||||
| `model_tier` | `ResolvedModel.tier` | `local` |
|
||||
|
||||
**MCP `complete_task` fields:**
|
||||
|
||||
```typescript
|
||||
await complete_task({
|
||||
// ...
|
||||
modelAlias: "qwen-coder-mlx",
|
||||
modelTier: "local",
|
||||
tokensTotal: tokens.input + tokens.output,
|
||||
});
|
||||
```
|
||||
|
||||
**`convoy_events` role_complete:**
|
||||
|
||||
```typescript
|
||||
await log_convoy_event({
|
||||
// ...
|
||||
metadata: {
|
||||
model: "qwen-coder-mlx",
|
||||
tier: "local",
|
||||
tokens_in: 12345,
|
||||
tokens_out: 6789,
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Default Mappings (Pipeline Template)
|
||||
|
||||
New workspaces get these seed routes:
|
||||
|
||||
| Role ID | Default Alias | Tier | Purpose |
|
||||
|---------|---------------|------|---------|
|
||||
| `role-conductor` | `claude-planning` | premium | Kickoff, classification |
|
||||
| `role-architect` | `claude-planning` | premium | Design, brief decomposition |
|
||||
| `role-ia-architect` | `claude-planning` | premium | Information architecture |
|
||||
| `role-ux-reviewer` | `qwen-coder` | fast | UI/UX review |
|
||||
| `role-implementer` | `qwen-coder` | fast | Code implementation |
|
||||
| `role-reviewer` | `qwen-coder` | fast | Code review |
|
||||
| `role-design-system-auditor` | `qwen-coder` | fast | Design system audit |
|
||||
| `role-a11y-auditor` | `qwen-coder` | fast | Accessibility audit |
|
||||
| `role-doc-writer` | `qwen-coder` | fast | Documentation writing |
|
||||
| `gate-copilot` | `claude-planning` | premium | Gate decision support |
|
||||
| `chat-default` | workspace choice | — | Web chat default |
|
||||
|
||||
Operators can override these defaults in Settings.
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Setting Up Local MLX + OmniRoute Hybrid
|
||||
|
||||
**Goal:** Use local MLX for coding, OmniRoute for reasoning.
|
||||
|
||||
1. **Create local MLX provider:** `qwen-coder-mlx` (port 8000)
|
||||
2. **Create OmniRoute provider:** `auto/reasoning` (cloud endpoint)
|
||||
3. **Route roles:**
|
||||
- `role-implementer` → `qwen-coder-mlx`
|
||||
- `role-conductor` → `auto/reasoning`
|
||||
- `role-architect` → `auto/reasoning-pro`
|
||||
4. **Convoy example:**
|
||||
```yaml
|
||||
model_policy:
|
||||
roles:
|
||||
role-conductor: auto/reasoning
|
||||
role-implementer: qwen-coder-mlx
|
||||
```
|
||||
|
||||
### Debugging Resolution Failures
|
||||
|
||||
**Symptoms:** Job fails with "Cannot resolve model for role-implementer"
|
||||
|
||||
**Debug steps:**
|
||||
1. Check brief frontmatter: `recommended_model` present?
|
||||
2. Check convoy frontmatter: `model_policy.roles[role-implementer]` present?
|
||||
3. Query DB: `SELECT * FROM role_model_routes WHERE workspace_id = ? AND role_id = 'role-implementer'`
|
||||
4. Check provider: Is alias enabled in `model_providers`?
|
||||
5. Test provider: Run `testProvider` MCP tool for the alias
|
||||
6. Check env var: Is `api_key_env_var` populated in worker process?
|
||||
|
||||
### Migration from Cursor Model Slugs
|
||||
|
||||
Legacy convoys may have Cursor slugs (`claude-4.6-opus-high-thinking`). Add legacy alias mapping in workspace metadata:
|
||||
|
||||
```json
|
||||
{
|
||||
"legacy_model_aliases": {
|
||||
"claude-4.6-opus-high-thinking": "claude-planning",
|
||||
"composer-2.5-fast": "qwen-coder"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
During resolution, check `legacy_model_aliases[alias]` before failing.
|
||||
|
||||
### OmniRoute as the Sole Provider
|
||||
|
||||
If you want all routing via OmniRoute:
|
||||
|
||||
1. **Create OmniRoute providers** for all 5 profiles
|
||||
- `auto`, `auto/coding`, `auto/coding-pro`, `auto/reasoning`, `auto/reasoning-pro`
|
||||
2. **Route roles** accordingly
|
||||
3. **All inference** goes through OmniRoute cloud endpoint
|
||||
4. **Advantages:** Dynamic model selection, load balancing, fallbacks
|
||||
5. **Disadvantages:** Network dependency, cost (vs local free)
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: Workspace with Local + Cloud Mix
|
||||
|
||||
**Providers:**
|
||||
```yaml
|
||||
# Local MLX (free, fast for coding)
|
||||
- alias: qwen-coder-mlx
|
||||
provider_type: openai_compatible
|
||||
base_url: http://mac:8000/v1
|
||||
tier: local
|
||||
|
||||
# Cloud Claude (costly, good for planning)
|
||||
- alias: claude-planning
|
||||
provider_type: anthropic
|
||||
tier: premium
|
||||
|
||||
# OmniRoute fallback
|
||||
- alias: auto/reasoning
|
||||
provider_type: omni_route
|
||||
tier: auto
|
||||
```
|
||||
|
||||
**Role routes:**
|
||||
- `role-conductor` → `claude-planning`
|
||||
- `role-implementer` → `qwen-coder-mlx`
|
||||
- `role-reviewer` → `qwen-coder-mlx`
|
||||
- `gate-copilot` → `auto/reasoning` (fallback when Claude unavailable)
|
||||
|
||||
**Convoy policy:**
|
||||
```yaml
|
||||
model_policy:
|
||||
roles:
|
||||
role-conductor: claude-planning
|
||||
role-implementer: qwen-coder-mlx
|
||||
escalate_to: auto/reasoning
|
||||
```
|
||||
|
||||
### Example 2: Brief Override for Complex Task
|
||||
|
||||
**Brief:** `.convoys/optimize-db/brief-5-complex-migration.md`
|
||||
|
||||
```yaml
|
||||
---
|
||||
brief: complex-db-migration
|
||||
recommended_model: auto/reasoning-pro
|
||||
model_tier: premium
|
||||
---
|
||||
|
||||
# Complex schema migration
|
||||
Needs reasoning about foreign keys and data integrity.
|
||||
```
|
||||
|
||||
The `recommended_model: auto/reasoning-pro` overrides the convoy's `model_policy.roles[role-implementer]`.
|
||||
|
||||
### Example 3: Testing Provider Connectivity
|
||||
|
||||
```bash
|
||||
# Via MCP tool
|
||||
testProvider({ alias: "qwen-coder-mlx" })
|
||||
|
||||
# Response
|
||||
{
|
||||
"success": true,
|
||||
"latency_ms": 245,
|
||||
"model": "mlx-community/Qwen2.5-Coder-32B-Instruct-4bit"
|
||||
}
|
||||
|
||||
# Error case
|
||||
{
|
||||
"success": false,
|
||||
"error": "Connection refused",
|
||||
"detail": "Check base_url and that mlx-openai-server is running on port 8000"
|
||||
}
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- **ADR:** `docs/adr-model-routing.md` (full specification)
|
||||
- **Schema:** `packages/database/src/schema/model_providers.ts`, `role_model_routes.ts`
|
||||
- **Resolver:** `packages/shared/src/orchestrator/resolve.ts` (or equivalent)
|
||||
- **OmniRoute docs:** `.convoys/model-routing-registry.md` if exists
|
||||
- **Local inference:** `docs/adr-model-routing.md#local-mlx-on-apple-silicon-mac`
|
||||
- **Worker integration:** `docs/worker.md#model-resolution`
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `backlog-sync` — brief frontmatter with `recommended_model`
|
||||
- `convoy-lifecycle` — convoy frontmatter with `model_policy`
|
||||
- `openhands-worker` — uses `ResolvedModel` for inference
|
||||
- `omniroute-setup` — operational guide for OmniRoute integration
|
||||
343
.agents/skills/tdd-red-green/SKILL.md
Normal file
343
.agents/skills/tdd-red-green/SKILL.md
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
---
|
||||
name: tdd-red-green
|
||||
description: Test-Driven Development loop: write a failing test (Red), make it pass with minimal code (Green), then refactor (Refactor). Write tests before implementation, use seams for isolation, and avoid common TDD anti-patterns. Use when implementing features, fixing bugs, or adding tests to existing code.
|
||||
---
|
||||
|
||||
# TDD: Red-Green-Refactor
|
||||
|
||||
A practical test-driven development loop adapted for Echodo's codebase. Write the test first, make it pass with minimal code, then refactor. This skill teaches you the TDD cycle, how to write effective tests, and common pitfalls to avoid.
|
||||
|
||||
## When to Activate
|
||||
|
||||
Use this skill when:
|
||||
- Implementing a new feature (add tests first)
|
||||
- Fixing a bug (write a test that reproduces it, then fix)
|
||||
- Adding coverage to existing code
|
||||
- Following Echodo's TDD convention in `role-implementer` briefs
|
||||
|
||||
## The Core Loop
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 1. Write failing test (Red) │
|
||||
│ → Test describes expected behavior │
|
||||
│ → Run test → fails (expected) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 2. Write minimal code to pass (Green) │
|
||||
│ → Only enough to make test pass │
|
||||
│ → No "preemptive" abstractions │
|
||||
│ → Run test → passes │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 3. Refactor (Refactor) │
|
||||
│ → Clean up without changing behavior │
|
||||
│ → Keep tests passing │
|
||||
│ → Run full test suite │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Step 1: Write Failing Test (Red)
|
||||
|
||||
### What Makes a Good Test?
|
||||
|
||||
1. **Describes behavior, not implementation** — "should create user with valid email" not "should call User.create"
|
||||
2. **Single assertion per test** (or very few) — easier to diagnose failures
|
||||
3. **Test name explains the scenario** — `describe('when email is invalid', () => { it('should return 400') })`
|
||||
4. **Arrange-Act-Assert structure** — clear setup, action, and expectation
|
||||
|
||||
### Test Location
|
||||
|
||||
Follow Echodo conventions:
|
||||
- Tests next to source: `src/index.ts` → `src/index.test.ts`
|
||||
- Test file suffix: `.test.ts` (Vitest)
|
||||
- Use existing test utilities in `packages/shared/test/` if available
|
||||
|
||||
### Example Red Test
|
||||
|
||||
```typescript
|
||||
// apps/web/server/routers/auth.test.ts
|
||||
describe('POST /api/auth/register', () => {
|
||||
it('should create a user with valid email and password', async () => {
|
||||
// Arrange
|
||||
const payload = {
|
||||
email: 'test@example.com',
|
||||
password: 'securepassword123'
|
||||
};
|
||||
|
||||
// Act
|
||||
const response = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(201);
|
||||
const body = await response.json();
|
||||
expect(body.user.email).toBe('test@example.com');
|
||||
});
|
||||
|
||||
it('should return 400 when email is invalid', async () => {
|
||||
// Arrange
|
||||
const payload = {
|
||||
email: 'not-an-email',
|
||||
password: 'securepassword123'
|
||||
};
|
||||
|
||||
// Act
|
||||
const response = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Run test → fails** (because route doesn't exist yet)
|
||||
|
||||
### When Bug-Fixing: Start with a Failing Test
|
||||
|
||||
Instead of:
|
||||
1. Reading the bug
|
||||
2. Fixing the code
|
||||
3. Running full test suite
|
||||
|
||||
Do this:
|
||||
1. Write a test that **reproduces the bug**
|
||||
2. Run test → fails (proves bug exists)
|
||||
3. Fix code
|
||||
4. Run test → passes (proves bug fixed)
|
||||
5. Run full suite
|
||||
|
||||
## Step 2: Write Minimal Code (Green)
|
||||
|
||||
### Key Principles
|
||||
|
||||
1. **Minimal implementation** — only enough to pass the test
|
||||
2. **No premature abstraction** — don't build "flexible" systems "just in case"
|
||||
3. **YAGNI** — You Aren't Gonna Need It
|
||||
4. **Trust the test** — if the test is right, the code should make it pass
|
||||
|
||||
### Example Green Implementation
|
||||
|
||||
```typescript
|
||||
// Minimal route to make test pass
|
||||
// apps/web/app/api/auth/register/route.ts
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
const registerSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8)
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json();
|
||||
|
||||
// Basic validation (the test checks this)
|
||||
const result = registerSchema.safeParse(body);
|
||||
if (!result.success) {
|
||||
return NextResponse.json({ error: 'Invalid input' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Minimal DB insert (simplified for example)
|
||||
const user = await createUser(result.data.email, result.data.password);
|
||||
|
||||
return NextResponse.json({ user }, { status: 201 });
|
||||
}
|
||||
|
||||
// Placeholder - in real code, call actual DB
|
||||
async function createUser(email: string, password: string) {
|
||||
return { id: '1', email };
|
||||
}
|
||||
```
|
||||
|
||||
**Run test → passes**
|
||||
|
||||
## Step 3: Refactor
|
||||
|
||||
### When to Refactor
|
||||
|
||||
- Code is duplicated
|
||||
- Names are unclear
|
||||
- Function does too much
|
||||
- Test is fragile or implementation-specific
|
||||
- You need to add a new test that requires a small change
|
||||
|
||||
### While Refactoring
|
||||
|
||||
1. **Keep tests passing** — run after every change
|
||||
2. **Small steps** — refactor one thing at a time
|
||||
3. **No new functionality** — only clean up existing code
|
||||
|
||||
### Example Refactor
|
||||
|
||||
Before:
|
||||
```typescript
|
||||
// auth.ts - duplicated validation
|
||||
function validateEmail(email: string) {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||
}
|
||||
|
||||
// Different file, same validation
|
||||
function isValidEmailAddress(email: string) {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||
}
|
||||
```
|
||||
|
||||
After (extract to shared):
|
||||
```typescript
|
||||
// lib/validation.ts
|
||||
export const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
export function isValidEmail(email: string): boolean {
|
||||
return emailRegex.test(email);
|
||||
}
|
||||
```
|
||||
|
||||
## Seams: Testing Without Dependencies
|
||||
|
||||
A **seam** is a place where you can alter behavior without changing the code being tested.
|
||||
|
||||
### Types of Seams
|
||||
|
||||
| Seam | How to Use | Example |
|
||||
|------|------------|---------|
|
||||
| **Dependency Injection** | Pass dependencies as params | `function process(db)` → `process(mockDb)` |
|
||||
| **Interface/Abstract class** | Swap implementations | `Database` abstract → `MockDatabase` |
|
||||
| **Factory functions** | Return different instances | `createClient()` → `createMockClient()` |
|
||||
| **Environment variables** | Control behavior | `if (process.env.NODE_ENV === 'test')` |
|
||||
| **Configuration objects** | Pass config at runtime | `new Service({ retries: 3 })` |
|
||||
|
||||
### Example: Seams in Echodo
|
||||
|
||||
```typescript
|
||||
// Without seam (hard to test)
|
||||
async function createUser(email: string) {
|
||||
const db = new PostgresDatabase(); // hard-coded
|
||||
return db.users.insert({ email });
|
||||
}
|
||||
|
||||
// With seam (testable)
|
||||
async function createUser(email: string, db: Database = defaultDb) {
|
||||
return db.users.insert({ email });
|
||||
}
|
||||
|
||||
// In test
|
||||
const mockDb = { users: { insert: vi.fn() } };
|
||||
await createUser('test@example.com', mockDb);
|
||||
expect(mockDb.users.insert).toHaveBeenCalledWith({ email: 'test@example.com' });
|
||||
```
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
### 1. Test After Implementation
|
||||
|
||||
❌ Write code → write tests
|
||||
✅ Write failing test → write code → pass → refactor
|
||||
|
||||
### 2. Implementation-Specific Tests
|
||||
|
||||
❌ `expect(service.createUser).toHaveBeenCalledWith(data)`
|
||||
✅ `expect(response.status).toBe(201)` (behavior, not implementation)
|
||||
|
||||
### 3. Brittle Tests
|
||||
|
||||
❌ Test checks exact string: `expect(body).toBe('{"id":"1"}')`
|
||||
✅ Test checks structure: `expect(body.user.id).toBeDefined()`
|
||||
|
||||
### 4. Shared State Between Tests
|
||||
|
||||
❌ Global variable modified in one test, relied on by another
|
||||
✅ Each test sets up its own state
|
||||
|
||||
### 5. Testing Too Much
|
||||
|
||||
❌ One test checks validation, DB insert, email send, response format
|
||||
✅ Separate tests for each concern
|
||||
|
||||
### 6. No Assertions
|
||||
|
||||
❌ `await service.process(data)` — no expect
|
||||
✅ `expect(result).toEqual(expected)`
|
||||
|
||||
### 7. Commented-Out Tests
|
||||
|
||||
❌ `// skip this test for now`
|
||||
✅ Delete or fix the test
|
||||
|
||||
## Echodo Testing Conventions
|
||||
|
||||
- **Framework:** Vitest (see `packages/*/vitest.config.ts`)
|
||||
- **Location:** Same directory as source, `.test.ts` suffix
|
||||
- **Mocking:** Use `vi.fn()` from Vitest, mock via dependency injection
|
||||
- **Database tests:** Use test database or in-memory alternative
|
||||
- **Run tests:** `pnpm test` or `pnpm --filter @tasks/<package> test`
|
||||
|
||||
## Example: Bug Fix Flow
|
||||
|
||||
**Bug report:** "Users can register with empty password"
|
||||
|
||||
### Before (wrong approach)
|
||||
1. Read bug report
|
||||
2. Add validation to route
|
||||
3. Run full test suite
|
||||
4. Deploy
|
||||
|
||||
### TDD Approach (right way)
|
||||
|
||||
1. **Write failing test:**
|
||||
```typescript
|
||||
it('should reject password shorter than 8 characters', async () => {
|
||||
const response = await register({ email: 'test@example.com', password: 'short' });
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
```
|
||||
|
||||
2. **Run test → fails** (shows bug exists)
|
||||
|
||||
3. **Fix code minimally:**
|
||||
```typescript
|
||||
const registerSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8)
|
||||
});
|
||||
```
|
||||
|
||||
4. **Run test → passes** (proves fix works)
|
||||
|
||||
5. **Run full suite** (no regressions)
|
||||
|
||||
6. **Refactor** if needed
|
||||
|
||||
## Integration with Other Skills
|
||||
|
||||
- **code-review-two-axis:** Reviewer checks for TDD pattern in implementation
|
||||
- **backlog-sync:** When testing markdown import/export features
|
||||
- **convoy-lifecycle:** When testing convoy status transitions
|
||||
- **model-routing:** When testing model resolution logic
|
||||
|
||||
## Running Tests in Echodo
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
pnpm test
|
||||
|
||||
# Run specific package
|
||||
pnpm --filter @tasks/web test
|
||||
pnpm --filter @tasks/shared test
|
||||
|
||||
# Watch mode
|
||||
pnpm --filter @tasks/web test:watch
|
||||
|
||||
# Coverage
|
||||
pnpm test --coverage
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- **Original skill:** https://github.com/mattpocock/skills/blob/main/skills/engineering/tdd/SKILL.md
|
||||
- **Kent Beck:** "Test-Driven Development: By Example"
|
||||
- **Martin Fowler:** "Refactoring" - Chapter on code smells
|
||||
- **Vitest docs:** https://vitest.dev/
|
||||
- **Echodo test config:** `packages/*/vitest.config.ts`
|
||||
0
.convoys/.gitkeep
Normal file
0
.convoys/.gitkeep
Normal file
123
.convoys/README.md
Normal file
123
.convoys/README.md
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
# Convoys (Echodo / tasks repo)
|
||||
|
||||
A **convoy** is a multi-PR work-stream coordinated by the agent pipeline. One convoy = one feature, bug fix, or epic slice. Each convoy is a Markdown file in this directory plus an optional sub-directory of implementer briefs.
|
||||
|
||||
> **Not Cursor Plan mode.** Pipeline convoys live here (`.convoys/*.md`). Cursor's native Plan feature writes to `.cursor/plans/*.plan.md` — a different artifact. For pipeline work, invoke `role-conductor` (when L2 roles are installed) and write `.convoys/<slug>.md`. See [`.cursor/rules/convoy-planning.mdc`](../.cursor/rules/convoy-planning.mdc).
|
||||
|
||||
Product backlog items still live under `plans/` and sync to Echodo via markdown import. Convoys track **how** a slice is executed (roles, briefs, gates) — not the canonical plan tree.
|
||||
|
||||
## File layout
|
||||
|
||||
```text
|
||||
.convoys/
|
||||
├── README.md (this file)
|
||||
├── .metrics.jsonl (local analytics — gitignored)
|
||||
├── <slug>.md (convoy file — role-conductor)
|
||||
└── <slug>/
|
||||
├── brief-1-<kebab-title>.md (role-architect)
|
||||
└── ...
|
||||
```
|
||||
|
||||
## Convoy file format
|
||||
|
||||
Frontmatter (set by `role-conductor`, appended by other roles):
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: <kebab-slug>
|
||||
classification: feature | hotfix | docs-only | infra-only | server-only | config-only
|
||||
success_metric: <one sentence>
|
||||
skip:
|
||||
- ia
|
||||
status: open | in-progress | merged | shipped | abandoned
|
||||
created: <YYYY-MM-DD>
|
||||
model_policy:
|
||||
roles:
|
||||
role-conductor: claude-planning
|
||||
role-implementer: qwen-coder
|
||||
escalate_to: claude-planning
|
||||
---
|
||||
```
|
||||
|
||||
Use **registry alias names** in `model_policy` (not Cursor model slugs). See [docs/adr-model-routing.md](../docs/adr-model-routing.md).
|
||||
|
||||
Body sections (typical order):
|
||||
|
||||
1. `## Why` (Conductor)
|
||||
2. `## Scope` (Conductor)
|
||||
3. `## Roles invoked` (Conductor)
|
||||
4. `## Todos` (Conductor → Architect)
|
||||
5. `## IA` (IA Architect — skip if `ia` in `skip:`)
|
||||
6. `## UX` (UX Reviewer)
|
||||
7. `## Architecture` (Architect)
|
||||
|
||||
After Architect, briefs live in `.convoys/<slug>/brief-N-*.md`. Implementers read only their brief.
|
||||
|
||||
## Skip flags
|
||||
|
||||
| Flag | Skips |
|
||||
| --- | --- |
|
||||
| `ia` | IA Architect |
|
||||
| `ux` | UX Reviewer |
|
||||
| `arch` | Architect (rare — use only for trivial hotfixes) |
|
||||
| `test` | Component tests |
|
||||
| `review` | Reviewer |
|
||||
| `visual` | Visual diff |
|
||||
| `a11y` | A11y auditor |
|
||||
| `design` | Design-system auditor |
|
||||
| `smoke` | Staging smoke |
|
||||
| `qa` | Manual QA |
|
||||
| `docs` | Doc Writer |
|
||||
| `flag` | Flag rollout |
|
||||
|
||||
Never skipped (human gates): `plan-approval`, `pr-merge`, `prod-promote`.
|
||||
|
||||
**Echodo server work** often uses `classification: server-only` with `skip: [ia, ux, design, a11y, visual, smoke]`.
|
||||
|
||||
## Status lifecycle
|
||||
|
||||
- `open` — convoy created; no briefs in flight
|
||||
- `in-progress` — at least one brief active or PR open
|
||||
- `merged` — all briefs merged; release pending
|
||||
- `shipped` — in production
|
||||
- `abandoned` — closed without shipping
|
||||
|
||||
## Kickoff ritual (this repo)
|
||||
|
||||
1. Open Cursor in the `tasks` repo on a feature branch.
|
||||
2. Prompt: *"Start a new convoy: \<idea\>. Success = \<metric\>."*
|
||||
3. Conductor writes `.convoys/<slug>.md` with `classification` and `skip` flags.
|
||||
4. **Echodo dogfood:** each implementer session should `claim_task` on the matching `plans/.../Task-*.md`, implement, then `complete_task` with `modelAlias` / `modelTier`.
|
||||
5. Architect maps briefs 1:1 to tasks when possible.
|
||||
|
||||
**Example (Epic 1 — done):** model provider registry convoy with `server-only`, briefs aligned to `Epic-model-provider-registry` tasks.
|
||||
|
||||
**Next convoys:** orchestrator (`Epic-apps-orchestrator`), convoy lifecycle MCP (`Epic-convoy-lifecycle-mcp`), connected repos (`Epic-connected-repos`).
|
||||
|
||||
## Self-analytics
|
||||
|
||||
Each L2 role appends one event via:
|
||||
|
||||
```bash
|
||||
bash scripts/log-convoy-event.sh role=role-implementer convoy=<slug> brief=1 \
|
||||
duration_s=120 model=qwen-coder model_tier=local outcome=complete
|
||||
```
|
||||
|
||||
`.convoys/.metrics.jsonl` is **gitignored** by default (metadata only, no prompts). Remove that line from `.gitignore` if you want team-visible metrics in git.
|
||||
|
||||
Validate a line was written:
|
||||
|
||||
```bash
|
||||
bash scripts/log-convoy-event.sh role=role-conductor convoy=test duration_s=1
|
||||
tail -1 .convoys/.metrics.jsonl | python3 -m json.tool
|
||||
```
|
||||
|
||||
## Multitask (Cursor 3.2+)
|
||||
|
||||
Use `/multitask` for audit fan-out (`role-reviewer` + design + a11y) or parallel implementers when architect marks briefs as disjoint. Set `multitask_group=audit-<convoy>-<pr>` on log lines for wall-clock analytics.
|
||||
|
||||
## Related docs
|
||||
|
||||
- [Plan automation foundation](../plans/Plan-automation-foundation/Plan-automation-foundation.md)
|
||||
- [ADR model routing](../docs/adr-model-routing.md)
|
||||
- [AGENTS.md](../AGENTS.md) — repo commands and multitenancy rules
|
||||
53
.convoys/model-provider-registry.md
Normal file
53
.convoys/model-provider-registry.md
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
---
|
||||
name: model-provider-registry
|
||||
classification: server-only
|
||||
success_metric: Operators configure Anthropic + local coder aliases and per-role routing from Settings; agent runs log modelAlias.
|
||||
skip:
|
||||
- ia
|
||||
- ux
|
||||
- design
|
||||
- a11y
|
||||
- visual
|
||||
- smoke
|
||||
status: shipped
|
||||
created: 2026-07-07
|
||||
model_policy:
|
||||
roles:
|
||||
role-conductor: claude-planning
|
||||
role-architect: claude-planning
|
||||
role-implementer: qwen-coder
|
||||
role-reviewer: qwen-coder
|
||||
escalate_to: claude-planning
|
||||
---
|
||||
|
||||
# Model provider registry (Epic 1)
|
||||
|
||||
## Why
|
||||
|
||||
Echodo needs a workspace-scoped model registry and `resolveModel()` contract before orchestrator/worker can dispatch unattended jobs. Epic 1 on `feat/automation-foundation` delivered the full slice.
|
||||
|
||||
## Scope
|
||||
|
||||
- `packages/shared` — schemas + `resolveModel()`
|
||||
- `model_providers` / `role_model_routes` tables
|
||||
- `api.ai.models` tRPC + Settings UI
|
||||
- `agent_runs` model columns + MCP `complete_task` fields
|
||||
|
||||
## Roles invoked
|
||||
|
||||
Conductor → Architect (briefs = Epic 1 tasks) → Implementer (per task) → Reviewer (PR).
|
||||
|
||||
## Outcome
|
||||
|
||||
Shipped in PR on `feat/automation-foundation`. Epic 2 convoy scaffolding added after merge for ongoing automation-foundation work.
|
||||
|
||||
## Briefs (architect mapping)
|
||||
|
||||
| Brief | Task slug |
|
||||
| --- | --- |
|
||||
| 1 | `shared-schemas-resolve-model` |
|
||||
| 2 | `db-model-providers-migration` |
|
||||
| 3 | `trpc-models-router` |
|
||||
| 4 | `settings-ai-providers-ui` |
|
||||
| 5 | `settings-ai-role-routing-ui` |
|
||||
| 6 | `agent-runs-model-logging` |
|
||||
68
.convoys/owner-cockpit-v02.md
Normal file
68
.convoys/owner-cockpit-v02.md
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
---
|
||||
name: owner-cockpit-v02
|
||||
classification: server-only
|
||||
success_metric: Fleet webhooks close PR loop (merged), completion gallery shows summaries+screenshots, ops copilot answers "what needs me?" with links — all from web without dumping plans/
|
||||
skip:
|
||||
- ia
|
||||
- design
|
||||
- a11y
|
||||
- visual
|
||||
- smoke
|
||||
status: merged
|
||||
created: 2026-08-25
|
||||
model_policy:
|
||||
roles:
|
||||
role-conductor: claude-planning
|
||||
role-architect: claude-planning
|
||||
role-implementer: qwen-coder
|
||||
role-doc-writer: qwen-coder
|
||||
role-reviewer: qwen-coder
|
||||
escalate_to: claude-planning
|
||||
---
|
||||
|
||||
# Owner cockpit v02 — forge webhooks + artifacts + copilot (post-v0.1)
|
||||
|
||||
## Why
|
||||
|
||||
v0.1 made second brain publishable (MCP create, harness-agnostic, Plans tree, Fleet). Worker closeout closed Epic 7 with doc_review→published + draft PR + operator notes + doc-writer. Remaining owner vision per `plans/Plan-automation-foundation/Plan-automation-foundation.md:222` — "fleet → Plans → Start → Go → PR → gates → completion + copilot" — still misses PR loop close (Epic 8), owner review pack (Epic 11), and ops chat (Epic 12). This convoy finishes the cockpit so a solo can land in Fleet, triage, and ask copilot without grepping.
|
||||
|
||||
Decision: `docs/decisions/2026-08-25-second-brain-os-pivot.md`.
|
||||
|
||||
## Scope
|
||||
|
||||
1. **Forge webhooks** `Epic-forge-webhooks` — `POST /api/webhooks/forge` (GitHub/Forgejo) verifies `X-Hub-Signature`/`X-Forgejo-Signature` via `FORGE_WEBHOOK_SECRET`, finds `echodo-convoy: <uuid>` marker in PR body, transitions `pr_review→merged` (`human-gate-2` automation path) and writes `convoy_events`.
|
||||
2. **PR checks recovery panel** `Task-pr-checks-recovery-panel.md` — Mission control detail shows PR checks + retry.
|
||||
3. **Completion artifacts** `Epic-owner-completion-artifacts` — `completion_summary` on `published/merged→released` (markdown summary + optional screenshot placeholder), gallery list, tRPC `listArtifacts`.
|
||||
4. **Ops copilot** `Epic-ops-copilot` — `ops-copilot-context-tools` (fleet + convoy + backlog queries scoped to workspace) + `ops-copilot-ui` chat panel + `draft-work` from chat (create Task via MCP).
|
||||
5. Doc-writer: SOP for webhook secret rotation + copilot prompt.
|
||||
|
||||
Out of scope: LiteLLM gateway, SQLite single-tenant, preference learning.
|
||||
|
||||
## Roles invoked
|
||||
|
||||
Conductor → Architect (4 briefs) → Implementer per brief (disjoint: webhooks, artifacts, copilot) → Doc-writer (SOP) → Reviewer.
|
||||
|
||||
## Todos
|
||||
|
||||
- [ ] Brief 1 forge webhook receiver
|
||||
- [ ] Brief 2 completion gallery + summaries
|
||||
- [ ] Brief 3 ops copilot (context tools + UI)
|
||||
- [ ] Brief 4 doc-writer SOPs
|
||||
|
||||
## IA
|
||||
|
||||
Skipped (`ia` skip set — server-only, no new top-level entities beyond `completion_artifacts` optional table; reuse `convoy_events metadata.prUrl` + `objects content`).
|
||||
|
||||
## UX
|
||||
|
||||
Partial — `ux` not skipped for copilot: chat panel reuses `components/ui/*`.
|
||||
|
||||
## Architecture
|
||||
|
||||
- Webhooks: `apps/web/app/api/webhooks/forge/route.ts` HMAC verify, `reconcile` via `transitionConvoy` `packages/shared/src/convoy-lifecycle/state-machine.ts:90` `pr_review→merged`.
|
||||
- Artifacts: optional `completion_artifacts` table or reuse `objects content.completionSummary` + `convoy_events metadata.completionScreenshot`.
|
||||
- Copilot: `apps/web/server/routers/copilot.ts` with `resolveModel(gate-copilot)` + context tools `fleet, convoys.list, backlog.listTree` scoped to `workspace_id`; UI `components/copilot/ops-copilot-panel.tsx`.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- v0.1 + worker-closeout (harness, doc_review, prUrl)
|
||||
36
.convoys/owner-cockpit-v02/brief-1-forge-webhook-receiver.md
Normal file
36
.convoys/owner-cockpit-v02/brief-1-forge-webhook-receiver.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
---
|
||||
brief: 1
|
||||
convoy: owner-cockpit-v02
|
||||
role: role-implementer
|
||||
title: Forge webhook receiver — GitHub/Forgejo → pr_review→merged
|
||||
status: ready
|
||||
priority: P0
|
||||
slice_dependencies: []
|
||||
recommended_model: qwen-coder
|
||||
model_tier: local
|
||||
---
|
||||
|
||||
# Brief 1 — `Task-forge-webhook-receiver.md` (Epic 8)
|
||||
|
||||
## Goal
|
||||
|
||||
`POST /api/webhooks/forge` verifies signature via `FORGE_WEBHOOK_SECRET`, finds `echodo-convoy: <uuid>` marker in PR body (from `apps/worker/src/git/pr.ts`), transitions `pr_review→merged` via `transitionConvoy` `packages/shared/src/convoy-lifecycle/state-machine.ts:90` as `automation`, writes `convoy_events`.
|
||||
|
||||
## Files
|
||||
|
||||
- `apps/web/app/api/webhooks/forge/route.ts` NEW — parse `X-Hub-Signature-256` (GitHub) or `X-Forgejo-Signature` (Forgejo) HMAC sha256 `FORGE_WEBHOOK_SECRET`, handle `pull_request: closed & merged=true` + `pr.body` marker regex `/echodo-convoy:\s*([0-9a-f-]{36})/`, lookup convoy by `id`, validate `status===pr_review`, call `transitionConvoy(db, {workspaceId, convoyId, toStatus:merged, actor:automation, reason: pr#N merged})`. Return 200 + `ok` even if no convoy (don't leak). Rate-limit per IP.
|
||||
- `apps/web/server/lib/forge/adapters.ts` — reuse `ForgeAdapter` if needed for verify.
|
||||
- `docs/operations/live-e2e-checklist.md` — add webhook step.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Forgejo/GitHub payload with `body: "… echodo-convoy: <id> …"` + valid HMAC transitions `pr_review→merged`; invalid signature 401; no marker 200 no-op.
|
||||
- Existing `pnpm lint && pnpm type-check` pass.
|
||||
|
||||
## Prompt
|
||||
|
||||
You are `role-implementer` brief 1. Read `packages/shared/src/convoy-lifecycle/state-machine.ts`, `apps/web/server/lib/forge/adapters.ts`, `apps/worker/src/git/pr.ts`, `docs/convoy-lifecycle.md`. Keep verify constant-time, workspace-scoped.
|
||||
|
||||
## Depends
|
||||
|
||||
None.
|
||||
37
.convoys/owner-cockpit-v02/brief-2-completion-artifacts.md
Normal file
37
.convoys/owner-cockpit-v02/brief-2-completion-artifacts.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
---
|
||||
brief: 2
|
||||
convoy: owner-cockpit-v02
|
||||
role: role-implementer
|
||||
title: Owner completion artifacts — summary + gallery
|
||||
status: ready
|
||||
priority: P1
|
||||
slice_dependencies: []
|
||||
recommended_model: qwen-coder
|
||||
model_tier: local
|
||||
---
|
||||
|
||||
# Brief 2 — `Epic-owner-completion-artifacts` (Epic 11)
|
||||
|
||||
## Goal
|
||||
|
||||
When `merged/published → released` (`human-gate-3`), generate completion summary markdown + optional screenshot placeholder, list in gallery for owner review. No browser automation v1 — screenshot is upload placeholder.
|
||||
|
||||
## Files
|
||||
|
||||
- `packages/database/src/schema/completion_artifacts.ts` NEW — `id uuid PK, workspace_id fk, convoy_id fk objects.id, summary_md text, screenshot_url text nullable, created_by fk users, created_at`. Or reuse `objects content.completionSummary` — pick minimal table for gallery query.
|
||||
- `apps/web/server/routers/convoys.ts` — add `complete` mutation (`doc_review→published→released` or `pr_review→merged→released`) that writes artifact + transitions `released` as `human-gate-3`; `listArtifacts` query for gallery.
|
||||
- `apps/web/app/(app)/[workspaceSlug]/artifacts/page.tsx` NEW — gallery grid, click → detail with summary + PR link + convoy timeline (`convoy_events`).
|
||||
- `packages/shared/src/worker` — generate summary template: `## Summary\nConvoy ${slug} ${classification} — ${successMetric}\nBriefs: ...\nModel: ... Harness: ...`.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Completing `doc_review→published→released` creates artifact row visible in `/artifacts`; screenshot upload placeholder shows.
|
||||
- `pnpm lint && pnpm type-check` pass.
|
||||
|
||||
## Prompt
|
||||
|
||||
You are `role-implementer` brief 2. Read `plans/Plan-automation-foundation/Epic-owner-completion-artifacts/Epic-owner-completion-artifacts.md`, `packages/shared/src/convoy-lifecycle/state-machine.ts:122`, `apps/web/server/routers/convoys.ts:237`. Keep artifact generation deterministic, no LLM call v1 (template only).
|
||||
|
||||
## Depends
|
||||
|
||||
None — disjoint from webhooks.
|
||||
39
.convoys/owner-cockpit-v02/brief-3-ops-copilot.md
Normal file
39
.convoys/owner-cockpit-v02/brief-3-ops-copilot.md
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
---
|
||||
brief: 3
|
||||
convoy: owner-cockpit-v02
|
||||
role: role-implementer
|
||||
title: Ops copilot — context tools + UI + draft work from chat
|
||||
status: ready
|
||||
priority: P1
|
||||
slice_dependencies: []
|
||||
recommended_model: claude-planning
|
||||
model_tier: premium
|
||||
---
|
||||
|
||||
# Brief 3 — `Epic-ops-copilot` (Epic 12)
|
||||
|
||||
## Goal
|
||||
|
||||
`ops copilot` answers "what needs me?" with links, triages, and can draft a `Task` from chat. Uses `gate-copilot` routing but broader context.
|
||||
|
||||
## Files
|
||||
|
||||
- `apps/web/server/routers/copilot.ts` NEW — `opsCopilot.generateText` tRPC: `resolveModel(gate-copilot)` `packages/shared/src/model-routing/index.ts`, context tools: `fleetStatus()` (reuse `convoys.fleet` logic), `convoyGet(workspaceSlug, convoyId)`, `backlogListTree(workspaceSlug)` (reuse `backlog.listTree`), `backlogSearch` (grep `markdown_backlog_items`). Tools are read-only, workspace-scoped, bounded. System prompt: "You are ops copilot for second brain OS… respond with links [workspace/slug]."
|
||||
- `apps/web/server/routers/harnesses.ts` — no change, just reuse `resolveModel`.
|
||||
- `apps/web/components/copilot/ops-copilot-panel.tsx` NEW — chat panel (reuses `components/ui/*`, `ai` SDK `useChat` if available or plain `generateText` loop), shows `fleet → needs you` citations, button `Draft Task` → calls `backlog.createTask` via `create-backlog-item` shape.
|
||||
- `apps/web/app/(app)/fleet/page.tsx` — add copilot FAB or sidebar embed.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- "What needs me?" returns gate inbox items with `[fleet]` links to convoys.
|
||||
- `Draft Task` from copilot creates `Task-*.md` via same path as Plans tree (tool `create-backlog-item` logic).
|
||||
- Workspace-scoped — no cross-tenant leakage.
|
||||
- `pnpm lint && pnpm type-check` pass.
|
||||
|
||||
## Prompt
|
||||
|
||||
You are `role-implementer` brief 3. Read `plans/Plan-automation-foundation/Epic-ops-copilot/Epic-ops-copilot.md`, `docs/adr-model-routing.md`, `apps/web/server/routers/convoys.ts:267` `gateCopilot` as reference, `docs/decisions/2026-08-25-second-brain-os-pivot.md`. Keep tools small (<3 tools), no secrets in prompt.
|
||||
|
||||
## Depends
|
||||
|
||||
None — can run parallel with 1,2.
|
||||
36
.convoys/owner-cockpit-v02/brief-4-doc-writer-sop.md
Normal file
36
.convoys/owner-cockpit-v02/brief-4-doc-writer-sop.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
---
|
||||
brief: 4
|
||||
convoy: owner-cockpit-v02
|
||||
role: role-doc-writer
|
||||
title: Doc-writer SOPs — webhook secret rotation + copilot prompt
|
||||
status: ready
|
||||
priority: P2
|
||||
slice_dependencies: [1]
|
||||
recommended_model: qwen-coder
|
||||
model_tier: local
|
||||
---
|
||||
|
||||
# Brief 4 — `role-doc-writer` SOPs for owner cockpit
|
||||
|
||||
## Goal
|
||||
|
||||
Document rotation and prompts so future agents read them as second-brain context, not just code.
|
||||
|
||||
## Files
|
||||
|
||||
- `docs/operations/webhook-secret-rotation.md` NEW — one page: where `FORGE_WEBHOOK_SECRET` lives (Coolify env), how to rotate (generate `openssl rand -hex 32`, update Forgejo webhook + Coolify `FORGE_WEBHOOK_SECRET`, test via `curl -H 'X-Hub-Signature-256: sha256=...' http://localhost:3000/api/webhooks/forge`).
|
||||
- `docs/copilot-prompt.md` NEW — system prompt for `gate-copilot` and `ops copilot` (reuse `convoys.ts:gut` prompt but versioned in `docs/` for harness portability).
|
||||
- `docs/examples/sop-published-flow.md` NEW — one SOP showing `Task-write-sop → doc_review → published → artifact gallery` e2e with screenshots placeholder.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Three docs exist, linked from `docs/README` or `docs/operations/live-e2e-checklist.md`.
|
||||
- No code, only markdown; `pnpm lint` still passes (markdown ignored).
|
||||
|
||||
## Prompt
|
||||
|
||||
You are `role-doc-writer` brief 4. Read `docs/convoy-lifecycle.md`, `apps/web/app/api/webhooks/forge/route.ts` (once brief 1 lands) to document accurately. Keep docs small, imperative, no narration.
|
||||
|
||||
## Depends
|
||||
|
||||
Brief 1 — needs webhook route to document accurately.
|
||||
58
.convoys/polish-dag-tiers-cost.md
Normal file
58
.convoys/polish-dag-tiers-cost.md
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
---
|
||||
name: polish-dag-tiers-cost
|
||||
classification: server-only
|
||||
success_metric: Cost caps + DAG viz + tiers + preflight + checks panel ship; Epic 7 and model-provider follow-ons close
|
||||
skip:
|
||||
- ia
|
||||
- a11y
|
||||
- visual
|
||||
- smoke
|
||||
status: merged
|
||||
created: 2026-08-25
|
||||
model_policy:
|
||||
roles:
|
||||
role-conductor: claude-planning
|
||||
role-architect: claude-planning
|
||||
role-implementer: qwen-coder
|
||||
role-doc-writer: qwen-coder
|
||||
role-reviewer: qwen-coder
|
||||
escalate_to: claude-planning
|
||||
---
|
||||
|
||||
# Polish — DAG + tiers + cost caps + preflight + checks (closing follow-ons)
|
||||
|
||||
## Why
|
||||
|
||||
Second-brain v0.1 + worker-closeout + owner-cockpit left `draft` follow-ons that block a clean `Plan-automation-foundation` `done`: `Task-cost-budget-ui-and-caps` Epic 1, `Task-repo-setup-preflight` Epic 3, `Task-pr-checks-recovery-panel` Epic 8, `Task-convoy-dag-visualization` + `Task-automation-tiers` Epic 5. This convoy closes them with `role-doc-writer` for SOPs.
|
||||
|
||||
Per `plans/Plan-automation-foundation/Plan-automation-foundation.md:332` Polish section and `docs/decisions/2026-08-25-second-brain-os-pivot.md`.
|
||||
|
||||
## Scope
|
||||
|
||||
1. **Cost budget caps** `Task-cost-budget-ui-and-caps` — month-to-date spend per `model_providers.metadata.tier`, `Budget caps` table `workspace_id` + `cap_usd` + `period: monthly`, dispatch pause when exceeded, tRPC `getSpend` + UI `Settings → AI → Budgets`.
|
||||
2. **DAG visualization** `Task-convoy-dag-visualization` — `components/mission-control/convoy-dag.tsx` small DAG for `ready/in_progress` brief `sliceDependencies`, `GET /mission-control?dag=1` panel.
|
||||
3. **Automation tiers** `Task-automation-tiers` — `automation_tier enum: manual|assist|auto` per convoy or workspace, `convoys.create` adds `tier`, Mission control badge.
|
||||
4. **Repo preflight** `Task-repo-setup-preflight` — `POST /api/connected-repos/preflight` checks `git ls-remote` + `worktree` writability before clone, UI shows result.
|
||||
5. **PR checks recovery panel** `Task-pr-checks-recovery-panel` — reuses webhook `prUrl`, shows checks status + `Retry` button (calls forge adapter `getChecks` stub).
|
||||
|
||||
Out of scope: Epic 11 screenshot capture (needs browser), full LiteLLM.
|
||||
|
||||
## Roles invoked
|
||||
|
||||
Conductor → Architect (5 briefs) → Implementer per brief (disjoint) → Doc-writer (SOP for caps/tiers) → Reviewer.
|
||||
|
||||
## Todos
|
||||
|
||||
- [ ] Brief 1 cost caps
|
||||
- [ ] Brief 2 DAG viz
|
||||
- [ ] Brief 3 tiers
|
||||
- [ ] Brief 4 preflight
|
||||
- [ ] Brief 5 checks panel
|
||||
|
||||
## IA
|
||||
|
||||
Skipped (`ia` in skip). New table only for budget caps; DAG/tiers reuse `objects.content`.
|
||||
|
||||
## UX
|
||||
|
||||
Partial — `ux` not skipped for DAG: simple SVG, not full canvas.
|
||||
37
.convoys/polish-dag-tiers-cost/brief-1-cost-caps.md
Normal file
37
.convoys/polish-dag-tiers-cost/brief-1-cost-caps.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
---
|
||||
brief: 1
|
||||
convoy: polish-dag-tiers-cost
|
||||
role: role-implementer
|
||||
title: Cost budget UI + caps — month-to-date spend and dispatch pause
|
||||
status: ready
|
||||
priority: P1
|
||||
slice_dependencies: []
|
||||
recommended_model: qwen-coder
|
||||
model_tier: local
|
||||
---
|
||||
|
||||
# Brief 1 — `Task-cost-budget-ui-and-caps` (Epic 1 follow-on)
|
||||
|
||||
## Goal
|
||||
|
||||
Month-to-date spend per alias/tier visible, caps pause dispatch when exceeded. Autensa benchmark row `cost tracking & budget caps` `plans/Plan-automation-foundation/Plan-automation-foundation.md:236`.
|
||||
|
||||
## Files
|
||||
|
||||
- `packages/database/src/schema/budget_caps.ts` NEW — `id uuid, workspace_id fk, cap_usd numeric(12,2), period varchar monthly, enabled bool, created_at`. + `spend_ledger` view or `convoy_events` sum query.
|
||||
- `apps/web/server/routers/budgets.ts` NEW — `getSpend(workspaceId)` sums `convoy_events.estimated_cost_usd` month-to-date grouped by `model_alias`; `getCap`/`upsertCap`/`deleteCap` admin only; `shouldPauseDispatch` helper for orchestrator.
|
||||
- `apps/orchestrator/src/tick.ts` — call `shouldPauseDispatch` before `enqueueJob`, return `pause` action when cap exceeded.
|
||||
- `apps/web/app/(app)/[workspaceSlug]/settings/ai/budgets/page.tsx` NEW — table cap + spend chart (simple bar), Test.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- `/settings/ai/budgets` shows MTD $42/$100 (example), `upsertCap cap_usd:100` → orchestrator `tick` returns `pause reason: budget cap` when sum > cap.
|
||||
- `pnpm lint && pnpm type-check` pass.
|
||||
|
||||
## Prompt
|
||||
|
||||
You are `role-implementer` brief 1. Read `plans/Plan-automation-foundation/Epic-model-provider-registry/Task-cost-budget-ui-and-caps.md`, `packages/database/src/schema/convoy_events.ts`, `apps/orchestrator/src/tick.ts`, `docs/adr-model-routing.md`. Keep ledger query simple (sum `estimated_cost_usd` where `occurred_at >= month_start`).
|
||||
|
||||
## Depends
|
||||
|
||||
None.
|
||||
36
.convoys/polish-dag-tiers-cost/brief-2-dag-viz.md
Normal file
36
.convoys/polish-dag-tiers-cost/brief-2-dag-viz.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
---
|
||||
brief: 2
|
||||
convoy: polish-dag-tiers-cost
|
||||
role: role-implementer
|
||||
title: Convoy DAG visualization for brief dependencies
|
||||
status: ready
|
||||
priority: P2
|
||||
slice_dependencies: []
|
||||
recommended_model: qwen-coder
|
||||
model_tier: local
|
||||
---
|
||||
|
||||
# Brief 2 — `Task-convoy-dag-visualization` (Epic 5)
|
||||
|
||||
## Goal
|
||||
|
||||
Small DAG for `ready/in_progress` brief `sliceDependencies` in Mission control detail — Autensa benchmark row `convoy DAG visualization`.
|
||||
|
||||
## Files
|
||||
|
||||
- `apps/web/components/mission-control/convoy-dag.tsx` NEW — SVG DAG (nodes = briefNumber, edges = sliceDependencies), uses `convoyBriefs` from `apps/web/server/lib/convoy/convoy-service.ts` `getConvoyBriefs`, highlights `completedBriefIds` green, `eligible` blue.
|
||||
- `apps/web/app/(app)/[workspaceSlug]/mission-control/page.tsx:12` — embed `ConvoyDag` in detail panel when `briefs.length >1`.
|
||||
- No DB migration.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- DAG renders for 3 briefs where `brief 2 depends [1]`, `brief 3 depends [2]` — edges drawn, no overlap.
|
||||
- `pnpm lint && pnpm type-check` pass.
|
||||
|
||||
## Prompt
|
||||
|
||||
You are `role-implementer` brief 2. Read `plans/Plan-automation-foundation/Epic-mission-control-gates-ui/Task-convoy-dag-visualization.md`, `packages/shared/src/orchestrator/convoy-driver.ts`, `apps/web/components/mission-control/convoy-detail-panel.tsx`. Keep SVG, no external lib.
|
||||
|
||||
## Depends
|
||||
|
||||
None.
|
||||
36
.convoys/polish-dag-tiers-cost/brief-3-tiers.md
Normal file
36
.convoys/polish-dag-tiers-cost/brief-3-tiers.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
---
|
||||
brief: 3
|
||||
convoy: polish-dag-tiers-cost
|
||||
role: role-implementer
|
||||
title: Automation tiers — manual|assist|auto per convoy
|
||||
status: ready
|
||||
priority: P2
|
||||
slice_dependencies: []
|
||||
recommended_model: qwen-coder
|
||||
model_tier: local
|
||||
---
|
||||
|
||||
# Brief 3 — `Task-automation-tiers` (Epic 5)
|
||||
|
||||
## Goal
|
||||
|
||||
`automation_tier enum manual|assist|auto` per convoy or workspace, Mission control badge, orchestrator respects `manual` → pause.
|
||||
|
||||
## Files
|
||||
|
||||
- `packages/shared/src/convoy-lifecycle/schemas.ts` — add `automationTierSchema` `manual|assist|auto`, add to `convoyObjectContentSchema` `automationTier` optional default `assist`.
|
||||
- `apps/web/server/routers/convoys.ts` — `create` accepts `automationTier`, `list` returns it, `orchestrator/src/tick.ts` if `tier===manual` return `pause reason: manual tier`.
|
||||
- `apps/web/components/mission-control/convoy-detail-panel.tsx` — badge `Tier: auto/assist/manual`.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- `create convoy tier:manual` appears `Tier: manual` badge; orchestrator `tick` on that convoy returns `pause` not `dispatch`.
|
||||
- `pnpm lint && pnpm type-check` pass.
|
||||
|
||||
## Prompt
|
||||
|
||||
You are `role-implementer` brief 3. Read `plans/Plan-automation-foundation/Epic-mission-control-gates-ui/Task-automation-tiers.md`, `packages/shared/src/convoy-lifecycle/schemas.ts`, `apps/orchestrator/src/tick.ts`. Keep tier default `assist` for backward compat.
|
||||
|
||||
## Depends
|
||||
|
||||
None.
|
||||
35
.convoys/polish-dag-tiers-cost/brief-4-preflight.md
Normal file
35
.convoys/polish-dag-tiers-cost/brief-4-preflight.md
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
---
|
||||
brief: 4
|
||||
convoy: polish-dag-tiers-cost
|
||||
role: role-implementer
|
||||
title: Repo preflight check before clone
|
||||
status: ready
|
||||
priority: P2
|
||||
slice_dependencies: []
|
||||
recommended_model: qwen-coder
|
||||
model_tier: local
|
||||
---
|
||||
|
||||
# Brief 4 — `Task-repo-setup-preflight` (Epic 3)
|
||||
|
||||
## Goal
|
||||
|
||||
`POST /api/connected-repos/preflight` checks `git ls-remote` + worktree writability before clone, UI shows result — Autensa benchmark row `repo setup preflight`.
|
||||
|
||||
## Files
|
||||
|
||||
- `apps/web/server/routers/connected-repos.ts` — add `preflight` procedure `input:{url}` → `git ls-remote url` (timeout 5s) + `fs.access(WORKER_WORKTREE_BASE, W_OK)`, returns `{ok, reason}`. Reuse `forge adapters`.
|
||||
- `apps/web/app/(app)/[workspaceSlug]/settings/repos/page.tsx` — call `preflight` on paste, show green/red before `create`.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Invalid URL `https://nope.invalid/repo.git` → `ok:false reason: ls-remote failed`; valid `https://github.com/octocat/Hello-World.git` → `ok:true`.
|
||||
- `pnpm lint && pnpm type-check` pass.
|
||||
|
||||
## Prompt
|
||||
|
||||
You are `role-implementer` brief 4. Read `plans/Plan-automation-foundation/Epic-connected-repos/Task-repo-setup-preflight.md`, `apps/web/server/routers/connected-repos.ts`, `apps/worker/src/git/worktree.ts`. Keep timeout small, no creds leak.
|
||||
|
||||
## Depends
|
||||
|
||||
None.
|
||||
36
.convoys/polish-dag-tiers-cost/brief-5-checks-panel.md
Normal file
36
.convoys/polish-dag-tiers-cost/brief-5-checks-panel.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
---
|
||||
brief: 5
|
||||
convoy: polish-dag-tiers-cost
|
||||
role: role-implementer
|
||||
title: PR checks recovery panel
|
||||
status: ready
|
||||
priority: P2
|
||||
slice_dependencies: []
|
||||
recommended_model: qwen-coder
|
||||
model_tier: local
|
||||
---
|
||||
|
||||
# Brief 5 — `Task-pr-checks-recovery-panel` (Epic 8)
|
||||
|
||||
## Goal
|
||||
|
||||
Mission control detail shows PR checks + Retry — `PR checks recovery` benchmark.
|
||||
|
||||
## Files
|
||||
|
||||
- `apps/web/server/lib/forge/adapters.ts` — add `getChecks({owner, repo, sha})` stub (GitHub `GET /repos/{owner}/{repo}/commits/{sha}/check-runs` with token, Forgejo `GET /repos/{owner}/{repo}/commits/{sha}/statuses`), returns `{checks: [{name, status, conclusion}], canRetry}`.
|
||||
- `apps/web/server/routers/convoys.ts` — add `getChecks` query that loads `convoy.prUrl` + resolves sha via `git ls-remote`, calls adapter.
|
||||
- `apps/web/components/mission-control/convoy-detail-panel.tsx` — panel `Checks` tab when `prUrl` present, shows list + `Retry` button (stub re-run if forge supports).
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Detail panel `Checks` tab shows checks for PR `sha` (mock if no token → `no token` message, not error).
|
||||
- `pnpm lint && pnpm type-check` pass.
|
||||
|
||||
## Prompt
|
||||
|
||||
You are `role-implementer` brief 5. Read `plans/Plan-automation-foundation/Epic-forge-webhooks/Task-pr-checks-recovery-panel.md`, `apps/web/server/lib/forge/adapters.ts`. Keep stub simple, never expose token.
|
||||
|
||||
## Depends
|
||||
|
||||
None — needs PR `prUrl` from prior convoys but works without.
|
||||
69
.convoys/second-brain-os-v01.md
Normal file
69
.convoys/second-brain-os-v01.md
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
---
|
||||
name: second-brain-os-v01
|
||||
classification: server-only
|
||||
success_metric: Web-first second brain OS publishable via docker compose up — any agent (OpenCode/Cursor) can create Plan/Epic/Task via MCP, Plans tree CRUD works without CLI, Fleet shows all workspaces, harness-agnostic worker runs doc + code tasks with BYO LLMs
|
||||
skip:
|
||||
- ia
|
||||
- ux
|
||||
- design
|
||||
- a11y
|
||||
- visual
|
||||
- smoke
|
||||
status: merged
|
||||
created: 2026-08-25
|
||||
model_policy:
|
||||
roles:
|
||||
role-conductor: claude-planning
|
||||
role-architect: claude-planning
|
||||
role-implementer: qwen-coder
|
||||
role-reviewer: qwen-coder
|
||||
escalate_to: claude-planning
|
||||
---
|
||||
|
||||
# Second Brain OS v0.1 — open self-hosted Obsidian for crews
|
||||
|
||||
## Why
|
||||
|
||||
Echodo pivots per `docs/decisions/2026-08-25-second-brain-os-pivot.md` from closed code-cockpit to open "Obsidian for agent crews": `docker compose up`, BYO LLMs (Ollama 11434 / MLX 8000 / NIM), any harness via AG-UI/MCP. Current `plans/` is hand-edited markdown + CLI import; Fleet is per-workspace kanban only; worker is code-only with hard worktree + 4-tool allowlist. This convoy makes the homelab dogfoodable + publishable for stars.
|
||||
|
||||
See `plans/Plan-automation-foundation/Plan-automation-foundation.md` v0.1 order (2026-08-25 pivot).
|
||||
|
||||
## Scope
|
||||
|
||||
1. MCP `create-backlog-item` — natural language `Plan/Epic/Task` creation (0.5d)
|
||||
2. Worker non-code: optional worktree + per-harness allowlist + `doc_review→published` (1d)
|
||||
3. Harness registry + WorkerAdapter: `harness_providers` + `role_harness_routes`, `AG-UI /api/ag-ui`, `Settings → Harnesses` (2-3d)
|
||||
4. Plans tree: `apps/web/app/(app)/[workspaceSlug]/plans/page.tsx` + `backlog.createPlan/createEpic/createTask` + `Start→Convoy` (1w) — Epic 10
|
||||
5. Fleet rollup: `/fleet` + SSE `convoy_events` feed (3-4d) — Epic 9
|
||||
|
||||
Out of scope: Epic 8 webhooks, Epic 11 artifacts, Epic 12 copilot (post-v0.1); LiteLLM required, per-user overrides.
|
||||
|
||||
## Roles invoked
|
||||
|
||||
Conductor → Architect (briefs = 5 above, 1:1 to roadmap) → Implementer per brief (sequential, each needs prior) → Reviewer (PR).
|
||||
|
||||
## Todos
|
||||
|
||||
- [ ] Brief 1 MCP create
|
||||
- [ ] Brief 2 worker non-code
|
||||
- [ ] Brief 3 harness registry — depends on 2
|
||||
- [ ] Brief 4 Plans tree — depends on 1,3
|
||||
- [ ] Brief 5 Fleet — depends on 3
|
||||
|
||||
## IA
|
||||
|
||||
Skipped (`ia` in skip). No new IA — `plans/` hierarchy `docs/Glossary.md:50` + `objects` `packages/database/src/schema/objects.ts:18` unchanged.
|
||||
|
||||
## UX
|
||||
|
||||
Skipped (`ux` in skip). Reuses existing `Mission control` kanban `apps/web/app/(app)/[workspaceSlug]/mission-control/page.tsx:12` + `Settings` patterns.
|
||||
|
||||
## Architecture
|
||||
|
||||
See decision log + `docs/adr-model-routing.md`. Key new:
|
||||
- `packages/database/src/schema/harness_providers.ts` (mirror `model_providers` `docs/adr-model-routing.md:78`)
|
||||
- `packages/shared/src/worker/executor.ts` `WorkerAdapter`
|
||||
- `apps/worker/src/handle-job.ts:40` dispatch via adapter
|
||||
- `apps/web/app/api/ag-ui/[workspace]/route.ts` AG-UI
|
||||
- `apps/mcp-server/src/tools/create-backlog-item.ts`
|
||||
- `apps/web/server/routers/backlog.ts:29` add CRUD for `plans/*.md`
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
---
|
||||
brief: 1
|
||||
convoy: second-brain-os-v01
|
||||
role: role-implementer
|
||||
title: MCP create-backlog-item tool for natural language Plan/Epic/Task authoring
|
||||
status: ready
|
||||
priority: P0
|
||||
slice_dependencies: []
|
||||
recommended_model: qwen-coder
|
||||
model_tier: local
|
||||
workspace_id: global
|
||||
---
|
||||
|
||||
# Brief 1 — MCP `create-backlog-item` (0.5d)
|
||||
|
||||
## Goal
|
||||
|
||||
Any agent (OpenCode/Cursor/Codex) via MCP can say "create an Epic for offline mode with 3 tasks" and it writes `plans/Plan-*/Epic-*/Task-*.md` + re-imports to `markdown_backlog_items` `packages/database/src/markdown-backlog/sync.ts:38`.
|
||||
|
||||
## Files
|
||||
|
||||
- `apps/mcp-server/src/tools/create-backlog-item.ts` NEW — zod schema `kind: plan|epic|task`, `slug`, `title`, `planSlug`, `epicSlug`, `status`, `priority`, `bodyMarkdown`, `agent_prompt`; validates frontmatter per `docs/templates/`, writes file via `exportBacklogItemToMarkdown` inverse, calls `syncMarkdownBacklogScan` or direct DB insert + `contentHash` `packages/database/src/markdown-backlog/parse.ts:1`.
|
||||
- `apps/mcp-server/src/tools/index.ts` register
|
||||
- `packages/database/src/markdown-backlog/parse.test.ts` add case
|
||||
|
||||
## Acceptance
|
||||
|
||||
- `mcp call create-backlog-item {workspace:"maya-workspace", kind:"task", slug:"research-prompt", planSlug:"clarity", epicSlug:"ai-tags", title:"Research prompt", bodyMarkdown:"..."}` creates `plans/Plan-clarity/Epic-ai-tags/Task-research-prompt.md` with YAML frontmatter + body, row appears in `markdown_backlog_items` without manual `pnpm import`.
|
||||
- Duplicate slug under same epic → error `CONFLICT`.
|
||||
- `pnpm lint && pnpm type-check && pnpm --filter @tasks/mcp-server test` (if exists) pass; `apps/mcp-server` still builds Docker.
|
||||
|
||||
## Prompt for implementer
|
||||
|
||||
You are `role-implementer` for `second-brain-os-v01` brief 1. Read `docs/decisions/2026-08-25-second-brain-os-pivot.md`, `packages/database/src/markdown-backlog/parse.ts`, `apps/mcp-server/src/tools/claim-task.ts:1` as reference. Implement exactly this brief, keep changes small, run `pnpm lint && pnpm type-check`.
|
||||
|
||||
## Dogfood
|
||||
|
||||
`claim_task` on the matching `plans/Plan-automation-foundation/.../Task-*.md` if exists (or ad-hoc) → `complete_task outcome:succeeded tokensTotal:... modelAlias:qwen-coder`.
|
||||
40
.convoys/second-brain-os-v01/brief-2-worker-non-code.md
Normal file
40
.convoys/second-brain-os-v01/brief-2-worker-non-code.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
---
|
||||
brief: 2
|
||||
convoy: second-brain-os-v01
|
||||
role: role-implementer
|
||||
title: Worker non-code — optional worktree + per-harness allowlist + doc_review→published
|
||||
status: ready
|
||||
priority: P0
|
||||
slice_dependencies: []
|
||||
recommended_model: qwen-coder
|
||||
model_tier: local
|
||||
---
|
||||
|
||||
# Brief 2 — Worker non-code (1d)
|
||||
|
||||
## Goal
|
||||
|
||||
`book/course/research/SOPs` docs don't need git worktree or `pnpm`. Worker must handle doc tasks writing straight to `docs/`/`plans/` no branch, with allowlist per harness.
|
||||
|
||||
## Files
|
||||
|
||||
- `apps/worker/src/git/worktree.ts:1` — add `needsWorktree: boolean` param; when false skip `ensureWorktree`, use `monorepoRoot` directly. Keep existing path for code briefs `job.briefId` present.
|
||||
- `apps/worker/src/tools/allowlist.ts:1` — make `allowlist` configurable per harness `harness.config.allowedCommands` (or `minimal: [read,write,grep]` only). Pass `allowedCommands` into `runCommandTool`.
|
||||
- `packages/shared/src/convoy-lifecycle/state-machine.ts:1` — add `doc_review → published` transition (mirror `pr_review → merged`), allow `human-gate-2` analogue `doc-approver`.
|
||||
- `apps/worker/src/handle-job.ts:40` — thread `harness.needsWorktree` + `harness.allowlist` through; log `harnessAlias` to `convoy_events`.
|
||||
- `docs/worker.md:1` document optional worktree.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- `Task-write-manifesto.md` (doc task) with `harness: doc-writer` creates `docs/manifesto.md` via `write_file` tool without worktree directory under `/tmp/echodo-worker-worktrees`.
|
||||
- Code task `Task-fix-lint` still creates worktree per prior behavior.
|
||||
- `transition_convoy_status` `doc_review → published` succeeds; existing `pr_review → merged` still works.
|
||||
- `pnpm lint && pnpm type-check && pnpm --filter @tasks/worker test` pass.
|
||||
|
||||
## Prompt
|
||||
|
||||
You are `role-implementer` brief 2. Read `docs/convoy-lifecycle.md`, `packages/shared/src/convoy-lifecycle/state-machine.ts`, `apps/worker/src/handle-job.ts`, `docs/decisions/2026-08-25-second-brain-os-pivot.md`. Implement small, gated.
|
||||
|
||||
## Depends
|
||||
|
||||
None — can run in parallel with brief 1.
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
---
|
||||
brief: 3
|
||||
convoy: second-brain-os-v01
|
||||
role: role-implementer
|
||||
title: Harness registry + WorkerAdapter + AG-UI + Settings → Harnesses
|
||||
status: ready
|
||||
priority: P0
|
||||
slice_dependencies: [1, 2]
|
||||
recommended_model: qwen-coder
|
||||
model_tier: local
|
||||
---
|
||||
|
||||
# Brief 3 — Harness registry + WorkerAdapter (2-3d) — THE big one
|
||||
|
||||
## Goal
|
||||
|
||||
Workspace admins CRUD harness providers (`minimal|crewai|langgraph|remote-ag-ui`) + map `role_id → harness_alias`, worker dispatches via adapter, any harness via `AG-UI /api/ag-ui/[workspace]`, test button proves reachability.
|
||||
|
||||
## Files
|
||||
|
||||
- `packages/database/src/schema/harness_providers.ts` NEW — mirror `model_providers` `docs/adr-model-routing.md:78`: `id, workspace_id fk, alias unique per workspace, display_name, harness_type enum, endpoint_url nullable, auth_env_var varchar nullable, config jsonb, enabled bool, created_at/updated_at`. + `role_harness_routes` `workspace_id, role_id varchar, harness_provider_id fk, enabled, sort_order`, unique `(workspace_id, role_id)`.
|
||||
- `packages/database/migrations/0015_*` via `pnpm db:generate`.
|
||||
- `packages/shared/src/worker/executor.ts` NEW — `WorkerExecutor {id, run(ctx, model):Promise<...>}`, `resolveHarnessForRole()` pure like `resolveModel` `packages/shared/src/model-routing/index.ts`.
|
||||
- `apps/worker/src/handle-job.ts:40` — replace hard `createMinimalSession` with `registry.get(harnessAlias).run(...)`; keep `MinimalSession` as `minimal` adapter.
|
||||
- `apps/web/server/routers/harnesses.ts` NEW — `list/create/update/delete/testHarness` (admin only, never return `auth` value, `test` does fetch `health` on endpoint with `Authorization: Bearer ${process.env[AUTH_ENV_VAR]}`).
|
||||
- `apps/web/app/(app)/[workspaceSlug]/settings/harnesses/page.tsx` NEW — table + add/edit dialog + test.
|
||||
- `apps/web/app/api/ag-ui/[workspace]/route.ts` NEW — POST ag-ui envelope, validate `workspace_id`, write `convoy_events`/`agent_runs`, enforce tenant fence.
|
||||
- `packages/database/src/schema/index.ts` exports.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Migration applies `pnpm db:generate && pnpm db:migrate` locally (no drift).
|
||||
- `Settings → Harnesses` CRUD works; `Test` on `remote-ag-ui https://crew.example.com/ag-ui` with `HARNESS_AUTH_TOKEN` env shows latency or error detail, never leaks token to browser.
|
||||
- `resolveHarnessForRole(workspaceId, "role-implementer")` returns `crewai` when mapped, `minimal` fallback.
|
||||
- Worker dispatched `role-implementer` via `crewai` adapter still `claim_task`/`complete_task` and writes `harness_alias` to `convoy_events.harness_alias` + `agent_runs`.
|
||||
- `pnpm lint && pnpm type-check && pnpm test` pass.
|
||||
|
||||
## Prompt
|
||||
|
||||
You are `role-implementer` brief 3. Read `docs/adr-model-routing.md`, `docs/decisions/2026-08-25-second-brain-os-pivot.md`, `packages/database/src/schema/model_providers.ts`, `packages/shared/src/model-routing/index.ts`. Keep diff minimal, follow existing Drizzle/tRPC patterns in `server/routers/ai-models.ts`.
|
||||
|
||||
## Depends
|
||||
|
||||
Briefs 1,2 — needs optional worktree done.
|
||||
44
.convoys/second-brain-os-v01/brief-4-plans-tree.md
Normal file
44
.convoys/second-brain-os-v01/brief-4-plans-tree.md
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
---
|
||||
brief: 4
|
||||
convoy: second-brain-os-v01
|
||||
role: role-implementer
|
||||
title: Plans tree — web CRUD for plans/ without CLI, Start→Convoy
|
||||
status: ready
|
||||
priority: P0
|
||||
slice_dependencies: [1, 3]
|
||||
recommended_model: qwen-coder
|
||||
model_tier: local
|
||||
---
|
||||
|
||||
# Brief 4 — Plans tree (1w) — Epic 10
|
||||
|
||||
## Goal
|
||||
|
||||
Web `Plans` is Obsidian-like tree — read/write `plans/Plan-*/Epic-*/Task-*.md` without `pnpm import:markdown-backlog` CLI; click `Start → Convoy`.
|
||||
|
||||
## Files
|
||||
|
||||
- `apps/web/server/routers/backlog.ts:29` — add `listTree` (returns hierarchical `Plan → Epic → Task` with `status/priority/repoPath`), `createPlan/createEpic/createTask` (`slug/title/bodyMarkdown/agent_prompt` → writes `.md` via `join(repoRoot, repoPath)` atomically `.tmp→rename`, then direct DB upsert or `syncMarkdownBacklogScan`), `updateTaskBody`, `getBacklogItem` already exists. Enforce slug `^[a-z0-9-]+$`, `workspace_id` fence, `updated_at` bump.
|
||||
- `apps/web/app/(app)/[workspaceSlug]/plans/page.tsx` NEW — tree component (collapsible `Radix Collapsible` + `ScrollArea`), status chips, search, `New Plan/Epic/Task` dialogs, `Start` button → calls `convoys.create` `apps/web/server/routers/convoys.ts:173` with `planSlug/epicSlug`.
|
||||
- Reuse `resolveWorkflowPrompt` preview in task detail drawer.
|
||||
- `docs/Glossary.md:50` hierarchy respected; `config/CursorSync.md:33` export allow-list preserved (web writes go through same export path).
|
||||
|
||||
## Acceptance
|
||||
|
||||
- `Plans` page shows `Plan-clarity → Epic-ai-tags → Task-local-tag-prompt [ready]` from DB without CLI after DB seed.
|
||||
- `New Task` dialog `title: Research prompt` `slug: research-prompt` creates `plans/Plan-clarity/Epic-ai-tags/Task-research-prompt.md` on disk + row in `markdown_backlog_items` visible after refresh without manual import.
|
||||
- `Start` on a `ready` task creates convoy `clarity-research-prompt` at `draft` and links `briefFiles: [repoPath]`.
|
||||
- `MCP create-backlog-item` brief 1 and web create produce same file format (round-trip parse test).
|
||||
- `pnpm lint && pnpm type-check` pass.
|
||||
|
||||
## Prompt
|
||||
|
||||
You are `role-implementer` brief 4. Read `plans/README.md`, `packages/database/src/markdown-backlog/parse.ts`, `apps/web/server/routers/backlog.ts`, `docs/decisions/2026-08-25-second-brain-os-pivot.md`. Follow existing tRPC patterns `ai-models.ts`. Keep UI to `components/ui/*` + Tailwind.
|
||||
|
||||
## Depends
|
||||
|
||||
Briefs 1,3 — needs MCP tool shape + harness registry for Start.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Full Notion drag-n-drop, breadcrumb perf — keep tree simple.
|
||||
38
.convoys/second-brain-os-v01/brief-5-fleet-rollup.md
Normal file
38
.convoys/second-brain-os-v01/brief-5-fleet-rollup.md
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
---
|
||||
brief: 5
|
||||
convoy: second-brain-os-v01
|
||||
role: role-implementer
|
||||
title: Fleet rollup /fleet + SSE convoy_events feed
|
||||
status: ready
|
||||
priority: P1
|
||||
slice_dependencies: [3]
|
||||
recommended_model: qwen-coder
|
||||
model_tier: local
|
||||
---
|
||||
|
||||
# Brief 5 — Fleet rollup `/fleet` + SSE (3-4d) — Epic 9
|
||||
|
||||
## Goal
|
||||
|
||||
`echodo.stillwell.cloud/fleet` shows *all* workspaces + `connected_repos` projects needing you — the "land and see everything" you asked for. Live `convoy_events` tail without refresh.
|
||||
|
||||
## Files
|
||||
|
||||
- `apps/web/server/routers/convoys.ts:119` — add `fleet` procedure (no `workspace` arg, uses `ctx.session.user.id` to gather all `workspaceMembers` workspaces → `listConvoyProjects` across them, return `byWorkspace, byStatus, gateInboxAcross`). Reuse `GATE_INBOX_STATUSES`.
|
||||
- `apps/web/app/(app)/fleet/page.tsx` NEW — top `Needs you` list, grid `byWorkspace` cards, reuse `ConvoyKanban` `apps/web/app/(app)/[workspaceSlug]/mission-control/page.tsx:12` + `ConvoyDetailPanel` but workspace-agnostic.
|
||||
- `apps/web/app/api/fleet/events/route.ts` NEW — SSE `text/event-stream` tailing `convoy_events` `packages/database/src/schema/convoy_events.ts:1` filtered to user's workspaces, `occurredAt desc` 30 rows + live publish via `pg_notify` or polling 5s fallback (reuse orchestrator poll).
|
||||
- `apps/web/components/fleet/fleet-activity-feed.tsx` NEW — auto-append without refresh.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- `/fleet` lists convoys from `maya-workspace` + `studio-internal` + `bakery-coop` together; `Needs you` shows `× pr_review 2d` count.
|
||||
- Clicking fleet card opens detail with correct `workspaceSlug` context for `gateAction`.
|
||||
- SSE feed shows `role_complete` event within 5s of worker `logConvoyEvent` `apps/mcp-server/src/tools/log-convoy-event.ts`.
|
||||
|
||||
## Prompt
|
||||
|
||||
You are `role-implementer` brief 5. Read `apps/web/server/routers/convoys.ts:173`, `apps/web/server/lib/convoy/convoy-service.ts`, `plans/Plan-automation-foundation/Epic-fleet-dashboard/Epic-fleet-dashboard.md`, `docs/decisions/2026-08-25-second-brain-os-pivot.md`. Keep polling fallback simple, no Redis pubsub v1.
|
||||
|
||||
## Depends
|
||||
|
||||
Brief 3 — needs harness field in events for display.
|
||||
62
.convoys/worker-closeout-docwriter.md
Normal file
62
.convoys/worker-closeout-docwriter.md
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
---
|
||||
name: worker-closeout-docwriter
|
||||
classification: server-only
|
||||
success_metric: Epic 7 closes — draft PR opens for code + doc_review→published for docs, operator notes work mid-build, doc-writer examples ship, live E2E checklist is runnable without Cursor
|
||||
skip:
|
||||
- ia
|
||||
- ux
|
||||
- design
|
||||
- a11y
|
||||
- visual
|
||||
- smoke
|
||||
status: merged
|
||||
created: 2026-08-25
|
||||
model_policy:
|
||||
roles:
|
||||
role-conductor: claude-planning
|
||||
role-architect: claude-planning
|
||||
role-implementer: qwen-coder
|
||||
role-doc-writer: qwen-coder
|
||||
role-reviewer: qwen-coder
|
||||
escalate_to: claude-planning
|
||||
---
|
||||
|
||||
# Worker closeout + doc writers — closing Epic 7 for second-brain v0.1
|
||||
|
||||
## Why
|
||||
|
||||
Second-brain v0.1 shipped harness-agnostic, Plans tree, Fleet. Epic 7 remains `in_progress` per `plans/Plan-automation-foundation/Epic-apps-worker-v1/Epic-apps-worker-v1.md:23` — tool loop + checkpoints are done, but **no draft PR** via forge adapter blocks Epic 8 dogfood, and **live E2E** `Task-worker-live-e2e-dogfood.md` needs operator proof. Doc writers were asked to be included — this convoy adds `role-doc-writer` for SOPs/book research pipelines (docs that use `doc_review→published` without worktree).
|
||||
|
||||
See `docs/decisions/2026-08-25-second-brain-os-pivot.md`.
|
||||
|
||||
## Scope
|
||||
|
||||
1. **Draft PR via forge** `Task-worker-draft-pr.md` — push branch if needed, `createPullRequest(draft)` on GitHub/Forgejo from worktree, persist URL to convoy, skip for non-implementer roles, handle both `pr_review` and `doc_review` (doc tasks publish without PR but still log).
|
||||
2. **Operator notes mid-build** `Task-worker-operator-notes.md` — `convoy_operator_messages` table (workspace-scoped, `queued|direct`), tRPC `postOperatorNote`/`listOperatorNotes`, worker poll between tool rounds, Mission control chat thread for `in_progress` convoys.
|
||||
3. **Doc-writer examples** — `role-doc-writer` routed via harness (uses `doc-writer` harness `needsWorktree:false`), writes book/course research example under `docs/examples/research-pipeline.md` + `docs/templates/doc-sop.md`, updates `docs/worker.md` with doc harness section.
|
||||
4. **Live E2E dogfood** `Task-worker-live-e2e-dogfood.md` — operator checklist doc (not code), proves `Go → worker` without Cursor; done only when checklist true on real Redis/Coolify.
|
||||
|
||||
## Roles invoked
|
||||
|
||||
Conductor → Architect (3 briefs map to 3 tasks + doc example) → Implementer per brief (disjoint: draft PR, operator notes, doc-writer) → Doc-writer (parallel) → Reviewer.
|
||||
|
||||
## Todos
|
||||
|
||||
- [ ] Brief 1 draft PR
|
||||
- [ ] Brief 2 operator notes
|
||||
- [ ] Brief 3 doc-writer examples + routing
|
||||
- [ ] Brief 4 live E2E verification (ops doc, no code)
|
||||
|
||||
## IA
|
||||
|
||||
Skipped (`ia` in skip). No new entities beyond `convoy_operator_messages`; `harness_providers` already exists from v0.1.
|
||||
|
||||
## UX
|
||||
|
||||
Skipped (`ux` in skip). Reuses `Mission control detail panel` thread section + `Gate co-pilot` separation.
|
||||
|
||||
## Architecture
|
||||
|
||||
- State machine: `doc_review→published` already added `packages/shared/src/convoy-lifecycle/state-machine.ts:9` — reuse.
|
||||
- Worker: `apps/worker/src/handle-job.ts:40` already optional worktree — add PR call after success before teardown, branch naming `echodo/<convoy>-<brief8>`, marker `echodo-convoy: <uuid>` in body for Epic 8 webhook.
|
||||
- Operator notes: poll `convoy_operator_messages` where `delivered_at IS NULL` each `checkpointEveryNSteps`; inject `Queued note: ...` into system prompt tail or prepend `Direct message: ...`.
|
||||
40
.convoys/worker-closeout-docwriter/brief-1-draft-pr-forge.md
Normal file
40
.convoys/worker-closeout-docwriter/brief-1-draft-pr-forge.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
---
|
||||
brief: 1
|
||||
convoy: worker-closeout-docwriter
|
||||
role: role-implementer
|
||||
title: Worker draft PR via forge adapter (code + doc)
|
||||
status: ready
|
||||
priority: P0
|
||||
slice_dependencies: []
|
||||
recommended_model: qwen-coder
|
||||
model_tier: local
|
||||
---
|
||||
|
||||
# Brief 1 — Draft PR `Task-worker-draft-pr.md`
|
||||
|
||||
## Goal
|
||||
|
||||
`apps/worker` after successful `role-implementer` session pushes worktree branch if needed, calls `createPullRequest(draft:true)` on GitHub/Forgejo (from `connected_repos` / `repo_links`), persists `prUrl`/`prNumber` to `convoy_events metadata` + `objects.content.prUrl`, marker `echodo-convoy: <convoyId>` in body for Epic 8 webhook.
|
||||
|
||||
## Files
|
||||
|
||||
- `apps/worker/src/git/pr.ts` NEW — `openDraftPR({monorepoRoot, worktreePath, branch, convoySlug, briefId, convoyId, workspaceId, forge})` → push `git push origin HEAD` (uses stored credentials / `GITHUB_TOKEN` env pattern) → `forge.createPullRequest({base: main, head: branch, title, body, draft:true})`. Body includes `Convoy: ${slug} Brief: ${briefId} echodo-convoy: ${convoyId}`.
|
||||
- `apps/worker/src/handle-job.ts:40` — after `session.run` success and before teardown, if `roleId==='role-implementer'` and `worktreePath` present, call `openDraftPR`; on doc harness `needsWorktree:false` skip push but still log `published` artifact.
|
||||
- `packages/database/src/schema/convoy_events.ts` — already has `metadata jsonb`, store `prUrl`.
|
||||
- `apps/web/server/lib/convoy/convoy-service.ts` — expose `prUrl` from latest `metadata.prUrl`.
|
||||
- `apps/web/components/mission-control/convoy-detail-panel.tsx` — show `PR` link when `metadata.prUrl`.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Implementer job on test repo `stwl-labs/ubiquitous-invention` (or any connected) produces draft PR URL; non-implementer `role-architect` job skips PR creation.
|
||||
- PR body contains marker consumable by Epic 8.
|
||||
- Detail panel shows link when present.
|
||||
- `pnpm lint && pnpm type-check && pnpm --filter @tasks/worker test` pass.
|
||||
|
||||
## Prompt
|
||||
|
||||
You are `role-implementer` brief 1. Read `plans/Plan-automation-foundation/Epic-apps-worker-v1/Task-worker-draft-pr.md`, `apps/web/server/lib/forge/adapters.ts`, `apps/worker/src/git/worktree.ts`, `docs/decisions/2026-08-25-second-brain-os-pivot.md`. Keep change small, follow existing `adapters.ts` interface.
|
||||
|
||||
## Depends
|
||||
|
||||
None.
|
||||
41
.convoys/worker-closeout-docwriter/brief-2-operator-notes.md
Normal file
41
.convoys/worker-closeout-docwriter/brief-2-operator-notes.md
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
---
|
||||
brief: 2
|
||||
convoy: worker-closeout-docwriter
|
||||
role: role-implementer
|
||||
title: Operator notes mid-build — queued + direct messages
|
||||
status: ready
|
||||
priority: P1
|
||||
slice_dependencies: []
|
||||
recommended_model: qwen-coder
|
||||
model_tier: local
|
||||
---
|
||||
|
||||
# Brief 2 — Operator notes `Task-worker-operator-notes.md`
|
||||
|
||||
## Goal
|
||||
|
||||
Let operators send `queued` notes and `direct` messages to an active worker session without waiting for gate — Autensa benchmark row `operator-notes` `plans/Plan-automation-foundation/Plan-automation-foundation.md:229`.
|
||||
|
||||
## Files
|
||||
|
||||
- `packages/database/src/schema/convoy_operator_messages.ts` NEW — `id uuid PK, workspace_id fk, convoy_id fk objects.id, brief_id fk optional, agent_run_id fk optional, delivery enum queued|direct, body text, author_user_id fk users, delivered_at timestamptz nullable, created_at defaultNow`. Index `workspace_id, convoy_id, delivered_at`.
|
||||
- `packages/database/migrations/0016_*` — `pnpm db:generate`.
|
||||
- `apps/web/server/routers/convoys.ts` — add `postOperatorNote` (workspace member; `delivery:queued|direct`, `body 1..2000`, `convoyId, briefId?`) inserts row; `listOperatorNotes` for detail panel thread.
|
||||
- `apps/worker/src/db/operator-notes.ts` NEW — `pollOperatorNotes(db, workspaceId, convoyId, briefId?)` where `delivered_at IS NULL` ordered `created_at`.
|
||||
- `apps/worker/src/handle-job.ts:40` — between tool rounds poll queued notes, append `Queued note from ${author}: ${body}` to `toolHistory` summary; before next `generateText` prepend direct messages to system prompt tail. Mark `delivered_at=now()` after injection. Log `harnessAlias`.
|
||||
- `apps/web/app/(app)/[workspaceSlug]/mission-control/convoy-detail-panel.tsx` — thread tab when `status===in_progress` (textarea + Send → `postOperatorNote`, list `listOperatorNotes`), distinct from gate co-pilot.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Operator posts `Queued note: use existing auth middleware` → worker sees it on next tool round (logged in checkpoint summary).
|
||||
- `Direct message` arrives before next model call when run active (show in worker log `direct message injected`).
|
||||
- Workspace-scoped, auditable (author + delivered_at visible in detail panel).
|
||||
- `pnpm lint && pnpm type-check` pass.
|
||||
|
||||
## Prompt
|
||||
|
||||
You are `role-implementer` brief 2. Read `plans/Plan-automation-foundation/Epic-apps-worker-v1/Task-worker-operator-notes.md`, `packages/database/src/schema/convoy_events.ts`, `apps/web/server/routers/convoys.ts:173`, `docs/worker.md`. Keep polling fallback simple (no Redis pubsub).
|
||||
|
||||
## Depends
|
||||
|
||||
None — disjoint from brief 1.
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
---
|
||||
brief: 3
|
||||
convoy: worker-closeout-docwriter
|
||||
role: role-doc-writer
|
||||
title: Doc-writer examples + routing for second brain (book/course/research SOPs)
|
||||
status: ready
|
||||
priority: P1
|
||||
slice_dependencies: []
|
||||
recommended_model: qwen-coder
|
||||
model_tier: local
|
||||
---
|
||||
|
||||
# Brief 3 — `role-doc-writer` for second brain (book / course / SOPs)
|
||||
|
||||
## Goal
|
||||
|
||||
Prove non-code second brain: `role-doc-writer` routed via harness `doc-writer` (`needsWorktree:false, allowedCommands:[]` `apps/worker/src/harness.ts:1`) writes publishable docs without `pnpm` or worktree, via `doc_review→published` `packages/shared/src/convoy-lifecycle/state-machine.ts:9`.
|
||||
|
||||
## Files
|
||||
|
||||
- `docs/examples/research-pipeline.md` NEW — one page: "Heavy research pipeline" example (source → claim → outline → publish), calls `doc_review→published` flow; used as doc harness e2e fixture.
|
||||
- `docs/templates/doc-sop.md` NEW — copy from `docs/templates/Task-*.md` but for SOPs: `kind: doc-sop, slug, title, status: draft→doc_review→published, body`.
|
||||
- `packages/shared/src/model-routing` / `harness` seeds — add doc role wiring note: `role-doc-writer → qwen-coder-mlx + doc-writer` suggested alias map `docs/adr-model-routing.md:234`.
|
||||
- `docs/worker.md` — extend doc harness section (already has optional worktree note from v0.1) with `role-doc-writer` example invocation.
|
||||
- `apps/web/server/routers/harnesses.ts:1` — ensure `harnessType: minimal` with `alias: doc-writer` preset is seedable (if not, document manual creation).
|
||||
|
||||
## Acceptance
|
||||
|
||||
- `role-doc-writer` job `docs/examples/research-pipeline.md` completes via `write_file` only (no `run_command`, no worktree under `/tmp/echodo-worker-worktrees`) and transitions `in_progress → doc_review` (`role-doc-writer` or automation), then `doc-approver` `doc_review→published` visible in Mission control.
|
||||
- SOP template renders in `docs/templates/` list.
|
||||
- `pnpm lint && pnpm type-check` pass.
|
||||
|
||||
## Prompt
|
||||
|
||||
You are `role-doc-writer` brief 3. Read `docs/decisions/2026-08-25-second-brain-os-pivot.md`, `docs/adr-model-routing.md:234`, `docs/convoy-lifecycle.md`, `packages/shared/src/convoy-lifecycle/schemas.ts:5`. Write docs that a future agent will read as context — keep them small, no narration comments.
|
||||
|
||||
## Depends
|
||||
|
||||
None — can run parallel with 1,2.
|
||||
|
||||
## Links
|
||||
|
||||
- `plans/Plan-automation-foundation/Plan-automation-foundation.md` v0.1 pivot
|
||||
- `apps/worker/src/harness.ts:1` `doc-writer` harness
|
||||
|
|
@ -2,8 +2,15 @@
|
|||
"_comment": "Echodo's own MCP server. Spawned per Cursor session over stdio. The 'mcp' script in apps/mcp-server/package.json invokes tsx with --env-file=../../.env so DATABASE_URL is read from the repo's .env without leaking it into this committed file. The -s/--silent flag on pnpm suppresses its progress output that would otherwise corrupt the MCP stdio protocol. If you fork the repo, copy .env.example → .env and fill in DATABASE_URL.",
|
||||
"mcpServers": {
|
||||
"echodo": {
|
||||
"command": "pnpm",
|
||||
"args": ["-s", "--filter", "@tasks/mcp-server", "mcp"]
|
||||
"command": "/Users/rstillw/.nvm/versions/node/v22.14.0/bin/pnpm",
|
||||
"args": [
|
||||
"-s",
|
||||
"-C",
|
||||
"/Users/rstillw/Documents/Personal Coding Projects/tasks",
|
||||
"--filter",
|
||||
"@tasks/mcp-server",
|
||||
"mcp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
35
.cursor/rules/convoy-planning.mdc
Normal file
35
.cursor/rules/convoy-planning.mdc
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
---
|
||||
description: Pipeline planning uses .convoys/*.md — not Cursor Plan files in .cursor/plans/
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Convoy planning (pipeline work)
|
||||
|
||||
When scoping, planning, or kicking off **pipeline work** (features, hotfixes, epics tracked by L2 roles), use **convoy files** — not Cursor's native Plan mode.
|
||||
|
||||
## Where plans live
|
||||
|
||||
| Use case | Location | Who writes it |
|
||||
| --- | --- | --- |
|
||||
| **Product backlog** (Plan/Epic/Task) | `plans/` → Echodo DB | Humans + markdown import |
|
||||
| **Pipeline convoy** (idea → briefs → PR) | `.convoys/<slug>.md` | `role-conductor`, then other L2 roles |
|
||||
| **Implementer briefs** | `.convoys/<slug>/brief-<N>-<title>.md` | `role-architect` |
|
||||
| **Cursor Plan mode** (ad-hoc, non-pipeline) | `.cursor/plans/*.plan.md` | Cursor UI — **not** the agent-pipeline |
|
||||
|
||||
## Do
|
||||
|
||||
- Start pipeline work: *"Run role-conductor: start a new convoy for [idea]. Success = [metric]."*
|
||||
- Read [`.convoys/README.md`](mdc:.convoys/README.md) for frontmatter, skip flags, and lifecycle.
|
||||
- Use `model_policy.roles` with **registry alias names** per [docs/adr-model-routing.md](mdc:docs/adr-model-routing.md).
|
||||
- Log role completion: `bash scripts/log-convoy-event.sh role=... convoy=...` when the script exists.
|
||||
- Dogfood Echodo: `claim_task` / `complete_task` on the matching `plans/**/Task-*.md` during implementer sessions.
|
||||
|
||||
## Do not
|
||||
|
||||
- Create `.cursor/plans/` files for convoy or L2-role work.
|
||||
- Store convoy execution state only in chat when a `.convoys/<slug>.md` file should exist.
|
||||
- Put secrets or API keys in convoy frontmatter — use `model_policy` alias names only.
|
||||
|
||||
## If a `.cursor/plans/` file already exists
|
||||
|
||||
That is Cursor's native Plan artifact, not a pipeline convoy. Copy useful content into `.convoys/<slug>.md` and continue from the convoy file.
|
||||
50
.dockerignore
Normal file
50
.dockerignore
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
# Docker build context is the repo root (see docker/docker-compose.coolify.yml).
|
||||
# Keep in sync with docker/.dockerignore.
|
||||
|
||||
# Dependencies & build outputs
|
||||
**/node_modules
|
||||
**/.pnpm-store
|
||||
**/.next
|
||||
**/dist
|
||||
**/.turbo
|
||||
|
||||
# Git & IDE (worker image copies .cursor; keep context lean via explicit COPY paths in Dockerfile)
|
||||
.git
|
||||
.gitignore
|
||||
.idea
|
||||
.vscode
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Env & secrets (compose should inject at runtime)
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
apps/web/.env
|
||||
|
||||
# Logs & caches
|
||||
logs
|
||||
**/*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
.turbo
|
||||
.cache
|
||||
|
||||
# Tests & misc
|
||||
coverage
|
||||
**/__tests__
|
||||
**/*.test.ts
|
||||
**/*.test.tsx
|
||||
**/*.spec.ts
|
||||
**/*.spec.tsx
|
||||
|
||||
# Docs (not needed in image)
|
||||
**/*.md
|
||||
LICENSE
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Docker (avoid nested contexts)
|
||||
docker/**/*.md
|
||||
93
.env.example
93
.env.example
|
|
@ -2,11 +2,9 @@
|
|||
# ECHODO — Environment Variables
|
||||
# =====================================================================
|
||||
# Local dev: copy this file to .env and adjust values.
|
||||
# Coolify (CT 107): paste these into the resource's "Environment Variables"
|
||||
# tab. Coolify also accepts a .env file uploaded via the UI.
|
||||
#
|
||||
# See AGENT-DEPLOY.md for shared CT 102 service credentials and the
|
||||
# CT 100 Traefik routing pattern.
|
||||
# Production (CT 116 standalone LXC): paste into /opt/submatter/.env on the host.
|
||||
# See docs/deployment-production.md and proxmox/ct116/README.md.
|
||||
# See AGENT-DEPLOY.md for shared CT 102 service credentials and Traefik routing.
|
||||
# =====================================================================
|
||||
|
||||
# ----- Database (CT 102 shared Postgres) -----
|
||||
|
|
@ -20,11 +18,41 @@
|
|||
# Production (Coolify, CT 102): dedicated `echodo` user/db on shared Postgres
|
||||
DATABASE_URL="postgresql://echodo:CHANGE_ME@192.168.68.102:5432/echodo"
|
||||
|
||||
# UUID of the workspace that owns imported `plans/` rows (slug or UUID from Settings).
|
||||
# For dogfooding this repo, use the `convoys-tasks` workspace id.
|
||||
# Required by `pnpm --filter @tasks/database import:markdown-backlog` and
|
||||
# `watch:markdown-backlog` (not loaded automatically — set in `.env`).
|
||||
MARKDOWN_BACKLOG_WORKSPACE_ID="00000000-0000-0000-0000-000000000000"
|
||||
|
||||
# ----- Redis (CT 102 shared Redis) -----
|
||||
# Use a dedicated DB index per AGENT-DEPLOY.md (ECHODO uses /11)
|
||||
# Local dev: redis://localhost:6379
|
||||
REDIS_URL="redis://:CHANGE_ME@192.168.68.102:6379/11"
|
||||
|
||||
# ----- Orchestrator (apps/orchestrator) -----
|
||||
# ORCHESTRATOR_DRY_RUN=1 logs dispatch/transition without Redis enqueue
|
||||
# ORCHESTRATOR_POLL_MS=5000
|
||||
# ORCHESTRATOR_STALL_MINUTES=120
|
||||
# ORCHESTRATOR_MAX_IMPLEMENTERS=2
|
||||
|
||||
# ----- Worker (apps/worker) -----
|
||||
# WORKER_DRY_RUN=1 skips Redis dequeue loop
|
||||
# WORKER_BLPOP_TIMEOUT_S=5
|
||||
# WORKER_MAX_TOOL_STEPS=12
|
||||
# WORKER_CHECKPOINT_EVERY_N_STEPS=1
|
||||
# WORKER_REPO_ROOT= # defaults to monorepo root (pnpm-workspace.yaml walk-up)
|
||||
# WORKER_WORKTREE_BASE=/tmp/echodo-worker-worktrees
|
||||
#
|
||||
# Phase B — OpenHands agent-server (optional; requires --profile agent-server)
|
||||
# WORKER_SESSION_TYPE=minimal # set to `openhands` when agent-server is running
|
||||
# AGENT_SERVER_URL=http://agent-server:8001
|
||||
# WORKER_OPENHANDS_WORKSPACE=/workspace/repo
|
||||
# SESSION_API_KEY= # must match LOCAL_BACKEND_API_KEY on agent-server
|
||||
# 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) -----
|
||||
# Local: http://localhost:3000 • Prod: https://echodo.stillwell.cloud
|
||||
NEXTAUTH_URL="http://localhost:3000"
|
||||
|
|
@ -52,10 +80,11 @@ AUTH_AUTHENTIK_ISSUER="https://auth.stillwell.cloud/application/o/echodo/"
|
|||
# ----- Realtime collab server (Hocuspocus / Yjs) -----
|
||||
# Server-side reference (used by Next.js server actions, if any)
|
||||
COLLAB_SERVER_URL="ws://localhost:1234"
|
||||
# Browser-facing — must be wss:// in production because the page is https
|
||||
# Browser-facing — must be wss:// in production because the page is https.
|
||||
# Baked into the web image at BUILD time (docker build args) — change requires rebuild.
|
||||
NEXT_PUBLIC_COLLAB_SERVER_URL="ws://localhost:1234"
|
||||
|
||||
# ----- Umami Analytics (CT 107 — analytics.stillwell.cloud) -----
|
||||
# ----- Umami Analytics (CT 112 — analytics.stillwell.cloud) -----
|
||||
# In Umami: Settings → Websites → Add website. Copy the website UUID.
|
||||
NEXT_PUBLIC_UMAMI_SCRIPT="https://analytics.stillwell.cloud/script.js"
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=""
|
||||
|
|
@ -64,24 +93,66 @@ NEXT_PUBLIC_UMAMI_WEBSITE_ID=""
|
|||
NEXT_PUBLIC_LIBREDESK_URL="https://support.stillwell.cloud"
|
||||
NEXT_PUBLIC_LIBREDESK_WIDGET_ID=""
|
||||
|
||||
# ----- Directus Feedback (CT 107 — directus.stillwell.cloud) -----
|
||||
# ----- Directus Feedback (archived — was CT 107) -----
|
||||
# Static token scoped write-only on the `feedback` collection
|
||||
NEXT_PUBLIC_DIRECTUS_URL="https://directus.stillwell.cloud"
|
||||
DIRECTUS_FEEDBACK_TOKEN=""
|
||||
|
||||
# ----- AI Providers -----
|
||||
# Use OPENAI_BASE_URL=http://192.168.68.108:11434/v1 to route through
|
||||
# local Ollama (CT 108) instead of the OpenAI API.
|
||||
# Registry aliases (Epic 1) read these env var NAMES from the DB — values
|
||||
# live here on the server/worker host, never in Postgres.
|
||||
#
|
||||
# Local Ollama on Mac:
|
||||
# OPENAI_BASE_URL=http://<mac-lan-ip>:11434/v1
|
||||
# OPENAI_API_KEY=ollama
|
||||
# OPENAI_MODEL=qwen2.5-coder:32b
|
||||
#
|
||||
# Local MLX (mlx-openai-server on Mac — preferred for parallel implementers):
|
||||
# MLX_SERVER_URL=http://<mac-lan-ip>:8000/v1
|
||||
# MLX_API_KEY=local
|
||||
# (Register as openai_compatible alias qwen-coder-mlx in Settings when Epic 1 ships)
|
||||
#
|
||||
# Cloud / free-tier (see docs/adr-model-routing.md):
|
||||
OPENAI_API_KEY=""
|
||||
OPENAI_BASE_URL=""
|
||||
ANTHROPIC_API_KEY=""
|
||||
GOOGLE_GENERATIVE_AI_API_KEY=""
|
||||
NVIDIA_API_KEY=""
|
||||
|
||||
# ----- OmniRoute (CT 108 — omniroute.stillwell.cloud) -----
|
||||
# Used by worker + web (Settings → AI provider tests) + Phase B agent-server.
|
||||
OMNIROUTE_BASE_URL="https://omniroute.stillwell.cloud/v1"
|
||||
OMNIROUTE_API_KEY=""
|
||||
OMNIROUTE_MODEL=auto/coding
|
||||
|
||||
# ----- Connected repos (Settings → Connected repos) -----
|
||||
# Token values live here; Settings stores only the env var NAME (e.g. FORGEJO_TOKEN).
|
||||
# Forgejo self-hosted:
|
||||
# Remote URL: https://forgejo.stillwell.cloud/rstillwell/Submatter.git
|
||||
# Token env var name in UI: FORGEJO_TOKEN
|
||||
FORGEJO_TOKEN=""
|
||||
# GitHub.com:
|
||||
# Remote URL: https://github.com/org/repo.git
|
||||
# Token env var name in UI: GITHUB_TOKEN
|
||||
GITHUB_TOKEN=""
|
||||
|
||||
# ----- Forge webhooks (PR merged → convoy transition) -----
|
||||
FORGE_WEBHOOK_SECRET=""
|
||||
|
||||
# ----- MCP Server -----
|
||||
MCP_SERVER_PORT=3001
|
||||
|
||||
# =====================================================================
|
||||
# Production Docker Compose build (8 GB hosts)
|
||||
# =====================================================================
|
||||
# Serialize image builds to avoid kernel OOM (exit 255) during export.
|
||||
# Dokploy / standalone: set Custom Build Command to `bash scripts/build-prod.sh`
|
||||
# Local verify: pnpm docker:build:prod
|
||||
# Legacy alias: pnpm docker:build:coolify
|
||||
|
||||
# =====================================================================
|
||||
# Local Docker Compose only (docker/docker-compose.yml)
|
||||
# Not needed for Coolify (Coolify uses CT 102's shared Postgres/Redis).
|
||||
# Not needed for production (uses CT 102 shared Postgres/Redis).
|
||||
# =====================================================================
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=postgres
|
||||
|
|
|
|||
41
.forgejo/workflows/ci.yml
Normal file
41
.forgejo/workflows/ci.yml
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Lint, type-check, test
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
|
||||
- name: Type-check
|
||||
run: pnpm type-check
|
||||
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
40
.forgejo/workflows/deploy-homelab.yml
Normal file
40
.forgejo/workflows/deploy-homelab.yml
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
name: Deploy homelab (CT 116)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: deploy-homelab-main
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy to CT 116
|
||||
runs-on: [self-hosted, linux, axiom]
|
||||
timeout-minutes: 45
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Sync and deploy over SSH
|
||||
env:
|
||||
DEPLOY_HOST: ${{ secrets.HOMELAB_DEPLOY_HOST }}
|
||||
DEPLOY_USER: ${{ secrets.HOMELAB_DEPLOY_USER }}
|
||||
DEPLOY_PATH: ${{ secrets.HOMELAB_DEPLOY_PATH }}
|
||||
DEPLOY_SSH_KEY: ${{ secrets.HOMELAB_DEPLOY_SSH_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${DEPLOY_HOST:-}" || -z "${DEPLOY_USER:-}" || -z "${DEPLOY_SSH_KEY:-}" ]]; then
|
||||
echo "Skipping deploy: set HOMELAB_DEPLOY_HOST, HOMELAB_DEPLOY_USER, HOMELAB_DEPLOY_SSH_KEY repo secrets."
|
||||
exit 0
|
||||
fi
|
||||
deploy_path="${DEPLOY_PATH:-/opt/submatter}"
|
||||
mkdir -p ~/.ssh
|
||||
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key
|
||||
chmod 600 ~/.ssh/deploy_key
|
||||
ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=accept-new \
|
||||
"${DEPLOY_USER}@${DEPLOY_HOST}" \
|
||||
"set -euo pipefail; cd '${deploy_path}' && bash scripts/git-sync-deploy.sh && bash scripts/deploy-prod.sh build"
|
||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
|
@ -26,8 +26,6 @@ jobs:
|
|||
# versions are more reproducible and the lockfile is pinned to 9.x.
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
|
|
|
|||
41
.github/workflows/deploy-homelab.yml
vendored
Normal file
41
.github/workflows/deploy-homelab.yml
vendored
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
name: Deploy homelab (CT 116)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
# CI already ran on the PR; main deploy is serialized and can take 15–30 min on axiom.
|
||||
concurrency:
|
||||
group: deploy-homelab-main
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy to CT 116
|
||||
runs-on: [self-hosted, linux, axiom]
|
||||
timeout-minutes: 45
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Sync and deploy over SSH
|
||||
env:
|
||||
DEPLOY_HOST: ${{ secrets.HOMELAB_DEPLOY_HOST }}
|
||||
DEPLOY_USER: ${{ secrets.HOMELAB_DEPLOY_USER }}
|
||||
DEPLOY_PATH: ${{ secrets.HOMELAB_DEPLOY_PATH }}
|
||||
DEPLOY_SSH_KEY: ${{ secrets.HOMELAB_DEPLOY_SSH_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${DEPLOY_HOST:-}" || -z "${DEPLOY_USER:-}" || -z "${DEPLOY_SSH_KEY:-}" ]]; then
|
||||
echo "Skipping deploy: set HOMELAB_DEPLOY_HOST, HOMELAB_DEPLOY_USER, HOMELAB_DEPLOY_SSH_KEY repo secrets."
|
||||
exit 0
|
||||
fi
|
||||
deploy_path="${DEPLOY_PATH:-/opt/submatter}"
|
||||
mkdir -p ~/.ssh
|
||||
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key
|
||||
chmod 600 ~/.ssh/deploy_key
|
||||
ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=accept-new \
|
||||
"${DEPLOY_USER}@${DEPLOY_HOST}" \
|
||||
"set -euo pipefail; cd '${deploy_path}' && bash scripts/git-sync-deploy.sh && bash scripts/deploy-prod.sh build"
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -55,3 +55,6 @@ docker/**/data/
|
|||
# misc
|
||||
*.pem
|
||||
coverage/
|
||||
|
||||
# Convoy pipeline metrics (local-only by default; see .convoys/README.md)
|
||||
.convoys/.metrics.jsonl
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
"@modelcontextprotocol/sdk": "^1.4.0",
|
||||
"@tasks/database": "workspace:*",
|
||||
"@tasks/shared": "workspace:*",
|
||||
"yaml": "^2.8.3",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -15,8 +15,10 @@ const d: any = require(drizzlePath);
|
|||
|
||||
export const and = d.and;
|
||||
export const asc = d.asc;
|
||||
export const desc = d.desc;
|
||||
export const eq = d.eq;
|
||||
export const ilike = d.ilike;
|
||||
export const inArray = d.inArray;
|
||||
export const isNull = d.isNull;
|
||||
export const or = d.or;
|
||||
export const sql = d.sql;
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ const mcp = new McpServer(
|
|||
},
|
||||
{
|
||||
instructions:
|
||||
"Tools and resources for the Tasks project management app: objects, workspaces, views, and templates.",
|
||||
"Tools and resources for Echodo: objects, workspaces, backlog claim/complete, and convoy lifecycle (create_convoy, transition_convoy_status, reconcile_from_files).",
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
147
apps/mcp-server/src/lib/convoy-files.ts
Normal file
147
apps/mcp-server/src/lib/convoy-files.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
// @ts-nocheck — Node built-ins; MCP tsconfig may omit @types/node in some paths.
|
||||
import { existsSync, readdirSync, readFileSync, appendFileSync, writeFileSync } from "node:fs";
|
||||
import { basename, join } from "node:path";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
|
||||
import type { ConvoyClassification, ConvoyFileStatus } from "@tasks/shared/convoy-lifecycle";
|
||||
|
||||
const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
|
||||
|
||||
export type ParsedConvoyFile = {
|
||||
filePath: string;
|
||||
slug: string;
|
||||
classification: ConvoyClassification | null;
|
||||
successMetric: string | null;
|
||||
skipFlags: string[];
|
||||
fileStatus: ConvoyFileStatus | null;
|
||||
bodyMarkdown: string;
|
||||
modelPolicy: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type PendingMcpSyncEntry = {
|
||||
ts: string;
|
||||
tool: string;
|
||||
args: Record<string, unknown>;
|
||||
error: string;
|
||||
};
|
||||
|
||||
function readString(fm: Record<string, unknown>, key: string): string | null {
|
||||
const value = fm[key];
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function readStringArray(fm: Record<string, unknown>, key: string): string[] {
|
||||
const value = fm[key];
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter((item): item is string => typeof item === "string");
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function parseConvoyMarkdownFile(
|
||||
filePath: string,
|
||||
raw: string,
|
||||
): ParsedConvoyFile | null {
|
||||
const fmMatch = raw.match(FRONTMATTER);
|
||||
let frontmatter: Record<string, unknown> = {};
|
||||
let bodyMarkdown = raw;
|
||||
if (fmMatch) {
|
||||
try {
|
||||
const parsed = parseYaml(fmMatch[1]);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
frontmatter = parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
bodyMarkdown = raw.slice(fmMatch[0].length);
|
||||
}
|
||||
|
||||
const slug =
|
||||
readString(frontmatter, "name") ??
|
||||
basename(filePath).replace(/\.md$/i, "");
|
||||
|
||||
const classification = readString(frontmatter, "classification");
|
||||
const fileStatus = readString(frontmatter, "status");
|
||||
const modelPolicyRaw = frontmatter.model_policy;
|
||||
const modelPolicy =
|
||||
modelPolicyRaw && typeof modelPolicyRaw === "object" && !Array.isArray(modelPolicyRaw)
|
||||
? (modelPolicyRaw as Record<string, unknown>)
|
||||
: null;
|
||||
|
||||
return {
|
||||
filePath,
|
||||
slug,
|
||||
classification: classification as ConvoyClassification | null,
|
||||
successMetric: readString(frontmatter, "success_metric"),
|
||||
skipFlags: readStringArray(frontmatter, "skip"),
|
||||
fileStatus: fileStatus as ConvoyFileStatus | null,
|
||||
bodyMarkdown,
|
||||
modelPolicy,
|
||||
};
|
||||
}
|
||||
|
||||
export function listConvoyMarkdownFiles(repoRoot: string): ParsedConvoyFile[] {
|
||||
const convoysDir = join(repoRoot, ".convoys");
|
||||
if (!existsSync(convoysDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const results: ParsedConvoyFile[] = [];
|
||||
for (const entry of readdirSync(convoysDir, { withFileTypes: true })) {
|
||||
if (!entry.isFile() || !entry.name.endsWith(".md") || entry.name === "README.md") {
|
||||
continue;
|
||||
}
|
||||
const filePath = join(convoysDir, entry.name);
|
||||
const raw = readFileSync(filePath, "utf8");
|
||||
const parsed = parseConvoyMarkdownFile(filePath, raw);
|
||||
if (parsed) {
|
||||
results.push(parsed);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export function pendingSyncPath(repoRoot: string): string {
|
||||
return join(repoRoot, ".convoys", ".pending-mcp-sync.jsonl");
|
||||
}
|
||||
|
||||
export function appendPendingSync(
|
||||
repoRoot: string,
|
||||
entry: Omit<PendingMcpSyncEntry, "ts"> & { ts?: string },
|
||||
): void {
|
||||
const path = pendingSyncPath(repoRoot);
|
||||
const line = JSON.stringify({
|
||||
ts: entry.ts ?? new Date().toISOString(),
|
||||
tool: entry.tool,
|
||||
args: entry.args,
|
||||
error: entry.error,
|
||||
});
|
||||
appendFileSync(path, `${line}\n`, "utf8");
|
||||
}
|
||||
|
||||
export function readPendingSync(repoRoot: string): PendingMcpSyncEntry[] {
|
||||
const path = pendingSyncPath(repoRoot);
|
||||
if (!existsSync(path)) {
|
||||
return [];
|
||||
}
|
||||
const raw = readFileSync(path, "utf8");
|
||||
const entries: PendingMcpSyncEntry[] = [];
|
||||
for (const line of raw.split("\n")) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const parsed = JSON.parse(line) as PendingMcpSyncEntry;
|
||||
entries.push(parsed);
|
||||
} catch {
|
||||
// skip corrupt lines
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function clearPendingSync(repoRoot: string): void {
|
||||
const path = pendingSyncPath(repoRoot);
|
||||
if (existsSync(path)) {
|
||||
writeFileSync(path, "", "utf8");
|
||||
}
|
||||
}
|
||||
94
apps/mcp-server/src/lib/convoy-objects.ts
Normal file
94
apps/mcp-server/src/lib/convoy-objects.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { and, eq, sql } from "../drizzle.js";
|
||||
import { db } from "../db.js";
|
||||
import { convoyEvents, objects } from "../schema.js";
|
||||
import {
|
||||
convoyObjectContentSchema,
|
||||
type ConvoyObjectContent,
|
||||
type ConvoyStatus,
|
||||
} from "@tasks/shared/convoy-lifecycle";
|
||||
|
||||
export async function findConvoyBySlug(workspaceId: string, slug: string) {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(objects)
|
||||
.where(
|
||||
and(
|
||||
eq(objects.workspaceId, workspaceId),
|
||||
eq(objects.type, "project"),
|
||||
sql`${objects.content} ->> 'convoySlug' = ${slug}`,
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
export async function loadConvoyProject(convoyId: string, workspaceId: string) {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(objects)
|
||||
.where(
|
||||
and(
|
||||
eq(objects.id, convoyId),
|
||||
eq(objects.workspaceId, workspaceId),
|
||||
eq(objects.type, "project"),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
export function parseConvoyContent(content: unknown): ConvoyObjectContent | null {
|
||||
const parsed = convoyObjectContentSchema.safeParse(content);
|
||||
return parsed.success ? parsed.data : null;
|
||||
}
|
||||
|
||||
export async function insertConvoyEvent(input: {
|
||||
workspaceId: string;
|
||||
convoyId: string;
|
||||
briefId?: string | null;
|
||||
role: string;
|
||||
actor?: string | null;
|
||||
eventType: string;
|
||||
classification?: string | null;
|
||||
skipFlags?: string[] | null;
|
||||
durationS?: number | null;
|
||||
outcome?: string | null;
|
||||
repo?: string | null;
|
||||
multitaskGroup?: string | null;
|
||||
modelAlias?: string | null;
|
||||
providerType?: string | null;
|
||||
modelId?: string | null;
|
||||
modelTier?: string | null;
|
||||
estimatedCostUsd?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}) {
|
||||
const [row] = await db
|
||||
.insert(convoyEvents)
|
||||
.values({
|
||||
workspaceId: input.workspaceId,
|
||||
convoyId: input.convoyId,
|
||||
briefId: input.briefId ?? null,
|
||||
role: input.role,
|
||||
actor: input.actor ?? null,
|
||||
eventType: input.eventType,
|
||||
classification: input.classification ?? null,
|
||||
skipFlags: input.skipFlags ?? null,
|
||||
durationS: input.durationS ?? null,
|
||||
outcome: input.outcome ?? null,
|
||||
repo: input.repo ?? null,
|
||||
multitaskGroup: input.multitaskGroup ?? null,
|
||||
modelAlias: input.modelAlias ?? null,
|
||||
providerType: input.providerType ?? null,
|
||||
modelId: input.modelId ?? null,
|
||||
modelTier: input.modelTier ?? null,
|
||||
estimatedCostUsd: input.estimatedCostUsd ?? null,
|
||||
metadata: input.metadata ?? null,
|
||||
})
|
||||
.returning();
|
||||
return row;
|
||||
}
|
||||
|
||||
export function currentConvoyStatus(row: { status: string | null }): ConvoyStatus {
|
||||
const status = row.status ?? "draft";
|
||||
return status as ConvoyStatus;
|
||||
}
|
||||
123
apps/mcp-server/src/lib/resolve-backlog-item.ts
Normal file
123
apps/mcp-server/src/lib/resolve-backlog-item.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { z } from "zod";
|
||||
|
||||
import { and, eq, isNull } from "../drizzle.js";
|
||||
import { db } from "../db.js";
|
||||
import { markdownBacklogItems } from "../schema.js";
|
||||
|
||||
export const backlogKindSchema = z.enum(["plan", "epic", "task"]);
|
||||
|
||||
export const backlogItemLookupFieldsSchema = z.object({
|
||||
repoPath: z
|
||||
.string()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe(
|
||||
"Repo-relative path under plans/, e.g. plans/Plan-automation-foundation/Epic-apps-worker-v1/Task-worker-minimal-tool-loop.md",
|
||||
),
|
||||
slug: z.string().min(1).optional(),
|
||||
planSlug: z.string().min(1).optional(),
|
||||
epicSlug: z.string().min(1).optional(),
|
||||
kind: backlogKindSchema.optional(),
|
||||
});
|
||||
|
||||
export const backlogItemLookupSchema = backlogItemLookupFieldsSchema.superRefine(
|
||||
(input, ctx) => {
|
||||
const hasRepoPath = Boolean(input.repoPath?.trim());
|
||||
const hasSlugKeys = Boolean(input.slug?.trim() && input.planSlug?.trim());
|
||||
if (!hasRepoPath && !hasSlugKeys) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message:
|
||||
"Provide repoPath, or slug + planSlug (add epicSlug for tasks/epics).",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export type BacklogItemLookup = z.infer<typeof backlogItemLookupFieldsSchema>;
|
||||
|
||||
export type ResolvedBacklogItem = {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
kind: string;
|
||||
slug: string;
|
||||
planSlug: string;
|
||||
epicSlug: string | null;
|
||||
repoPath: string;
|
||||
title: string;
|
||||
bodyMarkdown: string;
|
||||
status: string | null;
|
||||
priority: string | null;
|
||||
};
|
||||
|
||||
export const BACKLOG_ITEM_NOT_FOUND_MESSAGE =
|
||||
"Backlog item not found. Run `pnpm --filter @tasks/database import:markdown-backlog` (with MARKDOWN_BACKLOG_WORKSPACE_ID set) if the plans/ file exists but has not been imported yet.";
|
||||
|
||||
export function normalizeRepoPath(repoPath: string): string {
|
||||
return repoPath.trim().replace(/^\.\/+/, "");
|
||||
}
|
||||
|
||||
function buildLookupConditions(
|
||||
workspaceId: string,
|
||||
lookup: BacklogItemLookup,
|
||||
) {
|
||||
const conditions = [
|
||||
eq(markdownBacklogItems.workspaceId, workspaceId),
|
||||
isNull(markdownBacklogItems.archivedAt),
|
||||
];
|
||||
|
||||
if (lookup.repoPath?.trim()) {
|
||||
conditions.push(
|
||||
eq(markdownBacklogItems.repoPath, normalizeRepoPath(lookup.repoPath)),
|
||||
);
|
||||
return conditions;
|
||||
}
|
||||
|
||||
conditions.push(eq(markdownBacklogItems.slug, lookup.slug!.trim()));
|
||||
conditions.push(eq(markdownBacklogItems.planSlug, lookup.planSlug!.trim()));
|
||||
if (lookup.epicSlug?.trim()) {
|
||||
conditions.push(eq(markdownBacklogItems.epicSlug, lookup.epicSlug.trim()));
|
||||
}
|
||||
if (lookup.kind) {
|
||||
conditions.push(eq(markdownBacklogItems.kind, lookup.kind));
|
||||
}
|
||||
|
||||
return conditions;
|
||||
}
|
||||
|
||||
const backlogItemSelect = {
|
||||
id: markdownBacklogItems.id,
|
||||
workspaceId: markdownBacklogItems.workspaceId,
|
||||
kind: markdownBacklogItems.kind,
|
||||
slug: markdownBacklogItems.slug,
|
||||
planSlug: markdownBacklogItems.planSlug,
|
||||
epicSlug: markdownBacklogItems.epicSlug,
|
||||
repoPath: markdownBacklogItems.repoPath,
|
||||
title: markdownBacklogItems.title,
|
||||
bodyMarkdown: markdownBacklogItems.bodyMarkdown,
|
||||
status: markdownBacklogItems.status,
|
||||
priority: markdownBacklogItems.priority,
|
||||
};
|
||||
|
||||
export async function resolveBacklogItemById(
|
||||
backlogItemId: string,
|
||||
): Promise<ResolvedBacklogItem | null> {
|
||||
const [row] = await db
|
||||
.select(backlogItemSelect)
|
||||
.from(markdownBacklogItems)
|
||||
.where(eq(markdownBacklogItems.id, backlogItemId))
|
||||
.limit(1);
|
||||
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
export async function resolveBacklogItemsByLookup(
|
||||
workspaceId: string,
|
||||
lookup: BacklogItemLookup,
|
||||
): Promise<ResolvedBacklogItem[]> {
|
||||
return db
|
||||
.select(backlogItemSelect)
|
||||
.from(markdownBacklogItems)
|
||||
.where(and(...buildLookupConditions(workspaceId, lookup)))
|
||||
.limit(5);
|
||||
}
|
||||
|
|
@ -8,6 +8,12 @@ import {
|
|||
auditLog,
|
||||
markdownBacklogItems,
|
||||
} from "../schema.js";
|
||||
import {
|
||||
BACKLOG_ITEM_NOT_FOUND_MESSAGE,
|
||||
backlogItemLookupFieldsSchema,
|
||||
resolveBacklogItemById,
|
||||
resolveBacklogItemsByLookup,
|
||||
} from "../lib/resolve-backlog-item.js";
|
||||
import { resolveWorkspaceHandle } from "../lib/resolve-workspace.js";
|
||||
import { resolveWorkflowPrompt } from "../../../../packages/database/src/markdown-backlog/resolve-prompt.ts";
|
||||
import { exportBacklogItemToMarkdown } from "../../../../packages/database/src/markdown-backlog/export.ts";
|
||||
|
|
@ -40,7 +46,8 @@ import { toolCatch, toolErr, toolOk } from "./tool-result.js";
|
|||
* `agent_runs` separately.
|
||||
*/
|
||||
|
||||
const claimTaskInputSchema = z.object({
|
||||
const claimTaskInputSchema = z
|
||||
.object({
|
||||
workspace: z
|
||||
.string()
|
||||
.min(1)
|
||||
|
|
@ -48,7 +55,10 @@ const claimTaskInputSchema = z.object({
|
|||
backlogItemId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.describe("UUID of the markdown_backlog_items row being claimed."),
|
||||
.optional()
|
||||
.describe(
|
||||
"UUID of the markdown_backlog_items row being claimed. Optional when repoPath or slug keys are provided.",
|
||||
),
|
||||
actorUserId: z
|
||||
.string()
|
||||
.uuid()
|
||||
|
|
@ -78,6 +88,19 @@ const claimTaskInputSchema = z.object({
|
|||
.describe(
|
||||
"Model handle driving the session — e.g. 'claude-4.6-sonnet', 'gpt-5-codex'. Free-form because new models ship faster than we can rev a zod enum.",
|
||||
),
|
||||
})
|
||||
.merge(backlogItemLookupFieldsSchema)
|
||||
.superRefine((input, ctx) => {
|
||||
const hasId = Boolean(input.backlogItemId);
|
||||
const hasRepoPath = Boolean(input.repoPath?.trim());
|
||||
const hasSlugKeys = Boolean(input.slug?.trim() && input.planSlug?.trim());
|
||||
if (!hasId && !hasRepoPath && !hasSlugKeys) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message:
|
||||
"Provide backlogItemId, repoPath, or slug + planSlug (add epicSlug for tasks/epics).",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
@ -100,7 +123,7 @@ export function registerClaimTaskTool(mcp: McpServer): void {
|
|||
"claim_task",
|
||||
{
|
||||
description:
|
||||
"Open an agent_runs row against a backlog item, transition it to in_progress (if eligible), and return the effective workflow prompt for the session.",
|
||||
"Open an agent_runs row against a backlog item, transition it to in_progress (if eligible), and return the effective workflow prompt for the session. Identify the item by backlogItemId, repoPath, or slug + planSlug.",
|
||||
inputSchema: claimTaskInputSchema,
|
||||
},
|
||||
async (args) => {
|
||||
|
|
@ -108,27 +131,42 @@ export function registerClaimTaskTool(mcp: McpServer): void {
|
|||
const input = claimTaskInputSchema.parse(args);
|
||||
const ws = await resolveWorkspaceHandle(input.workspace);
|
||||
|
||||
const [item] = await db
|
||||
.select({
|
||||
id: markdownBacklogItems.id,
|
||||
workspaceId: markdownBacklogItems.workspaceId,
|
||||
title: markdownBacklogItems.title,
|
||||
bodyMarkdown: markdownBacklogItems.bodyMarkdown,
|
||||
status: markdownBacklogItems.status,
|
||||
})
|
||||
.from(markdownBacklogItems)
|
||||
.where(eq(markdownBacklogItems.id, input.backlogItemId))
|
||||
.limit(1);
|
||||
let item;
|
||||
let backlogItemId: string;
|
||||
|
||||
if (!item) {
|
||||
if (input.backlogItemId) {
|
||||
const found = await resolveBacklogItemById(input.backlogItemId);
|
||||
if (!found) {
|
||||
return toolErr(`Backlog item ${input.backlogItemId} not found`);
|
||||
}
|
||||
item = found;
|
||||
backlogItemId = found.id;
|
||||
} else {
|
||||
const matches = await resolveBacklogItemsByLookup(ws.id, {
|
||||
repoPath: input.repoPath,
|
||||
slug: input.slug,
|
||||
planSlug: input.planSlug,
|
||||
epicSlug: input.epicSlug,
|
||||
kind: input.kind,
|
||||
});
|
||||
if (matches.length === 0) {
|
||||
return toolErr(BACKLOG_ITEM_NOT_FOUND_MESSAGE);
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
return toolErr(
|
||||
`Ambiguous backlog lookup (${matches.length} matches). Add epicSlug/kind or use repoPath.`,
|
||||
);
|
||||
}
|
||||
item = matches[0]!;
|
||||
backlogItemId = item.id;
|
||||
}
|
||||
|
||||
if (item.workspaceId !== ws.id) {
|
||||
// Tenancy fence: the backlog item exists but in a different
|
||||
// workspace than the resolved handle. Refuse rather than 404 so
|
||||
// the caller can't fish for cross-tenant existence.
|
||||
return toolErr(
|
||||
`Backlog item ${input.backlogItemId} does not belong to workspace ${ws.slug}`,
|
||||
`Backlog item ${backlogItemId} does not belong to workspace ${ws.slug}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -150,7 +188,7 @@ export function registerClaimTaskTool(mcp: McpServer): void {
|
|||
.from(agentRuns)
|
||||
.where(
|
||||
and(
|
||||
eq(agentRuns.backlogItemId, input.backlogItemId),
|
||||
eq(agentRuns.backlogItemId, backlogItemId),
|
||||
isNull(agentRuns.finishedAt),
|
||||
),
|
||||
)
|
||||
|
|
@ -190,7 +228,7 @@ export function registerClaimTaskTool(mcp: McpServer): void {
|
|||
|
||||
const resolved = await resolveWorkflowPrompt(db, {
|
||||
workspaceId: ws.id,
|
||||
backlogItemId: input.backlogItemId,
|
||||
backlogItemId,
|
||||
});
|
||||
|
||||
return toolOk({
|
||||
|
|
@ -203,6 +241,7 @@ export function registerClaimTaskTool(mcp: McpServer): void {
|
|||
title: item.title,
|
||||
bodyMarkdown: item.bodyMarkdown,
|
||||
status: item.status,
|
||||
repoPath: item.repoPath,
|
||||
},
|
||||
workspace: { id: ws.id, slug: ws.slug, name: ws.name },
|
||||
});
|
||||
|
|
@ -219,7 +258,7 @@ export function registerClaimTaskTool(mcp: McpServer): void {
|
|||
.insert(agentRuns)
|
||||
.values({
|
||||
workspaceId: ws.id,
|
||||
backlogItemId: input.backlogItemId,
|
||||
backlogItemId,
|
||||
actorUserId,
|
||||
notes: input.notes ?? null,
|
||||
metadata: claimMeta,
|
||||
|
|
@ -230,7 +269,7 @@ export function registerClaimTaskTool(mcp: McpServer): void {
|
|||
await tx
|
||||
.update(markdownBacklogItems)
|
||||
.set({ status: "in_progress", updatedAt: new Date() })
|
||||
.where(eq(markdownBacklogItems.id, input.backlogItemId));
|
||||
.where(eq(markdownBacklogItems.id, backlogItemId));
|
||||
}
|
||||
|
||||
await tx.insert(auditLog).values({
|
||||
|
|
@ -240,7 +279,7 @@ export function registerClaimTaskTool(mcp: McpServer): void {
|
|||
targetType: "agent_run",
|
||||
targetId: created.id,
|
||||
metadata: {
|
||||
backlog_item_id: input.backlogItemId,
|
||||
backlog_item_id: backlogItemId,
|
||||
prior_status: priorStatus,
|
||||
transitioned: shouldTransition,
|
||||
...(claimMeta ?? {}),
|
||||
|
|
@ -253,7 +292,7 @@ export function registerClaimTaskTool(mcp: McpServer): void {
|
|||
|
||||
const resolved = await resolveWorkflowPrompt(db, {
|
||||
workspaceId: ws.id,
|
||||
backlogItemId: input.backlogItemId,
|
||||
backlogItemId,
|
||||
});
|
||||
|
||||
// Only export when the row's status actually changed. On a
|
||||
|
|
@ -269,7 +308,7 @@ export function registerClaimTaskTool(mcp: McpServer): void {
|
|||
try {
|
||||
const exportResult = await exportBacklogItemToMarkdown(db, {
|
||||
workspaceId: ws.id,
|
||||
backlogItemId: input.backlogItemId,
|
||||
backlogItemId,
|
||||
repoRootAbs: repoRoot,
|
||||
});
|
||||
if (exportResult.ok) {
|
||||
|
|
@ -302,6 +341,7 @@ export function registerClaimTaskTool(mcp: McpServer): void {
|
|||
title: item.title,
|
||||
bodyMarkdown: item.bodyMarkdown,
|
||||
status: shouldTransition ? "in_progress" : priorStatus,
|
||||
repoPath: item.repoPath,
|
||||
},
|
||||
workspace: { id: ws.id, slug: ws.slug, name: ws.name },
|
||||
export: exportSummary,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
import { modelTierSchema, providerTypeSchema } from "@tasks/shared/model-routing";
|
||||
import { db } from "../db.js";
|
||||
import { eq } from "../drizzle.js";
|
||||
import { agentRuns, auditLog, markdownBacklogItems } from "../schema.js";
|
||||
|
|
@ -44,6 +45,15 @@ const completeTaskInputSchema = z.object({
|
|||
.describe(
|
||||
"Late-bound model handle. Same merge semantics as `client` — pass this when the model that closed the session differs from the one that opened it.",
|
||||
),
|
||||
modelAlias: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(120)
|
||||
.optional()
|
||||
.describe("Registry alias used for this session (e.g. qwen-coder)."),
|
||||
modelTier: modelTierSchema.optional(),
|
||||
providerType: providerTypeSchema.optional(),
|
||||
modelId: z.string().min(1).max(200).optional(),
|
||||
});
|
||||
|
||||
type CompleteTaskInput = z.infer<typeof completeTaskInputSchema>;
|
||||
|
|
@ -115,6 +125,10 @@ export function registerCompleteTaskTool(mcp: McpServer): void {
|
|||
const incomingMeta: Record<string, unknown> = {};
|
||||
if (input.client) incomingMeta.client = input.client;
|
||||
if (input.model) incomingMeta.model = input.model;
|
||||
if (input.modelAlias) incomingMeta.modelAlias = input.modelAlias;
|
||||
if (input.modelTier) incomingMeta.modelTier = input.modelTier;
|
||||
if (input.providerType) incomingMeta.providerType = input.providerType;
|
||||
if (input.modelId) incomingMeta.modelId = input.modelId;
|
||||
const hasIncomingMeta = Object.keys(incomingMeta).length > 0;
|
||||
const mergedMeta = hasIncomingMeta
|
||||
? { ...(run.metadata ?? {}), ...incomingMeta }
|
||||
|
|
@ -131,6 +145,14 @@ export function registerCompleteTaskTool(mcp: McpServer): void {
|
|||
tokensTotal,
|
||||
notes: input.notes ?? null,
|
||||
error: input.error ?? null,
|
||||
...(input.modelAlias !== undefined
|
||||
? { modelAlias: input.modelAlias }
|
||||
: {}),
|
||||
...(input.modelTier !== undefined ? { modelTier: input.modelTier } : {}),
|
||||
...(input.providerType !== undefined
|
||||
? { providerType: input.providerType }
|
||||
: {}),
|
||||
...(input.modelId !== undefined ? { modelId: input.modelId } : {}),
|
||||
...(hasIncomingMeta ? { metadata: mergedMeta } : {}),
|
||||
})
|
||||
.where(eq(agentRuns.id, input.runId));
|
||||
|
|
|
|||
362
apps/mcp-server/src/tools/create-backlog-item.ts
Normal file
362
apps/mcp-server/src/tools/create-backlog-item.ts
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
import { mkdir, writeFile, rename, access } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
import { parseDocument, Scalar } from "yaml";
|
||||
|
||||
import { eq, and } from "../drizzle.js";
|
||||
import { db } from "../db.js";
|
||||
import { markdownBacklogItems } from "../schema.js";
|
||||
import { resolveWorkspaceHandle } from "../lib/resolve-workspace.js";
|
||||
import { resolveRepoRoot } from "../lib/repo-root.js";
|
||||
import { parentRepoPath } from "../../../../packages/database/src/markdown-backlog/paths.ts";
|
||||
import { hashFileContents } from "../../../../packages/database/src/markdown-backlog/parse.ts";
|
||||
import { toolCatch, toolErr, toolOk } from "./tool-result.js";
|
||||
|
||||
const slugRe = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
|
||||
const kindSchema = z.enum(["plan", "epic", "task"]);
|
||||
const statusSchema = z.enum(["draft", "ready", "in_progress", "blocked", "done", "cancelled"]);
|
||||
const prioritySchema = z.enum(["P0", "P1", "P2", "P3"]);
|
||||
|
||||
const createBacklogItemInputSchema = z
|
||||
.object({
|
||||
workspace: z.string().min(1).describe("Workspace UUID or slug (e.g. 'acme' or '550e8400-...')"),
|
||||
kind: kindSchema,
|
||||
slug: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(200)
|
||||
.regex(slugRe, "slug must be lowercase-hyphenated (a-z, 0-9, hyphen)"),
|
||||
title: z.string().min(1).max(500),
|
||||
planSlug: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(200)
|
||||
.regex(slugRe, "planSlug must be lowercase-hyphenated")
|
||||
.optional(),
|
||||
epicSlug: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(200)
|
||||
.regex(slugRe, "epicSlug must be lowercase-hyphenated")
|
||||
.optional(),
|
||||
status: statusSchema.optional(),
|
||||
priority: prioritySchema.optional(),
|
||||
bodyMarkdown: z.string().optional(),
|
||||
agent_prompt: z.string().nullable().optional().describe("Optional per-item agent prompt (multiline supported)"),
|
||||
})
|
||||
.superRefine((input, ctx) => {
|
||||
if (input.kind === "plan") {
|
||||
if (input.epicSlug) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "epicSlug must not be provided for kind=plan",
|
||||
path: ["epicSlug"],
|
||||
});
|
||||
}
|
||||
if (input.planSlug && input.planSlug !== input.slug) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "planSlug for kind=plan must equal slug or be omitted",
|
||||
path: ["planSlug"],
|
||||
});
|
||||
}
|
||||
}
|
||||
if (input.kind === "epic") {
|
||||
if (!input.planSlug?.trim()) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "planSlug is required for kind=epic",
|
||||
path: ["planSlug"],
|
||||
});
|
||||
}
|
||||
}
|
||||
if (input.kind === "task") {
|
||||
if (!input.planSlug?.trim()) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "planSlug is required for kind=task",
|
||||
path: ["planSlug"],
|
||||
});
|
||||
}
|
||||
if (!input.epicSlug?.trim()) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "epicSlug is required for kind=task",
|
||||
path: ["epicSlug"],
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function todayIso(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function defaultStatusForKind(kind: z.infer<typeof kindSchema>): string {
|
||||
if (kind === "task") return "ready";
|
||||
return "draft";
|
||||
}
|
||||
|
||||
function defaultBodyForKind(kind: string, title: string): string {
|
||||
if (kind === "plan") {
|
||||
return `# ${title}\n\nShort summary of the initiative and why it exists.\n`;
|
||||
}
|
||||
if (kind === "epic") {
|
||||
return `# ${title}\n\nOne paragraph: what milestone this epic delivers.\n`;
|
||||
}
|
||||
return `# ${title}\n\nOne or two sentences.\n`;
|
||||
}
|
||||
|
||||
function repoPathFor(input: {
|
||||
kind: z.infer<typeof kindSchema>;
|
||||
slug: string;
|
||||
planSlug?: string;
|
||||
epicSlug?: string;
|
||||
}): string {
|
||||
if (input.kind === "plan") {
|
||||
return `plans/Plan-${input.slug}/Plan-${input.slug}.md`;
|
||||
}
|
||||
if (input.kind === "epic") {
|
||||
return `plans/Plan-${input.planSlug}/Epic-${input.slug}/Epic-${input.slug}.md`;
|
||||
}
|
||||
return `plans/Plan-${input.planSlug}/Epic-${input.epicSlug}/Task-${input.slug}.md`;
|
||||
}
|
||||
|
||||
function buildFrontmatterYaml(input: {
|
||||
kind: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
planSlug: string | null;
|
||||
epicSlug: string | null;
|
||||
status: string;
|
||||
priority: string;
|
||||
agentPrompt: string | null;
|
||||
}): string {
|
||||
const doc = parseDocument("");
|
||||
// Ensure we start from empty map
|
||||
if (!doc.contents) {
|
||||
// parseDocument("") yields empty; ensure contents is mapping
|
||||
}
|
||||
doc.set("kind", input.kind);
|
||||
doc.set("slug", input.slug);
|
||||
doc.set("title", input.title);
|
||||
if (input.planSlug) doc.set("plan_slug", input.planSlug);
|
||||
if (input.epicSlug) doc.set("epic_slug", input.epicSlug);
|
||||
doc.set("status", input.status);
|
||||
doc.set("priority", input.priority);
|
||||
doc.set("updated_at", todayIso());
|
||||
if (input.agentPrompt !== null && input.agentPrompt.trim() !== "") {
|
||||
const isMultiline = input.agentPrompt.includes("\n");
|
||||
const node = doc.createNode(input.agentPrompt);
|
||||
if (node instanceof Scalar) {
|
||||
node.type = isMultiline ? Scalar.BLOCK_LITERAL : Scalar.PLAIN;
|
||||
}
|
||||
doc.set("agent_prompt", node);
|
||||
}
|
||||
const yamlText = String(doc).replace(/\n$/, "");
|
||||
return yamlText;
|
||||
}
|
||||
|
||||
async function fileExists(absPath: string): Promise<boolean> {
|
||||
try {
|
||||
await access(absPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function atomicWrite(absPath: string, contents: string): Promise<void> {
|
||||
const dir = dirname(absPath);
|
||||
await mkdir(dir, { recursive: true });
|
||||
const tmp = join(dir, `.${randomUUID()}.tmp`);
|
||||
await writeFile(tmp, contents, "utf8");
|
||||
await rename(tmp, absPath);
|
||||
}
|
||||
|
||||
export function registerCreateBacklogItemTool(mcp: McpServer): void {
|
||||
mcp.registerTool(
|
||||
"create-backlog-item",
|
||||
{
|
||||
description:
|
||||
"Create a new Plan/Epic/Task markdown file under plans/ and upsert markdown_backlog_items. Validates frontmatter per docs/templates/. Duplicate slug under same epic → CONFLICT.",
|
||||
inputSchema: createBacklogItemInputSchema,
|
||||
},
|
||||
async (args) => {
|
||||
try {
|
||||
const input = createBacklogItemInputSchema.parse(args);
|
||||
const ws = await resolveWorkspaceHandle(input.workspace);
|
||||
|
||||
const effectivePlanSlug =
|
||||
input.kind === "plan" ? input.slug : (input.planSlug?.trim() ?? null);
|
||||
const effectiveEpicSlug =
|
||||
input.kind === "task"
|
||||
? (input.epicSlug?.trim() ?? null)
|
||||
: input.kind === "epic"
|
||||
? null
|
||||
: null;
|
||||
|
||||
const status = input.status ?? defaultStatusForKind(input.kind);
|
||||
const priority = input.priority ?? "P2";
|
||||
const agentPrompt =
|
||||
input.agent_prompt !== undefined && input.agent_prompt !== null
|
||||
? input.agent_prompt
|
||||
: null;
|
||||
const bodyMarkdown = input.bodyMarkdown ?? defaultBodyForKind(input.kind, input.title);
|
||||
|
||||
const repoPath = repoPathFor({
|
||||
kind: input.kind,
|
||||
slug: input.slug,
|
||||
planSlug: effectivePlanSlug ?? undefined,
|
||||
epicSlug: effectiveEpicSlug ?? undefined,
|
||||
});
|
||||
|
||||
const repoRoot = resolveRepoRoot();
|
||||
if (!repoRoot) {
|
||||
return toolErr("MARKDOWN_BACKLOG_REPO_ROOT is disabled; cannot write plans/ file");
|
||||
}
|
||||
const absPath = join(repoRoot, ...repoPath.split("/"));
|
||||
|
||||
// Duplicate check: file exists
|
||||
if (await fileExists(absPath)) {
|
||||
return toolErr(`CONFLICT: backlog item already exists at ${repoPath}`);
|
||||
}
|
||||
|
||||
// Duplicate check: DB row exists for same workspace+repoPath
|
||||
const [existingRow] = await db
|
||||
.select({ id: markdownBacklogItems.id })
|
||||
.from(markdownBacklogItems)
|
||||
.where(and(eq(markdownBacklogItems.workspaceId, ws.id), eq(markdownBacklogItems.repoPath, repoPath)))
|
||||
.limit(1);
|
||||
if (existingRow) {
|
||||
return toolErr(`CONFLICT: backlog item already exists at ${repoPath}`);
|
||||
}
|
||||
|
||||
// Build frontmatter + raw markdown (exportBacklogItemToMarkdown inverse)
|
||||
// Plan files per template do not carry plan_slug/epic_slug
|
||||
const yamlPlanSlug = input.kind === "plan" ? null : effectivePlanSlug;
|
||||
const yamlEpicSlug = input.kind === "task" ? effectiveEpicSlug : null;
|
||||
const yamlText = buildFrontmatterYaml({
|
||||
kind: input.kind,
|
||||
slug: input.slug,
|
||||
title: input.title,
|
||||
planSlug: yamlPlanSlug,
|
||||
epicSlug: yamlEpicSlug,
|
||||
status,
|
||||
priority,
|
||||
agentPrompt,
|
||||
});
|
||||
|
||||
const body = bodyMarkdown.startsWith("\n") ? bodyMarkdown : `\n${bodyMarkdown}`;
|
||||
// Ensure body ends with newline
|
||||
const normalizedBody = body.endsWith("\n") ? body : `${body}\n`;
|
||||
const raw = `---\n${yamlText}\n---${normalizedBody}`;
|
||||
|
||||
const contentHash = hashFileContents(raw);
|
||||
|
||||
// Frontmatter stored in DB (mirrors sync's parsed frontmatter)
|
||||
const frontmatter: Record<string, unknown> = {
|
||||
kind: input.kind,
|
||||
slug: input.slug,
|
||||
title: input.title,
|
||||
status,
|
||||
priority,
|
||||
updated_at: todayIso(),
|
||||
};
|
||||
if (yamlPlanSlug) frontmatter.plan_slug = yamlPlanSlug;
|
||||
if (yamlEpicSlug) frontmatter.epic_slug = yamlEpicSlug;
|
||||
if (agentPrompt !== null && agentPrompt.trim() !== "") {
|
||||
frontmatter.agent_prompt = agentPrompt;
|
||||
}
|
||||
|
||||
// Atomic write .tmp → rename
|
||||
await atomicWrite(absPath, raw);
|
||||
|
||||
// Upsert markdown_backlog_items with contentHash
|
||||
// Compute parentId by looking up parent repo path if any
|
||||
const parentPath = parentRepoPath(repoPath);
|
||||
let parentId: string | null = null;
|
||||
if (parentPath) {
|
||||
const [parentRow] = await db
|
||||
.select({ id: markdownBacklogItems.id })
|
||||
.from(markdownBacklogItems)
|
||||
.where(and(eq(markdownBacklogItems.workspaceId, ws.id), eq(markdownBacklogItems.repoPath, parentPath)))
|
||||
.limit(1);
|
||||
parentId = parentRow?.id ?? null;
|
||||
}
|
||||
|
||||
let insertedId: string | null = null;
|
||||
try {
|
||||
const [inserted] = await db
|
||||
.insert(markdownBacklogItems)
|
||||
.values({
|
||||
workspaceId: ws.id,
|
||||
kind: input.kind,
|
||||
slug: input.slug,
|
||||
planSlug: effectivePlanSlug ?? input.slug,
|
||||
epicSlug: effectiveEpicSlug,
|
||||
parentId,
|
||||
repoPath,
|
||||
title: input.title,
|
||||
status,
|
||||
priority,
|
||||
owner: null,
|
||||
frontmatter,
|
||||
bodyMarkdown: normalizedBody.replace(/^\n/, ""),
|
||||
contentHash,
|
||||
workflowPrompt: agentPrompt?.trim() ? agentPrompt.trim() : null,
|
||||
})
|
||||
.returning({ id: markdownBacklogItems.id });
|
||||
insertedId = inserted?.id ?? null;
|
||||
} catch (e) {
|
||||
// Unique violation → CONFLICT (race with concurrent writer)
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (msg.includes("duplicate") || msg.includes("unique") || msg.includes("CONFLICT")) {
|
||||
return toolErr(`CONFLICT: backlog item already exists at ${repoPath}`);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
// If we had a parent but it was inserted concurrently after our lookup,
|
||||
// refresh parentId (best-effort)
|
||||
if (parentPath && !parentId) {
|
||||
const [parentRow] = await db
|
||||
.select({ id: markdownBacklogItems.id })
|
||||
.from(markdownBacklogItems)
|
||||
.where(and(eq(markdownBacklogItems.workspaceId, ws.id), eq(markdownBacklogItems.repoPath, parentPath)))
|
||||
.limit(1);
|
||||
if (parentRow) {
|
||||
await db
|
||||
.update(markdownBacklogItems)
|
||||
.set({ parentId: parentRow.id })
|
||||
.where(eq(markdownBacklogItems.id, insertedId!));
|
||||
parentId = parentRow.id;
|
||||
}
|
||||
}
|
||||
|
||||
return toolOk({
|
||||
workspace: { id: ws.id, slug: ws.slug, name: ws.name },
|
||||
repoPath,
|
||||
absPath,
|
||||
contentHash,
|
||||
backlogItem: {
|
||||
id: insertedId,
|
||||
kind: input.kind,
|
||||
slug: input.slug,
|
||||
planSlug: effectivePlanSlug,
|
||||
epicSlug: effectiveEpicSlug,
|
||||
title: input.title,
|
||||
status,
|
||||
priority,
|
||||
parentId,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
return toolCatch(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
76
apps/mcp-server/src/tools/create-brief.ts
Normal file
76
apps/mcp-server/src/tools/create-brief.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
|
||||
import { briefObjectContentSchema } from "@tasks/shared/convoy-lifecycle";
|
||||
|
||||
import { db } from "../db.js";
|
||||
import { objects } from "../schema.js";
|
||||
import { loadConvoyProject } from "../lib/convoy-objects.js";
|
||||
import { resolveWorkspaceHandle } from "../lib/resolve-workspace.js";
|
||||
import { toolCatch, toolErr, toolOk } from "./tool-result.js";
|
||||
|
||||
const createBriefInputSchema = z.object({
|
||||
workspace: z.string().min(1),
|
||||
convoyId: z.string().uuid(),
|
||||
briefNumber: z.number().int().positive(),
|
||||
title: z.string().min(1).max(500),
|
||||
files: z.array(z.string()).default([]),
|
||||
sliceDependencies: z.array(z.number().int().positive()).default([]),
|
||||
acceptance: z.string().optional(),
|
||||
bodyMarkdown: z.string().optional(),
|
||||
});
|
||||
|
||||
export function registerCreateBriefTool(mcp: McpServer): void {
|
||||
mcp.registerTool(
|
||||
"create_brief",
|
||||
{
|
||||
description:
|
||||
"Create an implementer brief as a task object child of a convoy project.",
|
||||
inputSchema: createBriefInputSchema,
|
||||
},
|
||||
async (args) => {
|
||||
try {
|
||||
const input = createBriefInputSchema.parse(args);
|
||||
const ws = await resolveWorkspaceHandle(input.workspace);
|
||||
|
||||
const convoy = await loadConvoyProject(input.convoyId, ws.id);
|
||||
if (!convoy) {
|
||||
return toolErr(`Convoy ${input.convoyId} not found in workspace ${ws.slug}`);
|
||||
}
|
||||
|
||||
const content = briefObjectContentSchema.parse({
|
||||
briefNumber: input.briefNumber,
|
||||
files: input.files,
|
||||
sliceDependencies: input.sliceDependencies,
|
||||
acceptance: input.acceptance,
|
||||
});
|
||||
|
||||
const [created] = await db
|
||||
.insert(objects)
|
||||
.values({
|
||||
type: "task",
|
||||
title: input.title,
|
||||
parentId: convoy.id,
|
||||
workspaceId: ws.id,
|
||||
description: input.bodyMarkdown ?? input.acceptance ?? null,
|
||||
status: "ready",
|
||||
content,
|
||||
sortOrder: input.briefNumber,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!created) {
|
||||
return toolErr("Failed to create brief");
|
||||
}
|
||||
|
||||
return toolOk({
|
||||
workspace: { id: ws.id, slug: ws.slug, name: ws.name },
|
||||
convoyId: convoy.id,
|
||||
brief: created,
|
||||
});
|
||||
} catch (e) {
|
||||
return toolCatch(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
85
apps/mcp-server/src/tools/create-convoy.ts
Normal file
85
apps/mcp-server/src/tools/create-convoy.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
convoyClassificationSchema,
|
||||
convoyObjectContentSchema,
|
||||
} from "@tasks/shared/convoy-lifecycle";
|
||||
|
||||
import { db } from "../db.js";
|
||||
import { objects } from "../schema.js";
|
||||
import { findConvoyBySlug } from "../lib/convoy-objects.js";
|
||||
import { resolveWorkspaceHandle } from "../lib/resolve-workspace.js";
|
||||
import { toolCatch, toolErr, toolOk } from "./tool-result.js";
|
||||
|
||||
const createConvoyInputSchema = z.object({
|
||||
workspace: z.string().min(1),
|
||||
slug: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(120)
|
||||
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "slug must be kebab-case"),
|
||||
classification: convoyClassificationSchema,
|
||||
skipFlags: z.array(z.string()).default([]),
|
||||
successMetric: z.string().min(1).max(2_000),
|
||||
ideaMarkdown: z.string().min(1),
|
||||
modelPolicy: z
|
||||
.object({
|
||||
roles: z.record(z.string()).optional(),
|
||||
escalate_to: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export function registerCreateConvoyTool(mcp: McpServer): void {
|
||||
mcp.registerTool(
|
||||
"create_convoy",
|
||||
{
|
||||
description:
|
||||
"Create a convoy project object (type=project, status=draft) in the workspace convoys-* tenant.",
|
||||
inputSchema: createConvoyInputSchema,
|
||||
},
|
||||
async (args) => {
|
||||
try {
|
||||
const input = createConvoyInputSchema.parse(args);
|
||||
const ws = await resolveWorkspaceHandle(input.workspace);
|
||||
|
||||
const existing = await findConvoyBySlug(ws.id, input.slug);
|
||||
if (existing) {
|
||||
return toolErr(`Convoy slug '${input.slug}' already exists in workspace ${ws.slug}`);
|
||||
}
|
||||
|
||||
const content = convoyObjectContentSchema.parse({
|
||||
convoySlug: input.slug,
|
||||
classification: input.classification,
|
||||
skipFlags: input.skipFlags,
|
||||
successMetric: input.successMetric,
|
||||
modelPolicy: input.modelPolicy,
|
||||
});
|
||||
|
||||
const [created] = await db
|
||||
.insert(objects)
|
||||
.values({
|
||||
type: "project",
|
||||
title: input.slug,
|
||||
workspaceId: ws.id,
|
||||
description: input.ideaMarkdown,
|
||||
status: "draft",
|
||||
content,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!created) {
|
||||
return toolErr("Failed to create convoy");
|
||||
}
|
||||
|
||||
return toolOk({
|
||||
workspace: { id: ws.id, slug: ws.slug, name: ws.name },
|
||||
convoy: created,
|
||||
});
|
||||
} catch (e) {
|
||||
return toolCatch(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
70
apps/mcp-server/src/tools/find-backlog-item.ts
Normal file
70
apps/mcp-server/src/tools/find-backlog-item.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
BACKLOG_ITEM_NOT_FOUND_MESSAGE,
|
||||
backlogItemLookupFieldsSchema,
|
||||
resolveBacklogItemsByLookup,
|
||||
} from "../lib/resolve-backlog-item.js";
|
||||
import { resolveWorkspaceHandle } from "../lib/resolve-workspace.js";
|
||||
import { toolCatch, toolErr, toolOk } from "./tool-result.js";
|
||||
|
||||
const findBacklogItemInputSchema = z
|
||||
.object({
|
||||
workspace: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe("Workspace UUID or slug (e.g. 'convoys-tasks')."),
|
||||
})
|
||||
.merge(backlogItemLookupFieldsSchema)
|
||||
.superRefine((input, ctx) => {
|
||||
const hasRepoPath = Boolean(input.repoPath?.trim());
|
||||
const hasSlugKeys = Boolean(input.slug?.trim() && input.planSlug?.trim());
|
||||
if (!hasRepoPath && !hasSlugKeys) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message:
|
||||
"Provide repoPath, or slug + planSlug (add epicSlug for tasks/epics).",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export function registerFindBacklogItemTool(mcp: McpServer): void {
|
||||
mcp.registerTool(
|
||||
"find_backlog_item",
|
||||
{
|
||||
description:
|
||||
"Resolve a markdown backlog item to its backlogItemId by repo path or slug keys. Optional when claim_task is called with repoPath directly.",
|
||||
inputSchema: findBacklogItemInputSchema,
|
||||
},
|
||||
async (args) => {
|
||||
try {
|
||||
const input = findBacklogItemInputSchema.parse(args);
|
||||
const ws = await resolveWorkspaceHandle(input.workspace);
|
||||
const rows = await resolveBacklogItemsByLookup(ws.id, input);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return toolErr(BACKLOG_ITEM_NOT_FOUND_MESSAGE);
|
||||
}
|
||||
|
||||
if (rows.length > 1) {
|
||||
return toolOk({
|
||||
workspace: { id: ws.id, slug: ws.slug, name: ws.name },
|
||||
ambiguous: true,
|
||||
matches: rows,
|
||||
count: rows.length,
|
||||
});
|
||||
}
|
||||
|
||||
const item = rows[0]!;
|
||||
return toolOk({
|
||||
workspace: { id: ws.id, slug: ws.slug, name: ws.name },
|
||||
backlogItem: item,
|
||||
backlogItemId: item.id,
|
||||
});
|
||||
} catch (e) {
|
||||
return toolCatch(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -6,6 +6,14 @@ import { registerSearchObjectsTool } from "./search-objects.js";
|
|||
import { registerUpdateObjectTool } from "./update-object.js";
|
||||
import { registerClaimTaskTool } from "./claim-task.js";
|
||||
import { registerCompleteTaskTool } from "./complete-task.js";
|
||||
import { registerCreateBacklogItemTool } from "./create-backlog-item.js";
|
||||
import { registerFindBacklogItemTool } from "./find-backlog-item.js";
|
||||
import { registerCreateConvoyTool } from "./create-convoy.js";
|
||||
import { registerCreateBriefTool } from "./create-brief.js";
|
||||
import { registerTransitionConvoyStatusTool } from "./transition-convoy-status.js";
|
||||
import { registerLogConvoyEventTool } from "./log-convoy-event.js";
|
||||
import { registerQueryManifestStatusTool } from "./query-manifest-status.js";
|
||||
import { registerReconcileFromFilesTool } from "./reconcile-from-files.js";
|
||||
|
||||
export function registerTools(mcp: McpServer): void {
|
||||
registerCreateObjectTool(mcp);
|
||||
|
|
@ -15,6 +23,14 @@ export function registerTools(mcp: McpServer): void {
|
|||
registerManageObjectTool(mcp);
|
||||
registerClaimTaskTool(mcp);
|
||||
registerCompleteTaskTool(mcp);
|
||||
registerCreateBacklogItemTool(mcp);
|
||||
registerFindBacklogItemTool(mcp);
|
||||
registerCreateConvoyTool(mcp);
|
||||
registerCreateBriefTool(mcp);
|
||||
registerTransitionConvoyStatusTool(mcp);
|
||||
registerLogConvoyEventTool(mcp);
|
||||
registerQueryManifestStatusTool(mcp);
|
||||
registerReconcileFromFilesTool(mcp);
|
||||
}
|
||||
|
||||
export { registerCreateObjectTool } from "./create-object.js";
|
||||
|
|
@ -24,3 +40,11 @@ export { registerListObjectsTool } from "./list-objects.js";
|
|||
export { registerManageObjectTool } from "./manage-object.js";
|
||||
export { registerClaimTaskTool } from "./claim-task.js";
|
||||
export { registerCompleteTaskTool } from "./complete-task.js";
|
||||
export { registerCreateBacklogItemTool } from "./create-backlog-item.js";
|
||||
export { registerFindBacklogItemTool } from "./find-backlog-item.js";
|
||||
export { registerCreateConvoyTool } from "./create-convoy.js";
|
||||
export { registerCreateBriefTool } from "./create-brief.js";
|
||||
export { registerTransitionConvoyStatusTool } from "./transition-convoy-status.js";
|
||||
export { registerLogConvoyEventTool } from "./log-convoy-event.js";
|
||||
export { registerQueryManifestStatusTool } from "./query-manifest-status.js";
|
||||
export { registerReconcileFromFilesTool } from "./reconcile-from-files.js";
|
||||
|
|
|
|||
82
apps/mcp-server/src/tools/log-convoy-event.ts
Normal file
82
apps/mcp-server/src/tools/log-convoy-event.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
|
||||
import { convoyEventTypeSchema } from "@tasks/shared/convoy-lifecycle";
|
||||
|
||||
import {
|
||||
insertConvoyEvent,
|
||||
loadConvoyProject,
|
||||
} from "../lib/convoy-objects.js";
|
||||
import { resolveWorkspaceHandle } from "../lib/resolve-workspace.js";
|
||||
import { toolCatch, toolErr, toolOk } from "./tool-result.js";
|
||||
|
||||
const logConvoyEventInputSchema = z.object({
|
||||
workspace: z.string().min(1),
|
||||
convoyId: z.string().uuid(),
|
||||
role: z.string().min(1).max(64),
|
||||
eventType: convoyEventTypeSchema.default("role_complete"),
|
||||
briefId: z.string().uuid().optional(),
|
||||
actor: z.string().min(1).max(64).optional(),
|
||||
classification: z.string().optional(),
|
||||
skipFlags: z.array(z.string()).optional(),
|
||||
durationS: z.number().int().nonnegative().optional(),
|
||||
outcome: z.string().optional(),
|
||||
repo: z.string().optional(),
|
||||
multitaskGroup: z.string().optional(),
|
||||
modelAlias: z.string().optional(),
|
||||
providerType: z.string().optional(),
|
||||
modelId: z.string().optional(),
|
||||
modelTier: z.string().optional(),
|
||||
estimatedCostUsd: z.number().nonnegative().optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
});
|
||||
|
||||
export function registerLogConvoyEventTool(mcp: McpServer): void {
|
||||
mcp.registerTool(
|
||||
"log_convoy_event",
|
||||
{
|
||||
description:
|
||||
"Append a convoy_events row (role metrics, status notes) for analytics and mission control.",
|
||||
inputSchema: logConvoyEventInputSchema,
|
||||
},
|
||||
async (args) => {
|
||||
try {
|
||||
const input = logConvoyEventInputSchema.parse(args);
|
||||
const ws = await resolveWorkspaceHandle(input.workspace);
|
||||
|
||||
const convoy = await loadConvoyProject(input.convoyId, ws.id);
|
||||
if (!convoy) {
|
||||
return toolErr(`Convoy ${input.convoyId} not found in workspace ${ws.slug}`);
|
||||
}
|
||||
|
||||
const event = await insertConvoyEvent({
|
||||
workspaceId: ws.id,
|
||||
convoyId: convoy.id,
|
||||
briefId: input.briefId ?? null,
|
||||
role: input.role,
|
||||
actor: input.actor ?? input.role,
|
||||
eventType: input.eventType,
|
||||
classification: input.classification ?? null,
|
||||
skipFlags: input.skipFlags ?? null,
|
||||
durationS: input.durationS ?? null,
|
||||
outcome: input.outcome ?? null,
|
||||
repo: input.repo ?? null,
|
||||
multitaskGroup: input.multitaskGroup ?? null,
|
||||
modelAlias: input.modelAlias ?? null,
|
||||
providerType: input.providerType ?? null,
|
||||
modelId: input.modelId ?? null,
|
||||
modelTier: input.modelTier ?? null,
|
||||
estimatedCostUsd:
|
||||
input.estimatedCostUsd !== undefined
|
||||
? String(input.estimatedCostUsd)
|
||||
: null,
|
||||
metadata: input.metadata ?? null,
|
||||
});
|
||||
|
||||
return toolOk({ event });
|
||||
} catch (e) {
|
||||
return toolCatch(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
74
apps/mcp-server/src/tools/query-manifest-status.ts
Normal file
74
apps/mcp-server/src/tools/query-manifest-status.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
|
||||
import { and, desc, eq } from "../drizzle.js";
|
||||
import { db } from "../db.js";
|
||||
import { convoyEvents, objects } from "../schema.js";
|
||||
import {
|
||||
currentConvoyStatus,
|
||||
loadConvoyProject,
|
||||
parseConvoyContent,
|
||||
} from "../lib/convoy-objects.js";
|
||||
import { resolveWorkspaceHandle } from "../lib/resolve-workspace.js";
|
||||
import { toolCatch, toolErr, toolOk } from "./tool-result.js";
|
||||
|
||||
const queryManifestInputSchema = z.object({
|
||||
workspace: z.string().min(1),
|
||||
convoyId: z.string().uuid(),
|
||||
eventLimit: z.number().int().min(1).max(100).default(20),
|
||||
});
|
||||
|
||||
export function registerQueryManifestStatusTool(mcp: McpServer): void {
|
||||
mcp.registerTool(
|
||||
"query_manifest_status",
|
||||
{
|
||||
description:
|
||||
"Return convoy project status, metadata, child brief count, and recent convoy_events.",
|
||||
inputSchema: queryManifestInputSchema,
|
||||
},
|
||||
async (args) => {
|
||||
try {
|
||||
const input = queryManifestInputSchema.parse(args);
|
||||
const ws = await resolveWorkspaceHandle(input.workspace);
|
||||
|
||||
const convoy = await loadConvoyProject(input.convoyId, ws.id);
|
||||
if (!convoy) {
|
||||
return toolErr(`Convoy ${input.convoyId} not found in workspace ${ws.slug}`);
|
||||
}
|
||||
|
||||
const briefs = await db
|
||||
.select({ id: objects.id, title: objects.title, status: objects.status })
|
||||
.from(objects)
|
||||
.where(
|
||||
and(eq(objects.parentId, convoy.id), eq(objects.workspaceId, ws.id)),
|
||||
);
|
||||
|
||||
const events = await db
|
||||
.select()
|
||||
.from(convoyEvents)
|
||||
.where(
|
||||
and(
|
||||
eq(convoyEvents.convoyId, convoy.id),
|
||||
eq(convoyEvents.workspaceId, ws.id),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(convoyEvents.occurredAt))
|
||||
.limit(input.eventLimit);
|
||||
|
||||
return toolOk({
|
||||
convoy: {
|
||||
id: convoy.id,
|
||||
title: convoy.title,
|
||||
status: currentConvoyStatus(convoy),
|
||||
content: parseConvoyContent(convoy.content),
|
||||
updatedAt: convoy.updatedAt,
|
||||
},
|
||||
briefs,
|
||||
recentEvents: events,
|
||||
});
|
||||
} catch (e) {
|
||||
return toolCatch(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
155
apps/mcp-server/src/tools/reconcile-from-files.ts
Normal file
155
apps/mcp-server/src/tools/reconcile-from-files.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
import { basename } from "node:path";
|
||||
|
||||
import {
|
||||
convoyClassificationSchema,
|
||||
convoyObjectContentSchema,
|
||||
mapFileStatusToServer,
|
||||
} from "@tasks/shared/convoy-lifecycle";
|
||||
|
||||
import { eq } from "../drizzle.js";
|
||||
import { db } from "../db.js";
|
||||
import { objects } from "../schema.js";
|
||||
import {
|
||||
clearPendingSync,
|
||||
listConvoyMarkdownFiles,
|
||||
readPendingSync,
|
||||
} from "../lib/convoy-files.js";
|
||||
import {
|
||||
findConvoyBySlug,
|
||||
insertConvoyEvent,
|
||||
} from "../lib/convoy-objects.js";
|
||||
import { resolveRepoRoot } from "../lib/repo-root.js";
|
||||
import { resolveWorkspaceHandle } from "../lib/resolve-workspace.js";
|
||||
import { toolCatch, toolErr, toolOk } from "./tool-result.js";
|
||||
|
||||
const reconcileInputSchema = z.object({
|
||||
workspace: z.string().min(1),
|
||||
repoPath: z.string().optional(),
|
||||
});
|
||||
|
||||
export function registerReconcileFromFilesTool(mcp: McpServer): void {
|
||||
mcp.registerTool(
|
||||
"reconcile_from_files",
|
||||
{
|
||||
description:
|
||||
"Sync `.convoys/*.md` files into Echodo project objects and report pending MCP outbox entries.",
|
||||
inputSchema: reconcileInputSchema,
|
||||
},
|
||||
async (args) => {
|
||||
try {
|
||||
const input = reconcileInputSchema.parse(args);
|
||||
const ws = await resolveWorkspaceHandle(input.workspace);
|
||||
const repoRoot = input.repoPath?.trim() || resolveRepoRoot();
|
||||
if (!repoRoot) {
|
||||
return toolErr(
|
||||
"Repo root not found. Set MARKDOWN_BACKLOG_REPO_ROOT or pass repoPath.",
|
||||
);
|
||||
}
|
||||
|
||||
const files = listConvoyMarkdownFiles(repoRoot);
|
||||
const pending = readPendingSync(repoRoot);
|
||||
const synced: Array<{ slug: string; convoyId: string; created: boolean }> = [];
|
||||
const skipped: Array<{ slug: string; reason: string }> = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.classification || !file.successMetric) {
|
||||
skipped.push({
|
||||
slug: file.slug,
|
||||
reason: "missing classification or success_metric in frontmatter",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const classification = convoyClassificationSchema.safeParse(file.classification);
|
||||
if (!classification.success) {
|
||||
skipped.push({ slug: file.slug, reason: "invalid classification" });
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = convoyObjectContentSchema.parse({
|
||||
convoySlug: file.slug,
|
||||
classification: classification.data,
|
||||
skipFlags: file.skipFlags,
|
||||
successMetric: file.successMetric,
|
||||
modelPolicy: file.modelPolicy ?? undefined,
|
||||
});
|
||||
|
||||
let convoy = await findConvoyBySlug(ws.id, file.slug);
|
||||
let created = false;
|
||||
|
||||
if (!convoy) {
|
||||
const [inserted] = await db
|
||||
.insert(objects)
|
||||
.values({
|
||||
type: "project",
|
||||
title: file.slug,
|
||||
workspaceId: ws.id,
|
||||
description: file.bodyMarkdown.trim() || null,
|
||||
status: mapFileStatusToServer(file.fileStatus ?? "open") ?? "draft",
|
||||
content,
|
||||
})
|
||||
.returning();
|
||||
if (!inserted) {
|
||||
skipped.push({ slug: file.slug, reason: "insert failed" });
|
||||
continue;
|
||||
}
|
||||
convoy = inserted;
|
||||
created = true;
|
||||
} else {
|
||||
const mappedStatus = file.fileStatus
|
||||
? mapFileStatusToServer(file.fileStatus)
|
||||
: null;
|
||||
const [updated] = await db
|
||||
.update(objects)
|
||||
.set({
|
||||
description: file.bodyMarkdown.trim() || convoy.description,
|
||||
content,
|
||||
...(mappedStatus ? { status: mappedStatus } : {}),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(objects.id, convoy.id))
|
||||
.returning();
|
||||
convoy = updated ?? convoy;
|
||||
}
|
||||
|
||||
await insertConvoyEvent({
|
||||
workspaceId: ws.id,
|
||||
convoyId: convoy.id,
|
||||
role: "reconcile_from_files",
|
||||
actor: "automation",
|
||||
eventType: "reconcile",
|
||||
repo: basename(repoRoot),
|
||||
metadata: {
|
||||
filePath: file.filePath,
|
||||
fileStatus: file.fileStatus,
|
||||
created,
|
||||
},
|
||||
});
|
||||
|
||||
synced.push({ slug: file.slug, convoyId: convoy.id, created });
|
||||
}
|
||||
|
||||
if (pending.length > 0) {
|
||||
clearPendingSync(repoRoot);
|
||||
}
|
||||
|
||||
return toolOk({
|
||||
workspace: { id: ws.id, slug: ws.slug, name: ws.name },
|
||||
repoRoot,
|
||||
synced,
|
||||
skipped,
|
||||
clearedPendingEntries: pending.length,
|
||||
pendingReplayNote:
|
||||
pending.length > 0
|
||||
? "Pending outbox entries were cleared after reporting; replay tooling is deferred."
|
||||
: null,
|
||||
pending,
|
||||
});
|
||||
} catch (e) {
|
||||
return toolCatch(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
102
apps/mcp-server/src/tools/transition-convoy-status.ts
Normal file
102
apps/mcp-server/src/tools/transition-convoy-status.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
ConvoyTransitionError,
|
||||
convoyStatusSchema,
|
||||
validateConvoyTransition,
|
||||
} from "@tasks/shared/convoy-lifecycle";
|
||||
|
||||
import { eq } from "../drizzle.js";
|
||||
import { db } from "../db.js";
|
||||
import { objects } from "../schema.js";
|
||||
import {
|
||||
currentConvoyStatus,
|
||||
insertConvoyEvent,
|
||||
loadConvoyProject,
|
||||
} from "../lib/convoy-objects.js";
|
||||
import { resolveWorkspaceHandle } from "../lib/resolve-workspace.js";
|
||||
import { toolCatch, toolErr, toolOk } from "./tool-result.js";
|
||||
|
||||
const transitionInputSchema = z.object({
|
||||
workspace: z.string().min(1),
|
||||
convoyId: z.string().uuid(),
|
||||
toStatus: convoyStatusSchema,
|
||||
actor: z.string().min(1).max(64),
|
||||
reason: z.string().min(1).max(2_000).optional(),
|
||||
});
|
||||
|
||||
export function registerTransitionConvoyStatusTool(mcp: McpServer): void {
|
||||
mcp.registerTool(
|
||||
"transition_convoy_status",
|
||||
{
|
||||
description:
|
||||
"Transition a convoy project through the server-enforced lifecycle state machine.",
|
||||
inputSchema: transitionInputSchema,
|
||||
},
|
||||
async (args) => {
|
||||
try {
|
||||
const input = transitionInputSchema.parse(args);
|
||||
const ws = await resolveWorkspaceHandle(input.workspace);
|
||||
|
||||
const convoy = await loadConvoyProject(input.convoyId, ws.id);
|
||||
if (!convoy) {
|
||||
return toolErr(`Convoy ${input.convoyId} not found in workspace ${ws.slug}`);
|
||||
}
|
||||
|
||||
const fromStatus = currentConvoyStatus(convoy);
|
||||
|
||||
try {
|
||||
validateConvoyTransition({
|
||||
from: fromStatus,
|
||||
to: input.toStatus,
|
||||
actor: input.actor,
|
||||
reason: input.reason,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ConvoyTransitionError) {
|
||||
return toolErr(
|
||||
JSON.stringify({
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
from: fromStatus,
|
||||
to: input.toStatus,
|
||||
actor: input.actor,
|
||||
}),
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(objects)
|
||||
.set({
|
||||
status: input.toStatus,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(objects.id, convoy.id))
|
||||
.returning();
|
||||
|
||||
await insertConvoyEvent({
|
||||
workspaceId: ws.id,
|
||||
convoyId: convoy.id,
|
||||
role: input.actor,
|
||||
actor: input.actor,
|
||||
eventType: "status_change",
|
||||
metadata: {
|
||||
from: fromStatus,
|
||||
to: input.toStatus,
|
||||
reason: input.reason ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
return toolOk({
|
||||
convoy: updated,
|
||||
transition: { from: fromStatus, to: input.toStatus, actor: input.actor },
|
||||
});
|
||||
} catch (e) {
|
||||
return toolCatch(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,8 @@
|
|||
import { defineConfig } from "tsup";
|
||||
|
||||
/** `yaml` uses dynamic require internally and breaks when inlined into ESM. */
|
||||
const bundledDatabaseExternals = ["yaml"] as const;
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts"],
|
||||
format: ["esm"],
|
||||
|
|
@ -9,6 +12,7 @@ export default defineConfig({
|
|||
// at import time — Node 20 cannot load .ts. Third-party deps stay external
|
||||
// and are installed via the regular node_modules layer in the runtime image.
|
||||
noExternal: [/^@tasks\//],
|
||||
external: [...bundledDatabaseExternals],
|
||||
target: "node20",
|
||||
platform: "node",
|
||||
clean: true,
|
||||
|
|
|
|||
28
apps/orchestrator/package.json
Normal file
28
apps/orchestrator/package.json
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"name": "@tasks/orchestrator",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"start": "node dist/index.mjs",
|
||||
"build": "tsup",
|
||||
"type-check": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tasks/database": "workspace:*",
|
||||
"@tasks/shared": "workspace:*",
|
||||
"drizzle-orm": "^0.38.0",
|
||||
"ioredis": "^5.4.2",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.0",
|
||||
"tsup": "^8.3.5",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^2.1.9"
|
||||
}
|
||||
}
|
||||
220
apps/orchestrator/src/db/convoy.ts
Normal file
220
apps/orchestrator/src/db/convoy.ts
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
import { and, desc, eq, inArray, isNull, lt, sql } from "drizzle-orm";
|
||||
|
||||
import type { db as dbInstance } from "@tasks/database";
|
||||
import { agentRuns, convoyEvents, objects, workerCheckpoints } from "@tasks/database/schema";
|
||||
import {
|
||||
briefObjectContentSchema,
|
||||
convoyObjectContentSchema,
|
||||
validateConvoyTransition,
|
||||
type ConvoyStatus,
|
||||
} from "@tasks/shared/convoy-lifecycle";
|
||||
import type { BriefSummary } from "@tasks/shared/orchestrator";
|
||||
|
||||
type Db = typeof dbInstance;
|
||||
|
||||
import type { AutomationTier } from "@tasks/shared/convoy-lifecycle";
|
||||
|
||||
export type LoadedConvoy = {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
title: string;
|
||||
status: ConvoyStatus;
|
||||
skipFlags: string[];
|
||||
briefs: BriefSummary[];
|
||||
completedRoles: Set<string>;
|
||||
completedBriefIds: Set<string>;
|
||||
automationTier?: AutomationTier;
|
||||
};
|
||||
|
||||
function parseStatus(raw: string | null): ConvoyStatus {
|
||||
const status = raw ?? "draft";
|
||||
return status as ConvoyStatus;
|
||||
}
|
||||
|
||||
export async function listActiveConvoys(db: Db): Promise<LoadedConvoy[]> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(objects)
|
||||
.where(
|
||||
and(
|
||||
eq(objects.type, "project"),
|
||||
sql`${objects.content} ->> 'convoySlug' IS NOT NULL`,
|
||||
inArray(objects.status, ["draft", "ready", "in_progress"]),
|
||||
),
|
||||
);
|
||||
|
||||
const loaded: LoadedConvoy[] = [];
|
||||
for (const row of rows) {
|
||||
const content = convoyObjectContentSchema.safeParse(row.content);
|
||||
if (!content.success) continue;
|
||||
|
||||
const briefRows = await db
|
||||
.select()
|
||||
.from(objects)
|
||||
.where(and(eq(objects.parentId, row.id), eq(objects.workspaceId, row.workspaceId)));
|
||||
|
||||
const events = await db
|
||||
.select({ role: convoyEvents.role, eventType: convoyEvents.eventType, briefId: convoyEvents.briefId })
|
||||
.from(convoyEvents)
|
||||
.where(eq(convoyEvents.convoyId, row.id))
|
||||
.orderBy(desc(convoyEvents.occurredAt))
|
||||
.limit(200);
|
||||
|
||||
const completedRoles = new Set<string>();
|
||||
const completedBriefIds = new Set<string>();
|
||||
for (const event of events) {
|
||||
if (event.eventType === "role_complete") {
|
||||
completedRoles.add(event.role);
|
||||
if (event.briefId) completedBriefIds.add(event.briefId);
|
||||
}
|
||||
}
|
||||
|
||||
const briefs: BriefSummary[] = briefRows.map((brief) => {
|
||||
const parsed = briefObjectContentSchema.safeParse(brief.content);
|
||||
return {
|
||||
id: brief.id,
|
||||
briefNumber: parsed.success ? parsed.data.briefNumber : brief.sortOrder,
|
||||
status: brief.status,
|
||||
sliceDependencies: parsed.success ? parsed.data.sliceDependencies : [],
|
||||
};
|
||||
});
|
||||
|
||||
loaded.push({
|
||||
id: row.id,
|
||||
workspaceId: row.workspaceId,
|
||||
title: row.title,
|
||||
status: parseStatus(row.status),
|
||||
skipFlags: content.data.skipFlags,
|
||||
briefs,
|
||||
completedRoles,
|
||||
completedBriefIds,
|
||||
automationTier: content.data.automationTier ?? "assist",
|
||||
});
|
||||
}
|
||||
|
||||
return loaded;
|
||||
}
|
||||
|
||||
export async function transitionConvoyInDb(
|
||||
db: Db,
|
||||
input: {
|
||||
workspaceId: string;
|
||||
convoyId: string;
|
||||
toStatus: ConvoyStatus;
|
||||
actor: string;
|
||||
reason?: string;
|
||||
},
|
||||
) {
|
||||
const [convoy] = await db
|
||||
.select()
|
||||
.from(objects)
|
||||
.where(
|
||||
and(
|
||||
eq(objects.id, input.convoyId),
|
||||
eq(objects.workspaceId, input.workspaceId),
|
||||
eq(objects.type, "project"),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!convoy) {
|
||||
throw new Error(`Convoy ${input.convoyId} not found`);
|
||||
}
|
||||
|
||||
const fromStatus = parseStatus(convoy.status);
|
||||
validateConvoyTransition({
|
||||
from: fromStatus,
|
||||
to: input.toStatus,
|
||||
actor: input.actor,
|
||||
reason: input.reason,
|
||||
});
|
||||
|
||||
await db
|
||||
.update(objects)
|
||||
.set({ status: input.toStatus, updatedAt: new Date() })
|
||||
.where(eq(objects.id, convoy.id));
|
||||
|
||||
await db.insert(convoyEvents).values({
|
||||
workspaceId: input.workspaceId,
|
||||
convoyId: convoy.id,
|
||||
role: input.actor,
|
||||
actor: input.actor,
|
||||
eventType: "status_change",
|
||||
metadata: {
|
||||
from: fromStatus,
|
||||
to: input.toStatus,
|
||||
reason: input.reason ?? null,
|
||||
source: "orchestrator",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export type StalledRunDispatch = {
|
||||
workspaceId: string;
|
||||
agentRunId: string;
|
||||
backlogItemId: string;
|
||||
convoyId: string;
|
||||
roleId: string;
|
||||
briefId?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Open agent runs past the stall threshold that have a workspace-scoped
|
||||
* checkpoint (enough context to re-dispatch with resume_from_checkpoint).
|
||||
*/
|
||||
export async function detectStalledRunsForResume(
|
||||
db: Db,
|
||||
stallMinutes: number,
|
||||
): Promise<StalledRunDispatch[]> {
|
||||
const cutoff = new Date(Date.now() - stallMinutes * 60_000);
|
||||
const openRuns = await db
|
||||
.select({
|
||||
id: agentRuns.id,
|
||||
workspaceId: agentRuns.workspaceId,
|
||||
backlogItemId: agentRuns.backlogItemId,
|
||||
startedAt: agentRuns.startedAt,
|
||||
})
|
||||
.from(agentRuns)
|
||||
.where(and(isNull(agentRuns.finishedAt), lt(agentRuns.startedAt, cutoff)))
|
||||
.limit(50);
|
||||
|
||||
if (openRuns.length === 0) return [];
|
||||
|
||||
const dispatches: StalledRunDispatch[] = [];
|
||||
for (const run of openRuns) {
|
||||
const [checkpoint] = await db
|
||||
.select({
|
||||
convoyId: workerCheckpoints.convoyId,
|
||||
briefId: workerCheckpoints.briefId,
|
||||
roleId: workerCheckpoints.roleId,
|
||||
})
|
||||
.from(workerCheckpoints)
|
||||
.where(
|
||||
and(
|
||||
eq(workerCheckpoints.workspaceId, run.workspaceId),
|
||||
eq(workerCheckpoints.agentRunId, run.id),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(workerCheckpoints.checkpointSeq))
|
||||
.limit(1);
|
||||
|
||||
if (!checkpoint) continue;
|
||||
|
||||
dispatches.push({
|
||||
workspaceId: run.workspaceId,
|
||||
agentRunId: run.id,
|
||||
backlogItemId: run.backlogItemId,
|
||||
convoyId: checkpoint.convoyId,
|
||||
roleId: checkpoint.roleId,
|
||||
briefId: checkpoint.briefId ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return dispatches;
|
||||
}
|
||||
|
||||
/** @deprecated Prefer detectStalledRunsForResume — kept for log-only callers. */
|
||||
export async function detectStalledConvoys(db: Db, stallMinutes: number): Promise<string[]> {
|
||||
const stalled = await detectStalledRunsForResume(db, stallMinutes);
|
||||
return stalled.map((run) => run.backlogItemId);
|
||||
}
|
||||
42
apps/orchestrator/src/env.ts
Normal file
42
apps/orchestrator/src/env.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { z } from "zod";
|
||||
|
||||
const envSchema = z.object({
|
||||
DATABASE_URL: z.string().min(1),
|
||||
REDIS_URL: z.string().min(1).optional(),
|
||||
ORCHESTRATOR_DRY_RUN: z
|
||||
.enum(["0", "1", "true", "false", ""])
|
||||
.optional()
|
||||
.transform((value) => value === "1" || value === "true"),
|
||||
ORCHESTRATOR_POLL_MS: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) => {
|
||||
const parsed = Number(value ?? "5000");
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 5_000;
|
||||
}),
|
||||
ORCHESTRATOR_STALL_MINUTES: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) => {
|
||||
const parsed = Number(value ?? "120");
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 120;
|
||||
}),
|
||||
ORCHESTRATOR_MAX_IMPLEMENTERS: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) => {
|
||||
const parsed = Number(value ?? "2");
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 2;
|
||||
}),
|
||||
});
|
||||
|
||||
export type OrchestratorEnv = z.infer<typeof envSchema>;
|
||||
|
||||
export function loadEnv(): OrchestratorEnv {
|
||||
const parsed = envSchema.safeParse(process.env);
|
||||
if (!parsed.success) {
|
||||
console.error("[orchestrator] Invalid environment:", parsed.error.flatten().fieldErrors);
|
||||
process.exit(1);
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
71
apps/orchestrator/src/index.ts
Normal file
71
apps/orchestrator/src/index.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { db } from "@tasks/database";
|
||||
import IoRedis from "ioredis";
|
||||
|
||||
import { detectStalledRunsForResume } from "./db/convoy.js";
|
||||
import { loadEnv } from "./env.js";
|
||||
import { enqueueJob } from "./queue/redis-queue.js";
|
||||
import { runOrchestratorTick } from "./tick.js";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const env = loadEnv();
|
||||
const redis = env.REDIS_URL ? new IoRedis(env.REDIS_URL) : null;
|
||||
|
||||
if (env.ORCHESTRATOR_DRY_RUN) {
|
||||
console.info("[orchestrator] DRY-RUN mode — jobs will be logged, not enqueued");
|
||||
} else if (!redis) {
|
||||
console.warn("[orchestrator] REDIS_URL not set — running in DRY-RUN mode");
|
||||
}
|
||||
|
||||
console.info(`[orchestrator] polling every ${env.ORCHESTRATOR_POLL_MS}ms`);
|
||||
|
||||
const loop = async () => {
|
||||
try {
|
||||
const results = await runOrchestratorTick(db, redis, {
|
||||
...env,
|
||||
ORCHESTRATOR_DRY_RUN: env.ORCHESTRATOR_DRY_RUN || !redis,
|
||||
});
|
||||
|
||||
const stalled = await detectStalledRunsForResume(db, env.ORCHESTRATOR_STALL_MINUTES);
|
||||
if (stalled.length > 0) {
|
||||
console.warn(
|
||||
`[orchestrator] stall detector: ${stalled.length} open agent_runs older than ${env.ORCHESTRATOR_STALL_MINUTES}m`,
|
||||
);
|
||||
|
||||
for (const run of stalled) {
|
||||
if (env.ORCHESTRATOR_DRY_RUN || !redis) {
|
||||
console.info(
|
||||
`[orchestrator] DRY-RUN resume_from_checkpoint convoy=${run.convoyId} role=${run.roleId} run=${run.agentRunId}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const job = await enqueueJob(redis, {
|
||||
type: "role_dispatch",
|
||||
workspaceId: run.workspaceId,
|
||||
convoyId: run.convoyId,
|
||||
roleId: run.roleId,
|
||||
briefId: run.briefId,
|
||||
resumeFromCheckpoint: true,
|
||||
});
|
||||
console.info(
|
||||
`[orchestrator] re-dispatched job=${job.id} resume_from_checkpoint run=${run.agentRunId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
console.info("[orchestrator] no active convoys");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[orchestrator] tick failed:", error);
|
||||
}
|
||||
};
|
||||
|
||||
await loop();
|
||||
setInterval(loop, env.ORCHESTRATOR_POLL_MS);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error("[orchestrator] fatal:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
31
apps/orchestrator/src/queue/redis-queue.ts
Normal file
31
apps/orchestrator/src/queue/redis-queue.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import type IoRedis from "ioredis";
|
||||
|
||||
import {
|
||||
ORCHESTRATOR_QUEUE_KEY,
|
||||
orchestratorJobSchema,
|
||||
type OrchestratorJob,
|
||||
} from "@tasks/shared/orchestrator";
|
||||
|
||||
export async function enqueueJob(redis: IoRedis, job: Omit<OrchestratorJob, "id" | "enqueuedAt">) {
|
||||
const payload = orchestratorJobSchema.parse({
|
||||
...job,
|
||||
id: randomUUID(),
|
||||
enqueuedAt: new Date().toISOString(),
|
||||
});
|
||||
await redis.rpush(ORCHESTRATOR_QUEUE_KEY, JSON.stringify(payload));
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function dequeueJob(redis: IoRedis, timeoutSeconds = 5): Promise<OrchestratorJob | null> {
|
||||
const result = await redis.blpop(ORCHESTRATOR_QUEUE_KEY, timeoutSeconds);
|
||||
if (!result) return null;
|
||||
const [, raw] = result;
|
||||
try {
|
||||
return orchestratorJobSchema.parse(JSON.parse(raw));
|
||||
} catch {
|
||||
console.error("[orchestrator] Dropping invalid job payload:", raw);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
124
apps/orchestrator/src/tick.ts
Normal file
124
apps/orchestrator/src/tick.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import type IoRedis from "ioredis";
|
||||
|
||||
import type { db as dbInstance } from "@tasks/database";
|
||||
import {
|
||||
planOrchestratorAction,
|
||||
type OrchestratorAction,
|
||||
type OrchestratorJob,
|
||||
} from "@tasks/shared/orchestrator";
|
||||
import type { AutomationTier } from "@tasks/shared/convoy-lifecycle";
|
||||
|
||||
import { listActiveConvoys, transitionConvoyInDb, type LoadedConvoy } from "./db/convoy.js";
|
||||
import { enqueueJob } from "./queue/redis-queue.js";
|
||||
import type { OrchestratorEnv } from "./env.js";
|
||||
|
||||
type Db = typeof dbInstance;
|
||||
|
||||
export type TickResult = {
|
||||
convoyId: string;
|
||||
action: OrchestratorAction;
|
||||
job?: OrchestratorJob;
|
||||
};
|
||||
|
||||
async function applyAction(
|
||||
db: Db,
|
||||
redis: IoRedis | null,
|
||||
env: OrchestratorEnv,
|
||||
convoy: LoadedConvoy,
|
||||
action: OrchestratorAction,
|
||||
): Promise<TickResult> {
|
||||
const base = { convoyId: convoy.id, action };
|
||||
|
||||
if (action.type === "noop" || action.type === "pause") {
|
||||
console.info(
|
||||
`[orchestrator] convoy=${convoy.title} status=${convoy.status} → ${action.type}: ${
|
||||
action.type === "pause" ? action.reason : action.reason
|
||||
}`,
|
||||
);
|
||||
return base;
|
||||
}
|
||||
|
||||
if (action.type === "transition") {
|
||||
if (env.ORCHESTRATOR_DRY_RUN) {
|
||||
console.info(
|
||||
`[orchestrator] DRY-RUN transition convoy=${convoy.title} ${convoy.status} → ${action.toStatus} (actor=${action.actor})`,
|
||||
);
|
||||
return base;
|
||||
}
|
||||
|
||||
await transitionConvoyInDb(db, {
|
||||
workspaceId: convoy.workspaceId,
|
||||
convoyId: convoy.id,
|
||||
toStatus: action.toStatus,
|
||||
actor: action.actor,
|
||||
});
|
||||
console.info(
|
||||
`[orchestrator] transition convoy=${convoy.title} → ${action.toStatus} (actor=${action.actor})`,
|
||||
);
|
||||
return base;
|
||||
}
|
||||
|
||||
if (action.type === "dispatch") {
|
||||
if (
|
||||
action.roleId === "role-implementer" &&
|
||||
convoy.status === "ready" &&
|
||||
!env.ORCHESTRATOR_DRY_RUN
|
||||
) {
|
||||
await transitionConvoyInDb(db, {
|
||||
workspaceId: convoy.workspaceId,
|
||||
convoyId: convoy.id,
|
||||
toStatus: "in_progress",
|
||||
actor: "role-implementer",
|
||||
});
|
||||
convoy.status = "in_progress";
|
||||
}
|
||||
|
||||
if (env.ORCHESTRATOR_DRY_RUN || !redis) {
|
||||
console.info(
|
||||
`[orchestrator] DRY-RUN dispatch convoy=${convoy.title} role=${action.roleId}${
|
||||
action.briefId ? ` brief=${action.briefId}` : ""
|
||||
}`,
|
||||
);
|
||||
return base;
|
||||
}
|
||||
|
||||
const job = await enqueueJob(redis, {
|
||||
type: "role_dispatch",
|
||||
workspaceId: convoy.workspaceId,
|
||||
convoyId: convoy.id,
|
||||
roleId: action.roleId,
|
||||
briefId: action.briefId,
|
||||
});
|
||||
console.info(
|
||||
`[orchestrator] enqueued job=${job.id} convoy=${convoy.title} role=${action.roleId}`,
|
||||
);
|
||||
return { ...base, job };
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
export async function runOrchestratorTick(
|
||||
db: Db,
|
||||
redis: IoRedis | null,
|
||||
env: OrchestratorEnv,
|
||||
): Promise<TickResult[]> {
|
||||
const convoys = await listActiveConvoys(db);
|
||||
const results: TickResult[] = [];
|
||||
|
||||
for (const convoy of convoys) {
|
||||
const action = planOrchestratorAction({
|
||||
convoyId: convoy.id,
|
||||
status: convoy.status,
|
||||
skipFlags: convoy.skipFlags,
|
||||
briefs: convoy.briefs,
|
||||
completedRoles: convoy.completedRoles,
|
||||
completedBriefIds: convoy.completedBriefIds,
|
||||
automationTier: convoy.automationTier,
|
||||
});
|
||||
|
||||
results.push(await applyAction(db, redis, env, convoy, action));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
10
apps/orchestrator/tsconfig.json
Normal file
10
apps/orchestrator/tsconfig.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"noEmit": true,
|
||||
"incremental": false
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
14
apps/orchestrator/tsup.config.ts
Normal file
14
apps/orchestrator/tsup.config.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { defineConfig } from "tsup";
|
||||
|
||||
const bundledDatabaseExternals = ["yaml"] as const;
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts"],
|
||||
format: ["esm"],
|
||||
dts: true,
|
||||
noExternal: [/^@tasks\//],
|
||||
external: [...bundledDatabaseExternals],
|
||||
target: "node20",
|
||||
platform: "node",
|
||||
clean: true,
|
||||
});
|
||||
7
apps/orchestrator/vitest.config.ts
Normal file
7
apps/orchestrator/vitest.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
204
apps/web/app/(app)/[workspaceSlug]/artifacts/page.tsx
Normal file
204
apps/web/app/(app)/[workspaceSlug]/artifacts/page.tsx
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { ExternalLink, ImageOff, Loader2 } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { api } from "@/lib/trpc";
|
||||
|
||||
export default function ArtifactsPage() {
|
||||
const params = useParams();
|
||||
const workspaceSlug =
|
||||
typeof params?.workspaceSlug === "string" ? params.workspaceSlug : undefined;
|
||||
const [selectedId, setSelectedId] = React.useState<string | null>(null);
|
||||
|
||||
const artifactsQuery = api.convoys.listArtifacts.useQuery(
|
||||
{ workspace: workspaceSlug! },
|
||||
{ enabled: Boolean(workspaceSlug) },
|
||||
);
|
||||
|
||||
const artifacts = artifactsQuery.data ?? [];
|
||||
const selected = artifacts.find((a) => a.id === selectedId) ?? null;
|
||||
|
||||
if (!workspaceSlug) return null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<header className="shrink-0 border-b border-border px-6 py-4">
|
||||
<h1 className="text-lg font-semibold">Completion artifacts</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Gallery of released convoys — summary + PR link + timeline
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[1fr_380px]">
|
||||
<section className="min-h-0 overflow-y-auto p-6">
|
||||
{artifactsQuery.isLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading artifacts…
|
||||
</div>
|
||||
) : artifacts.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-border p-8 text-center">
|
||||
<p className="text-sm font-medium">No completion artifacts yet</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Complete a convoy from merged or published via human-gate-3 to generate one.
|
||||
</p>
|
||||
<div className="mt-4 inline-flex items-center gap-2 rounded-md border border-border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
||||
<ImageOff className="size-4" />
|
||||
Screenshot upload placeholder — shows when no image is attached
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{artifacts.map((artifact) => {
|
||||
const isSelected = artifact.id === selectedId;
|
||||
return (
|
||||
<button
|
||||
key={artifact.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(artifact.id)}
|
||||
className={`text-left ${isSelected ? "ring-2 ring-primary" : ""} rounded-lg`}
|
||||
>
|
||||
<Card className={`h-full ${isSelected ? "border-primary" : ""}`}>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<CardTitle className="truncate text-sm">{artifact.convoySlug}</CardTitle>
|
||||
<Badge variant="secondary" className="shrink-0 text-[10px]">
|
||||
{artifact.convoyStatus}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(artifact.createdAt).toLocaleString()}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{artifact.screenshotUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={artifact.screenshotUrl}
|
||||
alt="screenshot"
|
||||
className="h-32 w-full rounded-md object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-32 w-full items-center justify-center rounded-md border border-dashed border-border bg-muted/20 p-3 text-xs text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ImageOff className="size-4" />
|
||||
Screenshot upload placeholder
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<pre className="line-clamp-3 whitespace-pre-wrap break-words rounded bg-muted/30 p-2 text-xs">
|
||||
{artifact.summaryMd.slice(0, 180)}
|
||||
{artifact.summaryMd.length > 180 ? "…" : ""}
|
||||
</pre>
|
||||
{artifact.pr.prUrl && (
|
||||
<a
|
||||
href={artifact.pr.prUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="inline-flex items-center gap-1 text-xs text-primary underline"
|
||||
>
|
||||
PR <ExternalLink className="size-3" />
|
||||
</a>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<aside className="min-h-0 overflow-y-auto border-l border-border bg-background p-4">
|
||||
{!selected ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
Select an artifact to see detail summary + PR link + convoy timeline.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold">{selected.convoySlug}</h2>
|
||||
<p className="text-xs text-muted-foreground">{selected.convoyTitle}</p>
|
||||
<p className="text-xs text-muted-foreground">{selected.convoyStatus}</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-border bg-muted/30 p-3">
|
||||
<p className="mb-1 text-xs font-medium uppercase text-muted-foreground">Summary</p>
|
||||
<pre className="whitespace-pre-wrap break-words text-sm">
|
||||
{selected.summaryMd}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-border p-3">
|
||||
<p className="mb-1 text-xs font-medium uppercase text-muted-foreground">PR link</p>
|
||||
{selected.pr.prUrl ? (
|
||||
<a
|
||||
href={selected.pr.prUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="break-all text-sm text-primary underline"
|
||||
>
|
||||
{selected.pr.prUrl}
|
||||
{selected.pr.prNumber ? ` (#${selected.pr.prNumber})` : ""}
|
||||
</a>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">No PR linked</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selected.screenshotUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={selected.screenshotUrl}
|
||||
alt="screenshot"
|
||||
className="w-full rounded-md border border-border object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-32 w-full items-center justify-center rounded-md border border-dashed border-border bg-muted/20 p-3 text-xs text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ImageOff className="size-4" />
|
||||
Screenshot upload placeholder — no screenshot yet
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-medium uppercase text-muted-foreground">
|
||||
Convoy timeline ({selected.timeline.length})
|
||||
</p>
|
||||
{selected.timeline.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No events</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{selected.timeline.map((ev) => (
|
||||
<li
|
||||
key={ev.id}
|
||||
className="rounded border border-border bg-muted/10 px-2 py-1.5 text-xs"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">{ev.eventType}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{new Date(ev.occurredAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
{ev.role}
|
||||
{ev.actor ? ` · ${ev.actor}` : ""}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
apps/web/app/(app)/[workspaceSlug]/mission-control/page.tsx
Normal file
72
apps/web/app/(app)/[workspaceSlug]/mission-control/page.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Plus, Radar } from "lucide-react";
|
||||
|
||||
import { ConvoyDetailPanel } from "@/components/mission-control/convoy-detail-panel";
|
||||
import { ConvoyKanban, GateInbox } from "@/components/mission-control/convoy-kanban";
|
||||
import { CreateConvoyDialog } from "@/components/mission-control/create-convoy-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export default function MissionControlPage() {
|
||||
const params = useParams();
|
||||
const workspaceSlug = params?.workspaceSlug as string | undefined;
|
||||
const [selectedId, setSelectedId] = React.useState<string | null>(null);
|
||||
const [createOpen, setCreateOpen] = React.useState(false);
|
||||
|
||||
if (!workspaceSlug) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<header className="flex shrink-0 items-center justify-between border-b border-border px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Radar className="size-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">Mission control</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Convoy kanban, human gates, and gate co-pilot
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="button" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="mr-2 size-4" />
|
||||
New convoy
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div className="grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[220px_1fr_320px]">
|
||||
<aside className="min-h-0 overflow-y-auto border-r border-border bg-muted/10">
|
||||
<GateInbox
|
||||
workspaceSlug={workspaceSlug}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<main className="flex min-h-0 flex-col overflow-hidden">
|
||||
<ConvoyKanban
|
||||
workspaceSlug={workspaceSlug}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
/>
|
||||
</main>
|
||||
|
||||
<aside className="min-h-0 border-l border-border bg-background">
|
||||
<ConvoyDetailPanel workspaceSlug={workspaceSlug} convoyId={selectedId} />
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<CreateConvoyDialog
|
||||
workspaceSlug={workspaceSlug}
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
onCreated={setSelectedId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
655
apps/web/app/(app)/[workspaceSlug]/plans/page.tsx
Normal file
655
apps/web/app/(app)/[workspaceSlug]/plans/page.tsx
Normal file
|
|
@ -0,0 +1,655 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
|
||||
import { ChevronDown, ChevronRight, FileText, Folder, FolderKanban, Loader2, Plus, Search, Rocket, X } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from "@/components/ui/sheet";
|
||||
import { api } from "@/lib/trpc";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const STATUS_VARIANT: Record<string, string> = {
|
||||
draft: "bg-muted text-muted-foreground",
|
||||
ready: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
|
||||
in_progress: "bg-amber-500/15 text-amber-600 dark:text-amber-400 border-amber-500/20",
|
||||
blocked: "bg-destructive/10 text-destructive border-destructive/20",
|
||||
done: "bg-sky-500/15 text-sky-600 dark:text-sky-400 border-sky-500/20",
|
||||
cancelled: "bg-muted text-muted-foreground",
|
||||
};
|
||||
|
||||
function StatusChip({ status }: { status: string | null }) {
|
||||
const s = status ?? "unknown";
|
||||
const cls = STATUS_VARIANT[s] ?? STATUS_VARIANT.draft;
|
||||
return (
|
||||
<span className={cn("inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium", cls)}>{s}</span>
|
||||
);
|
||||
}
|
||||
|
||||
function slugify(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.replace(/--+/g, "-");
|
||||
}
|
||||
|
||||
type DialogState =
|
||||
| { kind: "plan" }
|
||||
| { kind: "epic"; planSlug: string }
|
||||
| { kind: "task"; planSlug: string; epicSlug: string }
|
||||
| null;
|
||||
|
||||
export default function PlansPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const workspaceSlug = params?.workspaceSlug as string | undefined;
|
||||
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [expandedPlans, setExpandedPlans] = React.useState<Set<string>>(new Set());
|
||||
const [expandedEpics, setExpandedEpics] = React.useState<Set<string>>(new Set());
|
||||
const [selectedId, setSelectedId] = React.useState<string | null>(null);
|
||||
const [dialog, setDialog] = React.useState<DialogState>(null);
|
||||
const [formSlug, setFormSlug] = React.useState("");
|
||||
const [formTitle, setFormTitle] = React.useState("");
|
||||
const [formBody, setFormBody] = React.useState("");
|
||||
const [formAgentPrompt, setFormAgentPrompt] = React.useState("");
|
||||
const [formError, setFormError] = React.useState<string | null>(null);
|
||||
|
||||
const treeQuery = api.backlog.listTree.useQuery({ workspace: workspaceSlug! }, { enabled: Boolean(workspaceSlug) });
|
||||
const detailQuery = api.backlog.getBacklogItem.useQuery(
|
||||
{ workspace: workspaceSlug!, backlogItemId: selectedId! },
|
||||
{ enabled: Boolean(workspaceSlug && selectedId) },
|
||||
);
|
||||
const promptQuery = api.backlog.getWorkflowPrompt.useQuery(
|
||||
{ workspace: workspaceSlug!, backlogItemId: selectedId! },
|
||||
{ enabled: Boolean(workspaceSlug && selectedId) },
|
||||
);
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const createPlan = api.backlog.createPlan.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.backlog.listTree.invalidate();
|
||||
closeDialog();
|
||||
},
|
||||
onError: (e) => setFormError(e.message),
|
||||
});
|
||||
const createEpic = api.backlog.createEpic.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.backlog.listTree.invalidate();
|
||||
closeDialog();
|
||||
},
|
||||
onError: (e) => setFormError(e.message),
|
||||
});
|
||||
const createTask = api.backlog.createTask.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.backlog.listTree.invalidate();
|
||||
closeDialog();
|
||||
},
|
||||
onError: (e) => setFormError(e.message),
|
||||
});
|
||||
const startConvoy = api.backlog.startConvoy.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.convoys.kanban.invalidate();
|
||||
if (workspaceSlug) router.push(`/${workspaceSlug}/mission-control`);
|
||||
},
|
||||
onError: (e) => setFormError(e.message),
|
||||
});
|
||||
// Direct convoy.create path required by brief 4 — Start → Convoy via convoys.create with planSlug/epicSlug
|
||||
const createConvoyDirect = api.convoys.create.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.convoys.kanban.invalidate();
|
||||
if (workspaceSlug) router.push(`/${workspaceSlug}/mission-control`);
|
||||
},
|
||||
onError: (e) => setFormError(e.message),
|
||||
});
|
||||
|
||||
function closeDialog() {
|
||||
setDialog(null);
|
||||
setFormSlug("");
|
||||
setFormTitle("");
|
||||
setFormBody("");
|
||||
setFormAgentPrompt("");
|
||||
setFormError(null);
|
||||
}
|
||||
|
||||
function openPlanDialog() {
|
||||
setFormError(null);
|
||||
setFormSlug("");
|
||||
setFormTitle("");
|
||||
setFormBody("");
|
||||
setFormAgentPrompt("");
|
||||
setDialog({ kind: "plan" });
|
||||
}
|
||||
function openEpicDialog(planSlug: string) {
|
||||
setFormError(null);
|
||||
setFormSlug("");
|
||||
setFormTitle("");
|
||||
setFormBody("");
|
||||
setFormAgentPrompt("");
|
||||
setDialog({ kind: "epic", planSlug });
|
||||
}
|
||||
function openTaskDialog(planSlug: string, epicSlug: string) {
|
||||
setFormError(null);
|
||||
setFormSlug("");
|
||||
setFormTitle("");
|
||||
setFormBody("");
|
||||
setFormAgentPrompt("");
|
||||
setDialog({ kind: "task", planSlug, epicSlug });
|
||||
}
|
||||
|
||||
const tree = React.useMemo(() => treeQuery.data?.plans ?? [], [treeQuery.data?.plans]);
|
||||
|
||||
const filtered = React.useMemo(() => {
|
||||
if (!search.trim()) return tree;
|
||||
const q = search.trim().toLowerCase();
|
||||
return tree
|
||||
.map((plan) => {
|
||||
const planMatches = plan.title.toLowerCase().includes(q) || plan.slug.toLowerCase().includes(q);
|
||||
const epics = plan.epics
|
||||
.map((epic) => {
|
||||
const epicMatches = epic.title.toLowerCase().includes(q) || epic.slug.toLowerCase().includes(q);
|
||||
const tasks = epic.tasks.filter(
|
||||
(t) =>
|
||||
t.title.toLowerCase().includes(q) ||
|
||||
t.slug.toLowerCase().includes(q) ||
|
||||
(t.status ?? "").toLowerCase().includes(q),
|
||||
);
|
||||
if (epicMatches || tasks.length > 0 || planMatches) {
|
||||
return { ...epic, tasks: epicMatches && tasks.length === 0 ? epic.tasks : tasks };
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean) as typeof plan.epics;
|
||||
if (planMatches) return { ...plan, epics: epics.length ? epics : plan.epics };
|
||||
if (epics.length) return { ...plan, epics };
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean) as typeof tree;
|
||||
}, [tree, search]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (tree.length && expandedPlans.size === 0) {
|
||||
setExpandedPlans(new Set(tree.slice(0, 2).map((p) => p.slug)));
|
||||
}
|
||||
}, [tree, expandedPlans.size]);
|
||||
|
||||
if (!workspaceSlug) return null;
|
||||
|
||||
const isMutating = createPlan.isPending || createEpic.isPending || createTask.isPending;
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<header className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-border px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
|
||||
<FolderKanban className="size-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">Plans</h1>
|
||||
<p className="text-xs text-muted-foreground">Plan → Epic → Task tree from plans/ markdown</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input placeholder="Search plans, epics, tasks…" value={search} onChange={(e) => setSearch(e.target.value)} className="w-64 pl-8" />
|
||||
</div>
|
||||
<Button type="button" size="sm" onClick={openPlanDialog}>
|
||||
<Plus className="size-4" />
|
||||
New Plan
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<div className="flex min-h-0 flex-1 flex-col border-r border-border">
|
||||
{treeQuery.isLoading ? (
|
||||
<div className="flex items-center gap-2 p-6 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading plans…
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-8 text-center">
|
||||
<FolderKanban className="size-8 text-muted-foreground" />
|
||||
<p className="text-sm font-medium">No plans yet</p>
|
||||
<p className="max-w-sm text-xs text-muted-foreground">
|
||||
{search.trim()
|
||||
? "No results for your search."
|
||||
: "Create your first plan or run pnpm --filter @tasks/database watch:markdown-backlog to import plans/ after seeding."}
|
||||
</p>
|
||||
{!search.trim() && (
|
||||
<Button size="sm" onClick={openPlanDialog}>
|
||||
<Plus className="mr-1 size-4" />
|
||||
New Plan
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-1 p-3">
|
||||
{filtered.map((plan) => {
|
||||
const planOpen = expandedPlans.has(plan.slug);
|
||||
return (
|
||||
<CollapsiblePrimitive.Root
|
||||
key={plan.id}
|
||||
open={planOpen}
|
||||
onOpenChange={(open) =>
|
||||
setExpandedPlans((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (open) next.add(plan.slug);
|
||||
else next.delete(plan.slug);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
>
|
||||
<div className="group flex items-center gap-1 rounded-md px-1 py-1 hover:bg-muted/50">
|
||||
<CollapsiblePrimitive.Trigger asChild>
|
||||
<button type="button" className="flex items-center gap-1 rounded p-1 hover:bg-muted" aria-label={planOpen ? "Collapse" : "Expand"}>
|
||||
{planOpen ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}
|
||||
</button>
|
||||
</CollapsiblePrimitive.Trigger>
|
||||
<Folder className="size-4 shrink-0 text-primary" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedId(plan.id.startsWith("placeholder-") ? null : plan.id)}
|
||||
className={cn(
|
||||
"min-w-0 flex-1 truncate text-left text-sm font-medium hover:underline",
|
||||
selectedId === plan.id && "text-primary",
|
||||
)}
|
||||
>
|
||||
{plan.title || plan.slug}
|
||||
</button>
|
||||
<StatusChip status={plan.status} />
|
||||
<Badge variant="outline" className="text-[10px]">{plan.slug}</Badge>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => openEpicDialog(plan.slug)}>
|
||||
<Plus className="size-3" />
|
||||
Epic
|
||||
</Button>
|
||||
</div>
|
||||
<CollapsiblePrimitive.Content className="ml-5 border-l border-border pl-3">
|
||||
<div className="space-y-1 py-1">
|
||||
{plan.epics.length === 0 ? (
|
||||
<p className="px-2 py-1 text-xs text-muted-foreground">No epics — add one.</p>
|
||||
) : (
|
||||
plan.epics.map((epic) => {
|
||||
const epicKey = `${plan.slug}/${epic.slug}`;
|
||||
const epicOpen = expandedEpics.has(epicKey);
|
||||
return (
|
||||
<CollapsiblePrimitive.Root
|
||||
key={epic.id}
|
||||
open={epicOpen}
|
||||
onOpenChange={(open) =>
|
||||
setExpandedEpics((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (open) next.add(epicKey);
|
||||
else next.delete(epicKey);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-1 rounded-md px-1 py-1 hover:bg-muted/50">
|
||||
<CollapsiblePrimitive.Trigger asChild>
|
||||
<button type="button" className="flex items-center rounded p-1 hover:bg-muted">
|
||||
{epicOpen ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}
|
||||
</button>
|
||||
</CollapsiblePrimitive.Trigger>
|
||||
<FolderKanban className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedId(epic.id.startsWith("placeholder-") ? null : epic.id)}
|
||||
className={cn(
|
||||
"min-w-0 flex-1 truncate text-left text-sm hover:underline",
|
||||
selectedId === epic.id && "text-primary",
|
||||
)}
|
||||
>
|
||||
{epic.title || epic.slug}
|
||||
</button>
|
||||
<StatusChip status={epic.status} />
|
||||
<Badge variant="outline" className="hidden text-[10px] sm:inline-flex">{epic.slug}</Badge>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => openTaskDialog(plan.slug, epic.slug)}>
|
||||
<Plus className="size-3" />
|
||||
Task
|
||||
</Button>
|
||||
</div>
|
||||
<CollapsiblePrimitive.Content className="ml-5 border-l border-border pl-3">
|
||||
<ul className="space-y-0.5 py-1">
|
||||
{epic.tasks.length === 0 ? (
|
||||
<li className="px-2 py-1 text-xs text-muted-foreground">No tasks.</li>
|
||||
) : (
|
||||
epic.tasks.map((task) => (
|
||||
<li
|
||||
key={task.id}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-md px-2 py-1.5 hover:bg-muted",
|
||||
selectedId === task.id && "bg-muted ring-1 ring-primary/20",
|
||||
)}
|
||||
>
|
||||
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedId(task.id)}
|
||||
className="min-w-0 flex-1 truncate text-left text-sm"
|
||||
>
|
||||
<span className="font-medium">{task.title || task.slug}</span>
|
||||
<span className="ml-1 text-xs text-muted-foreground">{task.slug}</span>
|
||||
</button>
|
||||
<StatusChip status={task.status} />
|
||||
{task.priority && (
|
||||
<Badge variant="outline" className="hidden text-[10px] sm:inline-flex">
|
||||
{task.priority}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="hidden max-w-[140px] truncate text-[10px] text-muted-foreground sm:inline">{task.repoPath}</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={task.status === "ready" ? "default" : "outline"}
|
||||
className="h-7 shrink-0 px-2 text-xs"
|
||||
disabled={startConvoy.isPending || createConvoyDirect.isPending}
|
||||
onClick={() => {
|
||||
setSelectedId(task.id);
|
||||
// Primary: backlog.startConvoy (idempotent, creates briefFiles: [repoPath]); fallback/additional: convoys.create with planSlug/epicSlug per brief
|
||||
const convoySlug = `${task.planSlug}-${task.slug}`.toLowerCase();
|
||||
if (task.status === "ready") {
|
||||
startConvoy.mutate({ workspace: workspaceSlug, backlogItemId: task.id });
|
||||
} else {
|
||||
createConvoyDirect.mutate({
|
||||
workspace: workspaceSlug,
|
||||
slug: convoySlug,
|
||||
classification: "feature",
|
||||
successMetric: task.title || task.slug,
|
||||
ideaMarkdown: task.bodyMarkdown || `# ${task.title}`,
|
||||
planSlug: task.planSlug,
|
||||
epicSlug: task.epicSlug ?? undefined,
|
||||
backlogItemId: task.id,
|
||||
repoPath: task.repoPath,
|
||||
briefFiles: [task.repoPath],
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Rocket className="mr-1 size-3" />
|
||||
Start
|
||||
</Button>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
</CollapsiblePrimitive.Content>
|
||||
</CollapsiblePrimitive.Root>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</CollapsiblePrimitive.Content>
|
||||
</CollapsiblePrimitive.Root>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Sheet open={Boolean(selectedId)} onOpenChange={(open) => !open && setSelectedId(null)}>
|
||||
<SheetContent className="flex w-[480px] max-w-[90vw] flex-col sm:max-w-[480px]">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="pr-6">{detailQuery.data?.title ?? "Details"}</SheetTitle>
|
||||
<SheetDescription className="break-all text-xs">
|
||||
{detailQuery.data?.repoPath ?? ""}
|
||||
{detailQuery.data ? ` · ${detailQuery.data.kind}` : ""}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="space-y-4 p-1 pr-4">
|
||||
{detailQuery.isLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading…
|
||||
</div>
|
||||
) : detailQuery.data ? (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<StatusChip status={detailQuery.data.status} />
|
||||
{detailQuery.data.priority && <Badge variant="outline">{detailQuery.data.priority}</Badge>}
|
||||
<Badge variant="secondary">{detailQuery.data.kind}</Badge>
|
||||
</div>
|
||||
<div className="rounded-md border border-border bg-muted/20 p-3">
|
||||
<p className="text-xs font-medium text-muted-foreground">Repo path</p>
|
||||
<p className="mt-1 break-all font-mono text-xs">{detailQuery.data.repoPath}</p>
|
||||
<p className="mt-2 text-xs font-medium text-muted-foreground">Slugs</p>
|
||||
<p className="mt-1 text-xs">
|
||||
Plan: <code className="rounded bg-muted px-1 py-0.5">{detailQuery.data.planSlug}</code>
|
||||
{detailQuery.data.epicSlug ? (
|
||||
<>
|
||||
{" "}
|
||||
Epic: <code className="rounded bg-muted px-1 py-0.5">{detailQuery.data.epicSlug}</code>
|
||||
</>
|
||||
) : null}{" "}
|
||||
Slug: <code className="rounded bg-muted px-1 py-0.5">{detailQuery.data.slug}</code>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Body</h3>
|
||||
<pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-md border border-border bg-muted/20 p-3 text-xs leading-relaxed">
|
||||
{detailQuery.data.bodyMarkdown || "(empty)"}
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Effective prompt</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Source: {promptQuery.data?.source ?? "—"}</p>
|
||||
<pre className="mt-2 max-h-48 overflow-auto whitespace-pre-wrap break-words rounded-md border border-border bg-muted/20 p-3 text-xs leading-relaxed">
|
||||
{promptQuery.data?.effectivePrompt ?? promptQuery.data?.ownOverride ?? "(no prompt)"}
|
||||
</pre>
|
||||
{promptQuery.data?.ownOverride && <p className="mt-1 text-[10px] text-muted-foreground">Own override present</p>}
|
||||
</div>
|
||||
{detailQuery.data.kind === "task" && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={startConvoy.isPending || createConvoyDirect.isPending}
|
||||
onClick={() => {
|
||||
const d = detailQuery.data!;
|
||||
const convoySlug = `${d.planSlug}-${d.slug}`.toLowerCase();
|
||||
// Use convoys.create with planSlug/epicSlug as required by brief, via createConvoyDirect
|
||||
createConvoyDirect.mutate({
|
||||
workspace: workspaceSlug,
|
||||
slug: convoySlug,
|
||||
classification: "feature",
|
||||
successMetric: d.title || d.slug,
|
||||
ideaMarkdown: d.bodyMarkdown || `# ${d.title}`,
|
||||
planSlug: d.planSlug,
|
||||
epicSlug: d.epicSlug ?? undefined,
|
||||
backlogItemId: d.id,
|
||||
repoPath: d.repoPath,
|
||||
briefFiles: [d.repoPath],
|
||||
});
|
||||
}}
|
||||
>
|
||||
{(startConvoy.isPending || createConvoyDirect.isPending) ? <Loader2 className="size-4 animate-spin" /> : <Rocket className="size-4" />}
|
||||
Start → Convoy
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setSelectedId(null)}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{(startConvoy.error || createConvoyDirect.error) && (
|
||||
<p className="text-xs text-destructive">{(startConvoy.error ?? createConvoyDirect.error)?.message}</p>
|
||||
)}
|
||||
{(startConvoy.isSuccess || createConvoyDirect.isSuccess) && (
|
||||
<p className="text-xs text-emerald-600">
|
||||
Convoy {(startConvoy.data?.convoySlug ?? createConvoyDirect.data?.convoyId) ?? ""} created — check Mission Control.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Select an item to see details.</p>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
|
||||
<DialogPrimitive.Root open={Boolean(dialog)} onOpenChange={(open) => !open && closeDialog()}>
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/50" />
|
||||
<DialogPrimitive.Content className="fixed left-1/2 top-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-background p-6 shadow-lg">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<DialogPrimitive.Title className="text-lg font-semibold">
|
||||
{dialog?.kind === "plan" ? "New Plan" : dialog?.kind === "epic" ? "New Epic" : dialog?.kind === "task" ? "New Task" : "New"}
|
||||
</DialogPrimitive.Title>
|
||||
<DialogPrimitive.Close asChild>
|
||||
<button type="button" className="rounded p-1 hover:bg-muted" aria-label="Close">
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</DialogPrimitive.Close>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{dialog?.kind === "epic" && (
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">Plan</label>
|
||||
<div className="rounded-md border border-input bg-muted px-3 py-2 text-sm">{dialog.planSlug}</div>
|
||||
</div>
|
||||
)}
|
||||
{dialog?.kind === "task" && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">Plan</label>
|
||||
<div className="rounded-md border border-input bg-muted px-3 py-2 text-sm">{dialog.planSlug}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">Epic</label>
|
||||
<div className="rounded-md border border-input bg-muted px-3 py-2 text-sm">{dialog.epicSlug}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium" htmlFor="plans-slug">
|
||||
Slug <span className="font-normal text-muted-foreground">(a-z 0-9 -)</span>
|
||||
</label>
|
||||
<Input
|
||||
id="plans-slug"
|
||||
placeholder="my-slug"
|
||||
value={formSlug}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value.toLowerCase();
|
||||
setFormSlug(v);
|
||||
if (!formTitle.trim() || slugify(formTitle) === formSlug) {
|
||||
// keep title in sync helper? no auto
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">Must match ^[a-z0-9-]+$</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium" htmlFor="plans-title">
|
||||
Title
|
||||
</label>
|
||||
<Input
|
||||
id="plans-title"
|
||||
placeholder="Human readable title"
|
||||
value={formTitle}
|
||||
onChange={(e) => setFormTitle(e.target.value)}
|
||||
onBlur={() => {
|
||||
if (!formSlug.trim() && formTitle.trim()) setFormSlug(slugify(formTitle));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium" htmlFor="plans-body">
|
||||
Body markdown (optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="plans-body"
|
||||
className="min-h-[90px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
placeholder="# Summary…"
|
||||
value={formBody}
|
||||
onChange={(e) => setFormBody(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium" htmlFor="plans-prompt">
|
||||
Agent prompt (optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="plans-prompt"
|
||||
className="min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
placeholder="Custom prompt for agents…"
|
||||
value={formAgentPrompt}
|
||||
onChange={(e) => setFormAgentPrompt(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{formError && <p className="text-xs text-destructive">{formError}</p>}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button type="button" variant="outline" onClick={closeDialog}>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogPrimitive.Close>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={isMutating || !formSlug.trim() || !formTitle.trim()}
|
||||
onClick={() => {
|
||||
setFormError(null);
|
||||
const slug = formSlug.trim().toLowerCase();
|
||||
if (!SLUG_RE.test(slug)) {
|
||||
setFormError("slug must match ^[a-z0-9-]+$");
|
||||
return;
|
||||
}
|
||||
if (dialog?.kind === "plan") {
|
||||
createPlan.mutate({
|
||||
workspace: workspaceSlug,
|
||||
slug,
|
||||
title: formTitle.trim(),
|
||||
bodyMarkdown: formBody.trim() || undefined,
|
||||
agentPrompt: formAgentPrompt.trim() || null,
|
||||
});
|
||||
} else if (dialog?.kind === "epic") {
|
||||
createEpic.mutate({
|
||||
workspace: workspaceSlug,
|
||||
planSlug: dialog.planSlug,
|
||||
slug,
|
||||
title: formTitle.trim(),
|
||||
bodyMarkdown: formBody.trim() || undefined,
|
||||
agentPrompt: formAgentPrompt.trim() || null,
|
||||
});
|
||||
} else if (dialog?.kind === "task") {
|
||||
createTask.mutate({
|
||||
workspace: workspaceSlug,
|
||||
planSlug: dialog.planSlug,
|
||||
epicSlug: dialog.epicSlug,
|
||||
slug,
|
||||
title: formTitle.trim(),
|
||||
bodyMarkdown: formBody.trim() || undefined,
|
||||
agentPrompt: formAgentPrompt.trim() || null,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isMutating ? <Loader2 className="size-4 animate-spin" /> : <Plus className="size-4" />}
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
</DialogPrimitive.Root>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SLUG_RE = /^[a-z0-9-]+$/;
|
||||
|
|
@ -0,0 +1,419 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { Bot, Loader2, Plus, Sparkles, X } from "lucide-react";
|
||||
import { useParams } from "next/navigation";
|
||||
|
||||
import { SettingsAiNav } from "@/components/settings/settings-ai-nav";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/lib/trpc";
|
||||
import type { ProviderType } from "@tasks/shared/model-routing";
|
||||
|
||||
const PROVIDER_TYPES: ProviderType[] = [
|
||||
"anthropic",
|
||||
"openai",
|
||||
"google",
|
||||
"openai_compatible",
|
||||
"azure_openai",
|
||||
];
|
||||
|
||||
type ProviderForm = {
|
||||
alias: string;
|
||||
displayName: string;
|
||||
providerType: ProviderType;
|
||||
modelId: string;
|
||||
baseUrl: string;
|
||||
apiKeyEnvVar: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
const EMPTY_FORM: ProviderForm = {
|
||||
alias: "",
|
||||
displayName: "",
|
||||
providerType: "openai_compatible",
|
||||
modelId: "",
|
||||
baseUrl: "",
|
||||
apiKeyEnvVar: "",
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
const PRESETS: Array<{ label: string; form: Partial<ProviderForm> }> = [
|
||||
{
|
||||
label: "Ollama local",
|
||||
form: {
|
||||
alias: "qwen-coder",
|
||||
displayName: "Qwen Coder (Ollama)",
|
||||
providerType: "openai_compatible",
|
||||
modelId: "qwen2.5-coder:32b",
|
||||
baseUrl: "http://127.0.0.1:11434/v1",
|
||||
apiKeyEnvVar: "OLLAMA_API_KEY",
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "MLX local",
|
||||
form: {
|
||||
alias: "qwen-coder-mlx",
|
||||
displayName: "Qwen Coder (MLX)",
|
||||
providerType: "openai_compatible",
|
||||
modelId: "mlx-community/Qwen2.5-Coder-7B-Instruct-4bit",
|
||||
baseUrl: "http://127.0.0.1:8000/v1",
|
||||
apiKeyEnvVar: "MLX_API_KEY",
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Anthropic",
|
||||
form: {
|
||||
alias: "claude-planning",
|
||||
displayName: "Claude Planning",
|
||||
providerType: "anthropic",
|
||||
modelId: "claude-sonnet-4-20250514",
|
||||
apiKeyEnvVar: "ANTHROPIC_API_KEY",
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Gemini",
|
||||
form: {
|
||||
alias: "gemini-flash",
|
||||
displayName: "Gemini Flash",
|
||||
providerType: "google",
|
||||
modelId: "gemini-2.0-flash",
|
||||
apiKeyEnvVar: "GOOGLE_GENERATIVE_AI_API_KEY",
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "NVIDIA NIM",
|
||||
form: {
|
||||
alias: "nim-llama-70b",
|
||||
displayName: "NIM Llama 70B",
|
||||
providerType: "openai_compatible",
|
||||
modelId: "meta/llama-3.1-70b-instruct",
|
||||
baseUrl: "https://integrate.api.nvidia.com/v1",
|
||||
apiKeyEnvVar: "NVIDIA_API_KEY",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export default function AiProvidersSettingsPage() {
|
||||
const params = useParams();
|
||||
const workspaceSlug = params?.workspaceSlug as string | undefined;
|
||||
const utils = api.useUtils();
|
||||
|
||||
const listQuery = api.ai.models.listProviders.useQuery(
|
||||
{ workspace: workspaceSlug ?? "" },
|
||||
{ enabled: Boolean(workspaceSlug) },
|
||||
);
|
||||
|
||||
const [dialogOpen, setDialogOpen] = React.useState(false);
|
||||
const [editingId, setEditingId] = React.useState<string | null>(null);
|
||||
const [form, setForm] = React.useState<ProviderForm>(EMPTY_FORM);
|
||||
const [testResult, setTestResult] = React.useState<Record<string, string>>({});
|
||||
|
||||
const createMut = api.ai.models.createProvider.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.ai.models.listProviders.invalidate();
|
||||
setDialogOpen(false);
|
||||
},
|
||||
});
|
||||
const updateMut = api.ai.models.updateProvider.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.ai.models.listProviders.invalidate();
|
||||
setDialogOpen(false);
|
||||
},
|
||||
});
|
||||
const deleteMut = api.ai.models.deleteProvider.useMutation({
|
||||
onSuccess: () => void utils.ai.models.listProviders.invalidate(),
|
||||
});
|
||||
const testMut = api.ai.models.testProvider.useMutation({
|
||||
onSuccess: (result, vars) => {
|
||||
setTestResult((prev) => ({
|
||||
...prev,
|
||||
[vars.providerId]: result.ok
|
||||
? `OK in ${result.latencyMs}ms`
|
||||
: `Failed: ${result.error ?? "unknown"}`,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
const canManage = listQuery.data?.canManage ?? false;
|
||||
const providers = listQuery.data?.providers ?? [];
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingId(null);
|
||||
setForm(EMPTY_FORM);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (p: (typeof providers)[number]) => {
|
||||
setEditingId(p.id);
|
||||
setForm({
|
||||
alias: p.alias,
|
||||
displayName: p.displayName,
|
||||
providerType: p.providerType,
|
||||
modelId: p.modelId,
|
||||
baseUrl: p.baseUrl ?? "",
|
||||
apiKeyEnvVar: p.apiKeyEnvVar ?? "",
|
||||
enabled: p.enabled,
|
||||
});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
if (!workspaceSlug) return;
|
||||
const payload = {
|
||||
workspace: workspaceSlug,
|
||||
alias: form.alias,
|
||||
displayName: form.displayName,
|
||||
providerType: form.providerType,
|
||||
modelId: form.modelId,
|
||||
baseUrl: form.baseUrl.trim() || null,
|
||||
apiKeyEnvVar: form.apiKeyEnvVar.trim() || null,
|
||||
enabled: form.enabled,
|
||||
};
|
||||
if (editingId) {
|
||||
updateMut.mutate({ ...payload, id: editingId });
|
||||
} else {
|
||||
createMut.mutate(payload);
|
||||
}
|
||||
};
|
||||
|
||||
if (!workspaceSlug) {
|
||||
return <p className="p-8 text-sm text-muted-foreground">No workspace selected.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl px-8 py-10">
|
||||
<div className="mb-4 flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Sparkles className="size-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">AI model providers</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Registry aliases for orchestrator and pipeline roles. API keys live in server env
|
||||
vars only.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{canManage ? (
|
||||
<Button size="sm" className="gap-1.5" onClick={openCreate}>
|
||||
<Plus className="size-4" />
|
||||
Add provider
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<SettingsAiNav workspaceSlug={workspaceSlug} />
|
||||
|
||||
{listQuery.isLoading ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : listQuery.error ? (
|
||||
<p className="text-sm text-destructive">{listQuery.error.message}</p>
|
||||
) : providers.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed px-6 py-12 text-center text-sm text-muted-foreground">
|
||||
<Bot className="mx-auto mb-3 size-8 opacity-40" />
|
||||
No providers yet.{" "}
|
||||
{canManage ? "Add Ollama, MLX, or cloud aliases to get started." : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/40 text-xs uppercase tracking-wide text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left font-medium">Alias</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Type</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Model</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Base URL</th>
|
||||
<th className="px-4 py-2 text-left font-medium">On</th>
|
||||
{canManage ? (
|
||||
<th className="px-4 py-2 text-right font-medium">Actions</th>
|
||||
) : null}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{providers.map((p) => (
|
||||
<tr key={p.id} className="hover:bg-muted/20">
|
||||
<td className="px-4 py-2">
|
||||
<div className="font-medium">{p.alias}</div>
|
||||
<div className="text-xs text-muted-foreground">{p.displayName}</div>
|
||||
</td>
|
||||
<td className="px-4 py-2 font-mono text-xs">{p.providerType}</td>
|
||||
<td className="max-w-[20ch] truncate px-4 py-2 font-mono text-xs">
|
||||
{p.modelId}
|
||||
</td>
|
||||
<td className="max-w-[24ch] truncate px-4 py-2 font-mono text-xs text-muted-foreground">
|
||||
{p.baseUrl ?? "—"}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-xs">{p.enabled ? "yes" : "no"}</td>
|
||||
{canManage ? (
|
||||
<td className="space-x-2 px-4 py-2 text-right">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={testMut.isPending}
|
||||
onClick={() =>
|
||||
testMut.mutate({ workspace: workspaceSlug, providerId: p.id })
|
||||
}
|
||||
>
|
||||
Test
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => openEdit(p)}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Delete provider "${p.alias}"?`)) {
|
||||
deleteMut.mutate({ workspace: workspaceSlug, id: p.id });
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
{testResult[p.id] ? (
|
||||
<div className="mt-1 text-[10px] text-muted-foreground">
|
||||
{testResult[p.id]}
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
) : null}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogPrimitive.Root open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/50" />
|
||||
<DialogPrimitive.Content className="fixed left-1/2 top-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-6 shadow-lg">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<DialogPrimitive.Title className="text-lg font-semibold">
|
||||
{editingId ? "Edit provider" : "Add provider"}
|
||||
</DialogPrimitive.Title>
|
||||
<DialogPrimitive.Close asChild>
|
||||
<button type="button" className="rounded-md p-1 hover:bg-muted">
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</DialogPrimitive.Close>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex flex-wrap gap-1.5">
|
||||
{PRESETS.map((preset) => (
|
||||
<Button
|
||||
key={preset.label}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={() => setForm({ ...EMPTY_FORM, ...preset.form })}
|
||||
>
|
||||
{preset.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Field label="Alias">
|
||||
<Input
|
||||
value={form.alias}
|
||||
onChange={(e) => setForm((f) => ({ ...f, alias: e.target.value }))}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Display name">
|
||||
<Input
|
||||
value={form.displayName}
|
||||
onChange={(e) => setForm((f) => ({ ...f, displayName: e.target.value }))}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Provider type">
|
||||
<select
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={form.providerType}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
providerType: e.target.value as ProviderType,
|
||||
}))
|
||||
}
|
||||
>
|
||||
{PROVIDER_TYPES.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Model ID">
|
||||
<Input
|
||||
value={form.modelId}
|
||||
onChange={(e) => setForm((f) => ({ ...f, modelId: e.target.value }))}
|
||||
/>
|
||||
</Field>
|
||||
{form.providerType === "openai_compatible" ||
|
||||
form.providerType === "azure_openai" ? (
|
||||
<Field label="Base URL">
|
||||
<Input
|
||||
value={form.baseUrl}
|
||||
onChange={(e) => setForm((f) => ({ ...f, baseUrl: e.target.value }))}
|
||||
/>
|
||||
</Field>
|
||||
) : null}
|
||||
<Field label="API key env var">
|
||||
<Input
|
||||
value={form.apiKeyEnvVar}
|
||||
placeholder="ANTHROPIC_API_KEY"
|
||||
onChange={(e) => setForm((f) => ({ ...f, apiKeyEnvVar: e.target.value }))}
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-muted-foreground">
|
||||
Secret value is read from the server environment at inference time — never stored
|
||||
in the database.
|
||||
</p>
|
||||
</Field>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.enabled}
|
||||
onChange={(e) => setForm((f) => ({ ...f, enabled: e.target.checked }))}
|
||||
/>
|
||||
Enabled
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={submit}
|
||||
disabled={createMut.isPending || updateMut.isPending}
|
||||
>
|
||||
{createMut.isPending || updateMut.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
"Save"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
</DialogPrimitive.Root>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-muted-foreground">{label}</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,260 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { GitBranch, Loader2, Plus, Route } from "lucide-react";
|
||||
import { useParams } from "next/navigation";
|
||||
|
||||
import { SettingsAiNav } from "@/components/settings/settings-ai-nav";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/lib/trpc";
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
"role-conductor": "Conductor",
|
||||
"role-architect": "Architect",
|
||||
"role-ia-architect": "IA architect",
|
||||
"role-ux-reviewer": "UX reviewer",
|
||||
"role-implementer": "Implementer",
|
||||
"role-reviewer": "Reviewer",
|
||||
"role-design-system-auditor": "Design system auditor",
|
||||
"role-a11y-auditor": "A11y auditor",
|
||||
"role-doc-writer": "Doc writer",
|
||||
"gate-copilot": "Gate co-pilot",
|
||||
"chat-default": "Chat default",
|
||||
};
|
||||
|
||||
function roleLabel(roleId: string): string {
|
||||
return ROLE_LABELS[roleId] ?? roleId;
|
||||
}
|
||||
|
||||
function readTier(metadata: unknown): string | null {
|
||||
if (!metadata || typeof metadata !== "object") return null;
|
||||
const tier = (metadata as { tier?: unknown }).tier;
|
||||
return typeof tier === "string" ? tier : null;
|
||||
}
|
||||
|
||||
export default function AiRoleRoutingSettingsPage() {
|
||||
const params = useParams();
|
||||
const workspaceSlug = params?.workspaceSlug as string | undefined;
|
||||
const utils = api.useUtils();
|
||||
|
||||
const routesQuery = api.ai.models.listRoleRoutes.useQuery(
|
||||
{ workspace: workspaceSlug ?? "" },
|
||||
{ enabled: Boolean(workspaceSlug) },
|
||||
);
|
||||
const providersQuery = api.ai.models.listProviders.useQuery(
|
||||
{ workspace: workspaceSlug ?? "" },
|
||||
{ enabled: Boolean(workspaceSlug) },
|
||||
);
|
||||
|
||||
const [newRoleId, setNewRoleId] = React.useState("");
|
||||
const [newProviderId, setNewProviderId] = React.useState("");
|
||||
|
||||
const upsertMut = api.ai.models.upsertRoleRoute.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.ai.models.listRoleRoutes.invalidate();
|
||||
setNewRoleId("");
|
||||
setNewProviderId("");
|
||||
},
|
||||
});
|
||||
const deleteMut = api.ai.models.deleteRoleRoute.useMutation({
|
||||
onSuccess: () => void utils.ai.models.listRoleRoutes.invalidate(),
|
||||
});
|
||||
const resetMut = api.ai.models.resetPipelineDefaults.useMutation({
|
||||
onSuccess: () => void utils.ai.models.listRoleRoutes.invalidate(),
|
||||
});
|
||||
|
||||
const canManage = routesQuery.data?.canManage ?? false;
|
||||
const routes = routesQuery.data?.routes ?? [];
|
||||
const enabledProviders =
|
||||
providersQuery.data?.providers.filter((p) => p.enabled) ?? [];
|
||||
|
||||
if (!workspaceSlug) {
|
||||
return <p className="p-8 text-sm text-muted-foreground">No workspace selected.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl px-8 py-10">
|
||||
<div className="mb-4 flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Route className="size-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Role model routing</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Map pipeline <code className="text-[10px]">role_id</code> strings to provider aliases.
|
||||
Convoy <code className="text-[10px]">model_policy</code> overrides these at dispatch
|
||||
time — see docs/adr-model-routing.md.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{canManage ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={resetMut.isPending}
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
"Insert pipeline default routes for roles whose aliases already exist?",
|
||||
)
|
||||
) {
|
||||
resetMut.mutate({ workspace: workspaceSlug });
|
||||
}
|
||||
}}
|
||||
>
|
||||
Reset pipeline defaults
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<SettingsAiNav workspaceSlug={workspaceSlug} />
|
||||
|
||||
{routesQuery.isLoading ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/40 text-xs uppercase tracking-wide text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left font-medium">Role</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Provider alias</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Tier</th>
|
||||
<th className="px-4 py-2 text-left font-medium">On</th>
|
||||
{canManage ? (
|
||||
<th className="px-4 py-2 text-right font-medium">Actions</th>
|
||||
) : null}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{routes.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={canManage ? 5 : 4}
|
||||
className="px-4 py-8 text-center text-muted-foreground"
|
||||
>
|
||||
No routes configured. Add providers first, then map roles.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
routes.map((route) => {
|
||||
const tier = readTier(route.providerMetadata);
|
||||
return (
|
||||
<tr key={route.id} className="hover:bg-muted/20">
|
||||
<td className="px-4 py-2">
|
||||
<div className="font-medium">{roleLabel(route.roleId)}</div>
|
||||
<div className="font-mono text-[10px] text-muted-foreground">
|
||||
{route.roleId}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2 font-mono text-xs">{route.providerAlias}</td>
|
||||
<td className="px-4 py-2">
|
||||
{tier ? (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{tier}
|
||||
</Badge>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-xs">
|
||||
{route.enabled && route.providerEnabled ? "yes" : "no"}
|
||||
</td>
|
||||
{canManage ? (
|
||||
<td className="px-4 py-2 text-right">
|
||||
<select
|
||||
className="mr-2 rounded border border-input bg-background px-2 py-1 text-xs"
|
||||
value={route.providerAliasId}
|
||||
onChange={(e) =>
|
||||
upsertMut.mutate({
|
||||
workspace: workspaceSlug,
|
||||
roleId: route.roleId,
|
||||
providerAliasId: e.target.value,
|
||||
})
|
||||
}
|
||||
>
|
||||
{enabledProviders.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.alias}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Remove route for ${route.roleId}?`)) {
|
||||
deleteMut.mutate({ workspace: workspaceSlug, id: route.id });
|
||||
}
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</td>
|
||||
) : null}
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canManage ? (
|
||||
<div className="mt-8 rounded-lg border bg-muted/20 p-4">
|
||||
<div className="mb-3 flex items-center gap-2 text-sm font-medium">
|
||||
<GitBranch className="size-4" />
|
||||
Add custom role
|
||||
</div>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">role_id</label>
|
||||
<Input
|
||||
className="w-48 font-mono text-xs"
|
||||
placeholder="gate-copilot"
|
||||
value={newRoleId}
|
||||
onChange={(e) => setNewRoleId(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">Provider</label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={newProviderId}
|
||||
onChange={(e) => setNewProviderId(e.target.value)}
|
||||
>
|
||||
<option value="">Select…</option>
|
||||
{enabledProviders.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.alias}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="gap-1"
|
||||
disabled={!newRoleId.trim() || !newProviderId || upsertMut.isPending}
|
||||
onClick={() =>
|
||||
upsertMut.mutate({
|
||||
workspace: workspaceSlug,
|
||||
roleId: newRoleId.trim(),
|
||||
providerAliasId: newProviderId,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
165
apps/web/app/(app)/[workspaceSlug]/settings/harnesses/page.tsx
Normal file
165
apps/web/app/(app)/[workspaceSlug]/settings/harnesses/page.tsx
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { Loader2, Plus, Wrench, X } from "lucide-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/lib/trpc";
|
||||
import type { HarnessType } from "@tasks/shared/worker";
|
||||
|
||||
const HARNESS_TYPES: HarnessType[] = ["minimal", "crewai", "langgraph", "remote-ag-ui"];
|
||||
|
||||
type HarnessForm = {
|
||||
alias: string;
|
||||
displayName: string;
|
||||
harnessType: HarnessType;
|
||||
endpointUrl: string;
|
||||
authEnvVar: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
const EMPTY_FORM: HarnessForm = {
|
||||
alias: "",
|
||||
displayName: "",
|
||||
harnessType: "minimal",
|
||||
endpointUrl: "",
|
||||
authEnvVar: "",
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
export default function HarnessesSettingsPage() {
|
||||
const params = useParams();
|
||||
const workspaceSlug = params?.workspaceSlug as string | undefined;
|
||||
const utils = api.useUtils();
|
||||
const listQuery = api.harnesses.list.useQuery({ workspace: workspaceSlug ?? "" }, { enabled: Boolean(workspaceSlug) });
|
||||
const routesQuery = api.harnesses.listRoutes.useQuery({ workspace: workspaceSlug ?? "" }, { enabled: Boolean(workspaceSlug) });
|
||||
|
||||
const [dialogOpen, setDialogOpen] = React.useState(false);
|
||||
const [editingId, setEditingId] = React.useState<string | null>(null);
|
||||
const [form, setForm] = React.useState<HarnessForm>(EMPTY_FORM);
|
||||
const [testResult, setTestResult] = React.useState<Record<string, string>>({});
|
||||
const [newRoleId, setNewRoleId] = React.useState("");
|
||||
const [newHarnessId, setNewHarnessId] = React.useState("");
|
||||
|
||||
const createMut = api.harnesses.create.useMutation({ onSuccess: () => { void utils.harnesses.list.invalidate(); setDialogOpen(false); } });
|
||||
const updateMut = api.harnesses.update.useMutation({ onSuccess: () => { void utils.harnesses.list.invalidate(); setDialogOpen(false); } });
|
||||
const deleteMut = api.harnesses.delete.useMutation({ onSuccess: () => void utils.harnesses.list.invalidate() });
|
||||
const testMut = api.harnesses.testHarness.useMutation({
|
||||
onSuccess: (result, vars) => {
|
||||
setTestResult((prev) => ({ ...prev, [vars.harnessId]: result.ok ? `OK in ${result.latencyMs}ms` : `Failed: ${result.error ?? "unknown"}` }));
|
||||
},
|
||||
});
|
||||
const upsertRouteMut = api.harnesses.upsertRoute.useMutation({ onSuccess: () => { void utils.harnesses.listRoutes.invalidate(); setNewRoleId(""); setNewHarnessId(""); } });
|
||||
const deleteRouteMut = api.harnesses.deleteRoute.useMutation({ onSuccess: () => void utils.harnesses.listRoutes.invalidate() });
|
||||
|
||||
const canManage = listQuery.data?.canManage ?? false;
|
||||
const harnesses = listQuery.data?.harnesses ?? [];
|
||||
const routes = routesQuery.data?.routes ?? [];
|
||||
|
||||
const openCreate = () => { setEditingId(null); setForm(EMPTY_FORM); setDialogOpen(true); };
|
||||
const openEdit = (h: (typeof harnesses)[number]) => {
|
||||
setEditingId(h.id);
|
||||
setForm({ alias: h.alias, displayName: h.displayName, harnessType: h.harnessType as HarnessType, endpointUrl: h.endpointUrl ?? "", authEnvVar: h.authEnvVar ?? "", enabled: h.enabled });
|
||||
setDialogOpen(true);
|
||||
};
|
||||
const submit = () => {
|
||||
if (!workspaceSlug) return;
|
||||
const payload = { workspace: workspaceSlug, alias: form.alias, displayName: form.displayName, harnessType: form.harnessType, endpointUrl: form.endpointUrl.trim() || null, authEnvVar: form.authEnvVar.trim() || null, enabled: form.enabled };
|
||||
if (editingId) updateMut.mutate({ ...payload, id: editingId });
|
||||
else createMut.mutate(payload);
|
||||
};
|
||||
|
||||
if (!workspaceSlug) return <p className="p-8 text-sm text-muted-foreground">No workspace selected.</p>;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl px-8 py-10">
|
||||
<div className="mb-4 flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10"><Wrench className="size-5 text-primary" /></div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Harnesses</h1>
|
||||
<p className="text-xs text-muted-foreground">Providers for <code className="text-[10px]">minimal|crewai|langgraph|remote-ag-ui</code> + role routing. Auth tokens stay in server env.</p>
|
||||
</div>
|
||||
</div>
|
||||
{canManage ? <Button size="sm" className="gap-1.5" onClick={openCreate}><Plus className="size-4" />Add harness</Button> : null}
|
||||
</div>
|
||||
|
||||
{listQuery.isLoading ? <div className="flex justify-center py-16"><Loader2 className="size-6 animate-spin text-muted-foreground" /></div>
|
||||
: listQuery.error ? <p className="text-sm text-destructive">{listQuery.error.message}</p>
|
||||
: harnesses.length === 0 ? <div className="rounded-lg border border-dashed px-6 py-12 text-center text-sm text-muted-foreground">No harnesses yet. Add minimal or a remote AG-UI endpoint.</div>
|
||||
: (
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/40 text-xs uppercase tracking-wide text-muted-foreground">
|
||||
<tr><th className="px-4 py-2 text-left font-medium">Alias</th><th className="px-4 py-2 text-left font-medium">Type</th><th className="px-4 py-2 text-left font-medium">Endpoint</th><th className="px-4 py-2 text-left font-medium">On</th>{canManage ? <th className="px-4 py-2 text-right font-medium">Actions</th> : null}</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{harnesses.map((h) => (
|
||||
<tr key={h.id} className="hover:bg-muted/20">
|
||||
<td className="px-4 py-2"><div className="font-medium">{h.alias}</div><div className="text-xs text-muted-foreground">{h.displayName}</div></td>
|
||||
<td className="px-4 py-2 font-mono text-xs">{h.harnessType}</td>
|
||||
<td className="max-w-[24ch] truncate px-4 py-2 font-mono text-xs text-muted-foreground">{h.endpointUrl ?? "—"}</td>
|
||||
<td className="px-4 py-2 text-xs">{h.enabled ? "yes" : "no"}</td>
|
||||
{canManage ? (
|
||||
<td className="space-x-2 px-4 py-2 text-right">
|
||||
<Button variant="outline" size="sm" disabled={testMut.isPending} onClick={() => testMut.mutate({ workspace: workspaceSlug, harnessId: h.id })}>Test</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => openEdit(h)}>Edit</Button>
|
||||
<Button variant="ghost" size="sm" className="text-destructive" onClick={() => { if (window.confirm(`Delete harness "${h.alias}"?`)) deleteMut.mutate({ workspace: workspaceSlug, id: h.id }); }}>Delete</Button>
|
||||
{testResult[h.id] ? <div className="mt-1 text-[10px] text-muted-foreground">{testResult[h.id]}</div> : null}
|
||||
</td>
|
||||
) : null}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-10 rounded-lg border">
|
||||
<div className="border-b px-4 py-3 text-sm font-medium">Role → harness routing</div>
|
||||
{routesQuery.isLoading ? <div className="flex justify-center py-8"><Loader2 className="size-5 animate-spin text-muted-foreground" /></div>
|
||||
: (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/40 text-xs uppercase tracking-wide text-muted-foreground"><tr><th className="px-4 py-2 text-left font-medium">Role</th><th className="px-4 py-2 text-left font-medium">Harness</th><th className="px-4 py-2 text-left font-medium">Type</th>{canManage ? <th className="px-4 py-2 text-right font-medium">Actions</th> : null}</tr></thead>
|
||||
<tbody className="divide-y">
|
||||
{routes.length === 0 ? <tr><td colSpan={canManage ? 4 : 3} className="px-4 py-8 text-center text-muted-foreground">No harness routes. Map role-implementer to crewai etc.</td></tr>
|
||||
: routes.map((r) => (
|
||||
<tr key={r.id} className="hover:bg-muted/20">
|
||||
<td className="px-4 py-2 font-mono text-xs">{r.roleId}</td>
|
||||
<td className="px-4 py-2 font-mono text-xs">{r.harnessAlias}</td>
|
||||
<td className="px-4 py-2 font-mono text-xs">{r.harnessType}</td>
|
||||
{canManage ? <td className="px-4 py-2 text-right"><select className="mr-2 rounded border border-input bg-background px-2 py-1 text-xs" value={r.harnessProviderId} onChange={(e) => upsertRouteMut.mutate({ workspace: workspaceSlug!, roleId: r.roleId, harnessProviderId: e.target.value })}>{harnesses.filter((h) => h.enabled).map((h) => <option key={h.id} value={h.id}>{h.alias}</option>)}</select><Button variant="ghost" size="sm" className="text-destructive" onClick={() => { if (window.confirm(`Remove harness route for ${r.roleId}?`)) deleteRouteMut.mutate({ workspace: workspaceSlug!, id: r.id }); }}>Remove</Button></td> : null}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{canManage && harnesses.length > 0 ? (
|
||||
<div className="flex flex-wrap items-end gap-3 border-t bg-muted/20 p-4">
|
||||
<div><label className="mb-1 block text-xs text-muted-foreground">role_id</label><Input className="w-48 font-mono text-xs" placeholder="role-implementer" value={newRoleId} onChange={(e) => setNewRoleId(e.target.value)} /></div>
|
||||
<div><label className="mb-1 block text-xs text-muted-foreground">Harness</label><select className="h-9 rounded-md border border-input bg-background px-3 text-sm" value={newHarnessId} onChange={(e) => setNewHarnessId(e.target.value)}><option value="">Select…</option>{harnesses.filter((h) => h.enabled).map((h) => <option key={h.id} value={h.id}>{h.alias}</option>)}</select></div>
|
||||
<Button size="sm" className="gap-1" disabled={!newRoleId.trim() || !newHarnessId || upsertRouteMut.isPending} onClick={() => upsertRouteMut.mutate({ workspace: workspaceSlug!, roleId: newRoleId.trim(), harnessProviderId: newHarnessId })}><Plus className="size-3.5" />Map</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogPrimitive.Root open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogPrimitive.Portal><DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/50" /><DialogPrimitive.Content className="fixed left-1/2 top-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-6 shadow-lg">
|
||||
<div className="mb-4 flex items-center justify-between"><DialogPrimitive.Title className="text-lg font-semibold">{editingId ? "Edit harness" : "Add harness"}</DialogPrimitive.Title><DialogPrimitive.Close asChild><button type="button" className="rounded-md p-1 hover:bg-muted"><X className="size-4" /></button></DialogPrimitive.Close></div>
|
||||
<div className="space-y-3">
|
||||
<Field label="Alias"><Input value={form.alias} onChange={(e) => setForm((f) => ({ ...f, alias: e.target.value }))} /></Field>
|
||||
<Field label="Display name"><Input value={form.displayName} onChange={(e) => setForm((f) => ({ ...f, displayName: e.target.value }))} /></Field>
|
||||
<Field label="Harness type"><select className="flex h-9 w-full rounded-md border border-input bg-background px-3 text-sm" value={form.harnessType} onChange={(e) => setForm((f) => ({ ...f, harnessType: e.target.value as HarnessType }))}>{HARNESS_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}</select></Field>
|
||||
<Field label="Endpoint URL (nullable)"><Input placeholder="https://crew.example.com/ag-ui" value={form.endpointUrl} onChange={(e) => setForm((f) => ({ ...f, endpointUrl: e.target.value }))} /></Field>
|
||||
<Field label="Auth env var (write-only)"><Input placeholder="HARNESS_AUTH_TOKEN" value={form.authEnvVar} onChange={(e) => setForm((f) => ({ ...f, authEnvVar: e.target.value }))} /><p className="mt-1 text-[10px] text-muted-foreground">Token lives in server env, never returned to browser.</p></Field>
|
||||
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={form.enabled} onChange={(e) => setForm((f) => ({ ...f, enabled: e.target.checked }))} /> Enabled</label>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-2"><Button variant="outline" onClick={() => setDialogOpen(false)}>Cancel</Button><Button onClick={submit} disabled={createMut.isPending || updateMut.isPending}>{createMut.isPending || updateMut.isPending ? <Loader2 className="size-4 animate-spin" /> : "Save"}</Button></div>
|
||||
</DialogPrimitive.Content></DialogPrimitive.Portal>
|
||||
</DialogPrimitive.Root>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) { return <div><label className="mb-1 block text-xs font-medium text-muted-foreground">{label}</label>{children}</div>; }
|
||||
346
apps/web/app/(app)/[workspaceSlug]/settings/repos/page.tsx
Normal file
346
apps/web/app/(app)/[workspaceSlug]/settings/repos/page.tsx
Normal file
|
|
@ -0,0 +1,346 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { GitBranch, Loader2, Plus, X } from "lucide-react";
|
||||
import { useParams } from "next/navigation";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/lib/trpc";
|
||||
import type { ForgeType } from "@tasks/shared/connected-repos";
|
||||
|
||||
const FORGE_LABELS: Record<ForgeType, string> = {
|
||||
github: "GitHub",
|
||||
forgejo: "Forgejo / Gitea",
|
||||
gitlab: "GitLab",
|
||||
bitbucket: "Bitbucket",
|
||||
};
|
||||
|
||||
type ConnectForm = {
|
||||
remoteUrl: string;
|
||||
displayName: string;
|
||||
tokenEnvVar: string;
|
||||
};
|
||||
|
||||
const EMPTY_FORM: ConnectForm = {
|
||||
remoteUrl: "",
|
||||
displayName: "",
|
||||
tokenEnvVar: "",
|
||||
};
|
||||
|
||||
export default function ConnectedReposSettingsPage() {
|
||||
const params = useParams();
|
||||
const workspaceSlug = params?.workspaceSlug as string | undefined;
|
||||
const utils = api.useUtils();
|
||||
|
||||
const listQuery = api.connectedRepos.list.useQuery(
|
||||
{ workspace: workspaceSlug ?? "" },
|
||||
{ enabled: Boolean(workspaceSlug) },
|
||||
);
|
||||
|
||||
const [dialogOpen, setDialogOpen] = React.useState(false);
|
||||
const [form, setForm] = React.useState<ConnectForm>(EMPTY_FORM);
|
||||
const [parsedPreview, setParsedPreview] = React.useState<{
|
||||
forgeType: ForgeType;
|
||||
owner: string;
|
||||
repoName: string;
|
||||
apiBaseUrl: string;
|
||||
} | null>(null);
|
||||
const [testResult, setTestResult] = React.useState<Record<string, string>>({});
|
||||
|
||||
const parseMut = api.connectedRepos.parseRemote.useMutation({
|
||||
onSuccess: (parsed) => {
|
||||
setParsedPreview({
|
||||
forgeType: parsed.forgeType,
|
||||
owner: parsed.owner,
|
||||
repoName: parsed.repoName,
|
||||
apiBaseUrl: parsed.apiBaseUrl,
|
||||
});
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
displayName: prev.displayName || `${parsed.owner}/${parsed.repoName}`,
|
||||
}));
|
||||
},
|
||||
onError: () => setParsedPreview(null),
|
||||
});
|
||||
|
||||
const connectMut = api.connectedRepos.connect.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.connectedRepos.list.invalidate();
|
||||
setDialogOpen(false);
|
||||
setForm(EMPTY_FORM);
|
||||
setParsedPreview(null);
|
||||
},
|
||||
});
|
||||
const [connectError, setConnectError] = React.useState<string | null>(null);
|
||||
|
||||
const disconnectMut = api.connectedRepos.disconnect.useMutation({
|
||||
onSuccess: () => void utils.connectedRepos.list.invalidate(),
|
||||
});
|
||||
|
||||
const testMut = api.connectedRepos.testConnection.useMutation({
|
||||
onSuccess: (result, vars) => {
|
||||
setTestResult((prev) => ({
|
||||
...prev,
|
||||
[vars.repoId]: result.ok
|
||||
? `OK — default branch ${result.defaultBranch} (${result.latencyMs}ms)`
|
||||
: `Failed: ${result.error ?? "unknown"}`,
|
||||
}));
|
||||
if (result.ok) {
|
||||
void utils.connectedRepos.list.invalidate();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const canManage = listQuery.data?.canManage ?? false;
|
||||
const repos = listQuery.data?.repos ?? [];
|
||||
|
||||
const openCreate = () => {
|
||||
setForm(EMPTY_FORM);
|
||||
setParsedPreview(null);
|
||||
setConnectError(null);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleParse = () => {
|
||||
if (!workspaceSlug || !form.remoteUrl.trim()) {
|
||||
return;
|
||||
}
|
||||
parseMut.mutate({ workspace: workspaceSlug, remoteUrl: form.remoteUrl.trim() });
|
||||
};
|
||||
|
||||
const handleConnect = () => {
|
||||
if (!workspaceSlug || !form.remoteUrl.trim()) {
|
||||
return;
|
||||
}
|
||||
setConnectError(null);
|
||||
connectMut.mutate(
|
||||
{
|
||||
workspace: workspaceSlug,
|
||||
remoteUrl: form.remoteUrl.trim(),
|
||||
displayName: form.displayName.trim() || undefined,
|
||||
tokenEnvVar: form.tokenEnvVar.trim() || null,
|
||||
},
|
||||
{
|
||||
onError: (err) => setConnectError(err.message),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-6 py-8">
|
||||
<div className="mb-6 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Connected repos</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Link a git remote for worker automation (draft PRs, forge webhooks). Paste the clone
|
||||
URL — OAuth repo pickers are planned; today you set the API token on the server and
|
||||
reference its env var name here.
|
||||
</p>
|
||||
<details className="mt-3 text-sm text-muted-foreground">
|
||||
<summary className="cursor-pointer font-medium text-foreground">Examples</summary>
|
||||
<ul className="mt-2 list-disc space-y-2 pl-5">
|
||||
<li>
|
||||
<span className="font-medium text-foreground">Forgejo (self-hosted):</span>{" "}
|
||||
<code className="text-xs">https://forgejo.stillwell.cloud/rstillwell/Submatter.git</code>
|
||||
{" — "}token env var <code className="text-xs">FORGEJO_TOKEN</code> in{" "}
|
||||
<code className="text-xs">/opt/submatter/.env</code>, then click Test connection.
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-medium text-foreground">GitHub.com:</span>{" "}
|
||||
<code className="text-xs">https://github.com/org/repo.git</code>
|
||||
{" — "}token env var <code className="text-xs">GITHUB_TOKEN</code> (PAT with repo
|
||||
scope).
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
{canManage && (
|
||||
<Button type="button" onClick={openCreate}>
|
||||
<Plus className="mr-2 size-4" />
|
||||
Connect repo
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{listQuery.isLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading repos…
|
||||
</div>
|
||||
) : repos.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
|
||||
No connected repos yet.
|
||||
{canManage && " Connect one to prepare for orchestrator and worker automation."}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-border rounded-lg border border-border">
|
||||
{repos.map((repo) => (
|
||||
<li key={repo.id} className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="font-medium">{repo.displayName}</span>
|
||||
<span className="rounded bg-muted px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{FORGE_LABELS[repo.forgeType]}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 truncate text-sm text-muted-foreground">{repo.gitRemoteUrl}</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{repo.owner}/{repo.repoName}
|
||||
{repo.defaultBranch ? ` · ${repo.defaultBranch}` : ""}
|
||||
{repo.tokenEnvVar ? ` · token: ${repo.tokenEnvVar}` : ""}
|
||||
</p>
|
||||
{testResult[repo.id] && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{testResult[repo.id]}</p>
|
||||
)}
|
||||
</div>
|
||||
{canManage && (
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={testMut.isPending}
|
||||
onClick={() =>
|
||||
workspaceSlug &&
|
||||
testMut.mutate({ workspace: workspaceSlug, repoId: repo.id })
|
||||
}
|
||||
>
|
||||
Test connection
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive"
|
||||
disabled={disconnectMut.isPending}
|
||||
onClick={() =>
|
||||
workspaceSlug &&
|
||||
disconnectMut.mutate({ workspace: workspaceSlug, repoId: repo.id })
|
||||
}
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<DialogPrimitive.Root open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/50" />
|
||||
<DialogPrimitive.Content className="fixed left-1/2 top-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-background p-6 shadow-lg">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<DialogPrimitive.Title className="text-lg font-semibold">
|
||||
Connect repository
|
||||
</DialogPrimitive.Title>
|
||||
<DialogPrimitive.Close asChild>
|
||||
<button type="button" className="rounded p-1 hover:bg-muted" aria-label="Close">
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</DialogPrimitive.Close>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium" htmlFor="remote-url">
|
||||
Git remote URL
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="remote-url"
|
||||
placeholder="https://github.com/org/repo or git@host:org/repo.git"
|
||||
value={form.remoteUrl}
|
||||
onChange={(e) => setForm((f) => ({ ...f, remoteUrl: e.target.value }))}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={parseMut.isPending || !form.remoteUrl.trim()}
|
||||
onClick={handleParse}
|
||||
>
|
||||
Parse
|
||||
</Button>
|
||||
</div>
|
||||
{parseMut.error && (
|
||||
<p className="mt-1 text-xs text-destructive">{parseMut.error.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{parsedPreview && (
|
||||
<div className="rounded-md bg-muted/50 p-3 text-sm">
|
||||
<p>
|
||||
Detected{" "}
|
||||
<span className="font-medium">{FORGE_LABELS[parsedPreview.forgeType]}</span> —{" "}
|
||||
{parsedPreview.owner}/{parsedPreview.repoName}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{parsedPreview.apiBaseUrl}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium" htmlFor="display-name">
|
||||
Display name
|
||||
</label>
|
||||
<Input
|
||||
id="display-name"
|
||||
value={form.displayName}
|
||||
onChange={(e) => setForm((f) => ({ ...f, displayName: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium" htmlFor="token-env">
|
||||
API token env var
|
||||
</label>
|
||||
<Input
|
||||
id="token-env"
|
||||
placeholder="FORGEJO_TOKEN or GITHUB_TOKEN"
|
||||
value={form.tokenEnvVar}
|
||||
onChange={(e) => setForm((f) => ({ ...f, tokenEnvVar: e.target.value }))}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Name of the variable in the server <code className="text-xs">.env</code> — not the
|
||||
secret itself. Required for Test connection to succeed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{connectError && (
|
||||
<p className="text-sm text-destructive">{connectError}</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button type="button" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogPrimitive.Close>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={connectMut.isPending || !form.remoteUrl.trim()}
|
||||
onClick={handleConnect}
|
||||
>
|
||||
{connectMut.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
Connecting…
|
||||
</>
|
||||
) : (
|
||||
"Connect"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{connectMut.error && (
|
||||
<p className="text-xs text-destructive">{connectMut.error.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
</DialogPrimitive.Root>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -66,7 +66,7 @@ function formatTimestamp(when: Date | string): string {
|
|||
* for in-flight runs (no `finishedAt`). Below a minute we show seconds
|
||||
* with a single decimal so quick MCP calls don't all look like "0s".
|
||||
*/
|
||||
export function formatDuration(
|
||||
function formatDuration(
|
||||
startedAt: Date | string,
|
||||
finishedAt: Date | string | null,
|
||||
): string {
|
||||
|
|
@ -105,6 +105,19 @@ function describeOutcome(
|
|||
* null and ignore values that aren't strings (defensive against
|
||||
* hand-inserted rows from psql).
|
||||
*/
|
||||
function describeModel(row: {
|
||||
modelAlias: string | null;
|
||||
modelTier: string | null;
|
||||
modelId: string | null;
|
||||
metadata: unknown;
|
||||
}): string | null {
|
||||
if (row.modelAlias) {
|
||||
const tier = row.modelTier ? ` (${row.modelTier})` : "";
|
||||
return `${row.modelAlias}${tier}`;
|
||||
}
|
||||
return describeAgent(row.metadata).label;
|
||||
}
|
||||
|
||||
function describeAgent(metadata: unknown): {
|
||||
client: string | null;
|
||||
model: string | null;
|
||||
|
|
@ -221,6 +234,7 @@ export default function RunsPage() {
|
|||
<th className="px-4 py-2 text-left font-medium">Started</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Duration</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Actor</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Model</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Outcome</th>
|
||||
<th className="px-4 py-2 text-right font-medium">Tokens</th>
|
||||
</tr>
|
||||
|
|
@ -229,6 +243,7 @@ export default function RunsPage() {
|
|||
{runsQuery.data.rows.map((row) => {
|
||||
const { label, key } = describeOutcome(row.outcome, row.finishedAt);
|
||||
const agent = describeAgent(row.metadata);
|
||||
const modelLabel = describeModel(row);
|
||||
const isOpen = expanded === row.id;
|
||||
const hasDetail = Boolean(row.notes || row.error);
|
||||
return (
|
||||
|
|
@ -289,6 +304,9 @@ export default function RunsPage() {
|
|||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
<td className="max-w-[16ch] truncate px-4 py-2 text-xs text-muted-foreground">
|
||||
{modelLabel ?? "—"}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<span
|
||||
className={cn(
|
||||
|
|
@ -306,7 +324,7 @@ export default function RunsPage() {
|
|||
{isOpen && hasDetail ? (
|
||||
<tr>
|
||||
<td />
|
||||
<td colSpan={6} className="bg-muted/20 px-4 py-3 text-xs">
|
||||
<td colSpan={7} className="bg-muted/20 px-4 py-3 text-xs">
|
||||
{row.notes ? (
|
||||
<div className="mb-2">
|
||||
<div className="mb-1 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
|
|
|
|||
194
apps/web/app/(app)/fleet/page.tsx
Normal file
194
apps/web/app/(app)/fleet/page.tsx
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { gateLabelForStatus } from "@tasks/shared/convoy-lifecycle";
|
||||
import type { ConvoyStatus } from "@tasks/shared/convoy-lifecycle";
|
||||
import { Loader2, Radar } from "lucide-react";
|
||||
|
||||
import { ConvoyDetailPanel } from "@/components/mission-control/convoy-detail-panel";
|
||||
import { OpsCopilotFab } from "@/components/copilot/ops-copilot-panel";
|
||||
import { FleetActivityFeed } from "@/components/fleet/fleet-activity-feed";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/lib/trpc";
|
||||
|
||||
function formatAge(date: Date | string): string {
|
||||
const d = new Date(date);
|
||||
const diff = Date.now() - d.getTime();
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
if (days > 0) return `${days}d`;
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
if (hours > 0) return `${hours}h`;
|
||||
const mins = Math.floor(diff / (1000 * 60));
|
||||
return `${mins}m`;
|
||||
}
|
||||
|
||||
export default function FleetPage() {
|
||||
const fleetQuery = api.convoys.fleet.useQuery();
|
||||
const [selected, setSelected] = React.useState<{ convoyId: string; workspaceSlug: string } | null>(null);
|
||||
|
||||
if (fleetQuery.isLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-6 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading fleet…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (fleetQuery.error) {
|
||||
return <p className="p-6 text-sm text-destructive">{fleetQuery.error.message}</p>;
|
||||
}
|
||||
|
||||
const data = fleetQuery.data;
|
||||
const byWorkspace = data?.byWorkspace ?? [];
|
||||
const gateInboxAcross = data?.gateInboxAcross ?? [];
|
||||
const defaultWorkspaceSlug =
|
||||
selected?.workspaceSlug ?? byWorkspace[0]?.workspace.slug ?? gateInboxAcross[0]?.workspaceSlug ?? "";
|
||||
|
||||
// Group gate inbox by status for "× pr_review 2d" count display
|
||||
const gateByStatus = new Map<string, typeof gateInboxAcross>();
|
||||
for (const c of gateInboxAcross) {
|
||||
const arr = gateByStatus.get(c.status) ?? [];
|
||||
arr.push(c);
|
||||
gateByStatus.set(c.status, arr);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<header className="flex shrink-0 items-center gap-3 border-b border-border px-6 py-4">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Radar className="size-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">Fleet</h1>
|
||||
<p className="text-xs text-muted-foreground">All workspaces · gates needing you · live convoy events</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="grid min-h-0 flex-1 grid-cols-1 gap-4 overflow-y-auto p-4 lg:grid-cols-[260px_1fr_340px]">
|
||||
{/* Needs you */}
|
||||
<div className="flex min-h-0 flex-col gap-4">
|
||||
<section className="rounded-lg border border-border bg-card">
|
||||
<div className="border-b border-border p-4">
|
||||
<h2 className="text-sm font-semibold">Needs you</h2>
|
||||
<p className="text-xs text-muted-foreground">Gate inbox across all workspaces</p>
|
||||
{gateInboxAcross.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-xs">
|
||||
{[...gateByStatus.entries()].map(([status, list]) => (
|
||||
<span key={status} className="rounded bg-amber-500/10 px-2 py-0.5 text-amber-700 dark:text-amber-300">
|
||||
× {status} {list.length} · {formatAge(list[0]?.updatedAt ?? new Date())}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3">
|
||||
{gateInboxAcross.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No pending gates.</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{gateInboxAcross.map((convoy) => (
|
||||
<li key={`${convoy.workspaceId}-${convoy.id}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelected({ convoyId: convoy.id, workspaceSlug: convoy.workspaceSlug })}
|
||||
className={cn(
|
||||
"w-full rounded-md border border-amber-500/30 bg-amber-500/5 p-3 text-left text-sm hover:bg-amber-500/10",
|
||||
selected?.convoyId === convoy.id && "ring-2 ring-amber-500",
|
||||
)}
|
||||
>
|
||||
<p className="font-medium">{convoy.content?.convoySlug ?? convoy.title}</p>
|
||||
<p className="text-xs text-amber-700 dark:text-amber-300">{gateLabelForStatus(convoy.status as ConvoyStatus) ?? convoy.status}</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{convoy.workspaceName} · {convoy.workspaceSlug} · {formatAge(convoy.updatedAt)} ago
|
||||
</p>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<FleetActivityFeed />
|
||||
</div>
|
||||
|
||||
{/* byWorkspace cards grid */}
|
||||
<div className="min-h-0 space-y-4">
|
||||
{byWorkspace.length === 0 ? (
|
||||
<p className="rounded-lg border border-dashed p-6 text-sm text-muted-foreground">No workspaces or no convoys yet.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
{byWorkspace.map(({ workspace, convoys }) => {
|
||||
const workspaceGateCount = gateInboxAcross.filter((c) => c.workspaceId === workspace.id).length;
|
||||
return (
|
||||
<div key={workspace.id} className="rounded-lg border border-border bg-card">
|
||||
<div className="border-b border-border px-4 py-3">
|
||||
<h3 className="text-sm font-semibold">{workspace.name}</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{workspace.slug} · {convoys.length} convoys
|
||||
{workspaceGateCount > 0 ? ` · ${workspaceGateCount} needing you` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<ul className="space-y-2 p-3">
|
||||
{convoys.length === 0 ? (
|
||||
<li className="text-xs text-muted-foreground">No convoys.</li>
|
||||
) : (
|
||||
convoys.slice(0, 8).map((convoy) => (
|
||||
<li key={convoy.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelected({ convoyId: convoy.id, workspaceSlug: convoy.workspaceSlug })}
|
||||
className={cn(
|
||||
"w-full rounded-md border border-border bg-background p-2 text-left text-sm transition-colors hover:bg-muted",
|
||||
selected?.convoyId === convoy.id && "ring-2 ring-primary",
|
||||
)}
|
||||
>
|
||||
<p className="font-medium">{convoy.content?.convoySlug ?? convoy.title}</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{convoy.status} · {convoy.content?.classification ?? ""} · {formatAge(convoy.updatedAt)} ago
|
||||
</p>
|
||||
</button>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
{convoys.length > 8 && (
|
||||
<li className="text-center text-xs text-muted-foreground">+{convoys.length - 8} more</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* byStatus summary */}
|
||||
{data?.byStatus && Object.keys(data.byStatus).length > 0 && (
|
||||
<div className="rounded-lg border border-border bg-muted/10 p-3">
|
||||
<p className="mb-2 text-xs font-semibold uppercase text-muted-foreground">By status</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(data.byStatus).map(([status, list]) => (
|
||||
<span key={status} className="rounded border border-border bg-background px-2 py-1 text-xs">
|
||||
{status}: {(list as unknown[]).length}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Detail panel workspace-agnostic */}
|
||||
<aside className="min-h-0 border border-border bg-background lg:rounded-lg">
|
||||
{selected ? (
|
||||
<ConvoyDetailPanel workspaceSlug={selected.workspaceSlug} convoyId={selected.convoyId} />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center p-6 text-sm text-muted-foreground">
|
||||
Select a convoy to review gates and co-pilot suggestions.
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
<OpsCopilotFab workspaceSlug={defaultWorkspaceSlug} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
apps/web/app/(marketing)/landing/page.tsx
Normal file
30
apps/web/app/(marketing)/landing/page.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import type { Metadata } from "next";
|
||||
|
||||
import { CtaSection } from "@/components/marketing/cta-section";
|
||||
import { FeaturesSection } from "@/components/marketing/features-section";
|
||||
import { HeroSection } from "@/components/marketing/hero-section";
|
||||
import { HowItWorksSection } from "@/components/marketing/how-it-works-section";
|
||||
import { ManifestSection } from "@/components/marketing/manifest-section";
|
||||
import { SiteFooter } from "@/components/marketing/site-footer";
|
||||
import { SiteHeader } from "@/components/marketing/site-header";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Subagents. Frontmatter. One workspace.",
|
||||
};
|
||||
|
||||
export default function LandingPage() {
|
||||
return (
|
||||
<>
|
||||
<SiteHeader />
|
||||
<main>
|
||||
<HeroSection />
|
||||
<div className="marketing-hard-rule" aria-hidden />
|
||||
<FeaturesSection />
|
||||
<HowItWorksSection />
|
||||
<ManifestSection />
|
||||
<CtaSection />
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
99
apps/web/app/(marketing)/landing/pricing/page.tsx
Normal file
99
apps/web/app/(marketing)/landing/pricing/page.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
|
||||
import { Reveal, Stagger, StaggerItem } from "@/components/marketing/reveal";
|
||||
import { SiteFooter } from "@/components/marketing/site-footer";
|
||||
import { SiteHeader } from "@/components/marketing/site-header";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Pricing",
|
||||
};
|
||||
|
||||
const tiers = [
|
||||
{
|
||||
name: "Solo",
|
||||
price: "Free",
|
||||
period: "beta",
|
||||
description: "Personal workspace for agent workflow experiments.",
|
||||
features: ["1 workspace", "Markdown sync", "MCP tools", "Docs & planner"],
|
||||
href: "/sign-up",
|
||||
highlight: false,
|
||||
},
|
||||
{
|
||||
name: "Team",
|
||||
price: "$12",
|
||||
period: "seat / mo",
|
||||
description: "Convoys, run history, and routing for builder teams.",
|
||||
features: ["Unlimited workspaces", "Agent runs", "Model routing", "Audit log"],
|
||||
href: "/sign-up",
|
||||
highlight: true,
|
||||
},
|
||||
{
|
||||
name: "Fleet",
|
||||
price: "Custom",
|
||||
period: "contact",
|
||||
description: "Mission control for repos, tenants, and orchestration.",
|
||||
features: ["Connected repos", "Fleet dashboard", "Webhook gates", "SSO"],
|
||||
href: "mailto:hello@submatter.dev",
|
||||
highlight: false,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export default function PricingPage() {
|
||||
return (
|
||||
<>
|
||||
<SiteHeader />
|
||||
<main className="marketing-paper-grain min-h-screen bg-background">
|
||||
<div className="mx-auto max-w-6xl px-4 py-20 sm:px-6 sm:py-28">
|
||||
<Reveal>
|
||||
<p className="font-mono-brand text-[11px] uppercase tracking-[0.24em] text-muted-foreground">Pricing</p>
|
||||
<h1 className="mt-4 font-marketing text-[clamp(2.5rem,6vw,4rem)] font-semibold leading-[1.05] tracking-[-0.03em]">
|
||||
Seats, not tokens.
|
||||
</h1>
|
||||
<p className="mt-4 max-w-xl font-marketing text-muted-foreground">
|
||||
Beta numbers for feedback. Bring your own model keys always.
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
<Stagger className="mt-16 grid gap-0 border border-foreground lg:grid-cols-3">
|
||||
{tiers.map((tier, i) => (
|
||||
<StaggerItem
|
||||
key={tier.name}
|
||||
className={cn(
|
||||
"flex flex-col border-foreground p-8",
|
||||
i < tiers.length - 1 && "border-b lg:border-b-0 lg:border-r",
|
||||
tier.highlight && "bg-[hsl(var(--hero))] text-[hsl(var(--hero-foreground))]",
|
||||
)}
|
||||
>
|
||||
<p className="font-mono-brand text-[11px] uppercase tracking-[0.2em] opacity-70">{tier.name}</p>
|
||||
<p className="mt-4 flex items-baseline gap-2">
|
||||
<span className="font-marketing text-4xl font-semibold tracking-[-0.03em]">{tier.price}</span>
|
||||
<span className="font-mono-brand text-[11px] uppercase tracking-widest opacity-60">{tier.period}</span>
|
||||
</p>
|
||||
<p className="mt-4 font-marketing text-sm opacity-80">{tier.description}</p>
|
||||
<ul className="mt-8 flex-1 space-y-2 font-mono-brand text-[11px] uppercase tracking-wide">
|
||||
{tier.features.map((f) => (
|
||||
<li key={f}>{f}</li>
|
||||
))}
|
||||
</ul>
|
||||
<Link
|
||||
href={tier.href}
|
||||
className={cn(
|
||||
"mt-10 inline-flex items-center gap-2 font-mono-brand text-[11px] uppercase tracking-[0.18em]",
|
||||
tier.highlight ? "text-[hsl(var(--matter))]" : "text-foreground",
|
||||
)}
|
||||
>
|
||||
{tier.name === "Fleet" ? "Contact" : "Start"}
|
||||
<ArrowRight className="size-3.5" aria-hidden />
|
||||
</Link>
|
||||
</StaggerItem>
|
||||
))}
|
||||
</Stagger>
|
||||
</div>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
47
apps/web/app/(marketing)/layout.tsx
Normal file
47
apps/web/app/(marketing)/layout.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import type { Metadata } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
import { Barlow_Condensed, IBM_Plex_Sans, JetBrains_Mono } from "next/font/google";
|
||||
|
||||
import "@/styles/marketing.css";
|
||||
|
||||
const barlowCondensed = Barlow_Condensed({
|
||||
subsets: ["latin"],
|
||||
weight: ["700", "800"],
|
||||
variable: "--font-display",
|
||||
});
|
||||
|
||||
const ibmPlexSans = IBM_Plex_Sans({
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500", "600"],
|
||||
variable: "--font-marketing-sans",
|
||||
});
|
||||
|
||||
const jetbrainsMono = JetBrains_Mono({
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500"],
|
||||
variable: "--font-mono-brand",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: "Submatter — Subagents. Frontmatter. One workspace.",
|
||||
template: "%s · Submatter",
|
||||
},
|
||||
description:
|
||||
"Agent-native workspace with markdown backlog sync, MCP task lifecycle, and convoy pipelines for Cursor subagents.",
|
||||
openGraph: {
|
||||
title: "SBMTR — Submatter",
|
||||
description: "Subagents. Frontmatter. One workspace.",
|
||||
siteName: "Submatter",
|
||||
},
|
||||
};
|
||||
|
||||
export default function MarketingLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className={`marketing-theme min-h-screen bg-background font-marketing text-foreground antialiased ${barlowCondensed.variable} ${ibmPlexSans.variable} ${jetbrainsMono.variable}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
105
apps/web/app/api/ag-ui/[workspace]/route.ts
Normal file
105
apps/web/app/api/ag-ui/[workspace]/route.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { and, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { db } from "@tasks/database";
|
||||
import { agentRuns, convoyEvents, objects } from "@tasks/database/schema";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { resolveWorkspace } from "@/server/lib/resolve-workspace";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const agUiEnvelopeSchema = z.object({
|
||||
convoyId: z.string().uuid().optional(),
|
||||
briefId: z.string().uuid().nullable().optional(),
|
||||
roleId: z.string().min(1).max(120).optional(),
|
||||
eventType: z.string().min(1).max(64).optional(),
|
||||
outcome: z.string().min(1).max(20).optional(),
|
||||
harnessAlias: z.string().min(1).max(120).optional(),
|
||||
summary: z.string().max(5000).optional(),
|
||||
tokensTotal: z.number().int().nonnegative().optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
// Backlog lifecycle via ag-ui envelope (for remote harness that ran a task externally)
|
||||
backlogItemId: z.string().uuid().optional(),
|
||||
});
|
||||
|
||||
function json(status: number, body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
export async function POST(req: Request, { params }: { params: Promise<{ workspace: string }> }) {
|
||||
const { workspace: handle } = await params;
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return json(401, { error: "Unauthorized" });
|
||||
|
||||
let workspace;
|
||||
try {
|
||||
workspace = await resolveWorkspace({ handle, userId: session.user.id });
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : "Workspace not found";
|
||||
const code = (e as { code?: string })?.code;
|
||||
const status = code === "FORBIDDEN" ? 403 : code === "NOT_FOUND" ? 404 : 400;
|
||||
return json(status, { error: msg });
|
||||
}
|
||||
|
||||
let body: z.infer<typeof agUiEnvelopeSchema>;
|
||||
try {
|
||||
body = agUiEnvelopeSchema.parse(await req.json());
|
||||
} catch (e) {
|
||||
if (e instanceof z.ZodError) return json(400, { error: e.issues[0]?.message ?? "Invalid body" });
|
||||
return json(400, { error: "Invalid JSON" });
|
||||
}
|
||||
|
||||
// Tenant fence: if convoyId provided, ensure it belongs to this workspace
|
||||
if (body.convoyId) {
|
||||
const [convoy] = await db.select({ id: objects.id, workspaceId: objects.workspaceId }).from(objects).where(and(eq(objects.id, body.convoyId), eq(objects.workspaceId, workspace.id))).limit(1);
|
||||
if (!convoy) return json(403, { error: "convoyId not in workspace" });
|
||||
}
|
||||
if (body.briefId) {
|
||||
const [brief] = await db.select({ id: objects.id, workspaceId: objects.workspaceId }).from(objects).where(and(eq(objects.id, body.briefId), eq(objects.workspaceId, workspace.id))).limit(1);
|
||||
if (!brief) return json(403, { error: "briefId not in workspace" });
|
||||
}
|
||||
if (body.backlogItemId) {
|
||||
// validate backlog item belongs to workspace via agent_runs path? We check objects? backlog stored in markdown_backlog_items — skip heavy check, just allow if workspace matches via conduct?
|
||||
// For now allow but ensure if we can find agent run path.
|
||||
}
|
||||
|
||||
// Write convoy event if we have convoy context
|
||||
if (body.convoyId && body.roleId) {
|
||||
await db.insert(convoyEvents).values({
|
||||
workspaceId: workspace.id,
|
||||
convoyId: body.convoyId,
|
||||
briefId: body.briefId ?? null,
|
||||
role: body.roleId,
|
||||
actor: body.roleId,
|
||||
eventType: body.eventType ?? "ag_ui",
|
||||
outcome: body.outcome ?? null,
|
||||
harnessAlias: body.harnessAlias ?? null,
|
||||
metadata: {
|
||||
source: "ag-ui",
|
||||
summary: body.summary ?? null,
|
||||
tokensTotal: body.tokensTotal ?? null,
|
||||
...(body.metadata ?? {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Optionally touch agent run if backlogItemId provided — create or update latest open run
|
||||
if (body.backlogItemId && body.convoyId) {
|
||||
// best-effort: insert completed run for observability; if harness already claimed, this will be additive
|
||||
try {
|
||||
await db.insert(agentRuns).values({
|
||||
workspaceId: workspace.id,
|
||||
backlogItemId: body.backlogItemId,
|
||||
harnessAlias: body.harnessAlias ?? null,
|
||||
outcome: body.outcome ?? null,
|
||||
tokensTotal: body.tokensTotal ?? null,
|
||||
notes: body.summary?.slice(0, 2000) ?? null,
|
||||
metadata: { source: "ag-ui", ...(body.metadata ?? {}) },
|
||||
});
|
||||
} catch {
|
||||
// ignore duplicate
|
||||
}
|
||||
}
|
||||
|
||||
return json(200, { ok: true });
|
||||
}
|
||||
165
apps/web/app/api/fleet/events/route.ts
Normal file
165
apps/web/app/api/fleet/events/route.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import { desc, eq, inArray } from "drizzle-orm";
|
||||
|
||||
import { db } from "@tasks/database";
|
||||
import { convoyEvents, workspaceMembers, workspaces } from "@tasks/database/schema";
|
||||
import { auth } from "@/lib/auth";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
async function getUserWorkspaceIds(userId: string): Promise<string[]> {
|
||||
const memberRows = await db
|
||||
.select({ workspaceId: workspaceMembers.workspaceId })
|
||||
.from(workspaceMembers)
|
||||
.where(eq(workspaceMembers.userId, userId));
|
||||
const ownedRows = await db
|
||||
.select({ id: workspaces.id })
|
||||
.from(workspaces)
|
||||
.where(eq(workspaces.ownerUserId, userId));
|
||||
const ids = new Set<string>([...memberRows.map((r) => r.workspaceId), ...ownedRows.map((r) => r.id)]);
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
const userId = session.user.id;
|
||||
const workspaceIds = await getUserWorkspaceIds(userId);
|
||||
|
||||
if (workspaceIds.length === 0) {
|
||||
// No workspaces: return empty SSE that stays open with keepalive
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
const encoder = new TextEncoder();
|
||||
controller.enqueue(encoder.encode(`: no workspaces\n\n`));
|
||||
const iv = setInterval(() => {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`: keepalive\n\n`));
|
||||
} catch {
|
||||
clearInterval(iv);
|
||||
}
|
||||
}, 15000);
|
||||
},
|
||||
});
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
let closed = false;
|
||||
let pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const fetchRecent = async () =>
|
||||
db
|
||||
.select()
|
||||
.from(convoyEvents)
|
||||
.where(inArray(convoyEvents.workspaceId, workspaceIds))
|
||||
.orderBy(desc(convoyEvents.occurredAt))
|
||||
.limit(30);
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const send = (data: unknown) => {
|
||||
if (closed) return;
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
|
||||
} catch {
|
||||
closed = true;
|
||||
}
|
||||
};
|
||||
|
||||
const sendComment = (comment: string) => {
|
||||
if (closed) return;
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`: ${comment}\n\n`));
|
||||
} catch {
|
||||
closed = true;
|
||||
}
|
||||
};
|
||||
|
||||
let initial: Awaited<ReturnType<typeof fetchRecent>> = [];
|
||||
try {
|
||||
initial = await fetchRecent();
|
||||
} catch {
|
||||
initial = [];
|
||||
}
|
||||
|
||||
for (const ev of [...initial].reverse()) {
|
||||
send({
|
||||
id: ev.id,
|
||||
workspaceId: ev.workspaceId,
|
||||
convoyId: ev.convoyId,
|
||||
briefId: ev.briefId,
|
||||
role: ev.role,
|
||||
actor: ev.actor,
|
||||
eventType: ev.eventType,
|
||||
harnessAlias: ev.harnessAlias,
|
||||
occurredAt: ev.occurredAt.toISOString(),
|
||||
metadata: ev.metadata,
|
||||
classification: ev.classification,
|
||||
durationS: ev.durationS,
|
||||
outcome: ev.outcome,
|
||||
});
|
||||
}
|
||||
|
||||
const seen = new Set(initial.map((e) => e.id));
|
||||
|
||||
const poll = async () => {
|
||||
if (closed) return;
|
||||
try {
|
||||
const rows = await fetchRecent();
|
||||
const fresh = rows.filter((r) => !seen.has(r.id));
|
||||
for (const ev of [...fresh].reverse()) {
|
||||
seen.add(ev.id);
|
||||
send({
|
||||
id: ev.id,
|
||||
workspaceId: ev.workspaceId,
|
||||
convoyId: ev.convoyId,
|
||||
briefId: ev.briefId,
|
||||
role: ev.role,
|
||||
actor: ev.actor,
|
||||
eventType: ev.eventType,
|
||||
harnessAlias: ev.harnessAlias,
|
||||
occurredAt: ev.occurredAt.toISOString(),
|
||||
metadata: ev.metadata,
|
||||
classification: ev.classification,
|
||||
durationS: ev.durationS,
|
||||
outcome: ev.outcome,
|
||||
});
|
||||
}
|
||||
if (seen.size > 400) {
|
||||
const toKeep = new Set(rows.map((r) => r.id));
|
||||
for (const id of seen) {
|
||||
if (!toKeep.has(id) && seen.size > 200) seen.delete(id);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore poll errors
|
||||
}
|
||||
if (!closed) sendComment("keepalive");
|
||||
};
|
||||
|
||||
pollInterval = setInterval(poll, 5000);
|
||||
},
|
||||
cancel() {
|
||||
closed = true;
|
||||
if (pollInterval) clearInterval(pollInterval);
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
});
|
||||
}
|
||||
143
apps/web/app/api/webhooks/forge/route.ts
Normal file
143
apps/web/app/api/webhooks/forge/route.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
import { db } from "@tasks/database";
|
||||
import { objects } from "@tasks/database/schema";
|
||||
import { transitionConvoy } from "@/server/lib/convoy/convoy-service";
|
||||
import { rateLimit } from "@/server/lib/rate-limit";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const CONVOY_MARKER_RE = /echodo-convoy:\s*([0-9a-f-]{36})/i;
|
||||
const RATE_LIMIT = { limit: 60, windowMs: 60_000 } as const;
|
||||
|
||||
function getClientIp(req: Request): string {
|
||||
const fwd = req.headers.get("x-forwarded-for");
|
||||
if (fwd) {
|
||||
const first = fwd.split(",")[0]?.trim();
|
||||
if (first) return first;
|
||||
}
|
||||
const realIp = req.headers.get("x-real-ip");
|
||||
if (realIp?.trim()) return realIp.trim();
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function verifyHmacSignature(rawBody: string, signatureHeader: string | null, secret: string): boolean {
|
||||
if (!signatureHeader) return false;
|
||||
let sig = signatureHeader.trim();
|
||||
if (sig.startsWith("sha256=")) sig = sig.slice(7);
|
||||
const computed = createHmac("sha256", secret).update(Buffer.from(rawBody, "utf8")).digest("hex");
|
||||
const a = Buffer.from(computed, "utf8");
|
||||
const b = Buffer.from(sig, "utf8");
|
||||
if (a.length !== b.length) {
|
||||
try {
|
||||
timingSafeEqual(a, a);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
function json(status: number, body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(req: Request): Promise<Response> {
|
||||
const ip = getClientIp(req);
|
||||
const rl = rateLimit({
|
||||
key: `forge-webhook:${ip}`,
|
||||
limit: RATE_LIMIT.limit,
|
||||
windowMs: RATE_LIMIT.windowMs,
|
||||
});
|
||||
if (!rl.allowed) {
|
||||
return json(429, { ok: false, error: "rate_limited", retryAfterMs: rl.retryAfterMs });
|
||||
}
|
||||
|
||||
const secret = process.env.FORGE_WEBHOOK_SECRET?.trim();
|
||||
if (!secret) {
|
||||
return json(500, { ok: false, error: "webhook_not_configured" });
|
||||
}
|
||||
|
||||
let rawBody: string;
|
||||
try {
|
||||
rawBody = await req.text();
|
||||
} catch {
|
||||
return json(400, { ok: false, error: "invalid_body" });
|
||||
}
|
||||
|
||||
const hubSig = req.headers.get("x-hub-signature-256") ?? req.headers.get("x-hub-signature");
|
||||
const forgejoSig = req.headers.get("x-forgejo-signature");
|
||||
const providedSig = hubSig ?? forgejoSig;
|
||||
|
||||
if (!providedSig) {
|
||||
return json(401, { ok: false, error: "missing_signature" });
|
||||
}
|
||||
|
||||
if (!verifyHmacSignature(rawBody, providedSig, secret)) {
|
||||
return json(401, { ok: false, error: "invalid_signature" });
|
||||
}
|
||||
|
||||
let payload: Record<string, unknown>;
|
||||
try {
|
||||
payload = rawBody ? (JSON.parse(rawBody) as Record<string, unknown>) : {};
|
||||
} catch {
|
||||
return json(200, { ok: true });
|
||||
}
|
||||
|
||||
const action = typeof payload.action === "string" ? payload.action : null;
|
||||
const prRaw = payload.pull_request as Record<string, unknown> | null | undefined;
|
||||
|
||||
if (action !== "closed" || !prRaw || typeof prRaw !== "object") {
|
||||
return json(200, { ok: true });
|
||||
}
|
||||
|
||||
const merged = prRaw.merged === true;
|
||||
if (!merged) {
|
||||
return json(200, { ok: true });
|
||||
}
|
||||
|
||||
const bodyText = typeof prRaw.body === "string" ? prRaw.body : "";
|
||||
const match = CONVOY_MARKER_RE.exec(bodyText);
|
||||
if (!match?.[1]) {
|
||||
return json(200, { ok: true });
|
||||
}
|
||||
|
||||
const convoyId = match[1].toLowerCase();
|
||||
const prNumber = typeof prRaw.number === "number" ? prRaw.number : null;
|
||||
|
||||
const [convoyRow] = await db
|
||||
.select({ id: objects.id, workspaceId: objects.workspaceId, status: objects.status, type: objects.type })
|
||||
.from(objects)
|
||||
.where(eq(objects.id, convoyId))
|
||||
.limit(1);
|
||||
|
||||
if (!convoyRow || convoyRow.type !== "project") {
|
||||
return json(200, { ok: true });
|
||||
}
|
||||
|
||||
if (convoyRow.status !== "pr_review") {
|
||||
return json(200, { ok: true });
|
||||
}
|
||||
|
||||
const reason = prNumber !== null ? `pr#${prNumber} merged` : "pr merged";
|
||||
|
||||
try {
|
||||
await transitionConvoy(db, {
|
||||
workspaceId: convoyRow.workspaceId,
|
||||
convoyId: convoyRow.id,
|
||||
toStatus: "merged",
|
||||
actor: "automation",
|
||||
reason,
|
||||
});
|
||||
} catch {
|
||||
return json(200, { ok: true });
|
||||
}
|
||||
|
||||
return json(200, { ok: true });
|
||||
}
|
||||
|
|
@ -6,27 +6,14 @@ import { db } from "@tasks/database/client";
|
|||
import { workspaceMembers, workspaces } from "@tasks/database/schema";
|
||||
|
||||
/**
|
||||
* Root landing route. Decides where an authenticated user goes when they
|
||||
* hit `/`. The post-sign-in `callbackUrl` defaults here, so this is the
|
||||
* single source of truth for "where does a user actually land?"
|
||||
*
|
||||
* Decision: land on the user's oldest workspace home (`/{slug}/`). Why oldest:
|
||||
* we don't have a "last visited" column yet (deliberately deferred — see
|
||||
* Task-pick-workspace-landing-route) and oldest-membership is stable across
|
||||
* page loads. The workspace home is no longer a mockup; it now shows real
|
||||
* stats and recent activity via `objects.stats` / `objects.listRecent`.
|
||||
*
|
||||
* The auth callback (`ensureUserHasWorkspace` in `apps/web/lib/auth.ts`)
|
||||
* provisions a personal workspace on first sign-in, so the zero-workspace
|
||||
* branch below should be effectively unreachable for fresh sign-ins. It
|
||||
* survives only as a defensive fallback for sessions that were minted
|
||||
* before that provisioning logic existed.
|
||||
* Root landing route. Unauthenticated visitors go to the public marketing site;
|
||||
* signed-in users land on their oldest workspace home (`/{slug}/`).
|
||||
*/
|
||||
export default async function HomePage() {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
redirect("/sign-in");
|
||||
redirect("/landing");
|
||||
}
|
||||
|
||||
const [membership] = await db
|
||||
|
|
@ -41,11 +28,5 @@ export default async function HomePage() {
|
|||
redirect(`/${membership.slug}`);
|
||||
}
|
||||
|
||||
// Zero-workspace fallback. See header doc — this should be unreachable
|
||||
// post-`ensureUserHasWorkspace`. If we hit it anyway, bounce through
|
||||
// sign-in so the auth callback re-runs and provisions a workspace.
|
||||
// Track follow-up: a proper onboarding flow for users in this state
|
||||
// is captured in Plan-daily-driver-finish/Epic-shipping-the-shell/
|
||||
// Task-onboarding-zero-workspace-flow.md.
|
||||
redirect("/sign-in?error=no_workspace");
|
||||
}
|
||||
|
|
|
|||
375
apps/web/components/copilot/ops-copilot-panel.tsx
Normal file
375
apps/web/components/copilot/ops-copilot-panel.tsx
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Loader2, Send, Sparkles, X } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/lib/trpc";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
} from "@/components/ui/sheet";
|
||||
|
||||
type ChatRole = "user" | "assistant";
|
||||
|
||||
type ChatMessage = {
|
||||
id: string;
|
||||
role: ChatRole;
|
||||
content: string;
|
||||
};
|
||||
|
||||
function TypingDots() {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 px-1 py-2" aria-hidden>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="inline-block h-2 w-2 animate-bounce rounded-full bg-muted-foreground/70"
|
||||
style={{ animationDelay: `${i * 0.15}s` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OpsCopilotPanel({
|
||||
workspaceSlug,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
workspaceSlug: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const [messages, setMessages] = React.useState<ChatMessage[]>([]);
|
||||
const [input, setInput] = React.useState("");
|
||||
const [draftOpen, setDraftOpen] = React.useState(false);
|
||||
const [draftPlanSlug, setDraftPlanSlug] = React.useState("");
|
||||
const [draftEpicSlug, setDraftEpicSlug] = React.useState("");
|
||||
const [draftSlug, setDraftSlug] = React.useState("");
|
||||
const [draftTitle, setDraftTitle] = React.useState("");
|
||||
const [draftBody, setDraftBody] = React.useState("");
|
||||
const bottomRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const copilotMut = api.opsCopilot.generateText.useMutation({
|
||||
onSuccess: (data) => {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ id: crypto.randomUUID(), role: "assistant", content: data.text },
|
||||
]);
|
||||
},
|
||||
});
|
||||
|
||||
const createTaskMut = api.backlog.createTask.useMutation({
|
||||
onSuccess: () => {
|
||||
setDraftOpen(false);
|
||||
setDraftPlanSlug("");
|
||||
setDraftEpicSlug("");
|
||||
setDraftSlug("");
|
||||
setDraftTitle("");
|
||||
setDraftBody("");
|
||||
},
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages, copilotMut.isPending]);
|
||||
|
||||
const send = React.useCallback(() => {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed || copilotMut.isPending) return;
|
||||
if (!workspaceSlug) return;
|
||||
const userMsg: ChatMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: "user",
|
||||
content: trimmed,
|
||||
};
|
||||
const next = [...messages, userMsg];
|
||||
setMessages(next);
|
||||
setInput("");
|
||||
const history = next.slice(0, -1).map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
}));
|
||||
copilotMut.mutate({
|
||||
workspace: workspaceSlug,
|
||||
prompt: trimmed,
|
||||
messages: history.length > 0 ? history : undefined,
|
||||
});
|
||||
}, [input, copilotMut, messages, workspaceSlug]);
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
send();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDraftTask = () => {
|
||||
if (!workspaceSlug) return;
|
||||
if (!draftPlanSlug.trim() || !draftEpicSlug.trim() || !draftSlug.trim() || !draftTitle.trim()) {
|
||||
return;
|
||||
}
|
||||
createTaskMut.mutate({
|
||||
workspace: workspaceSlug,
|
||||
planSlug: draftPlanSlug.trim().toLowerCase(),
|
||||
epicSlug: draftEpicSlug.trim().toLowerCase(),
|
||||
slug: draftSlug.trim().toLowerCase(),
|
||||
title: draftTitle.trim(),
|
||||
bodyMarkdown: draftBody.trim() || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const isBusy = copilotMut.isPending;
|
||||
const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant");
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="flex w-full max-w-md flex-col p-0 sm:max-w-md">
|
||||
<SheetHeader className="shrink-0 border-b px-4 py-3 text-left">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="size-5 text-primary" />
|
||||
<SheetTitle className="text-base">Ops copilot</SheetTitle>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => onOpenChange(false)}
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<SheetDescription className="text-xs">
|
||||
Ask "what needs me?" — grounded in fleet, convoys and backlog. Links use [workspace/slug].
|
||||
{workspaceSlug ? ` · ${workspaceSlug}` : " · no workspace"}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-3 py-3">
|
||||
{messages.length === 0 && !isBusy ? (
|
||||
<div className="space-y-3 px-1 py-6 text-center">
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
|
||||
<Sparkles className="size-6 text-primary" />
|
||||
</div>
|
||||
<p className="text-sm font-medium">What needs you?</p>
|
||||
<p className="mx-auto max-w-[22rem] text-xs text-muted-foreground">
|
||||
Try "what needs my approval?" — fleet → needs you will be cited with [fleet] links.
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{["What needs me?", "What's ready to start?", "Show my gate inbox"].map((chip) => (
|
||||
<button
|
||||
key={chip}
|
||||
type="button"
|
||||
onClick={() => setInput(chip)}
|
||||
className="rounded-full border bg-card px-3 py-1.5 text-xs hover:bg-muted"
|
||||
>
|
||||
{chip}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{!workspaceSlug && (
|
||||
<p className="text-xs text-destructive">No workspace selected — create or select one first.</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-3">
|
||||
{messages.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className={cn("flex gap-2", m.role === "user" ? "justify-end" : "justify-start")}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[85%] rounded-lg px-3 py-2 text-sm whitespace-pre-wrap",
|
||||
m.role === "user"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "border bg-muted",
|
||||
)}
|
||||
>
|
||||
{m.content}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{isBusy && (
|
||||
<div className="flex gap-2">
|
||||
<div className="rounded-lg border bg-card px-3 py-2">
|
||||
<TypingDots />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{copilotMut.error && (
|
||||
<p className="rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
{copilotMut.error.message}
|
||||
</p>
|
||||
)}
|
||||
{copilotMut.data && (
|
||||
<p className="px-1 text-[10px] text-muted-foreground">
|
||||
Model: {copilotMut.data.resolvedAlias} ({copilotMut.data.modelId})
|
||||
</p>
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 border-t bg-muted/20 px-3 py-3">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{lastAssistant ? "Need to create work from this answer?" : "Draft a Task from chat"}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setDraftOpen((v) => !v)}
|
||||
>
|
||||
Draft Task
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{draftOpen && (
|
||||
<div className="mb-3 rounded-md border bg-background p-3">
|
||||
<p className="mb-2 text-xs font-medium">Create backlog task via create-backlog-item shape</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-medium uppercase text-muted-foreground">Plan slug</label>
|
||||
<Input
|
||||
value={draftPlanSlug}
|
||||
onChange={(e) => setDraftPlanSlug(e.target.value)}
|
||||
placeholder="automation-foundation"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-medium uppercase text-muted-foreground">Epic slug</label>
|
||||
<Input
|
||||
value={draftEpicSlug}
|
||||
onChange={(e) => setDraftEpicSlug(e.target.value)}
|
||||
placeholder="ops-copilot"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-medium uppercase text-muted-foreground">Task slug</label>
|
||||
<Input
|
||||
value={draftSlug}
|
||||
onChange={(e) => setDraftSlug(e.target.value)}
|
||||
placeholder="triage-bug"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-medium uppercase text-muted-foreground">Title</label>
|
||||
<Input
|
||||
value={draftTitle}
|
||||
onChange={(e) => setDraftTitle(e.target.value)}
|
||||
placeholder="Triage bug from copilot"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1">
|
||||
<label className="text-[10px] font-medium uppercase text-muted-foreground">Body markdown</label>
|
||||
<textarea
|
||||
value={draftBody}
|
||||
onChange={(e) => setDraftBody(e.target.value)}
|
||||
placeholder="One or two sentences. Will become Task-*.md"
|
||||
rows={3}
|
||||
className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setDraftOpen(false)}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
disabled={
|
||||
createTaskMut.isPending ||
|
||||
!draftPlanSlug.trim() ||
|
||||
!draftEpicSlug.trim() ||
|
||||
!draftSlug.trim() ||
|
||||
!draftTitle.trim() ||
|
||||
!workspaceSlug
|
||||
}
|
||||
onClick={handleDraftTask}
|
||||
>
|
||||
{createTaskMut.isPending ? <Loader2 className="size-3 animate-spin" /> : "Create Task"}
|
||||
</Button>
|
||||
</div>
|
||||
{createTaskMut.error && (
|
||||
<p className="mt-2 text-xs text-destructive">{createTaskMut.error.message}</p>
|
||||
)}
|
||||
{createTaskMut.isSuccess && (
|
||||
<p className="mt-2 text-xs text-green-600">Task created — check Plans tree.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder={workspaceSlug ? "What needs me?" : "Select workspace first"}
|
||||
disabled={isBusy || !workspaceSlug}
|
||||
rows={2}
|
||||
className={cn(
|
||||
"min-h-[44px] flex-1 resize-none rounded-md border border-input bg-background px-3 py-2 text-sm",
|
||||
"placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
className="h-[44px] w-[44px] shrink-0"
|
||||
onClick={send}
|
||||
disabled={isBusy || !input.trim() || !workspaceSlug}
|
||||
aria-label="Send"
|
||||
>
|
||||
{isBusy ? <Loader2 className="size-4 animate-spin" /> : <Send className="size-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
export function OpsCopilotFab({ workspaceSlug }: { workspaceSlug: string }) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="fixed bottom-6 right-6 z-40 h-12 w-12 rounded-full shadow-lg"
|
||||
size="icon"
|
||||
aria-label="Open ops copilot"
|
||||
>
|
||||
<Sparkles className="size-5" />
|
||||
</Button>
|
||||
<OpsCopilotPanel workspaceSlug={workspaceSlug} open={open} onOpenChange={setOpen} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
101
apps/web/components/fleet/fleet-activity-feed.tsx
Normal file
101
apps/web/components/fleet/fleet-activity-feed.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
type FleetEvent = {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
convoyId: string;
|
||||
briefId: string | null;
|
||||
role: string;
|
||||
actor: string | null;
|
||||
eventType: string;
|
||||
harnessAlias: string | null;
|
||||
occurredAt: string;
|
||||
metadata: Record<string, unknown> | null;
|
||||
classification?: string | null;
|
||||
durationS?: number | null;
|
||||
outcome?: string | null;
|
||||
};
|
||||
|
||||
function formatRelative(iso: string): string {
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const s = Math.floor(diff / 1000);
|
||||
if (s < 60) return `${s}s ago`;
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
return `${Math.floor(h / 24)}d ago`;
|
||||
}
|
||||
|
||||
export function FleetActivityFeed() {
|
||||
const [events, setEvents] = React.useState<FleetEvent[]>([]);
|
||||
const [connected, setConnected] = React.useState(false);
|
||||
const seenRef = React.useRef<Set<string>>(new Set());
|
||||
|
||||
React.useEffect(() => {
|
||||
const es = new EventSource("/api/fleet/events");
|
||||
|
||||
es.onopen = () => setConnected(true);
|
||||
es.onerror = () => setConnected(false);
|
||||
|
||||
es.onmessage = (e) => {
|
||||
try {
|
||||
const parsed = JSON.parse(e.data) as FleetEvent | FleetEvent[];
|
||||
const incoming = Array.isArray(parsed) ? parsed : [parsed];
|
||||
// Filter duplicates by id
|
||||
const fresh = incoming.filter((ev) => {
|
||||
if (!ev?.id || seenRef.current.has(ev.id)) return false;
|
||||
seenRef.current.add(ev.id);
|
||||
return true;
|
||||
});
|
||||
if (fresh.length === 0) return;
|
||||
setEvents((prev) => {
|
||||
const merged = [...fresh, ...prev];
|
||||
// keep sort by occurredAt desc, dedup already handled, limit 50
|
||||
merged.sort((a, b) => new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime());
|
||||
return merged.slice(0, 100);
|
||||
});
|
||||
} catch {
|
||||
// ignore malformed
|
||||
}
|
||||
};
|
||||
|
||||
return () => {
|
||||
es.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card">
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<h3 className="text-sm font-semibold">Activity feed</h3>
|
||||
<span className="text-[10px] text-muted-foreground">{connected ? "live" : "reconnecting…"}</span>
|
||||
</div>
|
||||
<div className="max-h-[360px] overflow-y-auto">
|
||||
{events.length === 0 ? (
|
||||
<p className="p-4 text-sm text-muted-foreground">No recent convoy events.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border">
|
||||
{events.map((ev) => (
|
||||
<li key={ev.id} className="px-4 py-2.5 text-sm">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">{ev.eventType}</span>
|
||||
<span className="text-xs text-muted-foreground">{formatRelative(ev.occurredAt)}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{ev.role}
|
||||
{ev.actor && ev.actor !== ev.role ? ` · ${ev.actor}` : ""}
|
||||
{ev.harnessAlias ? ` · ${ev.harnessAlias}` : ""}
|
||||
{ev.outcome ? ` · ${ev.outcome}` : ""}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground">convoy {ev.convoyId.slice(0, 8)}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import {
|
|||
LayoutGrid,
|
||||
MoreHorizontal,
|
||||
Presentation,
|
||||
Radar,
|
||||
Sparkles,
|
||||
UserPlus,
|
||||
Users,
|
||||
|
|
@ -34,6 +35,7 @@ const NAV_ITEMS: NavItem[] = [
|
|||
{ id: "home", label: "Home", segment: "", icon: Home },
|
||||
{ id: "spaces", label: "Spaces", segment: "", icon: LayoutGrid },
|
||||
{ id: "planner", label: "Planner", segment: "planner", icon: CalendarDays },
|
||||
{ id: "mission-control", label: "Mission control", segment: "mission-control", icon: Radar },
|
||||
{ id: "ai", label: "AI", segment: "ai", icon: Sparkles },
|
||||
{ id: "teams", label: "Teams", segment: "teams", icon: Users },
|
||||
{ id: "docs", label: "Docs", segment: "docs", icon: FileText },
|
||||
|
|
@ -47,6 +49,7 @@ function workspaceHref(workspaceSegment: string, segment: string) {
|
|||
|
||||
const RESERVED_ROOT_SEGMENTS = new Set([
|
||||
"planner",
|
||||
"mission-control",
|
||||
"ai",
|
||||
"teams",
|
||||
"docs",
|
||||
|
|
|
|||
|
|
@ -10,15 +10,18 @@ import {
|
|||
CheckSquare,
|
||||
ChevronDown,
|
||||
FileText,
|
||||
GitBranch,
|
||||
LayoutGrid,
|
||||
LayoutTemplate,
|
||||
LogOut,
|
||||
Pencil,
|
||||
Plus,
|
||||
Presentation,
|
||||
Radar,
|
||||
ScrollText,
|
||||
Search,
|
||||
Settings,
|
||||
Sparkles,
|
||||
User,
|
||||
Users,
|
||||
Zap,
|
||||
|
|
@ -144,6 +147,42 @@ export function TopHeader({ onOpenSearch, onQuickAction }: TopHeaderProps) {
|
|||
<ScrollText className="size-4 text-muted-foreground" />
|
||||
Audit log
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="gap-2.5 px-4"
|
||||
onSelect={() =>
|
||||
workspaceId && router.push(`/${workspaceId}/settings/ai/providers`)
|
||||
}
|
||||
>
|
||||
<Sparkles className="size-4 text-muted-foreground" />
|
||||
AI providers
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="gap-2.5 px-4"
|
||||
onSelect={() =>
|
||||
workspaceId && router.push(`/${workspaceId}/settings/ai/role-routing`)
|
||||
}
|
||||
>
|
||||
<Sparkles className="size-4 text-muted-foreground" />
|
||||
Role routing
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="gap-2.5 px-4"
|
||||
onSelect={() =>
|
||||
workspace?.slug && router.push(`/${workspace.slug}/mission-control`)
|
||||
}
|
||||
>
|
||||
<Radar className="size-4 text-muted-foreground" />
|
||||
Mission control
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="gap-2.5 px-4"
|
||||
onSelect={() =>
|
||||
workspace?.slug && router.push(`/${workspace.slug}/settings/repos`)
|
||||
}
|
||||
>
|
||||
<GitBranch className="size-4 text-muted-foreground" />
|
||||
Connected repos
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="gap-2.5 px-4"
|
||||
onSelect={() =>
|
||||
|
|
|
|||
40
apps/web/components/marketing/cta-section.tsx
Normal file
40
apps/web/components/marketing/cta-section.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import Link from "next/link";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
|
||||
import { Reveal } from "@/components/marketing/reveal";
|
||||
|
||||
export function CtaSection() {
|
||||
return (
|
||||
<section className="border-b border-foreground bg-background py-24 sm:py-32">
|
||||
<div className="mx-auto max-w-6xl px-4 sm:px-6">
|
||||
<Reveal className="border border-foreground bg-[hsl(var(--hero))] p-10 text-[hsl(var(--hero-foreground))] sm:p-16">
|
||||
<p className="font-mono-brand text-[11px] uppercase tracking-[0.28em] text-[hsl(var(--hero-muted))]">---</p>
|
||||
<h2 className="mt-6 font-display text-[clamp(3.5rem,12vw,8rem)] font-extrabold leading-[0.9] tracking-[0.08em]">
|
||||
SBMTR
|
||||
</h2>
|
||||
<p className="mt-8 max-w-xl font-marketing text-lg text-[hsl(var(--hero-muted))]">
|
||||
Open Monday morning in Submatter. Your backlog is already in git — give your agents a workspace that
|
||||
respects that.
|
||||
</p>
|
||||
<div className="mt-10 flex flex-wrap items-center gap-8">
|
||||
<Link
|
||||
href="/sign-up"
|
||||
className="group inline-flex items-center gap-3 font-mono-brand text-[11px] uppercase tracking-[0.18em]"
|
||||
>
|
||||
<span className="border-b-2 border-[hsl(var(--matter))] pb-1 text-[hsl(var(--hero-foreground))] transition-colors group-hover:text-[hsl(var(--matter))]">
|
||||
Start free
|
||||
</span>
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
<Link
|
||||
href="/landing/pricing"
|
||||
className="font-mono-brand text-[11px] uppercase tracking-[0.18em] text-[hsl(var(--hero-muted))] hover:text-[hsl(var(--hero-foreground))]"
|
||||
>
|
||||
Pricing
|
||||
</Link>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
71
apps/web/components/marketing/features-section.tsx
Normal file
71
apps/web/components/marketing/features-section.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { Reveal, Stagger, StaggerItem } from "@/components/marketing/reveal";
|
||||
|
||||
const capabilities = [
|
||||
{
|
||||
index: "01",
|
||||
title: "Markdown backlog",
|
||||
body: "Plan / Epic / Task files in git. Frontmatter is the schema agents and humans both read.",
|
||||
mono: "plans/Plan-*/Epic-*/Task-*.md",
|
||||
},
|
||||
{
|
||||
index: "02",
|
||||
title: "Bidirectional sync",
|
||||
body: "Importer mirrors YAML into Postgres. Cursor mappings keep slugs aligned with agent ids.",
|
||||
mono: "watch:markdown-backlog",
|
||||
},
|
||||
{
|
||||
index: "03",
|
||||
title: "MCP lifecycle",
|
||||
body: "claim_task → work → complete_task. Auditable runs instead of ad-hoc object patches.",
|
||||
mono: "workspace_id scoped",
|
||||
},
|
||||
{
|
||||
index: "04",
|
||||
title: "Convoy gates",
|
||||
body: "Role-based subagents with human approval between architect, implementer, and reviewer.",
|
||||
mono: "ready → in_progress → done",
|
||||
},
|
||||
{
|
||||
index: "05",
|
||||
title: "Full workspace",
|
||||
body: "Docs, boards, forms, whiteboards — one object model, not a task queue bolted on.",
|
||||
mono: "multitenant · workspace_id",
|
||||
},
|
||||
{
|
||||
index: "06",
|
||||
title: "Model routing",
|
||||
body: "Per-role tiers in convoy frontmatter — fast implementers, premium architects.",
|
||||
mono: "model_policy: composer-fast",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function FeaturesSection() {
|
||||
return (
|
||||
<section id="features" className="marketing-paper-grain border-b border-foreground bg-background py-24 sm:py-32">
|
||||
<div className="relative mx-auto max-w-6xl px-4 sm:px-6">
|
||||
<Reveal className="mb-16 grid gap-8 lg:grid-cols-12 lg:items-end">
|
||||
<div className="lg:col-span-8">
|
||||
<p className="font-mono-brand text-[11px] uppercase tracking-[0.24em] text-muted-foreground">Capabilities</p>
|
||||
<h2 className="mt-4 font-marketing text-[clamp(2rem,5vw,3.5rem)] font-semibold leading-[1.05] tracking-[-0.03em]">
|
||||
Built for the agent pipeline era — not the slide-deck era.
|
||||
</h2>
|
||||
</div>
|
||||
<p className="font-marketing text-sm leading-relaxed text-muted-foreground lg:col-span-4 lg:text-right">
|
||||
Swiss grid. Zero decoration. Every line earns its place.
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
<Stagger className="grid border border-foreground lg:grid-cols-2 [&>*]:border-b [&>*]:border-foreground [&>*:last-child]:border-b-0 lg:[&>*:nth-child(odd)]:border-r lg:[&>*:nth-last-child(-n+2)]:border-b-0">
|
||||
{capabilities.map((item) => (
|
||||
<StaggerItem key={item.index} className="p-8">
|
||||
<p className="font-mono-brand text-[11px] text-matter">{item.index}</p>
|
||||
<h3 className="mt-4 font-marketing text-xl font-semibold tracking-[-0.02em]">{item.title}</h3>
|
||||
<p className="mt-3 font-marketing text-sm leading-relaxed text-muted-foreground">{item.body}</p>
|
||||
<p className="mt-6 font-mono-brand text-[11px] text-foreground">{item.mono}</p>
|
||||
</StaggerItem>
|
||||
))}
|
||||
</Stagger>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
64
apps/web/components/marketing/hero-section.tsx
Normal file
64
apps/web/components/marketing/hero-section.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import Link from "next/link";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
|
||||
import { MarqueeStrip } from "@/components/marketing/marquee-strip";
|
||||
import { ProductDemo } from "@/components/marketing/product-demo";
|
||||
import { Reveal } from "@/components/marketing/reveal";
|
||||
import { ScrambleText } from "@/components/marketing/scramble-text";
|
||||
|
||||
export function HeroSection() {
|
||||
return (
|
||||
<section className="marketing-aurora marketing-grid-field relative min-h-[92vh] overflow-hidden text-[hsl(var(--hero-foreground))]">
|
||||
<div className="relative mx-auto flex min-h-[92vh] max-w-6xl flex-col justify-between px-4 pb-0 pt-10 sm:px-6">
|
||||
<div className="flex flex-1 flex-col justify-center py-12 lg:py-20">
|
||||
<Reveal>
|
||||
<p className="font-mono-brand text-[11px] uppercase tracking-[0.28em] text-[hsl(var(--hero-muted))]">
|
||||
Submatter · agent-native workspace
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={0.05} className="mt-8">
|
||||
<h1 className="font-display text-[clamp(4.5rem,16vw,11rem)] font-extrabold leading-[0.85] tracking-[0.06em]">
|
||||
<ScrambleText text="SBMTR" durationMs={1400} />
|
||||
</h1>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={0.12} className="mt-10 max-w-2xl">
|
||||
<p className="font-marketing text-xl leading-snug tracking-[-0.02em] text-[hsl(var(--hero-foreground))] sm:text-2xl">
|
||||
The coordination plane between{" "}
|
||||
<span className="font-mono-brand text-[hsl(var(--matter))]">plans/</span>, your team, and
|
||||
Cursor subagents.
|
||||
</p>
|
||||
<p className="mt-4 font-marketing text-base leading-relaxed text-[hsl(var(--hero-muted))]">
|
||||
Markdown frontmatter is the contract. MCP is the handshake. Convoys are how work ships.
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={0.18} className="mt-10 flex flex-wrap items-center gap-8">
|
||||
<Link
|
||||
href="/sign-up"
|
||||
className="group inline-flex items-center gap-3 font-marketing text-sm font-medium uppercase tracking-[0.12em] text-[hsl(var(--hero-foreground))]"
|
||||
>
|
||||
<span className="border-b-2 border-[hsl(var(--matter))] pb-1 transition-colors group-hover:text-[hsl(var(--matter))]">
|
||||
Start free
|
||||
</span>
|
||||
<ArrowRight className="size-4 transition-transform group-hover:translate-x-1" aria-hidden />
|
||||
</Link>
|
||||
<Link
|
||||
href="#manifest"
|
||||
className="font-mono-brand text-[11px] uppercase tracking-[0.2em] text-[hsl(var(--hero-muted))] transition-colors hover:text-[hsl(var(--hero-foreground))]"
|
||||
>
|
||||
Read the manifest
|
||||
</Link>
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
<Reveal delay={0.22} y={40} className="pb-8">
|
||||
<ProductDemo className="border-[hsl(var(--hero-border))]" />
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
<MarqueeStrip />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
58
apps/web/components/marketing/how-it-works-section.tsx
Normal file
58
apps/web/components/marketing/how-it-works-section.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { Reveal } from "@/components/marketing/reveal";
|
||||
|
||||
const logLines = [
|
||||
{ time: "09:14:02", level: "sync", msg: "imported plans/Plan-automation-foundation/Epic-convoy-lifecycle-mcp/Task-mcp-claim-task-tool.md" },
|
||||
{ time: "09:14:03", level: "mcp", msg: "claim_task · workspace_id=ws_8a2 · agent=cursor-ide · run=ar_8f2a" },
|
||||
{ time: "09:14:04", level: "convoy", msg: "role-implementer · model=composer-fast · status=in_progress" },
|
||||
{ time: "09:18:41", level: "gate", msg: "human approval required · role-reviewer · awaiting operator" },
|
||||
{ time: "09:22:15", level: "sync", msg: "frontmatter status → done · cursor_todo_id mapped" },
|
||||
{ time: "09:22:16", level: "mcp", msg: "complete_task · run=ar_8f2a · duration=8m14s" },
|
||||
] as const;
|
||||
|
||||
const levelColor: Record<string, string> = {
|
||||
sync: "text-agent",
|
||||
mcp: "text-matter",
|
||||
convoy: "text-foreground",
|
||||
gate: "text-matter",
|
||||
};
|
||||
|
||||
export function HowItWorksSection() {
|
||||
return (
|
||||
<section id="pipeline" className="border-b border-foreground bg-[hsl(var(--hero))] py-24 text-[hsl(var(--hero-foreground))] sm:py-32">
|
||||
<div className="mx-auto max-w-6xl px-4 sm:px-6">
|
||||
<Reveal className="mb-12 flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p className="font-mono-brand text-[11px] uppercase tracking-[0.24em] text-[hsl(var(--hero-muted))]">
|
||||
Pipeline log
|
||||
</p>
|
||||
<h2 className="mt-4 font-marketing text-[clamp(2rem,4vw,3rem)] font-semibold tracking-[-0.03em]">
|
||||
From frontmatter to shipped
|
||||
</h2>
|
||||
</div>
|
||||
<p className="max-w-md font-mono-brand text-[11px] leading-relaxed text-[hsl(var(--hero-muted))]">
|
||||
tail -f .convoys/.metrics.jsonl
|
||||
<br />
|
||||
# auditable agent runs, not black-box chat
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={0.08}>
|
||||
<div className="border border-[hsl(var(--hero-border))] bg-[hsl(0_0%_6%)]">
|
||||
<div className="border-b border-[hsl(var(--hero-border))] px-4 py-2 font-mono-brand text-[10px] uppercase tracking-widest text-[hsl(var(--hero-muted))]">
|
||||
submatter · fleet · live
|
||||
</div>
|
||||
<ul className="divide-y divide-[hsl(var(--hero-border))]">
|
||||
{logLines.map((line) => (
|
||||
<li key={line.time} className="grid gap-2 px-4 py-3 font-mono-brand text-[12px] sm:grid-cols-[5rem_4rem_1fr] sm:gap-4">
|
||||
<span className="text-[hsl(var(--hero-muted))]">{line.time}</span>
|
||||
<span className={levelColor[line.level] ?? "text-[hsl(var(--hero-foreground))]"}>{line.level}</span>
|
||||
<span className="text-[hsl(var(--hero-foreground)/0.9)]">{line.msg}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
61
apps/web/components/marketing/logo.tsx
Normal file
61
apps/web/components/marketing/logo.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import Link from "next/link";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type LogoProps = {
|
||||
className?: string;
|
||||
showTagline?: boolean;
|
||||
size?: "sm" | "md" | "lg";
|
||||
variant?: "dark" | "light";
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: { rule: "w-8", word: "text-xl tracking-[0.22em]", tag: "text-[10px]" },
|
||||
md: { rule: "w-10", word: "text-2xl tracking-[0.24em]", tag: "text-xs" },
|
||||
lg: { rule: "w-14", word: "text-4xl tracking-[0.26em]", tag: "text-sm" },
|
||||
};
|
||||
|
||||
export function Logo({
|
||||
className,
|
||||
showTagline = false,
|
||||
size = "md",
|
||||
variant = "light",
|
||||
}: LogoProps) {
|
||||
const s = sizeClasses[size];
|
||||
const isDark = variant === "dark";
|
||||
|
||||
return (
|
||||
<Link href="/landing" className={cn("group inline-flex flex-col gap-1", className)}>
|
||||
<span
|
||||
className={cn(
|
||||
"h-0.5 rounded-full transition-colors",
|
||||
s.rule,
|
||||
isDark
|
||||
? "bg-[hsl(var(--hero-foreground))] group-hover:bg-[hsl(var(--matter))]"
|
||||
: "bg-foreground group-hover:bg-primary",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"font-display font-extrabold leading-none",
|
||||
s.word,
|
||||
isDark ? "text-[hsl(var(--hero-foreground))]" : "text-foreground",
|
||||
)}
|
||||
>
|
||||
SBMTR
|
||||
</span>
|
||||
{showTagline ? (
|
||||
<span
|
||||
className={cn(
|
||||
"font-marketing",
|
||||
s.tag,
|
||||
isDark ? "text-[hsl(var(--hero-muted))]" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
Submatter
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
57
apps/web/components/marketing/manifest-section.tsx
Normal file
57
apps/web/components/marketing/manifest-section.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { Reveal } from "@/components/marketing/reveal";
|
||||
|
||||
const principles = [
|
||||
{
|
||||
title: "Frontmatter is law",
|
||||
body: "If it is not in YAML at the top of the file, it is not part of the contract. Agents read the same fields humans edit.",
|
||||
},
|
||||
{
|
||||
title: "Repo-native backlog",
|
||||
body: "plans/ lives in git. Submatter syncs — it does not replace your source of truth.",
|
||||
},
|
||||
{
|
||||
title: "Humans at the gates",
|
||||
body: "Convoys pause for approval. Nothing merges because an agent got confident.",
|
||||
},
|
||||
{
|
||||
title: "Tenant-scoped everything",
|
||||
body: "workspace_id on every row. Multitenancy is not a migration you schedule for later.",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function ManifestSection() {
|
||||
return (
|
||||
<section id="manifest" className="marketing-paper-grain border-b border-foreground bg-background py-24 sm:py-32">
|
||||
<div className="mx-auto max-w-6xl px-4 sm:px-6">
|
||||
<Reveal className="mb-16">
|
||||
<p className="font-mono-brand text-[11px] uppercase tracking-[0.24em] text-muted-foreground">Manifest</p>
|
||||
<h2 className="mt-4 max-w-3xl font-marketing text-[clamp(2rem,5vw,3.25rem)] font-semibold leading-[1.08] tracking-[-0.03em]">
|
||||
We are not building another chat box with a kanban view glued on.
|
||||
</h2>
|
||||
</Reveal>
|
||||
|
||||
<div className="grid gap-0 border border-foreground lg:grid-cols-2">
|
||||
{principles.map((item, i) => (
|
||||
<Reveal key={item.title} delay={i * 0.06}>
|
||||
<article
|
||||
className={`border-foreground p-8 ${i % 2 === 0 ? "lg:border-r" : ""} ${i < 2 ? "border-b" : ""}`}
|
||||
>
|
||||
<h3 className="font-marketing text-lg font-semibold tracking-[-0.02em]">{item.title}</h3>
|
||||
<p className="mt-3 font-marketing text-sm leading-relaxed text-muted-foreground">{item.body}</p>
|
||||
</article>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Reveal delay={0.2} className="mt-16 border-l-4 border-matter pl-6">
|
||||
<p className="font-marketing text-lg leading-relaxed text-foreground">
|
||||
Subagents. Frontmatter. One workspace.
|
||||
</p>
|
||||
<p className="mt-2 font-mono-brand text-[11px] text-muted-foreground">
|
||||
kind: manifest · version: 2 · status: locked
|
||||
</p>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
49
apps/web/components/marketing/marquee-strip.tsx
Normal file
49
apps/web/components/marketing/marquee-strip.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
|
||||
const items = [
|
||||
"frontmatter",
|
||||
"subagents",
|
||||
"claim_task",
|
||||
"complete_task",
|
||||
"convoy",
|
||||
"workspace_id",
|
||||
"plans/",
|
||||
"mcp",
|
||||
"markdown-backlog",
|
||||
"human-gates",
|
||||
"model_policy",
|
||||
"role-implementer",
|
||||
"role-reviewer",
|
||||
"role-architect",
|
||||
] as const;
|
||||
|
||||
export function MarqueeStrip({ className, variant = "dark" }: { className?: string; variant?: "dark" | "light" }) {
|
||||
const track = [...items, ...items];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden border-y py-3",
|
||||
variant === "dark"
|
||||
? "border-[hsl(var(--hero-border))] bg-[hsl(var(--hero))]"
|
||||
: "border-foreground bg-background",
|
||||
className,
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
<div className="marketing-marquee-track flex w-max gap-8 whitespace-nowrap px-4">
|
||||
{track.map((item, i) => (
|
||||
<span
|
||||
key={`${item}-${i}`}
|
||||
className={cn(
|
||||
"font-mono-brand text-[11px] uppercase tracking-[0.18em]",
|
||||
variant === "dark" ? "text-[hsl(var(--hero-muted))]" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
91
apps/web/components/marketing/product-demo.tsx
Normal file
91
apps/web/components/marketing/product-demo.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
"use client";
|
||||
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import { Bot, CheckCircle2, GitBranch, Terminal } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const pipelineSteps = [
|
||||
{ role: "architect", model: "opus", status: "done" as const },
|
||||
{ role: "implementer", model: "fast", status: "active" as const },
|
||||
{ role: "reviewer", model: "fast", status: "queued" as const },
|
||||
];
|
||||
|
||||
export function ProductDemo({ className }: { className?: string }) {
|
||||
const reduceMotion = useReducedMotion();
|
||||
|
||||
return (
|
||||
<div className={cn("relative overflow-hidden border bg-[hsl(0_0%_6%)]", className)}>
|
||||
<div className="flex items-center justify-between border-b border-[hsl(var(--hero-border))] px-4 py-3">
|
||||
<div className="flex gap-2 font-mono-brand text-[10px] uppercase tracking-widest text-[hsl(var(--hero-muted))]">
|
||||
<Terminal className="size-3.5" aria-hidden />
|
||||
<span>workspace · convoy</span>
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-2 font-mono-brand text-[10px] text-[hsl(var(--agent))]">
|
||||
<span className="marketing-pulse-dot size-1.5 bg-[hsl(var(--agent))]" />
|
||||
mcp connected
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid lg:grid-cols-2">
|
||||
<div className="border-b border-[hsl(var(--hero-border))] p-5 lg:border-b-0 lg:border-r">
|
||||
<p className="mb-3 font-mono-brand text-[10px] text-[hsl(var(--hero-muted))]">plans/…/Task-*.md</p>
|
||||
<pre className="font-mono-brand text-[12px] leading-[1.7] text-[hsl(var(--hero-foreground))]">
|
||||
<span className="text-[hsl(var(--hero-muted))]">---{"\n"}</span>
|
||||
<span className="text-[hsl(var(--agent))]">kind</span>
|
||||
<span className="text-[hsl(var(--hero-muted))]">: </span>task{"\n"}
|
||||
<span className="text-[hsl(var(--agent))]">status</span>
|
||||
<span className="text-[hsl(var(--hero-muted))]">: </span>
|
||||
<span className="text-[hsl(var(--matter))]">in_progress</span>
|
||||
{"\n"}
|
||||
<span className="text-[hsl(var(--agent))]">priority</span>
|
||||
<span className="text-[hsl(var(--hero-muted))]">: </span>P1{"\n"}
|
||||
<span className="text-[hsl(var(--agent))]">model_policy</span>
|
||||
<span className="text-[hsl(var(--hero-muted))]">: </span>composer-fast{"\n"}
|
||||
<span className="text-[hsl(var(--hero-muted))]">---</span>
|
||||
<span className="marketing-cursor-blink text-[hsl(var(--matter))]">█</span>
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="p-5">
|
||||
<div className="mb-4 flex items-center gap-2 font-mono-brand text-[10px] uppercase tracking-widest text-[hsl(var(--hero-muted))]">
|
||||
<GitBranch className="size-3.5" aria-hidden />
|
||||
convoy pipeline
|
||||
</div>
|
||||
<ol className="space-y-2">
|
||||
{pipelineSteps.map((step, index) => (
|
||||
<motion.li
|
||||
key={step.role}
|
||||
className={cn(
|
||||
"flex items-center justify-between border px-3 py-2.5",
|
||||
step.status === "active"
|
||||
? "border-[hsl(var(--matter))] bg-[hsl(var(--matter)/0.07)]"
|
||||
: "border-[hsl(var(--hero-border))]",
|
||||
)}
|
||||
initial={reduceMotion ? false : { opacity: 0, x: 16 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.2 + index * 0.1, duration: 0.4 }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{step.status === "done" ? (
|
||||
<CheckCircle2 className="size-3.5 text-[hsl(var(--agent))]" aria-hidden />
|
||||
) : (
|
||||
<Bot
|
||||
className={cn(
|
||||
"size-3.5",
|
||||
step.status === "active" ? "text-[hsl(var(--matter))]" : "text-[hsl(var(--hero-muted))]",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
<span className="font-mono-brand text-xs">role-{step.role}</span>
|
||||
</div>
|
||||
<span className="font-mono-brand text-[10px] text-[hsl(var(--hero-muted))]">{step.model}</span>
|
||||
</motion.li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
90
apps/web/components/marketing/reveal.tsx
Normal file
90
apps/web/components/marketing/reveal.tsx
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
|
||||
type RevealProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
delay?: number;
|
||||
y?: number;
|
||||
};
|
||||
|
||||
export function Reveal({ children, className, delay = 0, y = 24 }: RevealProps) {
|
||||
const reduceMotion = useReducedMotion();
|
||||
|
||||
if (reduceMotion) {
|
||||
return <div className={className}>{children}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={className}
|
||||
initial={{ opacity: 0, y }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-60px" }}
|
||||
transition={{ duration: 0.55, delay, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
type StaggerProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
stagger?: number;
|
||||
};
|
||||
|
||||
export function Stagger({ children, className, stagger = 0.08 }: StaggerProps) {
|
||||
const reduceMotion = useReducedMotion();
|
||||
|
||||
if (reduceMotion) {
|
||||
return <div className={className}>{children}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={className}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, margin: "-40px" }}
|
||||
variants={{
|
||||
hidden: {},
|
||||
visible: { transition: { staggerChildren: stagger } },
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StaggerItem({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const reduceMotion = useReducedMotion();
|
||||
|
||||
if (reduceMotion) {
|
||||
return <div className={className}>{children}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={className}
|
||||
variants={{
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.5, ease: [0.22, 1, 0.36, 1] },
|
||||
},
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
64
apps/web/components/marketing/scramble-text.tsx
Normal file
64
apps/web/components/marketing/scramble-text.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789/_-";
|
||||
|
||||
type ScrambleTextProps = {
|
||||
text: string;
|
||||
className?: string;
|
||||
durationMs?: number;
|
||||
};
|
||||
|
||||
export function ScrambleText({ text, className, durationMs = 1200 }: ScrambleTextProps) {
|
||||
const reduceMotion = useReducedMotion();
|
||||
const [display, setDisplay] = useState(text);
|
||||
|
||||
useEffect(() => {
|
||||
if (reduceMotion) {
|
||||
setDisplay(text);
|
||||
return;
|
||||
}
|
||||
|
||||
let frame = 0;
|
||||
const totalFrames = Math.ceil(durationMs / 32);
|
||||
const locked = new Set<number>();
|
||||
|
||||
const id = window.setInterval(() => {
|
||||
frame += 1;
|
||||
const progress = frame / totalFrames;
|
||||
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
if (text[i] === " ") continue;
|
||||
if (Math.random() < progress * 0.15 + 0.05) locked.add(i);
|
||||
}
|
||||
|
||||
const next = text
|
||||
.split("")
|
||||
.map((char, i) => {
|
||||
if (char === " ") return " ";
|
||||
if (locked.has(i) || progress > 0.92) return char;
|
||||
return CHARSET[Math.floor(Math.random() * CHARSET.length)] ?? char;
|
||||
})
|
||||
.join("");
|
||||
|
||||
setDisplay(next);
|
||||
|
||||
if (frame >= totalFrames) {
|
||||
window.clearInterval(id);
|
||||
setDisplay(text);
|
||||
}
|
||||
}, 32);
|
||||
|
||||
return () => window.clearInterval(id);
|
||||
}, [text, durationMs, reduceMotion]);
|
||||
|
||||
return (
|
||||
<motion.span className={cn("inline-block", className)} initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
|
||||
{display}
|
||||
</motion.span>
|
||||
);
|
||||
}
|
||||
45
apps/web/components/marketing/site-footer.tsx
Normal file
45
apps/web/components/marketing/site-footer.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import Link from "next/link";
|
||||
|
||||
import { Logo } from "@/components/marketing/logo";
|
||||
import { MarqueeStrip } from "@/components/marketing/marquee-strip";
|
||||
|
||||
const footerLinks = [
|
||||
{ href: "/landing#features", label: "Capabilities" },
|
||||
{ href: "/landing#manifest", label: "Manifest" },
|
||||
{ href: "/landing/pricing", label: "Pricing" },
|
||||
{ href: "/sign-in", label: "Sign in" },
|
||||
] as const;
|
||||
|
||||
export function SiteFooter() {
|
||||
return (
|
||||
<footer className="bg-background">
|
||||
<MarqueeStrip variant="light" />
|
||||
<div className="mx-auto flex max-w-6xl flex-col gap-10 px-4 py-14 sm:px-6 md:flex-row md:items-start md:justify-between">
|
||||
<div className="space-y-3">
|
||||
<Logo size="sm" showTagline variant="light" />
|
||||
<p className="max-w-xs font-marketing text-sm text-muted-foreground">
|
||||
Subagents. Frontmatter. One workspace.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<nav className="flex flex-wrap gap-x-10 gap-y-3" aria-label="Footer">
|
||||
{footerLinks.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="font-mono-brand text-[11px] uppercase tracking-[0.16em] text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="marketing-hard-rule" aria-hidden />
|
||||
<div className="mx-auto flex max-w-6xl flex-col gap-2 px-4 py-5 sm:flex-row sm:items-center sm:justify-between sm:px-6">
|
||||
<p className="font-mono-brand text-[11px] text-muted-foreground">© {new Date().getFullYear()} Submatter</p>
|
||||
<p className="font-mono-brand text-[11px] text-muted-foreground">v2 · sbmtr.io</p>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
53
apps/web/components/marketing/site-header.tsx
Normal file
53
apps/web/components/marketing/site-header.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import Link from "next/link";
|
||||
|
||||
import { Logo } from "@/components/marketing/logo";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const nav = [
|
||||
{ href: "/landing#features", label: "Capabilities" },
|
||||
{ href: "/landing#pipeline", label: "Pipeline" },
|
||||
{ href: "/landing#manifest", label: "Manifest" },
|
||||
{ href: "/landing/pricing", label: "Pricing" },
|
||||
] as const;
|
||||
|
||||
export function SiteHeader({ className }: { className?: string }) {
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"sticky top-0 z-50 border-b border-[hsl(var(--hero-border))] bg-[hsl(var(--hero)/0.92)] text-[hsl(var(--hero-foreground))] backdrop-blur-sm",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between gap-6 px-4 sm:px-6">
|
||||
<Logo size="sm" variant="dark" />
|
||||
|
||||
<nav className="hidden items-center gap-8 md:flex" aria-label="Main">
|
||||
{nav.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="font-mono-brand text-[11px] uppercase tracking-[0.16em] text-[hsl(var(--hero-muted))] transition-colors hover:text-[hsl(var(--hero-foreground))]"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
href="/sign-in"
|
||||
className="hidden font-mono-brand text-[11px] uppercase tracking-[0.16em] text-[hsl(var(--hero-muted))] transition-colors hover:text-[hsl(var(--hero-foreground))] sm:inline"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
<Link
|
||||
href="/sign-up"
|
||||
className="inline-flex h-9 items-center border border-[hsl(var(--matter))] px-4 font-mono-brand text-[11px] uppercase tracking-[0.14em] text-[hsl(var(--matter))] transition-colors hover:bg-[hsl(var(--matter))] hover:text-[hsl(var(--hero))]"
|
||||
>
|
||||
Start
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
130
apps/web/components/mission-control/convoy-dag.tsx
Normal file
130
apps/web/components/mission-control/convoy-dag.tsx
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
"use client";
|
||||
|
||||
import { BriefSummary } from "@tasks/shared/orchestrator";
|
||||
|
||||
interface ConvoyDagProps {
|
||||
briefs: Array<{
|
||||
id: string;
|
||||
briefNumber: number;
|
||||
sliceDependencies: number[];
|
||||
status: string | null;
|
||||
}>;
|
||||
completedBriefIds: Set<string>;
|
||||
}
|
||||
|
||||
export function ConvoyDag({ briefs, completedBriefIds }: ConvoyDagProps) {
|
||||
if (briefs.length === 0) return null;
|
||||
|
||||
const briefByNum = new Map(briefs.map((b) => [b.briefNumber, b]));
|
||||
|
||||
const nodeSize = 60;
|
||||
const spacing = 180;
|
||||
const startX = 80;
|
||||
const startY = 80;
|
||||
|
||||
const getPosition = (num: number) => ({
|
||||
x: startX + ((num - 1) % 4) * spacing,
|
||||
y: startY + Math.floor((num - 1) / 4) * 140,
|
||||
});
|
||||
|
||||
const briefsSorted = [...briefs].sort((a, b) => a.briefNumber - b.briefNumber);
|
||||
|
||||
return (
|
||||
<div className="w-full h-[300px] overflow-auto bg-muted/30 rounded-lg p-4 relative">
|
||||
<svg
|
||||
className="w-full h-full"
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
viewBox="0 0 800 600"
|
||||
>
|
||||
<defs>
|
||||
<marker
|
||||
id="arrowhead"
|
||||
markerWidth="10"
|
||||
markerHeight="7"
|
||||
refX="9"
|
||||
refY="3.5"
|
||||
orient="auto"
|
||||
>
|
||||
<path d="M0,0 L10,3.5 L0,7 Z" fill="currentColor" />
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
{briefsSorted.map((brief) => {
|
||||
const pos = getPosition(brief.briefNumber);
|
||||
const isComplete = completedBriefIds.has(brief.id);
|
||||
const isEligible = !isComplete && brief.sliceDependencies.every((dep) => {
|
||||
const depBrief = briefByNum.get(dep);
|
||||
return depBrief && completedBriefIds.has(depBrief.id);
|
||||
});
|
||||
|
||||
brief.sliceDependencies.forEach((depNum) => {
|
||||
const dep = briefByNum.get(depNum);
|
||||
if (dep) {
|
||||
const depPos = getPosition(depNum);
|
||||
return (
|
||||
<line
|
||||
key={`edge-${depNum}-${brief.briefNumber}`}
|
||||
x1={depPos.x + nodeSize}
|
||||
y1={depPos.y + nodeSize / 2}
|
||||
x2={pos.x}
|
||||
y2={pos.y + nodeSize / 2}
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
markerEnd="url(#arrowhead)"
|
||||
opacity={0.4}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<g key={`node-${brief.briefNumber}`} transform={`translate(${pos.x}, ${pos.y})`}>
|
||||
<rect
|
||||
width={nodeSize}
|
||||
height={nodeSize}
|
||||
rx={8}
|
||||
fill={isComplete ? "var(--green-3)" : isEligible ? "var(--blue-3)" : "var(--gray-4)"}
|
||||
stroke={isComplete ? "var(--green-7)" : isEligible ? "var(--blue-7)" : "var(--gray-7)"}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<text
|
||||
x={nodeSize / 2}
|
||||
y={nodeSize / 2 + 4}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fill="var(--white)"
|
||||
fontSize={14}
|
||||
fontWeight={600}
|
||||
>
|
||||
B{brief.briefNumber}
|
||||
</text>
|
||||
{isComplete && (
|
||||
<circle
|
||||
cx={nodeSize - 12}
|
||||
cy={12}
|
||||
r={10}
|
||||
fill="var(--green-7)"
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
<p className="mt-4 text-xs text-muted-foreground">
|
||||
Green = Complete | Blue = Ready | Gray = Blocked
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getPosition(num: number) {
|
||||
const nodeSize = 60;
|
||||
const spacing = 180;
|
||||
const startX = 80;
|
||||
const startY = 80;
|
||||
return {
|
||||
x: 80 + ((num - 1) % 4) * 180,
|
||||
y: 80 + Math.floor((num - 1) / 4) * 140,
|
||||
};
|
||||
}
|
||||
298
apps/web/components/mission-control/convoy-detail-panel.tsx
Normal file
298
apps/web/components/mission-control/convoy-detail-panel.tsx
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import type { ConvoyStatus } from "@tasks/shared/convoy-lifecycle";
|
||||
import { Loader2, Sparkles } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/lib/trpc";
|
||||
|
||||
function gateActionsForStatus(status: ConvoyStatus): Array<{
|
||||
action: "approve" | "revise" | "request_changes" | "promote";
|
||||
label: string;
|
||||
variant?: "default" | "outline" | "destructive";
|
||||
}> {
|
||||
switch (status) {
|
||||
case "convoy_review":
|
||||
return [
|
||||
{ action: "approve", label: "Approve plan" },
|
||||
{ action: "revise", label: "Request changes", variant: "outline" },
|
||||
];
|
||||
case "pr_review":
|
||||
return [
|
||||
{ action: "approve", label: "Approve merge" },
|
||||
{ action: "request_changes", label: "Request changes", variant: "outline" },
|
||||
];
|
||||
case "merged":
|
||||
return [{ action: "promote", label: "Promote to prod" }];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function ConvoyDetailPanel({
|
||||
workspaceSlug,
|
||||
convoyId,
|
||||
}: {
|
||||
workspaceSlug: string;
|
||||
convoyId: string | null;
|
||||
}) {
|
||||
const utils = api.useUtils();
|
||||
const [copilotText, setCopilotText] = React.useState<string | null>(null);
|
||||
const [noteBody, setNoteBody] = React.useState("");
|
||||
const [noteDelivery, setNoteDelivery] = React.useState<"queued" | "direct">("queued");
|
||||
|
||||
const detailQuery = api.convoys.get.useQuery(
|
||||
{ workspace: workspaceSlug, convoyId: convoyId ?? "" },
|
||||
{ enabled: Boolean(workspaceSlug && convoyId) },
|
||||
);
|
||||
|
||||
const gateMut = api.convoys.gateAction.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.convoys.kanban.invalidate();
|
||||
if (convoyId) void utils.convoys.get.invalidate({ workspace: workspaceSlug, convoyId });
|
||||
},
|
||||
});
|
||||
|
||||
const copilotMut = api.convoys.gateCopilot.useMutation({
|
||||
onSuccess: (result) => setCopilotText(result.text),
|
||||
});
|
||||
|
||||
const operatorNotesQuery = api.convoys.listOperatorNotes.useQuery(
|
||||
{ workspace: workspaceSlug, convoyId: convoyId ?? "" },
|
||||
{ enabled: Boolean(workspaceSlug && convoyId) },
|
||||
);
|
||||
|
||||
const postNoteMut = api.convoys.postOperatorNote.useMutation({
|
||||
onSuccess: () => {
|
||||
setNoteBody("");
|
||||
void operatorNotesQuery.refetch();
|
||||
},
|
||||
});
|
||||
|
||||
if (!convoyId) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-6 text-sm text-muted-foreground">
|
||||
Select a convoy to review gates and co-pilot suggestions.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (detailQuery.isLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-6 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading convoy…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const convoy = detailQuery.data?.convoy;
|
||||
if (!convoy) {
|
||||
return <p className="p-6 text-sm text-muted-foreground">Convoy not found.</p>;
|
||||
}
|
||||
|
||||
const status = convoy.status as ConvoyStatus;
|
||||
const actions = gateActionsForStatus(status);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col overflow-y-auto p-4">
|
||||
<div className="mb-4">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{convoy.content?.convoySlug ?? convoy.title}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">Status: {status}</p>
|
||||
{convoy.content?.successMetric && (
|
||||
<p className="mt-2 text-sm text-muted-foreground">{convoy.content.successMetric}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-4 rounded-md border border-border bg-muted/30 p-3 text-sm">
|
||||
<p className="mb-1 text-xs font-medium uppercase text-muted-foreground">
|
||||
Worker checkpoints
|
||||
</p>
|
||||
<p>
|
||||
{detailQuery.data?.checkpoints.count ?? 0} checkpoint
|
||||
{(detailQuery.data?.checkpoints.count ?? 0) === 1 ? "" : "s"}
|
||||
{detailQuery.data?.checkpoints.lastOccurredAt
|
||||
? ` · last ${new Date(detailQuery.data.checkpoints.lastOccurredAt).toLocaleString()}`
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{detailQuery.data?.pr?.prUrl && (
|
||||
<div className="mb-4 rounded-md border border-border bg-muted/30 p-3 text-sm">
|
||||
<p className="mb-1 text-xs font-medium uppercase text-muted-foreground">PR</p>
|
||||
<a
|
||||
href={detailQuery.data.pr.prUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="break-all text-primary underline"
|
||||
>
|
||||
{detailQuery.data.pr.prUrl}
|
||||
{typeof detailQuery.data.pr.prNumber === "number" && detailQuery.data.pr.prNumber
|
||||
? ` (#${detailQuery.data.pr.prNumber})`
|
||||
: ""}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{convoy.description && (
|
||||
<div className="mb-4 rounded-md border border-border bg-muted/30 p-3">
|
||||
<p className="mb-1 text-xs font-medium uppercase text-muted-foreground">Plan</p>
|
||||
<pre className="whitespace-pre-wrap text-sm">{convoy.description}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actions.length > 0 && (
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
{actions.map((item) => (
|
||||
<Button
|
||||
key={item.action}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={item.variant ?? "default"}
|
||||
disabled={gateMut.isPending}
|
||||
onClick={() =>
|
||||
gateMut.mutate({
|
||||
workspace: workspaceSlug,
|
||||
convoyId,
|
||||
action: item.action,
|
||||
})
|
||||
}
|
||||
>
|
||||
{gateMut.isPending ? <Loader2 className="size-4 animate-spin" /> : item.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-4 rounded-md border border-border p-3">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<p className="flex items-center gap-1 text-sm font-medium">
|
||||
<Sparkles className="size-4 text-primary" />
|
||||
Gate co-pilot
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={copilotMut.isPending}
|
||||
onClick={() =>
|
||||
copilotMut.mutate({
|
||||
workspace: workspaceSlug,
|
||||
convoyId,
|
||||
})
|
||||
}
|
||||
>
|
||||
{copilotMut.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
"Summarize"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{copilotMut.data && (
|
||||
<p className="mb-2 text-[10px] text-muted-foreground">
|
||||
Model: {copilotMut.data.resolvedAlias} ({copilotMut.data.modelId})
|
||||
</p>
|
||||
)}
|
||||
{copilotMut.error && (
|
||||
<p className="text-xs text-destructive">{copilotMut.error.message}</p>
|
||||
)}
|
||||
{copilotText && (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none text-sm">
|
||||
<p className="whitespace-pre-wrap">{copilotText}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(detailQuery.data?.briefs.length ?? 0) > 0 && (
|
||||
<div className="mb-4">
|
||||
<p className="mb-2 text-xs font-medium uppercase text-muted-foreground">Briefs</p>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{detailQuery.data?.briefs.map((brief) => (
|
||||
<li key={brief.id} className="rounded border border-border px-2 py-1">
|
||||
{brief.title}{" "}
|
||||
<span className="text-muted-foreground">({brief.status ?? "—"})</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "in_progress" && convoyId && (
|
||||
<div className="rounded-md border border-border p-3">
|
||||
<p className="mb-2 text-xs font-medium uppercase text-muted-foreground">
|
||||
Operator notes — in progress thread
|
||||
</p>
|
||||
<p className="mb-2 text-[11px] text-muted-foreground">
|
||||
Distinct from gate co-pilot. Queued notes inject at next tool round; direct messages prepend before next model call.
|
||||
</p>
|
||||
<div className="mb-3 space-y-2 max-h-64 overflow-y-auto">
|
||||
{(operatorNotesQuery.data ?? []).length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No operator messages yet.</p>
|
||||
) : (
|
||||
(operatorNotesQuery.data ?? []).map((note) => (
|
||||
<div key={note.id} className="rounded border border-border bg-muted/20 p-2 text-xs">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span
|
||||
className={
|
||||
note.delivery === "direct"
|
||||
? "rounded bg-primary/10 px-1.5 py-0.5 font-medium text-primary"
|
||||
: "rounded bg-muted px-1.5 py-0.5 font-medium"
|
||||
}
|
||||
>
|
||||
{note.delivery}
|
||||
</span>
|
||||
<span className="text-muted-foreground">{new Date(note.createdAt).toLocaleString()}</span>
|
||||
{note.deliveredAt && (
|
||||
<span className="text-green-600">delivered {new Date(note.deliveredAt).toLocaleString()}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap text-sm">{note.body}</p>
|
||||
<p className="mt-1 text-[10px] text-muted-foreground">author {note.authorUserId.slice(0, 8)}</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<textarea
|
||||
className="min-h-[72px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
placeholder="Queued note: use existing auth middleware"
|
||||
value={noteBody}
|
||||
onChange={(e) => setNoteBody(e.target.value)}
|
||||
maxLength={2000}
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<select
|
||||
className="rounded-md border border-input bg-background px-2 py-1 text-xs"
|
||||
value={noteDelivery}
|
||||
onChange={(e) => setNoteDelivery(e.target.value as "queued" | "direct")}
|
||||
>
|
||||
<option value="queued">Queued note</option>
|
||||
<option value="direct">Direct message</option>
|
||||
</select>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={postNoteMut.isPending || !noteBody.trim()}
|
||||
onClick={() =>
|
||||
postNoteMut.mutate({
|
||||
workspace: workspaceSlug,
|
||||
convoyId,
|
||||
delivery: noteDelivery,
|
||||
body: noteBody.trim(),
|
||||
})
|
||||
}
|
||||
>
|
||||
{postNoteMut.isPending ? <Loader2 className="size-4 animate-spin" /> : "Send"}
|
||||
</Button>
|
||||
</div>
|
||||
{postNoteMut.error && <p className="text-xs text-destructive">{postNoteMut.error.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
128
apps/web/components/mission-control/convoy-kanban.tsx
Normal file
128
apps/web/components/mission-control/convoy-kanban.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { gateLabelForStatus } from "@tasks/shared/convoy-lifecycle";
|
||||
import type { ConvoyStatus } from "@tasks/shared/convoy-lifecycle";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/lib/trpc";
|
||||
|
||||
type ConvoyCard = {
|
||||
id: string;
|
||||
title: string;
|
||||
status: ConvoyStatus;
|
||||
content: { convoySlug: string; classification: string; successMetric: string } | null;
|
||||
};
|
||||
|
||||
export function ConvoyKanban({
|
||||
workspaceSlug,
|
||||
selectedId,
|
||||
onSelect,
|
||||
}: {
|
||||
workspaceSlug: string;
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
const kanbanQuery = api.convoys.kanban.useQuery(
|
||||
{ workspace: workspaceSlug },
|
||||
{ enabled: Boolean(workspaceSlug) },
|
||||
);
|
||||
|
||||
if (kanbanQuery.isLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-6 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading kanban…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const columns = kanbanQuery.data?.columns ?? [];
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 gap-3 overflow-x-auto p-4">
|
||||
{columns.map((column) => (
|
||||
<div
|
||||
key={column.status}
|
||||
className="flex w-56 shrink-0 flex-col rounded-lg border border-border bg-muted/20"
|
||||
>
|
||||
<div className="border-b border-border px-3 py-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{column.label}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground">{column.convoys.length}</p>
|
||||
</div>
|
||||
<ul className="flex flex-1 flex-col gap-2 overflow-y-auto p-2">
|
||||
{column.convoys.map((convoy) => (
|
||||
<li key={convoy.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(convoy.id)}
|
||||
className={cn(
|
||||
"w-full rounded-md border border-border bg-background p-2 text-left text-sm transition-colors hover:bg-muted",
|
||||
selectedId === convoy.id && "ring-2 ring-primary",
|
||||
)}
|
||||
>
|
||||
<p className="font-medium">{convoy.content?.convoySlug ?? convoy.title}</p>
|
||||
{convoy.content?.classification && (
|
||||
<p className="mt-0.5 text-[10px] text-muted-foreground">
|
||||
{convoy.content.classification}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GateInbox({
|
||||
workspaceSlug,
|
||||
selectedId,
|
||||
onSelect,
|
||||
}: {
|
||||
workspaceSlug: string;
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
const kanbanQuery = api.convoys.kanban.useQuery(
|
||||
{ workspace: workspaceSlug },
|
||||
{ enabled: Boolean(workspaceSlug) },
|
||||
);
|
||||
|
||||
const inbox = kanbanQuery.data?.gateInbox ?? [];
|
||||
|
||||
return (
|
||||
<div className="border-b border-border p-4">
|
||||
<h2 className="text-sm font-semibold">Needs you</h2>
|
||||
<p className="text-xs text-muted-foreground">Gate 1, 2, and 3 approvals</p>
|
||||
{inbox.length === 0 ? (
|
||||
<p className="mt-3 text-sm text-muted-foreground">No pending gates.</p>
|
||||
) : (
|
||||
<ul className="mt-3 space-y-2">
|
||||
{inbox.map((convoy: ConvoyCard) => (
|
||||
<li key={convoy.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(convoy.id)}
|
||||
className={cn(
|
||||
"w-full rounded-md border border-amber-500/30 bg-amber-500/5 p-3 text-left text-sm hover:bg-amber-500/10",
|
||||
selectedId === convoy.id && "ring-2 ring-amber-500",
|
||||
)}
|
||||
>
|
||||
<p className="font-medium">{convoy.content?.convoySlug ?? convoy.title}</p>
|
||||
<p className="text-xs text-amber-700 dark:text-amber-300">
|
||||
{gateLabelForStatus(convoy.status)}
|
||||
</p>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue