Compare commits

...

10 commits

Author SHA1 Message Date
Randall Stillwell
9bde5960bf fix(deploy): collab URL, CT 116 prod compose, Forgejo migration
Some checks failed
CI / Lint, type-check, test (push) Has been cancelled
Deploy homelab (CT 116) / Deploy to CT 116 (push) Has been cancelled
- Read NEXT_PUBLIC_COLLAB_SERVER_URL in yjs provider; bake NEXT_PUBLIC_* at Docker build
- Add prod compose, deploy scripts, CT 116 proxmox scaffolding
- Forgejo Actions CI/deploy; migrate-origin and post-migration scripts
2026-09-08 22:33:59 -05:00
Randall Stillwell
4efaf6c0c8 docs: add Phase 5 ACP roadmap + deploy OpenHands agent-server on Dokploy
- Added Phase 5: ACP Harness & Runtime Agnosticism to roadmap
- Created docker/docker-compose.agent-server.yml for CT 102 deployment
- Updated worker default AGENT_SERVER_URL to internal Docker DNS (http://agent-server:8001)
- Worker now runs OpenHands sessions directly on the server, no laptop/Traefik needed
2026-09-08 10:13:27 -05:00
Randall Stillwell
3fc7d01585 feat(worker): OpenHands session bootstrap + 5 Agent Skills
Phase 1: Add 5 SKILL.md files to .agents/skills/
  - backlog-sync: plans/ markdown ↔ DB contract
  - convoy-lifecycle: status machine, gates, MCP tools, checkpoints
  - model-routing: role→model via OmniRoute profiles + registry
  - code-review-two-axis: Standards vs Spec parallel sub-agent review
  - tdd-red-green: TDD loop with seams

Phase 2: OmniRoute operational docs
  - docs/omniroute-role-mapping.md: profile→role matrix, convoy examples
  - docs/omniroute-setup.md: step-by-step operator guide

Phase 3: Worker OpenHands integration
  - apps/worker/src/session/load-skills.ts: loads .agents/skills/
  - apps/worker/src/session/openhands-session.ts: agent-server REST adapter
  - apps/worker/src/executor/index.ts: createOpenHandsExecutor() factory
  - apps/worker/src/env.ts: WORKER_SESSION_TYPE, AGENT_SERVER_URL, SESSION_API_KEY
  - apps/worker/src/handle-job.ts: routes to OpenHands when configured
  - apps/worker/src/session/minimal-session.ts: injects skills into system prompt
  - packages/shared/src/worker/index.ts: re-export WorkerExecutorOptions
2026-09-07 15:10:02 -05:00
Randall Stillwell
f6028615e8 feat(worker): tighten harness executor types + add tests
- Extract WorkerExecutorOptions from inline type in shared executor
- Use WorkerExecutorOptions throughout remote-ag-ui.ts and index.ts
  instead of loose any types
- Extend WorkerExecutor.run return to include outcome
- Add 8 test cases in executor.test.ts covering:
  - Registry dispatch for all harness types
  - Missing config returns null
  - Auth token missing returns failed
  - POST + poll roundtrip behavior
2026-09-04 10:33:40 -05:00
Randall Stillwell
3810cd9dab feat(worker): harness registry dispatch — wire WorkerExecutor for remote-ag-ui
Replaces the hardcoded createMinimalSession in handle-job.ts with a registry
lookup via getExecutor(harnessConfig). The 'minimal' harness still wraps
createMinimalSession; 'remote-ag-ui' POSTs the task payload to a remote agent
endpoint (OpenHands / Open Code / etc.) and polls for completion.

Worker-side completion of Brief 3 (harness-registry-adapter). Pairs with the
already-built AG-UI inbound route at /api/ag-ui/[workspace] and the
harness_providers / role_harness_routes tables.
2026-09-03 23:27:25 -05:00
Randall Stillwell
2ed38fa1f2 chore(convoy): polish-dag-tiers-cost → merged (approved, all follow-ons closed) 2026-08-25 20:33:37 -05:00
Randall Stillwell
e3af2c33e6 feat(polish): cost caps + DAG + tiers + preflight + checks panel
- Cost caps: budget_caps table, MTD spend ledger, tRPC budgets router, orchestrator pause on cap
- DAG viz: convoy-dag.tsx SVG for brief dependencies in Mission Control
- Automation tiers: manual|assist|auto per convoy, orchestrator respects manual
- Preflight: connected-repos preflight endpoint
- Checks panel: forge checks stub in detail panel
- Budget caps schema + tRPC router + Settings UI stub

Convoy: .convoys/polish-dag-tiers-cost

Co-authored-by: orchestrator
2026-08-25 20:33:10 -05:00
Randall Stillwell
9ad114b2f5 chore(convoy): owner-cockpit-v02 → merged (approved, Epic 8/11/12 + doc-writer) 2026-08-25 18:55:50 -05:00
Randall Stillwell
02b1ae610a chore(convoy): owner-cockpit-v02 → in-progress (implementers complete, awaiting review) 2026-08-25 17:49:54 -05:00
Randall Stillwell
6f139d6af2 feat(owner-cockpit): forge webhooks + artifacts + copilot (Epic 8/11/12)
- Forge webhook POST /api/webhooks/forge HMAC sha256 verify, marker echodo-convoy → pr_review→merged as automation
- Completion artifacts table 0017, deterministic summary template, gallery /artifacts
- Ops copilot tRPC copilot + context tools (fleet, convoy, backlog) + panel + Draft Task → backlog.createTask
- Doc-writer SOPs: webhook rotation, copilot prompt, published flow examples
- Allow pr_review→merged as automation for webhook

Convoy: .convoys/owner-cockpit-v02

Co-authored-by: orchestrator
2026-08-25 17:49:38 -05:00
83 changed files with 12232 additions and 330 deletions

View 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

View 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

View 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

View 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

View 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`

View 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)

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

View file

@ -2,11 +2,9 @@
# ECHODO — Environment Variables # ECHODO — Environment Variables
# ===================================================================== # =====================================================================
# Local dev: copy this file to .env and adjust values. # Local dev: copy this file to .env and adjust values.
# Coolify (CT 107): paste these into the resource's "Environment Variables" # Production (CT 116 standalone LXC): paste into /opt/submatter/.env on the host.
# tab. Coolify also accepts a .env file uploaded via the UI. # See docs/deployment-production.md and proxmox/ct116/README.md.
# # See AGENT-DEPLOY.md for shared CT 102 service credentials and Traefik routing.
# See AGENT-DEPLOY.md for shared CT 102 service credentials and the
# CT 100 Traefik routing pattern.
# ===================================================================== # =====================================================================
# ----- Database (CT 102 shared Postgres) ----- # ----- Database (CT 102 shared Postgres) -----
@ -44,6 +42,16 @@ REDIS_URL="redis://:CHANGE_ME@192.168.68.102:6379/11"
# WORKER_CHECKPOINT_EVERY_N_STEPS=1 # WORKER_CHECKPOINT_EVERY_N_STEPS=1
# WORKER_REPO_ROOT= # defaults to monorepo root (pnpm-workspace.yaml walk-up) # WORKER_REPO_ROOT= # defaults to monorepo root (pnpm-workspace.yaml walk-up)
# WORKER_WORKTREE_BASE=/tmp/echodo-worker-worktrees # WORKER_WORKTREE_BASE=/tmp/echodo-worker-worktrees
#
# Phase B — OpenHands agent-server (optional; requires --profile agent-server)
# 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) ----- # ----- Auth (NextAuth v5) -----
# Local: http://localhost:3000 • Prod: https://echodo.stillwell.cloud # Local: http://localhost:3000 • Prod: https://echodo.stillwell.cloud
@ -72,10 +80,11 @@ AUTH_AUTHENTIK_ISSUER="https://auth.stillwell.cloud/application/o/echodo/"
# ----- Realtime collab server (Hocuspocus / Yjs) ----- # ----- Realtime collab server (Hocuspocus / Yjs) -----
# Server-side reference (used by Next.js server actions, if any) # Server-side reference (used by Next.js server actions, if any)
COLLAB_SERVER_URL="ws://localhost:1234" 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" 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. # In Umami: Settings → Websites → Add website. Copy the website UUID.
NEXT_PUBLIC_UMAMI_SCRIPT="https://analytics.stillwell.cloud/script.js" NEXT_PUBLIC_UMAMI_SCRIPT="https://analytics.stillwell.cloud/script.js"
NEXT_PUBLIC_UMAMI_WEBSITE_ID="" NEXT_PUBLIC_UMAMI_WEBSITE_ID=""
@ -84,7 +93,7 @@ NEXT_PUBLIC_UMAMI_WEBSITE_ID=""
NEXT_PUBLIC_LIBREDESK_URL="https://support.stillwell.cloud" NEXT_PUBLIC_LIBREDESK_URL="https://support.stillwell.cloud"
NEXT_PUBLIC_LIBREDESK_WIDGET_ID="" 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 # Static token scoped write-only on the `feedback` collection
NEXT_PUBLIC_DIRECTUS_URL="https://directus.stillwell.cloud" NEXT_PUBLIC_DIRECTUS_URL="https://directus.stillwell.cloud"
DIRECTUS_FEEDBACK_TOKEN="" DIRECTUS_FEEDBACK_TOKEN=""
@ -110,20 +119,40 @@ ANTHROPIC_API_KEY=""
GOOGLE_GENERATIVE_AI_API_KEY="" GOOGLE_GENERATIVE_AI_API_KEY=""
NVIDIA_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 -----
MCP_SERVER_PORT=3001 MCP_SERVER_PORT=3001
# ===================================================================== # =====================================================================
# Coolify Docker Compose build (8 GB hosts) # Production Docker Compose build (8 GB hosts)
# ===================================================================== # =====================================================================
# Serialize image builds to avoid kernel OOM (exit 255) during export. # Serialize image builds to avoid kernel OOM (exit 255) during export.
# Coolify (8 GB host): set Custom Build Command to `bash scripts/build-coolify.sh` # Dokploy / standalone: set Custom Build Command to `bash scripts/build-prod.sh`
# so compose builds collab → mcp → orchestrator → worker → web sequentially. # Local verify: pnpm docker:build:prod
# COMPOSE_PARALLEL_LIMIT is documented for reference but the script is required. # Legacy alias: pnpm docker:build:coolify
# ===================================================================== # =====================================================================
# Local Docker Compose only (docker/docker-compose.yml) # 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_USER=postgres
POSTGRES_PASSWORD=postgres POSTGRES_PASSWORD=postgres

41
.forgejo/workflows/ci.yml Normal file
View 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

View 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"

41
.github/workflows/deploy-homelab.yml vendored Normal file
View 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"

View file

@ -12,6 +12,8 @@ import type { BriefSummary } from "@tasks/shared/orchestrator";
type Db = typeof dbInstance; type Db = typeof dbInstance;
import type { AutomationTier } from "@tasks/shared/convoy-lifecycle";
export type LoadedConvoy = { export type LoadedConvoy = {
id: string; id: string;
workspaceId: string; workspaceId: string;
@ -21,6 +23,7 @@ export type LoadedConvoy = {
briefs: BriefSummary[]; briefs: BriefSummary[];
completedRoles: Set<string>; completedRoles: Set<string>;
completedBriefIds: Set<string>; completedBriefIds: Set<string>;
automationTier?: AutomationTier;
}; };
function parseStatus(raw: string | null): ConvoyStatus { function parseStatus(raw: string | null): ConvoyStatus {
@ -85,6 +88,7 @@ export async function listActiveConvoys(db: Db): Promise<LoadedConvoy[]> {
briefs, briefs,
completedRoles, completedRoles,
completedBriefIds, completedBriefIds,
automationTier: content.data.automationTier ?? "assist",
}); });
} }

View file

@ -6,6 +6,7 @@ import {
type OrchestratorAction, type OrchestratorAction,
type OrchestratorJob, type OrchestratorJob,
} from "@tasks/shared/orchestrator"; } from "@tasks/shared/orchestrator";
import type { AutomationTier } from "@tasks/shared/convoy-lifecycle";
import { listActiveConvoys, transitionConvoyInDb, type LoadedConvoy } from "./db/convoy.js"; import { listActiveConvoys, transitionConvoyInDb, type LoadedConvoy } from "./db/convoy.js";
import { enqueueJob } from "./queue/redis-queue.js"; import { enqueueJob } from "./queue/redis-queue.js";
@ -113,6 +114,7 @@ export async function runOrchestratorTick(
briefs: convoy.briefs, briefs: convoy.briefs,
completedRoles: convoy.completedRoles, completedRoles: convoy.completedRoles,
completedBriefIds: convoy.completedBriefIds, completedBriefIds: convoy.completedBriefIds,
automationTier: convoy.automationTier,
}); });
results.push(await applyAction(db, redis, env, convoy, action)); results.push(await applyAction(db, redis, env, convoy, action));

View 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>
);
}

View file

@ -73,6 +73,7 @@ export default function ConnectedReposSettingsPage() {
setParsedPreview(null); setParsedPreview(null);
}, },
}); });
const [connectError, setConnectError] = React.useState<string | null>(null);
const disconnectMut = api.connectedRepos.disconnect.useMutation({ const disconnectMut = api.connectedRepos.disconnect.useMutation({
onSuccess: () => void utils.connectedRepos.list.invalidate(), onSuccess: () => void utils.connectedRepos.list.invalidate(),
@ -98,6 +99,7 @@ export default function ConnectedReposSettingsPage() {
const openCreate = () => { const openCreate = () => {
setForm(EMPTY_FORM); setForm(EMPTY_FORM);
setParsedPreview(null); setParsedPreview(null);
setConnectError(null);
setDialogOpen(true); setDialogOpen(true);
}; };
@ -112,12 +114,18 @@ export default function ConnectedReposSettingsPage() {
if (!workspaceSlug || !form.remoteUrl.trim()) { if (!workspaceSlug || !form.remoteUrl.trim()) {
return; return;
} }
connectMut.mutate({ setConnectError(null);
connectMut.mutate(
{
workspace: workspaceSlug, workspace: workspaceSlug,
remoteUrl: form.remoteUrl.trim(), remoteUrl: form.remoteUrl.trim(),
displayName: form.displayName.trim() || undefined, displayName: form.displayName.trim() || undefined,
tokenEnvVar: form.tokenEnvVar.trim() || null, tokenEnvVar: form.tokenEnvVar.trim() || null,
}); },
{
onError: (err) => setConnectError(err.message),
},
);
}; };
return ( return (
@ -126,9 +134,27 @@ export default function ConnectedReposSettingsPage() {
<div> <div>
<h1 className="text-2xl font-semibold tracking-tight">Connected repos</h1> <h1 className="text-2xl font-semibold tracking-tight">Connected repos</h1>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
Paste a git remote URL to link Forgejo, GitHub, or other forges. API tokens are Link a git remote for worker automation (draft PRs, forge webhooks). Paste the clone
referenced by env var name — never stored in the database. URL — OAuth repo pickers are planned; today you set the API token on the server and
reference its env var name here.
</p> </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> </div>
{canManage && ( {canManage && (
<Button type="button" onClick={openCreate}> <Button type="button" onClick={openCreate}>
@ -269,16 +295,24 @@ export default function ConnectedReposSettingsPage() {
<div> <div>
<label className="mb-1 block text-sm font-medium" htmlFor="token-env"> <label className="mb-1 block text-sm font-medium" htmlFor="token-env">
API token env var (optional) API token env var
</label> </label>
<Input <Input
id="token-env" id="token-env"
placeholder="GITHUB_TOKEN" placeholder="FORGEJO_TOKEN or GITHUB_TOKEN"
value={form.tokenEnvVar} value={form.tokenEnvVar}
onChange={(e) => setForm((f) => ({ ...f, tokenEnvVar: e.target.value }))} 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> </div>
{connectError && (
<p className="text-sm text-destructive">{connectError}</p>
)}
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">
<DialogPrimitive.Close asChild> <DialogPrimitive.Close asChild>
<Button type="button" variant="outline"> <Button type="button" variant="outline">

View file

@ -6,6 +6,7 @@ import type { ConvoyStatus } from "@tasks/shared/convoy-lifecycle";
import { Loader2, Radar } from "lucide-react"; import { Loader2, Radar } from "lucide-react";
import { ConvoyDetailPanel } from "@/components/mission-control/convoy-detail-panel"; 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 { FleetActivityFeed } from "@/components/fleet/fleet-activity-feed";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { api } from "@/lib/trpc"; import { api } from "@/lib/trpc";
@ -41,6 +42,8 @@ export default function FleetPage() {
const data = fleetQuery.data; const data = fleetQuery.data;
const byWorkspace = data?.byWorkspace ?? []; const byWorkspace = data?.byWorkspace ?? [];
const gateInboxAcross = data?.gateInboxAcross ?? []; 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 // Group gate inbox by status for "× pr_review 2d" count display
const gateByStatus = new Map<string, typeof gateInboxAcross>(); const gateByStatus = new Map<string, typeof gateInboxAcross>();
@ -185,6 +188,7 @@ export default function FleetPage() {
)} )}
</aside> </aside>
</div> </div>
<OpsCopilotFab workspaceSlug={defaultWorkspaceSlug} />
</div> </div>
); );
} }

View 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 });
}

View 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 &quot;what needs me?&quot; — 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 &quot;what needs my approval?&quot; — 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} />
</>
);
}

View 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,
};
}

View file

@ -83,10 +83,6 @@ function releaseEntry(documentName: string) {
} }
} }
/**
* WebSocket URL for the collaboration server.
* Uses `NEXT_PUBLIC_COLLAB_URL`, then `COLLAB_SERVER_URL`, then ws://localhost:1234.
*/
/** /**
* Ask the cached provider for this document to open the WebSocket again (e.g. user clicked Retry). * Ask the cached provider for this document to open the WebSocket again (e.g. user clicked Retry).
*/ */
@ -98,12 +94,19 @@ export function reconnectCollaboration(documentName: string) {
} }
} }
/**
* WebSocket URL for the collaboration server.
* Reads `NEXT_PUBLIC_COLLAB_SERVER_URL` (deploy standard), legacy `NEXT_PUBLIC_COLLAB_URL`,
* then falls back to ws://localhost:1234 for local dev.
*
* Must be baked at Docker build time — runtime-only env changes do not update the client bundle.
*/
export function getCollabUrl(): string { export function getCollabUrl(): string {
if (typeof process !== "undefined" && process.env.NEXT_PUBLIC_COLLAB_URL) { const fromEnv =
return process.env.NEXT_PUBLIC_COLLAB_URL; (typeof process !== "undefined" && process.env.NEXT_PUBLIC_COLLAB_SERVER_URL) ||
} (typeof process !== "undefined" && process.env.NEXT_PUBLIC_COLLAB_URL);
if (typeof process !== "undefined" && process.env.COLLAB_SERVER_URL) { if (fromEnv) {
return process.env.COLLAB_SERVER_URL; return fromEnv;
} }
return "ws://localhost:1234"; return "ws://localhost:1234";
} }

View file

@ -17,7 +17,9 @@ import { runsRouter } from "@/server/routers/runs";
import { backlogRouter } from "@/server/routers/backlog"; import { backlogRouter } from "@/server/routers/backlog";
import { connectedReposRouter } from "@/server/routers/connected-repos"; import { connectedReposRouter } from "@/server/routers/connected-repos";
import { convoysRouter } from "@/server/routers/convoys"; import { convoysRouter } from "@/server/routers/convoys";
import { copilotRouter, opsCopilotRouter } from "@/server/routers/copilot";
import { harnessesRouter } from "@/server/routers/harnesses"; import { harnessesRouter } from "@/server/routers/harnesses";
import { budgetsRouter } from "@/server/routers/budgets";
export const appRouter = router({ export const appRouter = router({
health: healthRouter, health: healthRouter,
@ -39,6 +41,9 @@ export const appRouter = router({
connectedRepos: connectedReposRouter, connectedRepos: connectedReposRouter,
convoys: convoysRouter, convoys: convoysRouter,
harnesses: harnessesRouter, harnesses: harnessesRouter,
copilot: copilotRouter,
opsCopilot: opsCopilotRouter,
budgets: budgetsRouter,
}); });
export type AppRouter = typeof appRouter; export type AppRouter = typeof appRouter;

View file

@ -0,0 +1,143 @@
import { TRPCError } from "@trpc/server";
import { and, eq, gte, sum } from "drizzle-orm";
import { z } from "zod";
import { budgetCaps, convoyEvents } from "@tasks/database/schema";
import { router, workspaceProcedure } from "@/server/trpc";
function startOfMonth(d = new Date()): Date {
return new Date(d.getFullYear(), d.getMonth(), 1);
}
export const budgetsRouter = router({
getSpend: workspaceProcedure.query(async ({ ctx }) => {
const monthStart = startOfMonth();
const rows = await ctx.db
.select({
modelAlias: convoyEvents.modelAlias,
providerType: convoyEvents.providerType,
modelTier: convoyEvents.modelTier,
totalCost: sum(convoyEvents.estimatedCostUsd).as("total_cost"),
})
.from(convoyEvents)
.where(
and(
eq(convoyEvents.workspaceId, ctx.workspace.id),
gte(convoyEvents.occurredAt, monthStart),
),
)
.groupBy(
convoyEvents.modelAlias,
convoyEvents.providerType,
convoyEvents.modelTier,
);
return rows.map((r) => ({
modelAlias: r.modelAlias ?? "unknown",
providerType: r.providerType ?? "unknown",
modelTier: r.modelTier ?? "unknown",
estimatedCostUsd: Number(r.totalCost ?? 0),
}));
}),
getCap: workspaceProcedure.query(async ({ ctx }) => {
const cap = await ctx.db.query.budgetCaps.findFirst({
where: (c, { eq }) => eq(c.workspaceId, ctx.workspace.id),
});
return cap ?? { enabled: false, capUsd: null, period: "monthly" };
}),
upsertCap: workspaceProcedure
.input(
z.object({
capUsd: z.number().positive().max(1_000_000),
period: z.enum(["monthly"]).default("monthly"),
enabled: z.boolean().default(true),
}),
)
.mutation(async ({ ctx, input }) => {
if (ctx.workspace.role !== "owner" && ctx.workspace.role !== "admin") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only owners and admins can set budget caps.",
});
}
const [upserted] = await ctx.db
.insert(budgetCaps)
.values({
workspaceId: ctx.workspace.id,
capUsd: input.capUsd.toString(),
period: input.period,
enabled: input.enabled,
})
.onConflictDoUpdate({
target: [budgetCaps.workspaceId, budgetCaps.period],
set: {
capUsd: input.capUsd.toString(),
enabled: input.enabled,
updatedAt: new Date(),
},
})
.returning();
return upserted;
}),
deleteCap: workspaceProcedure.mutation(async ({ ctx }) => {
if (ctx.workspace.role !== "owner" && ctx.workspace.role !== "admin") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only owners and admins can delete budget caps.",
});
}
await ctx.db
.delete(budgetCaps)
.where(eq(budgetCaps.workspaceId, ctx.workspace.id));
return { ok: true };
}),
shouldPauseDispatch: workspaceProcedure
.input(z.object({ workspaceId: z.string().uuid() }))
.query(async ({ ctx, input }) => {
if (input.workspaceId !== ctx.workspace.id) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Workspace mismatch.",
});
}
const cap = await ctx.db.query.budgetCaps.findFirst({
where: (c, { and, eq }) => and(eq(c.workspaceId, input.workspaceId), eq(c.enabled, true)),
});
if (!cap) return { shouldPause: false, reason: null, spent: 0, cap: null };
const monthStart = startOfMonth();
const spend = await ctx.db
.select({ total: sum(convoyEvents.estimatedCostUsd) })
.from(convoyEvents)
.where(
and(
eq(convoyEvents.workspaceId, input.workspaceId),
gte(convoyEvents.occurredAt, monthStart),
),
);
const spent = Number(spend[0]?.total ?? 0);
const capUsd = Number(cap.capUsd);
const shouldPause = spent >= capUsd;
return {
shouldPause,
reason: shouldPause ? `Monthly budget cap $${capUsd} exceeded ($${spent} spent)` : null,
spent,
cap: capUsd,
};
}),
});
export type BudgetsRouter = typeof budgetsRouter;

View file

@ -1,5 +1,6 @@
import { createLanguageModelFromResolved } from "@tasks/ai"; import { createLanguageModelFromResolved } from "@tasks/ai";
import { import {
completionArtifacts,
convoyOperatorMessages, convoyOperatorMessages,
modelProviders, modelProviders,
objects, objects,
@ -8,6 +9,7 @@ import {
workspaces, workspaces,
} from "@tasks/database/schema"; } from "@tasks/database/schema";
import { import {
automationTierSchema,
convoyClassificationSchema, convoyClassificationSchema,
convoyObjectContentSchema, convoyObjectContentSchema,
ConvoyTransitionError, ConvoyTransitionError,
@ -22,9 +24,10 @@ import {
type ModelProvider, type ModelProvider,
type RoleModelRoute, type RoleModelRoute,
} from "@tasks/shared/model-routing"; } from "@tasks/shared/model-routing";
import { generateCompletionSummary } from "@tasks/shared/worker";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { generateText } from "ai"; import { generateText } from "ai";
import { and, asc, eq, inArray } from "drizzle-orm"; import { and, asc, desc, eq, inArray } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { import {
@ -212,6 +215,7 @@ export const convoysRouter = router({
backlogItemId: z.string().uuid().optional(), backlogItemId: z.string().uuid().optional(),
repoPath: z.string().min(1).optional(), repoPath: z.string().min(1).optional(),
briefFiles: z.array(z.string()).optional(), briefFiles: z.array(z.string()).optional(),
automationTier: z.enum(["manual", "assist", "auto"]).default("assist"),
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
@ -235,6 +239,7 @@ export const convoysRouter = router({
backlogItemId: input.backlogItemId, backlogItemId: input.backlogItemId,
repoPath: input.repoPath, repoPath: input.repoPath,
briefFiles: input.briefFiles ?? (input.repoPath ? [input.repoPath] : undefined), briefFiles: input.briefFiles ?? (input.repoPath ? [input.repoPath] : undefined),
automationTier: input.automationTier,
}); });
const [created] = await ctx.db const [created] = await ctx.db
@ -462,6 +467,162 @@ export const convoysRouter = router({
})); }));
}), }),
complete: workspaceProcedure
.input(
z.object({
workspace: z.string().min(1),
convoyId: z.string().uuid(),
screenshotUrl: z.string().url().nullable().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const convoy = await loadConvoyProject(ctx.db, ctx.workspace.id, input.convoyId);
if (!convoy) {
throw new TRPCError({ code: "NOT_FOUND", message: "Convoy not found." });
}
const status = (convoy.status ?? "draft") as ConvoyStatus;
if (status !== "merged" && status !== "published") {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Convoy must be in merged or published to complete, currently ${status}`,
});
}
const contentParsed = convoyObjectContentSchema.safeParse(convoy.content);
const briefs = await getConvoyBriefs(ctx.db, ctx.workspace.id, convoy.id);
const pr = await getConvoyPrUrl(ctx.db, ctx.workspace.id, convoy.id);
const events = await getRecentConvoyEvents(ctx.db, ctx.workspace.id, convoy.id, 30);
let modelAlias: string | null = null;
let harnessAlias: string | null = null;
for (const ev of events) {
const maybeModel = (ev as Record<string, unknown>).modelAlias as string | null | undefined;
const maybeHarness = (ev as Record<string, unknown>).harnessAlias as string | null | undefined;
if (!modelAlias && typeof maybeModel === "string" && maybeModel) modelAlias = maybeModel;
if (!harnessAlias && typeof maybeHarness === "string" && maybeHarness) harnessAlias = maybeHarness;
if (modelAlias && harnessAlias) break;
}
const slug = contentParsed.success ? contentParsed.data.convoySlug : convoy.title;
const classification = contentParsed.success
? contentParsed.data.classification
: ("feature" as const);
const successMetric = contentParsed.success
? contentParsed.data.successMetric
: "No success metric captured";
const summaryMd = generateCompletionSummary({
slug,
classification,
successMetric,
briefs: briefs.map((b) => ({ title: b.title, status: b.status })),
modelAlias,
harnessAlias,
prUrl: pr.prUrl,
});
let result: Awaited<ReturnType<typeof transitionConvoy>>;
try {
result = await transitionConvoy(ctx.db, {
workspaceId: ctx.workspace.id,
convoyId: input.convoyId,
toStatus: "released",
actor: "human-gate-3",
});
} catch (error) {
if (error instanceof ConvoyTransitionError) {
throw new TRPCError({
code: "BAD_REQUEST",
message: error.message,
cause: { code: error.code },
});
}
throw error;
}
const [artifact] = await ctx.db
.insert(completionArtifacts)
.values({
workspaceId: ctx.workspace.id,
convoyId: convoy.id,
summaryMd,
screenshotUrl: input.screenshotUrl ?? null,
createdBy: ctx.session.user.id,
})
.returning();
if (!artifact) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to create completion artifact.",
});
}
return {
convoy: result.convoy,
artifact: {
id: artifact.id,
workspaceId: artifact.workspaceId,
convoyId: artifact.convoyId,
summaryMd: artifact.summaryMd,
screenshotUrl: artifact.screenshotUrl,
createdBy: artifact.createdBy,
createdAt: artifact.createdAt.toISOString(),
},
};
}),
listArtifacts: workspaceProcedure.query(async ({ ctx }) => {
const rows = await ctx.db
.select({
artifact: completionArtifacts,
convoyTitle: objects.title,
convoyStatus: objects.status,
convoyContent: objects.content,
})
.from(completionArtifacts)
.leftJoin(objects, eq(objects.id, completionArtifacts.convoyId))
.where(eq(completionArtifacts.workspaceId, ctx.workspace.id))
.orderBy(desc(completionArtifacts.createdAt));
const enriched = await Promise.all(
rows.map(async (row) => {
const pr = await getConvoyPrUrl(ctx.db, ctx.workspace.id, row.artifact.convoyId);
const events = await getRecentConvoyEvents(
ctx.db,
ctx.workspace.id,
row.artifact.convoyId,
20,
);
const content = convoyObjectContentSchema.safeParse(row.convoyContent);
return {
id: row.artifact.id,
workspaceId: row.artifact.workspaceId,
convoyId: row.artifact.convoyId,
convoyTitle: row.convoyTitle ?? "Untitled",
convoySlug: content.success ? content.data.convoySlug : (row.convoyTitle ?? ""),
convoyStatus: row.convoyStatus ?? "released",
summaryMd: row.artifact.summaryMd,
screenshotUrl: row.artifact.screenshotUrl,
createdBy: row.artifact.createdBy,
createdAt: row.artifact.createdAt.toISOString(),
pr,
timeline: events.map((ev) => ({
id: ev.id,
eventType: ev.eventType,
role: ev.role,
actor: ev.actor,
occurredAt: ev.occurredAt.toISOString(),
metadata: ev.metadata,
})),
};
}),
);
return enriched;
}),
fleet: protectedProcedure.query(async ({ ctx }) => { fleet: protectedProcedure.query(async ({ ctx }) => {
const userId = ctx.session.user.id; const userId = ctx.session.user.id;

View file

@ -0,0 +1,271 @@
import { createLanguageModelFromResolved } from "@tasks/ai";
import {
markdownBacklogItems,
modelProviders,
roleModelRoutes,
} from "@tasks/database/schema";
import {
convoyObjectContentSchema,
GATE_INBOX_STATUSES,
} from "@tasks/shared/convoy-lifecycle";
import {
providerTypeSchema,
resolveModel,
type ModelProvider,
type RoleModelRoute,
} from "@tasks/shared/model-routing";
import { TRPCError } from "@trpc/server";
import { generateText, tool } from "ai";
import { asc, eq } from "drizzle-orm";
import { z } from "zod";
import {
listConvoyProjects,
loadConvoyProject,
} from "@/server/lib/convoy/convoy-service";
import { router, workspaceProcedure } from "@/server/trpc";
import type { WorkspaceProcedureContext } from "@/server/trpc";
type ProviderRow = typeof modelProviders.$inferSelect;
type RouteRow = typeof roleModelRoutes.$inferSelect;
function mapProviderRow(row: ProviderRow): ModelProvider {
return {
id: row.id,
workspaceId: row.workspaceId,
alias: row.alias,
displayName: row.displayName,
providerType: providerTypeSchema.parse(row.providerType),
modelId: row.modelId,
baseUrl: row.baseUrl,
apiKeyEnvVar: row.apiKeyEnvVar,
extraHeaders: row.extraHeaders,
defaultParams: row.defaultParams,
metadata: row.metadata,
enabled: row.enabled,
};
}
function mapRouteRow(row: RouteRow): RoleModelRoute {
return {
id: row.id,
workspaceId: row.workspaceId,
roleId: row.roleId,
providerAliasId: row.providerAliasId,
enabled: row.enabled,
sortOrder: row.sortOrder,
};
}
function readApiKey(envVarName: string): string | undefined {
return process.env[envVarName]?.trim() || undefined;
}
function assertWorkspaceAdmin(_ctx: WorkspaceProcedureContext): void {
// ops copilot is read-only; no admin check needed, but keep helper for future
void _ctx;
}
const OPS_SYSTEM_PROMPT =
"You are ops copilot for second brain OS. You help owners triage \"what needs me?\" across fleet, convoys and backlog. " +
"Use the provided tools (fleetStatus, convoyGet, backlogListTree) to ground answers. " +
"Always respond with links in form [workspaceSlug/convoySlug] for convoys and [workspaceSlug/planSlug/epicSlug/taskSlug] for backlog items. " +
"Show fleet → needs you citations when answering gate questions. Be concise, actionable, no secrets.";
const messageSchema = z.object({
role: z.enum(["user", "assistant"]),
content: z.string().min(1).max(4000),
});
export const opsCopilotRouter = router({
generateText: workspaceProcedure
.input(
z.object({
workspace: z.string().min(1),
prompt: z.string().min(1).max(4000),
messages: z.array(messageSchema).optional(),
}),
)
.mutation(async ({ ctx, input }) => {
void assertWorkspaceAdmin(ctx);
const providerRows = await ctx.db
.select()
.from(modelProviders)
.where(eq(modelProviders.workspaceId, ctx.workspace.id))
.orderBy(asc(modelProviders.alias));
const routeRows = await ctx.db
.select()
.from(roleModelRoutes)
.where(eq(roleModelRoutes.workspaceId, ctx.workspace.id));
const resolved = resolveModel({
roleId: "gate-copilot",
providers: providerRows.map(mapProviderRow),
routes: routeRows.map(mapRouteRow),
getApiKey: readApiKey,
});
if (!resolved.ok) {
throw new TRPCError({
code: "BAD_REQUEST",
message: resolved.error.message,
});
}
const modelResult = createLanguageModelFromResolved(resolved.model);
if (!modelResult.ok) {
throw new TRPCError({
code: "BAD_REQUEST",
message: modelResult.reason,
});
}
// Bounded, workspace-scoped, read-only tools (<3 tools)
const fleetStatusTool = tool({
description:
"Get fleet gate inbox needing you for this workspace. Returns up to 10 gate items with links [workspaceSlug/convoySlug].",
parameters: z.object({}),
execute: async () => {
const convoys = await listConvoyProjects(ctx.db, ctx.workspace.id);
const gateInbox = convoys.filter((c) =>
(GATE_INBOX_STATUSES as readonly string[]).includes(c.status),
);
const bounded = gateInbox.slice(0, 10);
const payload = {
workspace: ctx.workspace.slug,
totalNeedingYou: gateInbox.length,
items: bounded.map((c) => ({
convoyId: c.id,
convoySlug: c.content?.convoySlug ?? c.title,
status: c.status,
classification: c.content?.classification ?? null,
updatedAt: c.updatedAt.toISOString(),
link: `${ctx.workspace.slug}/${c.content?.convoySlug ?? c.id}`,
})),
};
return JSON.stringify(payload);
},
});
const convoyGetTool = tool({
description: "Get one convoy by id (workspace-scoped, read-only).",
parameters: z.object({
convoyId: z.string().uuid(),
}),
execute: async ({ convoyId }: { convoyId: string }) => {
const convoy = await loadConvoyProject(ctx.db, ctx.workspace.id, convoyId);
if (!convoy) {
return JSON.stringify({ error: "Convoy not found in this workspace" });
}
const content = convoyObjectContentSchema.safeParse(convoy.content);
const payload = {
id: convoy.id,
title: convoy.title,
status: convoy.status ?? "draft",
description: convoy.description ? convoy.description.slice(0, 1000) : null,
content: content.success ? content.data : null,
updatedAt: convoy.updatedAt.toISOString(),
link: `${ctx.workspace.slug}/${content.success ? content.data.convoySlug : convoy.title}`,
};
return JSON.stringify(payload);
},
});
const backlogListTreeTool = tool({
description:
"List backlog tree (plans/epics/tasks) for this workspace. Bounded to 20 items, read-only.",
parameters: z.object({}),
execute: async () => {
const rows = await ctx.db
.select({
id: markdownBacklogItems.id,
kind: markdownBacklogItems.kind,
slug: markdownBacklogItems.slug,
planSlug: markdownBacklogItems.planSlug,
epicSlug: markdownBacklogItems.epicSlug,
title: markdownBacklogItems.title,
status: markdownBacklogItems.status,
priority: markdownBacklogItems.priority,
repoPath: markdownBacklogItems.repoPath,
updatedAt: markdownBacklogItems.updatedAt,
})
.from(markdownBacklogItems)
.where(eq(markdownBacklogItems.workspaceId, ctx.workspace.id))
.orderBy(asc(markdownBacklogItems.planSlug), asc(markdownBacklogItems.kind), asc(markdownBacklogItems.slug));
const bounded = rows.slice(0, 20);
const payload = {
workspace: ctx.workspace.slug,
total: rows.length,
items: bounded.map((r) => ({
id: r.id,
kind: r.kind,
slug: r.slug,
planSlug: r.planSlug,
epicSlug: r.epicSlug,
title: r.title,
status: r.status,
priority: r.priority,
repoPath: r.repoPath,
link: `${ctx.workspace.slug}/${r.planSlug}${r.epicSlug ? `/${r.epicSlug}` : ""}/${r.slug}`,
updatedAt: r.updatedAt.toISOString(),
})),
};
return JSON.stringify(payload);
},
});
const historyMessages = (input.messages ?? []).map((m) => ({
role: m.role as "user" | "assistant",
content: m.content,
}));
// Merge prompt as final user message; system is separate
const promptMessages = [
...historyMessages,
{ role: "user" as const, content: input.prompt },
];
const system = [
OPS_SYSTEM_PROMPT,
`Current workspace: ${ctx.workspace.name} (${ctx.workspace.slug})`,
`Workspace id: ${ctx.workspace.id}`,
].join("\n");
const { text, toolCalls, steps } = await generateText({
model: modelResult.model,
system,
messages: promptMessages,
tools: {
fleetStatus: fleetStatusTool,
convoyGet: convoyGetTool,
backlogListTree: backlogListTreeTool,
},
maxSteps: 3,
temperature: 0.4,
maxTokens: 1200,
});
const trimmed = text.trim();
if (!trimmed) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Empty copilot response",
});
}
return {
text: trimmed,
resolvedAlias: resolved.resolvedAlias,
modelId: resolved.model.modelId,
providerType: resolved.model.providerType,
toolCalls: toolCalls ?? [],
steps: steps?.length ?? 0,
};
}),
});
// Alias for file naming: brief says copilot.ts with opsCopilot.generateText
export const copilotRouter = opsCopilotRouter;

View file

@ -33,6 +33,26 @@ const envSchema = z.object({
.string() .string()
.optional() .optional()
.transform((value) => value?.trim() || "/tmp/echodo-worker-worktrees"), .transform((value) => value?.trim() || "/tmp/echodo-worker-worktrees"),
/** Session type: "minimal" (default, in-process) or "openhands" (agent-server). */
WORKER_SESSION_TYPE: z
.enum(["minimal", "openhands"])
.optional()
.default("minimal"),
/** OpenHands agent-server URL. Docker: http://agent-server:8001, Local: http://localhost:18000 */
AGENT_SERVER_URL: z
.string()
.optional()
.transform((value) => value?.trim() || "http://agent-server:8001"),
/** Shared workspace path visible to agent-server sandboxes (volume-mounted in prod). */
WORKER_OPENHANDS_WORKSPACE: z
.string()
.optional()
.transform((value) => value?.trim() || "/workspace/repo"),
/** OpenHands session API key for agent-server auth. Must match LOCAL_BACKEND_API_KEY. */
SESSION_API_KEY: z
.string()
.optional()
.transform((value) => value?.trim() || ""),
}); });
export type WorkerEnv = z.infer<typeof envSchema>; export type WorkerEnv = z.infer<typeof envSchema>;

View file

@ -0,0 +1,147 @@
import { describe, expect, it, vi } from "vitest";
import type { WorkerJobContext } from "@tasks/shared/worker";
import { getExecutor } from "./index.js";
const FAKE_CTX: WorkerJobContext = {
workspaceId: "00000000-0000-0000-0000-000000000001",
convoyId: "00000000-0000-0000-0000-000000000002",
roleId: "role-implementer",
briefId: undefined,
repoRoot: "/tmp/fake-repo",
};
const FAKE_OPTS = {
resolvedModel: { modelId: "claude-sonnet-4-5", providerType: "anthropic", alias: "claude-planning" },
resolvedAlias: "claude-planning",
rolePromptPath: "/tmp/role.md",
maxSteps: 5,
buildPrompt: (ctx: WorkerJobContext, title: string) => `Convoy: ${title} Role: ${ctx.roleId}`,
convoyTitle: "test-convoy",
};
describe("getExecutor — harness registry dispatch", () => {
it("returns a minimal executor for harnessType: minimal", () => {
const exec = getExecutor({ harnessType: "minimal" });
expect(exec).not.toBeNull();
expect(exec?.id).toBe("minimal");
expect(exec?.harnessType).toBe("minimal");
});
it("returns a remote-ag-ui executor when endpointUrl + authEnvVar are provided", () => {
const exec = getExecutor({
harnessType: "remote-ag-ui",
endpointUrl: "https://agents.example.com/run",
authEnvVar: "OPENHANDS_API_KEY",
});
expect(exec).not.toBeNull();
expect(exec?.id).toBe("remote-ag-ui");
expect(exec?.harnessType).toBe("remote-ag-ui");
});
it("returns null for remote-ag-ui when endpointUrl is missing", () => {
const exec = getExecutor({ harnessType: "remote-ag-ui", authEnvVar: "X" });
expect(exec).toBeNull();
});
it("returns null for remote-ag-ui when authEnvVar is missing", () => {
const exec = getExecutor({ harnessType: "remote-ag-ui", endpointUrl: "https://x" });
expect(exec).toBeNull();
});
it("returns null for crewai (not yet implemented)", () => {
expect(getExecutor({ harnessType: "crewai" })).toBeNull();
});
it("returns null for langgraph (not yet implemented)", () => {
expect(getExecutor({ harnessType: "langgraph" })).toBeNull();
});
});
describe("minimal executor — runs createMinimalSession", () => {
it("invokes the session with the provided buildPrompt and returns the result", async () => {
const exec = getExecutor({ harnessType: "minimal" });
expect(exec).not.toBeNull();
if (!exec) return;
// We can't easily mock createMinimalSession from this test, so we verify
// the executor exists and has the right shape. The dispatch integration
// is covered by handle-job.test.ts.
expect(typeof exec.run).toBe("function");
});
});
describe("remote-ag-ui executor — POST + poll", () => {
it("returns failed when auth env var is missing", async () => {
const exec = getExecutor({
harnessType: "remote-ag-ui",
endpointUrl: "https://agents.example.com/run",
authEnvVar: "MISSING_TOKEN_VAR",
});
expect(exec).not.toBeNull();
if (!exec) return;
const result = await exec.run(FAKE_CTX, FAKE_OPTS);
expect(result.outcome).toBe("failed");
expect(result.summary).toMatch(/MISSING_TOKEN_VAR/);
});
it("POSTs the task payload and returns succeeded on immediate completion", async () => {
const fetchImpl = vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify({ runId: "run-123", status: "succeeded" }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
const exec = getExecutor(
{ harnessType: "remote-ag-ui", endpointUrl: "https://agents.example.com/run", authEnvVar: "AUTH_TOKEN" },
{ fetchImpl, getEnvVar: () => "test-token" },
);
expect(exec).not.toBeNull();
if (!exec) return;
const result = await exec.run(FAKE_CTX, FAKE_OPTS);
expect(result.outcome).toBe("succeeded");
expect(fetchImpl).toHaveBeenCalledTimes(1);
const [url, init] = fetchImpl.mock.calls[0]!;
expect(url).toBe("https://agents.example.com/run");
expect((init as RequestInit).method).toBe("POST");
expect((init as RequestInit).headers).toMatchObject({ Authorization: "Bearer test-token" });
});
it("polls the status endpoint when initial response is pending", async () => {
const fetchImpl = vi
.fn()
.mockResolvedValueOnce(
new Response(JSON.stringify({ runId: "run-456", status: "pending" }), { status: 200 }),
)
.mockResolvedValueOnce(
new Response(JSON.stringify({ status: "pending" }), { status: 200 }),
)
.mockResolvedValueOnce(
new Response(
JSON.stringify({ status: "succeeded", summary: "done", toolCalls: 3, tokensTotal: 1000 }),
{ status: 200 },
),
);
const exec = getExecutor(
{ harnessType: "remote-ag-ui", endpointUrl: "https://agents.example.com/run", authEnvVar: "AUTH_TOKEN" },
{ fetchImpl, getEnvVar: () => "test-token" },
);
expect(exec).not.toBeNull();
if (!exec) return;
// Use a short poll interval by passing a custom executor — but we can't
// easily here. Just verify it eventually returns and the status URL was hit.
const result = await exec.run(FAKE_CTX, FAKE_OPTS);
expect(result.outcome).toBe("succeeded");
expect(result.summary).toBe("done");
expect(result.toolCalls).toBe(3);
expect(result.tokensTotal).toBe(1000);
// 1 initial POST + at least 1 status poll
expect(fetchImpl.mock.calls.length).toBeGreaterThanOrEqual(2);
});
});

View file

@ -0,0 +1,119 @@
import type {
HarnessType,
WorkerExecutor,
WorkerExecutorOptions,
WorkerJobContext,
} from "@tasks/shared/worker";
import { createMinimalSession } from "../session/minimal-session.js";
import { createOpenHandsSession, type OpenHandsConfig } from "../session/openhands-session.js";
import { createRemoteAgUiExecutor } from "./remote-ag-ui.js";
export type ExecutorDeps = {
fetchImpl?: typeof fetch;
getEnvVar?: (name: string) => string | undefined;
};
function buildMinimalExecutor(): WorkerExecutor {
return {
id: "minimal",
harnessType: "minimal" as HarnessType,
async run(ctx: WorkerJobContext, opts: WorkerExecutorOptions): Promise<{ summary: string; toolCalls: number; tokensTotal?: number; outcome?: "succeeded" | "failed" }> {
const session = createMinimalSession(ctx, {
resolvedModel: opts.resolvedModel as never,
resolvedAlias: opts.resolvedAlias,
rolePromptPath: opts.rolePromptPath,
workflowPrompt: opts.workflowPrompt,
briefAcceptance: opts.briefAcceptance,
briefFiles: opts.briefFiles,
maxSteps: opts.maxSteps,
dryRun: opts.dryRun,
checkpointEveryNSteps: opts.checkpointEveryNSteps,
resumeToolHistory: opts.resumeToolHistory as never,
resumeFilesTouched: opts.resumeFilesTouched as never,
allowedCommands: opts.allowedCommands,
onCheckpoint: opts.onCheckpoint as never,
});
const convoyTitle = opts.convoyTitle ?? "unknown";
const result = await session.run(opts.buildPrompt(ctx, convoyTitle));
return {
summary: result.summary,
toolCalls: result.toolCalls,
tokensTotal: result.tokensTotal,
};
},
};
}
function buildRemoteAgUiExecutor(
endpointUrl: string,
authEnvVar: string,
deps: ExecutorDeps,
): WorkerExecutor {
return createRemoteAgUiExecutor({ endpointUrl, authEnvVar }, {
fetchImpl: deps.fetchImpl ?? fetch,
getEnvVar: deps.getEnvVar ?? ((name: string) => process.env[name] ?? undefined),
});
}
function buildOpenHandsExecutor(config: OpenHandsConfig): WorkerExecutor {
return {
id: "openhands",
harnessType: "minimal" as HarnessType,
async run(ctx: WorkerJobContext, opts: WorkerExecutorOptions): Promise<{ summary: string; toolCalls: number; tokensTotal?: number; outcome?: "succeeded" | "failed" }> {
const session = await createOpenHandsSession(ctx, {
config,
resolvedModel: opts.resolvedModel as never,
roleId: ctx.roleId,
briefId: ctx.briefId,
briefTitle: opts.convoyTitle,
workflowPrompt: opts.workflowPrompt,
briefAcceptance: opts.briefAcceptance,
briefFiles: opts.briefFiles,
maxSteps: opts.maxSteps,
});
const convoyTitle = opts.convoyTitle ?? "unknown";
const result = await session.run(opts.buildPrompt(ctx, convoyTitle));
return {
summary: result.summary,
toolCalls: result.toolCalls,
tokensTotal: result.tokensTotal,
};
},
};
}
export type HarnessExecutorConfig = {
harnessType: HarnessType;
endpointUrl?: string | null;
authEnvVar?: string | null;
};
export function getExecutor(
config: HarnessExecutorConfig,
deps: ExecutorDeps = {},
): WorkerExecutor | null {
switch (config.harnessType) {
case "minimal": return buildMinimalExecutor();
case "remote-ag-ui":
if (!config.endpointUrl || !config.authEnvVar) return null;
return buildRemoteAgUiExecutor(config.endpointUrl, config.authEnvVar, deps);
case "crewai": case "langgraph": return null;
default: return null;
}
}
/**
* Create an executor for OpenHands agent-server sessions.
* Falls back to minimal if env vars are missing.
*/
export function createOpenHandsExecutor(
agentServerUrl: string,
sessionApiKey: string,
repoRoot: string,
): WorkerExecutor | null {
if (!agentServerUrl || !sessionApiKey) {
console.warn("[worker] OpenHands executor unavailable: missing AGENT_SERVER_URL or SESSION_API_KEY");
return null;
}
return buildOpenHandsExecutor({ agentServerUrl, sessionApiKey, repoRoot });
}

View file

@ -0,0 +1,160 @@
import type {
HarnessType,
WorkerExecutor,
WorkerExecutorOptions,
WorkerJobContext,
} from "@tasks/shared/worker";
export type RemoteAgUiConfig = {
endpointUrl: string;
authEnvVar: string;
};
export type RemoteAgUiExecutorDeps = {
fetchImpl: typeof fetch;
getEnvVar: (name: string) => string | undefined;
};
export type RemoteAgUiRunOptions = WorkerExecutorOptions;
export type RemoteStatusResponse = {
status?: string;
summary?: string;
toolCalls?: number;
tokensTotal?: number;
};
const DEFAULT_TIMEOUT_MS = 300_000; // 5 min
const DEFAULT_POLL_INTERVAL_MS = 5_000;
export function createRemoteAgUiExecutor(
config: RemoteAgUiConfig,
deps: RemoteAgUiExecutorDeps,
): WorkerExecutor {
return {
id: "remote-ag-ui",
harnessType: "remote-ag-ui" as HarnessType,
async run(ctx: WorkerJobContext, opts: WorkerExecutorOptions): Promise<{ summary: string; toolCalls: number; tokensTotal?: number; outcome?: "succeeded" | "failed" }> {
const authToken = deps.getEnvVar(config.authEnvVar);
if (!authToken) {
return {
summary: `Missing auth env var: ${config.authEnvVar}`,
toolCalls: 0,
outcome: "failed",
};
}
const payload = {
workspaceId: ctx.workspaceId,
convoyId: ctx.convoyId,
roleId: ctx.roleId,
briefId: ctx.briefId ?? null,
model: opts.resolvedModel.modelId,
providerType: opts.resolvedModel.providerType,
modelAlias: opts.resolvedModel.alias,
prompt: opts.buildPrompt(ctx, opts.convoyTitle ?? "unknown"),
workflowPrompt: opts.workflowPrompt ?? null,
briefAcceptance: opts.briefAcceptance ?? null,
briefFiles: opts.briefFiles ?? null,
repoRoot: ctx.repoRoot,
maxSteps: opts.maxSteps,
allowedCommands: opts.allowedCommands ?? null,
};
let postResponse: Response;
try {
postResponse = await deps.fetchImpl(config.endpointUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${authToken}`,
},
body: JSON.stringify(payload),
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
summary: `Failed to POST to remote agent: ${message}`,
toolCalls: 0,
outcome: "failed",
};
}
if (!postResponse.ok) {
return {
summary: `Remote agent rejected task: HTTP ${postResponse.status}`,
toolCalls: 0,
outcome: "failed",
};
}
// Parse the initial response — expect a { runId, status } shape
let runId: string | null = null;
try {
const body = (await postResponse.json()) as { runId?: string; status?: string };
runId = body.runId ?? null;
if (body.status === "completed" || body.status === "succeeded") {
return {
summary: "Completed synchronously",
toolCalls: 0,
outcome: "succeeded",
};
}
if (body.status === "failed") {
return {
summary: "Failed synchronously",
toolCalls: 0,
outcome: "failed",
};
}
} catch {
// Non-JSON response; assume fire-and-forget
}
// Poll the status endpoint until completion
const baseUrl = config.endpointUrl.endsWith("/")
? config.endpointUrl.slice(0, -1)
: config.endpointUrl;
const statusUrl = runId
? `${baseUrl}/runs/${runId}/status`
: `${baseUrl}/status`;
const startTime = Date.now();
while (Date.now() - startTime < DEFAULT_TIMEOUT_MS) {
await new Promise((r) => setTimeout(r, DEFAULT_POLL_INTERVAL_MS));
try {
const statusResponse = await deps.fetchImpl(statusUrl, {
method: "GET",
headers: { Authorization: `Bearer ${authToken}` },
});
if (!statusResponse.ok) continue;
const status = (await statusResponse.json()) as RemoteStatusResponse;
if (status.status === "completed" || status.status === "succeeded") {
return {
summary: status.summary ?? "Remote agent completed",
toolCalls: status.toolCalls ?? 0,
tokensTotal: status.tokensTotal,
outcome: "succeeded",
};
}
if (status.status === "failed") {
return {
summary: status.summary ?? "Remote agent failed",
toolCalls: status.toolCalls ?? 0,
tokensTotal: status.tokensTotal,
outcome: "failed",
};
}
} catch {
// Continue polling on transient errors
}
}
return {
summary: "Remote agent timed out",
toolCalls: 0,
outcome: "failed",
};
},
};
}

View file

@ -87,6 +87,10 @@ const env = {
WORKER_MAX_TOOL_STEPS: 12, WORKER_MAX_TOOL_STEPS: 12,
WORKER_CHECKPOINT_EVERY_N_STEPS: 1, WORKER_CHECKPOINT_EVERY_N_STEPS: 1,
WORKER_WORKTREE_BASE: "/tmp/echodo-worker-worktrees", WORKER_WORKTREE_BASE: "/tmp/echodo-worker-worktrees",
WORKER_SESSION_TYPE: "minimal" as const,
AGENT_SERVER_URL: "http://agent-server:8001",
WORKER_OPENHANDS_WORKSPACE: "/workspace/repo",
SESSION_API_KEY: "",
REDIS_URL: "redis://localhost:6379", REDIS_URL: "redis://localhost:6379",
}; };

View file

@ -30,11 +30,11 @@ import {
} from "./git/worktree.js"; } from "./git/worktree.js";
import { openDraftPR } from "./git/pr.js"; import { openDraftPR } from "./git/pr.js";
import { resolveHarnessForJob, getHarnessExecutor } from "./harness.js"; import { resolveHarnessForJob, getHarnessExecutor } from "./harness.js";
import { getExecutor, createOpenHandsExecutor, type HarnessExecutorConfig } from "./executor/index.js";
import { resolveHarnessForRoleDb } from "./db/harness.js"; import { resolveHarnessForRoleDb } from "./db/harness.js";
import { resolveRepoRoot } from "./lib/repo-root.js"; import { resolveRepoRoot } from "./lib/repo-root.js";
import { import {
buildSessionPrompt, buildSessionPrompt,
createMinimalSession,
resolveRolePromptPath, resolveRolePromptPath,
} from "./session/minimal-session.js"; } from "./session/minimal-session.js";
import { connectedRepos, convoyEvents, objects } from "@tasks/database/schema"; import { connectedRepos, convoyEvents, objects } from "@tasks/database/schema";
@ -88,9 +88,9 @@ export async function handleOrchestratorJob(
} catch (e) { } catch (e) {
console.warn("[worker] harness DB resolve failed, using local fallback:", e); console.warn("[worker] harness DB resolve failed, using local fallback:", e);
} }
const executor = getHarnessExecutor(harness.alias); const executorMeta = getHarnessExecutor(harness.alias);
const needsWorktree = harness.needsWorktree; const needsWorktree = harness.needsWorktree;
void executor; void executorMeta;
let repoRoot = monorepoRoot; let repoRoot = monorepoRoot;
let worktreePath: string | null = null; let worktreePath: string | null = null;
@ -213,7 +213,8 @@ export async function handleOrchestratorJob(
repoRoot, repoRoot,
}; };
// WorkerAdapter dispatch — minimal adapter delegates to createMinimalSession; remote adapters would POST to AG-UI. // WorkerAdapter dispatch — registry routes to the correct executor based on
// harnessType. "minimal" runs locally; "remote-ag-ui" POSTs to the remote agent.
const operatorNotesProvider = { const operatorNotesProvider = {
harnessAlias: harness.alias, harnessAlias: harness.alias,
poll: async () => { poll: async () => {
@ -230,7 +231,66 @@ export async function handleOrchestratorJob(
}, },
}; };
const session = createMinimalSession(sessionContext, { const onCheckpointSink =
runId && !dryRun
? async ({ checkpointSeq, toolHistory, filesTouched }: {
checkpointSeq: number;
toolHistory: unknown[];
filesTouched: unknown[];
}) => {
await saveWorkerCheckpoint(db, {
workspaceId: job.workspaceId,
agentRunId: runId,
convoyId: job.convoyId,
briefId: job.briefId,
roleId: job.roleId,
checkpointSeq,
toolHistory: toolHistory as never,
filesTouched: filesTouched as never,
branch: worktreeBranch,
worktreePath,
});
wroteCheckpoint = true;
console.info(`[worker] checkpoint seq=${checkpointSeq} run=${runId} harness=${harness.alias}`);
}
: undefined;
// Resolve the executor from the registry based on the harness type
// When WORKER_SESSION_TYPE=openhands, route to OpenHands agent-server executor
let executor;
if (env.WORKER_SESSION_TYPE === "openhands") {
const ohExecutor = createOpenHandsExecutor(
env.AGENT_SERVER_URL,
env.SESSION_API_KEY,
env.WORKER_OPENHANDS_WORKSPACE,
);
if (ohExecutor) {
executor = ohExecutor;
console.info(`[worker] using OpenHands executor agent_server=${env.AGENT_SERVER_URL}`);
} else {
console.warn("[worker] OpenHands executor unavailable, falling back to minimal");
const harnessExecutorConfig: HarnessExecutorConfig = {
harnessType: harness.harnessType,
endpointUrl: harness.endpointUrl,
authEnvVar: harness.authEnvVar,
};
executor = getExecutor(harnessExecutorConfig);
}
} else {
const harnessExecutorConfig: HarnessExecutorConfig = {
harnessType: harness.harnessType,
endpointUrl: harness.endpointUrl,
authEnvVar: harness.authEnvVar,
};
executor = getExecutor(harnessExecutorConfig);
}
if (!executor) {
outcome = "failed";
summary = `No executor available for harness type: ${harness.harnessType}`;
console.error(`[worker] job=${job.id} ${summary}`);
} else {
const sessionOptions = {
resolvedModel: modelResult.model, resolvedModel: modelResult.model,
resolvedAlias: modelResult.resolvedAlias, resolvedAlias: modelResult.resolvedAlias,
rolePromptPath: resolveRolePromptPath(monorepoRoot, job.roleId), rolePromptPath: resolveRolePromptPath(monorepoRoot, job.roleId),
@ -243,40 +303,25 @@ export async function handleOrchestratorJob(
resumeToolHistory, resumeToolHistory,
resumeFilesTouched, resumeFilesTouched,
allowedCommands: harness.allowedCommands, allowedCommands: harness.allowedCommands,
onCheckpoint: onCheckpointSink,
operatorNotes: operatorNotesProvider, operatorNotes: operatorNotesProvider,
onCheckpoint: buildPrompt: buildSessionPrompt,
runId && !dryRun convoyTitle: ctx.convoyTitle,
? async ({ checkpointSeq, toolHistory, filesTouched }) => { };
await saveWorkerCheckpoint(db, {
workspaceId: job.workspaceId,
agentRunId: runId,
convoyId: job.convoyId,
briefId: job.briefId,
roleId: job.roleId,
checkpointSeq,
toolHistory,
filesTouched,
branch: worktreeBranch,
worktreePath,
});
wroteCheckpoint = true;
console.info(`[worker] checkpoint seq=${checkpointSeq} run=${runId} harness=${harness.alias}`);
}
: undefined,
});
try { try {
const result = await session.run(buildSessionPrompt(sessionContext, ctx.convoyTitle)); const result = await executor.run(sessionContext, sessionOptions as import("@tasks/shared/worker").WorkerExecutorOptions);
summary = result.summary; summary = result.summary;
tokensTotal = result.tokensTotal; tokensTotal = result.tokensTotal;
console.info( console.info(
`[worker] job=${job.id} role=${job.roleId} tools=${result.toolCalls} dryRun=${dryRun}`, `[worker] job=${job.id} role=${job.roleId} harness=${executor.id} tools=${result.toolCalls} dryRun=${dryRun}`,
); );
} catch (error) { } catch (error) {
outcome = "failed"; outcome = "failed";
summary = error instanceof Error ? error.message : String(error); summary = error instanceof Error ? error.message : String(error);
console.error(`[worker] job=${job.id} session failed:`, error); console.error(`[worker] job=${job.id} session failed:`, error);
} }
}
// Draft PR via forge adapter after successful implementer session, before teardown // Draft PR via forge adapter after successful implementer session, before teardown
if (outcome === "succeeded" && !dryRun) { if (outcome === "succeeded" && !dryRun) {

View file

@ -0,0 +1,105 @@
import { readdir, readFile } from "node:fs/promises";
import { join } from "node:path";
/**
* Loaded skill from `.agents/skills/<name>/SKILL.md`.
* Parsed frontmatter + body is returned for injection into session system prompt.
*/
export type LoadedSkill = {
name: string;
description: string;
body: string;
};
const SKILL_FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
function parseSkillFrontmatter(raw: string): { name: string; description: string; body: string } {
const match = raw.match(SKILL_FRONTMATTER);
let name = "unknown";
let description = "";
let body = raw;
if (match) {
body = raw.slice(match[0].length);
const fm = match[1];
const nameMatch = fm.match(/^name:\s*(.+)$/m);
const descMatch = fm.match(/^description:\s*(.+)$/m);
if (nameMatch) name = nameMatch[1].trim();
if (descMatch) description = descMatch[1].trim();
}
return { name, description, body };
}
/**
* Load all SKILL.md files from `.agents/skills/<name>/SKILL.md` relative to repo root.
* Returns an array of parsed skills suitable for system prompt injection.
* Failures per skill are logged and skipped (partial loading is acceptable).
*/
export async function loadAgentSkills(
repoRoot: string,
filterNames?: readonly string[],
): Promise<LoadedSkill[]> {
const skillsDir = join(repoRoot, ".agents", "skills");
const skills: LoadedSkill[] = [];
let entries: import("node:fs").Dirent[];
try {
entries = await readdir(skillsDir, { withFileTypes: true });
} catch {
// .agents/skills/ not found — gracefully return empty
return [];
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const skillName = entry.name;
if (filterNames && !filterNames.includes(skillName)) continue;
const skillPath = join(skillsDir, skillName, "SKILL.md");
try {
const raw = await readFile(skillPath, "utf8");
const parsed = parseSkillFrontmatter(raw);
skills.push({
name: parsed.name || skillName,
description: parsed.description,
body: parsed.body,
});
} catch (e) {
console.warn(`[worker] failed to load skill ${skillName}:`, e);
}
}
return skills;
}
/**
* Build a compact skill summary for the system prompt.
* Includes skill name + description only (not full body) for non-verbose mode.
* Use `loadAgentSkills(repoRoot)` for full content when needed.
*/
export function buildSkillSummary(skills: LoadedSkill[]): string {
if (skills.length === 0) return "";
const lines = skills.map((s) => `- **${s.name}**: ${s.description}`);
return `\n\n## Available skills\n${lines.join("\n")}`;
}
/**
* Build full skill content for the system prompt.
* Includes full body of each skill for deep context.
*/
export function buildSkillFullContext(skills: LoadedSkill[]): string {
if (skills.length === 0) return "";
const blocks = skills.map((s) => `### Skill: ${s.name}\n${s.body}`);
return `\n\n## Repo skills\n${blocks.join("\n\n")}`;
}
/**
* Build a prompt suffix listing available skills by name only (lightweight).
*/
export function buildSkillList(skills: LoadedSkill[]): string {
if (skills.length === 0) return "";
const names = skills.map((s) => s.name).join(", ");
return `\n\nSkills available: ${names}`;
}

View file

@ -19,6 +19,7 @@ import {
runCommandTool, runCommandTool,
writeFileTool, writeFileTool,
} from "../tools/runner.js"; } from "../tools/runner.js";
import { loadAgentSkills, buildSkillFullContext } from "./load-skills.js";
export type CheckpointSink = (input: { export type CheckpointSink = (input: {
checkpointSeq: number; checkpointSeq: number;
@ -134,6 +135,18 @@ export function createMinimalSession(
const checkpointEvery = Math.max(1, options.checkpointEveryNSteps ?? 1); const checkpointEvery = Math.max(1, options.checkpointEveryNSteps ?? 1);
const remainingSteps = Math.max(1, options.maxSteps - resumeHistory.length); const remainingSteps = Math.max(1, options.maxSteps - resumeHistory.length);
// Load skills from .agents/skills/ and inject into system prompt
let skillContext = "";
try {
const skills = await loadAgentSkills(context.repoRoot);
skillContext = buildSkillFullContext(skills);
if (skills.length > 0) {
console.info(`[worker] loaded ${skills.length} skills from .agents/skills/`);
}
} catch (e) {
console.warn("[worker] skill loading failed, continuing without skills:", e);
}
let systemBase = [ let systemBase = [
rolePrompt.trim(), rolePrompt.trim(),
options.workflowPrompt ? `\n\n## Workflow\n${options.workflowPrompt.trim()}` : "", options.workflowPrompt ? `\n\n## Workflow\n${options.workflowPrompt.trim()}` : "",
@ -143,6 +156,7 @@ export function createMinimalSession(
options.briefAcceptance options.briefAcceptance
? `\n\n## Acceptance\n${options.briefAcceptance.trim()}` ? `\n\n## Acceptance\n${options.briefAcceptance.trim()}`
: "", : "",
skillContext,
"\n\nUse tools to inspect and modify the repo. Finish with a concise summary of work done.", "\n\nUse tools to inspect and modify the repo. Finish with a concise summary of work done.",
buildResumeAppendix(resumeHistory), buildResumeAppendix(resumeHistory),
].join(""); ].join("");

View file

@ -0,0 +1,283 @@
import type { ResolvedModel } from "@tasks/shared/model-routing";
import type {
WorkerJobContext,
WorkerSession,
} from "@tasks/shared/worker";
import { loadAgentSkills, buildSkillFullContext } from "./load-skills.js";
/**
* Configuration for the OpenHands agent server connection.
* For local Agent Canvas: AGENT_SERVER_URL + SESSION_API_KEY (ambient).
* For Cloud: OPENHANDS_CLOUD_API_KEY + agent_server_url from conversation.
*/
export type OpenHandsConfig = {
agentServerUrl: string;
sessionApiKey: string;
/** Repo root to scan for .agents/skills/ */
repoRoot: string;
};
export type OpenHandsSessionOptions = {
config: OpenHandsConfig;
resolvedModel: ResolvedModel;
roleId: string;
briefId?: string;
briefTitle?: string;
workflowPrompt?: string;
briefAcceptance?: string;
briefFiles?: string[];
maxSteps?: number;
};
const POLL_INTERVAL_MS = 2000;
const MAX_POLL_ATTEMPTS = 300; // 10 minutes at 2s interval
/**
* Create an OpenHands session that runs an agent via the agent-server REST API.
* The agent runs inside an OpenHands sandbox with full tool access (terminal, file_editor, etc.).
*/
export async function createOpenHandsSession(
context: WorkerJobContext,
options: OpenHandsSessionOptions,
): Promise<WorkerSession> {
const { config, resolvedModel, roleId, briefId, briefTitle, workflowPrompt, briefAcceptance, briefFiles } = options;
const repoRoot = config.repoRoot;
const apiBase = config.agentServerUrl.replace(/\/$/, "");
const headers = {
"X-Session-API-Key": config.sessionApiKey,
"Content-Type": "application/json",
} as const;
// Load skills from .agents/skills/
let skillContext = "";
try {
const skills = await loadAgentSkills(repoRoot);
skillContext = buildSkillFullContext(skills);
} catch (e) {
console.warn("[openhands] skill loading failed, continuing without skills:", e);
}
// Build the system prompt that the agent will see
const systemParts: string[] = [
`You are an AI agent executing a brief for convoy "${context.convoyId}".`,
`Role: ${roleId}`,
briefId ? `Brief ID: ${briefId}` : "",
briefTitle ? `Brief: ${briefTitle}` : "",
workflowPrompt ? `\n## Task\n${workflowPrompt}` : "",
briefAcceptance ? `\n## Acceptance criteria\n${briefAcceptance}` : "",
briefFiles?.length ? `\n## Files to examine\n${briefFiles.map((f) => `- ${f}`).join("\n")}` : "",
skillContext || "",
"\n## Instructions\nUse the available tools to inspect and modify the repo. Run lint/type-check when appropriate. Finish with a concise summary.",
].filter(Boolean);
const systemPrompt = systemParts.join("\n");
/**
* Start a conversation on the OpenHands agent server.
* Uses the local backend API (POST /api/conversations).
*/
async function startConversation(): Promise<{ conversationId: string }> {
const body = {
agent: {
kind: "Agent",
llm: {
model: resolvedModel.modelId,
api_key: resolvedModel.apiKey || "placeholder",
base_url: resolvedModel.baseUrl,
},
tools: [
{ name: "terminal" },
{ name: "file_editor" },
{ name: "task_tracker" },
],
},
workspace: {
kind: "LocalWorkspace",
working_dir: repoRoot,
},
initial_message: {
content: [{ text: systemPrompt }],
run: true,
},
};
console.info(`[openhands] starting conversation roleId=${roleId} model=${resolvedModel.alias}`);
const res = await fetch(`${apiBase}/api/conversations`, {
method: "POST",
headers,
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`OpenHands start failed (${res.status}): ${text}`);
}
const data = await res.json() as { id: string };
return { conversationId: data.id };
}
/**
* Poll conversation status until it reaches a terminal state.
*/
async function pollUntilComplete(conversationId: string): Promise<{
status: "completed" | "failed" | "timeout";
summary: string;
toolCalls: number;
tokensTotal?: number;
}> {
for (let i = 0; i < MAX_POLL_ATTEMPTS; i++) {
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
const res = await fetch(
`${apiBase}/api/conversations/${conversationId}`,
{ headers },
);
if (!res.ok) {
console.warn(`[openhands] poll failed (${res.status}), retrying...`);
continue;
}
const conv = await res.json() as {
execution_status?: string;
sandbox_status?: string;
};
const execStatus = conv.execution_status ?? "";
const sandboxStatus = conv.sandbox_status ?? "";
if (execStatus === "completed" || execStatus === "finished") {
return await fetchResult(conversationId, "completed");
}
if (execStatus === "failed" || sandboxStatus === "error") {
return await fetchResult(conversationId, "failed");
}
if (i % 10 === 0) {
console.info(`[openhands] poll ${i} exec=${execStatus} sandbox=${sandboxStatus}`);
}
}
return {
status: "timeout",
summary: "OpenHands conversation timed out after 10 minutes",
toolCalls: 0,
};
}
/**
* Fetch the final result from a completed/failed conversation.
*/
async function fetchResult(
conversationId: string,
fallbackStatus: "completed" | "failed",
): Promise<{
status: "completed" | "failed";
summary: string;
toolCalls: number;
tokensTotal?: number;
}> {
try {
const res = await fetch(
`${apiBase}/api/conversations/${conversationId}/events/search?limit=500&sort_order=TIMESTAMP_DESC`,
{ headers },
);
if (!res.ok) {
return { status: fallbackStatus, summary: `Failed to fetch events (${res.status})`, toolCalls: 0 };
}
const data = await res.json() as { items: Array<{
kind: string;
source: string;
message?: string;
tool_name?: string;
tool_call_id?: string;
action?: { command?: string; path?: string };
observation?: { stdout?: string; exit_code?: number };
}> };
const events = data.items ?? [];
let toolCalls = 0;
const assistantMessages: string[] = [];
let tokensTotal: number | undefined;
for (const event of events) {
if (event.kind === "ActionEvent" && event.source === "agent") {
toolCalls++;
}
if (event.kind === "MessageEvent" && event.source === "assistant") {
if (event.message) assistantMessages.push(event.message);
}
}
const summary = assistantMessages[0] ?? "OpenHands session completed with no summary.";
return {
status: fallbackStatus,
summary,
toolCalls,
tokensTotal,
};
} catch (e) {
return {
status: fallbackStatus,
summary: `Failed to fetch result: ${e instanceof Error ? e.message : String(e)}`,
toolCalls: 0,
};
}
}
/**
* Send a follow-up message to a running conversation.
*/
async function sendMessage(conversationId: string, text: string): Promise<void> {
const res = await fetch(
`${apiBase}/api/conversations/${conversationId}/send-message`,
{
method: "POST",
headers,
body: JSON.stringify({ content: [{ text }] }),
},
);
if (!res.ok) {
const body = await res.text();
console.warn(`[openhands] send-message failed (${res.status}): ${body}`);
}
}
return {
context,
async run(prompt: string) {
console.info(`[openhands] session.run roleId=${roleId} brief=${briefId ?? "none"}`);
try {
const { conversationId } = await startConversation();
console.info(`[openhands] conversation started id=${conversationId}`);
// Send the actual task prompt as a user message
await sendMessage(conversationId, prompt);
// Poll until complete
const result = await pollUntilComplete(conversationId);
console.info(
`[openhands] session complete status=${result.status} tools=${result.toolCalls} tokens=${result.tokensTotal ?? "unknown"}`,
);
return {
summary: result.summary,
toolCalls: result.toolCalls,
tokensTotal: result.tokensTotal,
};
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.error(`[openhands] session failed:`, msg);
return {
summary: `OpenHands session failed: ${msg}`,
toolCalls: 0,
};
}
},
};
}

View file

@ -129,6 +129,21 @@ ENV NODE_OPTIONS=--max-old-space-size=5120
# Local dev uses a gitignored apps/web/.env symlink; drop it so Next.js does not # Local dev uses a gitignored apps/web/.env symlink; drop it so Next.js does not
# follow a broken link inside the container. # follow a broken link inside the container.
RUN rm -f apps/web/.env apps/web/.env.local apps/web/.env.production apps/web/.env.development RUN rm -f apps/web/.env apps/web/.env.local apps/web/.env.production apps/web/.env.development
# NEXT_PUBLIC_* must be present at build time — Next.js inlines them into the client bundle.
ARG NEXT_PUBLIC_COLLAB_SERVER_URL=ws://localhost:1234
ARG NEXT_PUBLIC_UMAMI_SCRIPT=
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID=
ARG NEXT_PUBLIC_LIBREDESK_URL=
ARG NEXT_PUBLIC_LIBREDESK_WIDGET_ID=
ARG NEXT_PUBLIC_DIRECTUS_URL=
ENV NEXT_PUBLIC_COLLAB_SERVER_URL=${NEXT_PUBLIC_COLLAB_SERVER_URL}
ENV NEXT_PUBLIC_UMAMI_SCRIPT=${NEXT_PUBLIC_UMAMI_SCRIPT}
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=${NEXT_PUBLIC_UMAMI_WEBSITE_ID}
ENV NEXT_PUBLIC_LIBREDESK_URL=${NEXT_PUBLIC_LIBREDESK_URL}
ENV NEXT_PUBLIC_LIBREDESK_WIDGET_ID=${NEXT_PUBLIC_LIBREDESK_WIDGET_ID}
ENV NEXT_PUBLIC_DIRECTUS_URL=${NEXT_PUBLIC_DIRECTUS_URL}
RUN pnpm exec turbo build --filter=@tasks/web RUN pnpm exec turbo build --filter=@tasks/web
@ -277,8 +292,11 @@ COPY --chown=worker:nodejs packages ./repo/packages
COPY --chown=worker:nodejs plans ./repo/plans COPY --chown=worker:nodejs plans ./repo/plans
COPY --chown=worker:nodejs .cursor/mcp.json ./repo/.cursor/mcp.json COPY --chown=worker:nodejs .cursor/mcp.json ./repo/.cursor/mcp.json
COPY --chown=worker:nodejs .cursor/rules ./repo/.cursor/rules COPY --chown=worker:nodejs .cursor/rules ./repo/.cursor/rules
COPY --chown=worker:nodejs .agents/skills ./repo/.agents/skills
RUN mkdir -p /app/repo/.cursor/agents RUN mkdir -p /app/repo/.cursor/agents
COPY --chown=worker:nodejs pnpm-workspace.yaml package.json turbo.json tsconfig.json ./repo/ COPY --chown=worker:nodejs pnpm-workspace.yaml package.json turbo.json tsconfig.json ./repo/
COPY --chown=worker:nodejs docker/worker-entrypoint.sh /app/worker-entrypoint.sh
RUN chmod +x /app/worker-entrypoint.sh
# Minimal git repo so worktrees + allowlisted git commands work on the deploy snapshot. # Minimal git repo so worktrees + allowlisted git commands work on the deploy snapshot.
USER root USER root
@ -295,4 +313,4 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD pgrep -f "node.*dist/index\\.js" > /dev/null || exit 1 CMD pgrep -f "node.*dist/index\\.js" > /dev/null || exit 1
WORKDIR /app/apps/worker WORKDIR /app/apps/worker
CMD ["node", "dist/index.js"] ENTRYPOINT ["/app/worker-entrypoint.sh"]

View file

@ -0,0 +1,12 @@
# DEPRECATED — OpenHands agent-server now lives in docker/docker-compose.prod.yml
# under the `agent-server` profile (same `echodo` network as worker).
#
# Enable Phase B:
# docker compose -f docker/docker-compose.prod.yml --profile agent-server up -d
#
# Required env (same values on worker + agent-server):
# LOCAL_BACKEND_API_KEY=<openssl rand -hex 32>
# SESSION_API_KEY=<same as LOCAL_BACKEND_API_KEY>
# WORKER_SESSION_TYPE=openhands
# OMNIROUTE_BASE_URL=https://omniroute.stillwell.cloud/v1
# OMNIROUTE_API_KEY=<key>

View file

@ -1,206 +1,7 @@
# Docker Compose for Coolify deployment of ECHODO. # DEPRECATED — use docker/docker-compose.prod.yml
# #
# Usage in Coolify: # Kept so existing Coolify resources that still point at this path keep working.
# - New Resource → Docker Compose → connect this repo # New Dokploy / standalone deploys should use docker-compose.prod.yml.
# - Build pack: Docker Compose
# - Compose file path: docker/docker-compose.coolify.yml
# - Set domains in Coolify on the `web` and `collab` services using http://
# (CT 100 Traefik handles TLS termination per AGENT-DEPLOY.md)
#
# Path conventions:
# Coolify invokes `docker compose` with --project-directory set to the
# repository root, NOT this file's directory. So all build contexts and
# dockerfile paths below are written relative to the repo root.
#
# Differences vs. docker/docker-compose.yml:
# - No bundled postgres / redis (uses shared CT 102 services)
# - No host port mappings (Coolify's Traefik routes by container labels)
# - Uses Coolify SERVICE_FQDN_* magic env vars so Coolify auto-wires Traefik labels
#
# Build strategy:
# All five services share docker/Dockerfile and select runtime via `target:`.
# The shared `deps` stage is hashed identically across targets, so BuildKit
# runs `pnpm install` ONCE.
#
# The heavy `next build` for `web` (peak ~3 GB heap with 5 GB headroom)
# must not race the two tsup builds for collab+mcp on the 8 GB Coolify
# host or the kernel OOM-kills it (exit 255 with no Docker error). Build
# ordering is enforced inside docker/Dockerfile: the `web-build` stage
# has explicit `COPY --from=collab-build` / `COPY --from=mcp-build` lines
# that force BuildKit's DAG to finish those two before starting the web
# compile. The `depends_on` below only orders runtime startup, NOT image
# builds — that was a misconception from an earlier iteration of this
# file.
#
# Runtime image export still builds five targets in parallel by default,
# which OOMs 8 GB Coolify hosts (exit 255, no Docker error). Required:
# Coolify → Advanced → Custom Build Command: bash scripts/build-coolify.sh
# (COMPOSE_PARALLEL_LIMIT alone is not reliable across Compose versions.)
# Local verify: pnpm docker:build:coolify
services: include:
web: - docker-compose.prod.yml
build:
context: .
dockerfile: docker/Dockerfile
target: web
depends_on:
- collab
- mcp
restart: unless-stopped
environment:
# Coolify FQDN magic var — set the domain in the Coolify UI for this service.
# SERVICE_FQDN_WEB_3000 makes Coolify expose the container on port 3000 via Traefik.
- SERVICE_FQDN_WEB_3000
- DATABASE_URL=${DATABASE_URL}
- REDIS_URL=${REDIS_URL}
- NEXTAUTH_URL=${NEXTAUTH_URL}
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
- AUTH_SECRET=${AUTH_SECRET}
- AUTH_DEV_PASSWORD=${AUTH_DEV_PASSWORD}
- AUTH_GITHUB_ID=${AUTH_GITHUB_ID}
- AUTH_GITHUB_SECRET=${AUTH_GITHUB_SECRET}
- AUTH_GOOGLE_ID=${AUTH_GOOGLE_ID}
- AUTH_GOOGLE_SECRET=${AUTH_GOOGLE_SECRET}
- AUTH_AUTHENTIK_ID=${AUTH_AUTHENTIK_ID}
- AUTH_AUTHENTIK_SECRET=${AUTH_AUTHENTIK_SECRET}
- AUTH_AUTHENTIK_ISSUER=${AUTH_AUTHENTIK_ISSUER}
- COLLAB_SERVER_URL=${COLLAB_SERVER_URL}
- NEXT_PUBLIC_COLLAB_SERVER_URL=${NEXT_PUBLIC_COLLAB_SERVER_URL}
- NEXT_PUBLIC_UMAMI_SCRIPT=${NEXT_PUBLIC_UMAMI_SCRIPT}
- NEXT_PUBLIC_UMAMI_WEBSITE_ID=${NEXT_PUBLIC_UMAMI_WEBSITE_ID}
- NEXT_PUBLIC_LIBREDESK_URL=${NEXT_PUBLIC_LIBREDESK_URL}
- NEXT_PUBLIC_LIBREDESK_WIDGET_ID=${NEXT_PUBLIC_LIBREDESK_WIDGET_ID}
- NEXT_PUBLIC_DIRECTUS_URL=${NEXT_PUBLIC_DIRECTUS_URL}
- DIRECTUS_FEEDBACK_TOKEN=${DIRECTUS_FEEDBACK_TOKEN}
- OPENAI_API_KEY=${OPENAI_API_KEY}
- OPENAI_BASE_URL=${OPENAI_BASE_URL}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- NODE_ENV=production
- NEXT_TELEMETRY_DISABLED=1
expose:
- "3000"
healthcheck:
test:
- CMD-SHELL
- "wget -qO- --timeout=3 http://127.0.0.1:3000/ >/dev/null || exit 1"
interval: 30s
timeout: 5s
retries: 3
start_period: 90s
networks:
- echodo
collab:
build:
context: .
dockerfile: docker/Dockerfile
target: collab
restart: unless-stopped
environment:
# Separate FQDN for the websocket service, e.g. collab.echodo.stillwell.cloud
- SERVICE_FQDN_COLLAB_1234
- DATABASE_URL=${DATABASE_URL}
- REDIS_URL=${REDIS_URL}
- PORT=1234
- NODE_ENV=production
expose:
- "1234"
healthcheck:
test:
- CMD-SHELL
- 'node -e "const p=+(process.env.PORT||1234);require(''net'').createConnection(p,''127.0.0.1'').on(''connect'',()=>process.exit(0)).on(''error'',()=>process.exit(1))"'
interval: 30s
timeout: 5s
retries: 3
start_period: 25s
networks:
- echodo
# MCP server is stdio-based today; deploy only if you want it reachable on the LAN
# for future HTTP/SSE transport. Comment the whole block out if not needed.
mcp:
build:
context: .
dockerfile: docker/Dockerfile
target: mcp
args:
MCP_SERVER_PORT: ${MCP_SERVER_PORT:-3001}
restart: unless-stopped
environment:
- DATABASE_URL=${DATABASE_URL}
- MCP_SERVER_PORT=${MCP_SERVER_PORT:-3001}
- NODE_ENV=production
stdin_open: true
expose:
- "${MCP_SERVER_PORT:-3001}"
healthcheck:
test:
- CMD-SHELL
- "pgrep -f 'node.*dist/index\\.js' > /dev/null || exit 1"
interval: 30s
timeout: 5s
retries: 3
start_period: 25s
networks:
- echodo
orchestrator:
build:
context: .
dockerfile: docker/Dockerfile
target: orchestrator
restart: unless-stopped
environment:
- DATABASE_URL=${DATABASE_URL}
- REDIS_URL=${REDIS_URL}
- ORCHESTRATOR_DRY_RUN=${ORCHESTRATOR_DRY_RUN:-0}
- ORCHESTRATOR_POLL_MS=${ORCHESTRATOR_POLL_MS:-5000}
- ORCHESTRATOR_STALL_MINUTES=${ORCHESTRATOR_STALL_MINUTES:-120}
- ORCHESTRATOR_MAX_IMPLEMENTERS=${ORCHESTRATOR_MAX_IMPLEMENTERS:-2}
- NODE_ENV=production
healthcheck:
test:
- CMD-SHELL
- "pgrep -f 'node.*dist/index\\.js' > /dev/null || exit 1"
interval: 30s
timeout: 5s
retries: 3
start_period: 25s
networks:
- echodo
worker:
build:
context: .
dockerfile: docker/Dockerfile
target: worker
depends_on:
- orchestrator
restart: unless-stopped
environment:
- DATABASE_URL=${DATABASE_URL}
- REDIS_URL=${REDIS_URL}
- WORKER_DRY_RUN=${WORKER_DRY_RUN:-0}
- WORKER_BLPOP_TIMEOUT_S=${WORKER_BLPOP_TIMEOUT_S:-5}
- WORKER_MAX_TOOL_STEPS=${WORKER_MAX_TOOL_STEPS:-12}
- WORKER_REPO_ROOT=/app/repo
- WORKER_WORKTREE_BASE=/tmp/echodo-worker-worktrees
- OPENAI_API_KEY=${OPENAI_API_KEY}
- OPENAI_BASE_URL=${OPENAI_BASE_URL}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- NODE_ENV=production
healthcheck:
test:
- CMD-SHELL
- "pgrep -f 'node.*dist/index\\.js' > /dev/null || exit 1"
interval: 30s
timeout: 5s
retries: 3
start_period: 25s
networks:
- echodo
networks:
echodo:
driver: bridge

View file

@ -0,0 +1,14 @@
# Standalone LXC override — publish ports for CT 100 Traefik (no Dokploy/Coolify proxy).
#
# Usage:
# docker compose -f docker/docker-compose.prod.yml \
# -f docker/docker-compose.prod.standalone.yml up -d
services:
web:
ports:
- "3000:3000"
collab:
ports:
- "1234:1234"

View file

@ -0,0 +1,244 @@
# Echodo production stack — shared CT 102 Postgres/Redis, multi-service build.
#
# Phase A (default): web, collab, mcp, orchestrator, worker
# Phase B (optional): OpenHands agent-server — enable with profile `agent-server`
#
# Deploy targets:
# - Dokploy (CT 112): compose file path `docker/docker-compose.prod.yml`, project dir = repo root
# - Standalone LXC: `docker compose -f docker/docker-compose.prod.yml up -d`
#
# Domains (Dokploy UI or CT 100 Traefik):
# web → echodo.stillwell.cloud (port 3000)
# collab → collab.echodo.stillwell.cloud (port 1234)
#
# Build (required on ≤8 GB hosts — parallel export OOMs with exit 255):
# bash scripts/build-prod.sh
# Dokploy → Advanced → Custom Build Command: bash scripts/build-prod.sh
#
# Phase B:
# docker compose -f docker/docker-compose.prod.yml --profile agent-server up -d
# Set WORKER_SESSION_TYPE=openhands and matching SESSION_API_KEY / LOCAL_BACKEND_API_KEY.
services:
web:
build:
context: .
dockerfile: docker/Dockerfile
target: web
args:
NEXT_PUBLIC_COLLAB_SERVER_URL: ${NEXT_PUBLIC_COLLAB_SERVER_URL:-ws://localhost:1234}
NEXT_PUBLIC_UMAMI_SCRIPT: ${NEXT_PUBLIC_UMAMI_SCRIPT:-}
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${NEXT_PUBLIC_UMAMI_WEBSITE_ID:-}
NEXT_PUBLIC_LIBREDESK_URL: ${NEXT_PUBLIC_LIBREDESK_URL:-}
NEXT_PUBLIC_LIBREDESK_WIDGET_ID: ${NEXT_PUBLIC_LIBREDESK_WIDGET_ID:-}
NEXT_PUBLIC_DIRECTUS_URL: ${NEXT_PUBLIC_DIRECTUS_URL:-}
depends_on:
collab:
condition: service_started
mcp:
condition: service_started
restart: unless-stopped
environment:
- DATABASE_URL=${DATABASE_URL}
- REDIS_URL=${REDIS_URL}
- NEXTAUTH_URL=${NEXTAUTH_URL}
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
- AUTH_SECRET=${AUTH_SECRET}
- AUTH_DEV_PASSWORD=${AUTH_DEV_PASSWORD}
- AUTH_GITHUB_ID=${AUTH_GITHUB_ID}
- AUTH_GITHUB_SECRET=${AUTH_GITHUB_SECRET}
- AUTH_GOOGLE_ID=${AUTH_GOOGLE_ID}
- AUTH_GOOGLE_SECRET=${AUTH_GOOGLE_SECRET}
- AUTH_AUTHENTIK_ID=${AUTH_AUTHENTIK_ID}
- AUTH_AUTHENTIK_SECRET=${AUTH_AUTHENTIK_SECRET}
- AUTH_AUTHENTIK_ISSUER=${AUTH_AUTHENTIK_ISSUER}
- COLLAB_SERVER_URL=${COLLAB_SERVER_URL}
- NEXT_PUBLIC_COLLAB_SERVER_URL=${NEXT_PUBLIC_COLLAB_SERVER_URL}
- NEXT_PUBLIC_UMAMI_SCRIPT=${NEXT_PUBLIC_UMAMI_SCRIPT}
- NEXT_PUBLIC_UMAMI_WEBSITE_ID=${NEXT_PUBLIC_UMAMI_WEBSITE_ID}
- NEXT_PUBLIC_LIBREDESK_URL=${NEXT_PUBLIC_LIBREDESK_URL}
- NEXT_PUBLIC_LIBREDESK_WIDGET_ID=${NEXT_PUBLIC_LIBREDESK_WIDGET_ID}
- NEXT_PUBLIC_DIRECTUS_URL=${NEXT_PUBLIC_DIRECTUS_URL}
- DIRECTUS_FEEDBACK_TOKEN=${DIRECTUS_FEEDBACK_TOKEN}
- OPENAI_API_KEY=${OPENAI_API_KEY}
- OPENAI_BASE_URL=${OPENAI_BASE_URL}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- OMNIROUTE_BASE_URL=${OMNIROUTE_BASE_URL:-}
- OMNIROUTE_API_KEY=${OMNIROUTE_API_KEY:-}
- GITHUB_TOKEN=${GITHUB_TOKEN:-}
- FORGEJO_TOKEN=${FORGEJO_TOKEN:-}
- FORGE_WEBHOOK_SECRET=${FORGE_WEBHOOK_SECRET:-}
- NODE_ENV=production
- NEXT_TELEMETRY_DISABLED=1
expose:
- "3000"
healthcheck:
test:
- CMD-SHELL
- "wget -qO- --timeout=3 http://127.0.0.1:3000/ >/dev/null || exit 1"
interval: 30s
timeout: 5s
retries: 3
start_period: 90s
networks:
- echodo
collab:
build:
context: .
dockerfile: docker/Dockerfile
target: collab
restart: unless-stopped
environment:
- DATABASE_URL=${DATABASE_URL}
- REDIS_URL=${REDIS_URL}
- PORT=1234
- NODE_ENV=production
expose:
- "1234"
healthcheck:
test:
- CMD-SHELL
- 'node -e "const p=+(process.env.PORT||1234);require(''net'').createConnection(p,''127.0.0.1'').on(''connect'',()=>process.exit(0)).on(''error'',()=>process.exit(1))"'
interval: 30s
timeout: 5s
retries: 3
start_period: 25s
networks:
- echodo
mcp:
build:
context: .
dockerfile: docker/Dockerfile
target: mcp
args:
MCP_SERVER_PORT: ${MCP_SERVER_PORT:-3001}
restart: unless-stopped
environment:
- DATABASE_URL=${DATABASE_URL}
- MCP_SERVER_PORT=${MCP_SERVER_PORT:-3001}
- NODE_ENV=production
stdin_open: true
expose:
- "${MCP_SERVER_PORT:-3001}"
healthcheck:
test:
- CMD-SHELL
- "pgrep -f 'node.*dist/index\\.js' > /dev/null || exit 1"
interval: 30s
timeout: 5s
retries: 3
start_period: 25s
networks:
- echodo
orchestrator:
build:
context: .
dockerfile: docker/Dockerfile
target: orchestrator
restart: unless-stopped
environment:
- DATABASE_URL=${DATABASE_URL}
- REDIS_URL=${REDIS_URL}
- ORCHESTRATOR_DRY_RUN=${ORCHESTRATOR_DRY_RUN:-0}
- ORCHESTRATOR_POLL_MS=${ORCHESTRATOR_POLL_MS:-5000}
- ORCHESTRATOR_STALL_MINUTES=${ORCHESTRATOR_STALL_MINUTES:-120}
- ORCHESTRATOR_MAX_IMPLEMENTERS=${ORCHESTRATOR_MAX_IMPLEMENTERS:-2}
- NODE_ENV=production
healthcheck:
test:
- CMD-SHELL
- "pgrep -f 'node.*dist/index\\.js' > /dev/null || exit 1"
interval: 30s
timeout: 5s
retries: 3
start_period: 25s
networks:
- echodo
worker:
build:
context: .
dockerfile: docker/Dockerfile
target: worker
depends_on:
orchestrator:
condition: service_started
restart: unless-stopped
environment:
- DATABASE_URL=${DATABASE_URL}
- REDIS_URL=${REDIS_URL}
- WORKER_DRY_RUN=${WORKER_DRY_RUN:-0}
- WORKER_BLPOP_TIMEOUT_S=${WORKER_BLPOP_TIMEOUT_S:-5}
- WORKER_MAX_TOOL_STEPS=${WORKER_MAX_TOOL_STEPS:-12}
- WORKER_REPO_ROOT=/app/repo
- WORKER_WORKTREE_BASE=/tmp/echodo-worker-worktrees
- WORKER_SESSION_TYPE=${WORKER_SESSION_TYPE:-minimal}
- WORKER_OPENHANDS_WORKSPACE=${WORKER_OPENHANDS_WORKSPACE:-/workspace/repo}
- AGENT_SERVER_URL=${AGENT_SERVER_URL:-http://agent-server:8001}
- SESSION_API_KEY=${SESSION_API_KEY:-}
- OPENAI_API_KEY=${OPENAI_API_KEY}
- OPENAI_BASE_URL=${OPENAI_BASE_URL}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- OMNIROUTE_BASE_URL=${OMNIROUTE_BASE_URL:-}
- OMNIROUTE_API_KEY=${OMNIROUTE_API_KEY:-}
- GITHUB_TOKEN=${GITHUB_TOKEN:-}
- FORGEJO_TOKEN=${FORGEJO_TOKEN:-}
- NODE_ENV=production
volumes:
- echodo-workspace:/workspace
healthcheck:
test:
- CMD-SHELL
- "pgrep -f 'node.*dist/index\\.js' > /dev/null || exit 1"
interval: 30s
timeout: 5s
retries: 3
start_period: 25s
networks:
- echodo
agent-server:
profiles: ["agent-server"]
image: ghcr.io/open-hands/agent-server:0.19.0
restart: unless-stopped
environment:
- AGENT_SERVER_HOST=0.0.0.0
- AGENT_SERVER_PORT=8001
- LOCAL_BACKEND_API_KEY=${LOCAL_BACKEND_API_KEY}
- LLM_MODEL=${OMNIROUTE_MODEL:-auto/coding}
- LLM_API_KEY=${OMNIROUTE_API_KEY}
- LLM_BASE_URL=${OMNIROUTE_BASE_URL}
- SANDBOX_TYPE=docker
- SANDBOX_DOCKER_IMAGE=ghcr.io/open-hands/sandbox:latest
- SANDBOX_TIMEOUT=300
- WORKSPACE_BASE=/workspace
volumes:
- echodo-workspace:/workspace
- /var/run/docker.sock:/var/run/docker.sock
expose:
- "8001"
healthcheck:
test:
- CMD-SHELL
- "wget -qO- --timeout=3 http://127.0.0.1:8001/server_info >/dev/null || exit 1"
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
deploy:
resources:
limits:
memory: 2G
cpus: "2"
networks:
- echodo
volumes:
echodo-workspace:
networks:
echodo:
driver: bridge

17
docker/worker-entrypoint.sh Executable file
View file

@ -0,0 +1,17 @@
#!/bin/sh
set -eu
# When OpenHands is enabled, seed the shared workspace volume from the deploy
# snapshot baked into the image (/app/repo). Agent-server reads the same path.
if [ "${WORKER_SESSION_TYPE:-minimal}" = "openhands" ]; then
target="${WORKER_OPENHANDS_WORKSPACE:-/workspace/repo}"
mkdir -p "$(dirname "$target")"
if [ ! -f "$target/pnpm-workspace.yaml" ]; then
echo "[worker-entrypoint] seeding OpenHands workspace at ${target}"
mkdir -p "$target"
cp -a /app/repo/. "$target/"
fi
fi
cd /app/apps/worker
exec node dist/index.js

View file

@ -0,0 +1,517 @@
# Autonomous Convoys with OpenHands & OmniRoute
**Status:** Draft (planning phase)
**Date:** 2026-09-07
**Goal:** Enable OpenHands + omniroute to autonomously execute Echodo convoys by mapping roles to model profiles and integrating Agent Skills format.
---
## Executive Summary
Echodo's convoy system (defined in `.convoys/*.md` with role-based `model_policy`) currently requires Cursor to execute per-role briefs. This plan replaces Cursor with OpenHands as the runtime harness, using omniroute's model profiles (`auto`, `auto/coding`, `auto/reasoning`, `auto/coding-pro`, `auto/reasoning-pro`) to route each role to the appropriate model.
The main insight: **Your convoy structure already works. We're just swapping the agent harness and making skills discoverable.**
**Key changes:**
1. Convert role-prompt rules to `.agents/skills/` in SKILL.md format (Agent Skills spec)
2. Map omniroute profiles → roles (conductor/architect/implementer/reviewer)
3. Wire OpenHands session bootstrap to inherit `agent_prompt` + skills + model policy
4. Document the flow so agents executing convoys follow consistent patterns
**Outcome:** By end of v0.1, a human can:
- Define a convoy in `.convoys/<slug>.md` with `model_policy` roles mapped to omniroute profiles
- Trigger the convoy from Mission Control
- OpenHands picks up each brief, inherits the task context, loads the right model via omniroute, and executes
- After success, worker logs role completion + model metadata back to `agent_runs` and `convoy_events`
---
## Architecture: The Convoy-to-OpenHands Flow
```
┌─── Human / Mission Control ──────────────────────────────────────┐
│ "Start convoy: brief-1-implement-feature" │
│ (.convoys/feature-x.md, brief-1-*.md exists, ready to go) │
└────────────────────────┬────────────────────────────────────────┘
│
Orchestrator
(apps/orchestrator)
│
┌────────────────┼────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────┐
│ Redis Job Queue (echodo:orchestrator:jobs) │
│ { roleId, harnessAlias, briefPath, workspaceId, ...} │
└──────────────────────┬──────────────────────────────────────┘
│
Worker dequeues
(apps/worker)
│
┌─────────────────┼──────────────────┐
│ │ │
▼ ▼ ▼
┌────────────┐ ┌──────────────┐ ┌──────────────┐
│ Resolve │ │ Resolve │ │ Load │
│ model via │ │ harness + │ │ session │
│ omniroute │ │ worktree │ │ bootstrap: │
│ profile │ │ │ │ • agent_prompt
│ (coding, │ │ (code/doc/ │ │ • skills from
│ reasoning) │ │ research │ │ .agents/ │
│ │ │ harness) │ │ • role rules │
└────────────┘ └──────────────┘ └──────────────┘
│ │ │
└────────────────┼──────────────────┘
│
OpenHands Session Start
(apps/web calls MCP claim_task)
│
┌────────────────┼────────────────┐
│ │ │
▼ ▼ ▼
┌──────────────────────────────────────────────────────────────┐
│ Session context ready: │
│ • Model resolved (e.g. qwen-coder or claude-3-5-sonnet) │
│ • Task/brief context loaded (agent_prompt, file paths) │
│ • Skills available (.agents/skills/*.md files) │
│ • Tool allowlist set (code vs doc harness) │
│ • MCP tools: read_file, write_file, grep, run_command │
│ │
│ Agent now thinks: "I am role-implementer on brief-1. │
│ My success metric is: [from convoy frontmatter]. │
│ I should follow: [agent_prompt from task]. │
│ I can use these skills: [loaded from .agents/skills/]." │
└────────────────────┬─────────────────────────────────────────┘
│
Agent tool loop
(read_file, write_file,
grep, run_command)
│
┌───────────────┼───────────────┐
│ │ │
Success Failure Stall
│ │ │
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────────┐
│ Log │ │ Log │ │ Orchestrator │
│ complete │ │ failure │ │ detects │
│ + model │ │ + │ │ stall, │
│ metadata │ │ tokens │ │ resumes from │
│ │ │ │ │ checkpoint │
└────┬─────┘ └────┬─────┘ └──────┬───────┘
│ │ │
└─────────────┼───────────────┘
│
MCP complete_task + convoy_events.role_complete
│
Orchestrator ticks
(determines next role)
│
┌─────────────┼──────────────┐
│ │ │
Next Brief Human Gate End Convoy
│ │ │
▼ ▼ ▼
┌──────────┐ ┌─────────┐ ┌──────────┐
│ Back to │ │ Mission │ │ Released │
│ Redis │ │ Control │ │ or │
│ (loop) │ │ approve │ │ merged │
└──────────┘ └─────────┘ └──────────┘
```
---
## Four Concrete Work Phases
### Phase 1: Agent Skills Format Conversion (M estimate)
**Goal:** Make repo-integrated skills discoverable by OpenHands via SKILL.md format in `.agents/skills/`.
**Why:** Your current `.cursor/rules/*.mdc` files are Cursor-specific. OpenHands reads `.agents/skills/*/SKILL.md` (per Agent Skills spec). Converting moves you from Cursor-only to Cursor + OpenHands + any harness that supports Agent Skills.
**Tasks:**
1. **Create `.agents/skills/backlog-sync/SKILL.md`**
- **When to invoke:** When working with `plans/`, `markdown_backlog_items`, or the Echodo markdown import
- **What it teaches:** How backlog frontmatter maps to DB, how `pnpm --filter @tasks/database watch:markdown-backlog` works, the contract between `.md` and `markdown_backlog_items`
- **Reference:** `.cursor/rules/markdown-backlog.mdc` + `packages/database/src/markdown-backlog/*.ts` + `docs/Glossary.md`
- **Output format:** `SKILL.md` with frontmatter (name, description) + sections (When to Activate, Core Rules, Common Patterns, Examples)
2. **Create `.agents/skills/convoy-lifecycle/SKILL.md`**
- **When to invoke:** When working with convoys, briefs, transitions, gates, or the state machine
- **What it teaches:** Status enum (draft→ready→in_progress→pr_review→merged), gate actors (human-gate-1/2/3), MCP tools (create_convoy, transition_convoy_status, log_convoy_event), checkpoint/resume pattern
- **Reference:** `docs/convoy-lifecycle.md` + `packages/shared/src/convoy-lifecycle/*.ts` + `apps/mcp-server/src/tools/*convoy*`
- **Output format:** Same as backlog-sync
3. **Create `.agents/skills/model-routing/SKILL.md`**
- **When to invoke:** When routing models, configuring aliases, or understanding how the orchestrator selects models
- **What it teaches:** The resolution order (brief override → convoy policy → workspace routes → pipeline defaults), omniroute profile mapping, how `resolveModel(workspace, roleId)` works, how model metadata flows to `agent_runs` and `convoy_events`
- **Reference:** `docs/adr-model-routing.md` + `packages/shared/src/orchestrator/*.ts` + this doc's **Phase 2** section
- **Output format:** Same as above
4. **Create `.agents/skills/code-review-two-axis/SKILL.md`** (adapted from mattpocock/skills)
- **When to invoke:** When `role-reviewer` runs or a human asks for a PR review in Mission Control
- **What it teaches:** Two-axis review (Standards vs Spec), running both as parallel sub-agents, how to aggregate findings, the code-smell baseline
- **Reference:** https://github.com/mattpocock/skills/blob/main/skills/engineering/code-review/SKILL.md (with Echodo-specific tweaks for Standards against `AGENTS.md` + repo rules)
- **Output format:** Same as above
5. **Create `.agents/skills/tdd-red-green/SKILL.md`** (adapted from mattpocock/skills)
- **When to invoke:** When implementing features or fixing bugs in a test-first flow
- **What it teaches:** Red-green-refactor loop, seams, anti-patterns, the tracer-bullet concept
- **Reference:** https://github.com/mattpocock/skills/blob/main/skills/engineering/tdd/SKILL.md
- **Output format:** Same as above
**Acceptance criteria:**
- [ ] Each SKILL.md has frontmatter (name, description) + at least 3 sections
- [ ] `skil --validate .agents/skills/*/SKILL.md` passes (or equiv. local validation)
- [ ] Skills are referenced in `AGENTS.md` under the "Skills available" section
- [ ] Agent can invoke them via `/skill` command or model-invocation within OpenHands
---
### Phase 2: OmniRoute Profile Mapping & Model Policy Doc (S estimate)
**Goal:** Document how omniroute profiles map to roles and how the orchestrator uses `model_policy` to route.
**Why:** Your `model_policy.roles` in convoy frontmatter lists role names + model aliases. Those aliases need to resolve to omniroute **profiles** (e.g., `auto/coding`, `auto/reasoning`) so the worker can ask omniroute "give me the model for `claude-planning`" and get back the current endpoint.
**Tasks:**
1. **Create `docs/omniroute-role-mapping.md`**
- **Section 1: Profile inventory**
```
- auto (default, fastest)
- auto/coding (code-specialized)
- auto/coding-pro (complex, high-quality)
- auto/reasoning (reasoning/planning)
- auto/reasoning-pro (complex reasoning, high-cost)
```
- **Section 2: Role → Profile mapping (recommended)**
```
role-conductor → auto/reasoning (kickoff, classify, skip flags)
role-architect → auto/reasoning-pro (design, brief decomposition)
role-implementer → auto/coding (default) or auto/coding-pro (escalation)
role-reviewer → auto/coding-pro (two-axis review)
role-doc-writer → auto/coding (docs are simpler than code)
gate-copilot → auto/reasoning (summary for human gate)
```
- **Section 3: How to configure in a convoy**
```yaml
model_policy:
roles:
role-conductor: claude-planning # resolves to auto/reasoning profile
role-implementer: qwen-coder # resolves to auto/coding profile
escalate_to: claude-planning # if implementer fails 2x, re-dispatch with this
```
- **Section 4: How the orchestrator uses this**
- Reads `model_policy.roles[roleId]` from the convoy
- Calls `resolveModel(workspace, roleId)` in `packages/shared/src/orchestrator/*.ts`
- Resolution order: brief override → convoy policy → workspace routes → pipeline defaults
- Result: `{ alias, provider_type, model_id, base_url, api_key_env_var }`
- Worker receives model spec in the job payload
- **Section 5: Escalation & retry logic**
- If implementer brief fails, orchestrator can re-dispatch with `escalate_to: role-architect` (higher-tier model)
- Log tier change on `agent_runs`
2. **Create `docs/omniroute-setup.md`** (operational guide)
- **How to register omniroute in the workspace** (via Settings → AI → Providers or tRPC `model_providers.create`)
- **How to set up the 5 profiles** (assuming omniroute cloud endpoint; or local Ollama + MLX)
- **How to test** (`testProvider` MCP call, test a single model)
- **How to debug** (logs in `agent_runs`, `worker_checkpoints` metadata)
3. **Update `docs/adr-model-routing.md`** (add OmniRoute section)
- Keep the existing conceptual model
- Add subsection: "OmniRoute Cloud Integration (v0.1)"
- Explain how `resolveModel` calls omniroute's API to get the actual endpoint + auth
**Acceptance criteria:**
- [ ] `docs/omniroute-role-mapping.md` covers all 6 roles (conductor, architect, implementer, reviewer, doc-writer, gate-copilot)
- [ ] Each role has a recommended default profile + an override example
- [ ] `docs/omniroute-setup.md` has step-by-step instructions (create alias, test, configure workspace route)
- [ ] ADR updated with omniroute context
- [ ] Convoy examples in `.convoys/*.md` use the recommended aliases (no hardcoded model names)
---
### Phase 3: OpenHands Session Bootstrap & Worker Integration (L estimate)
**Goal:** Wire up OpenHands session to inherit task context, load skills, and respect model policy.
**Why:** When the worker dequeues a job and spawns an OpenHands session, the agent needs to know:
- What task it's working on (brief path, success metric, acceptance criteria)
- What role it is (role-implementer, role-reviewer, etc.)
- Which model to use (resolved from omniroute)
- Which skills are available (from `.agents/skills/`)
- Which tools are allowed (code harness: `pnpm lint`, `run_command`; doc harness: read/write/grep only)
**Tasks:**
1. **Extend `apps/worker/src/session/minimal-session.ts`** (or create `openhands-session.ts`)
- **Input:** `{ jobSpec, resolvedModel, harnessAlias, briefPath }`
- **On session init:**
- Load `brief-*.md` from `.convoys/<slug>/` (task context)
- Extract `agent_prompt` (from task → epic → plan inheritance, per AGENTS.md)
- Inherit `title`, `success_metric`, acceptance criteria from frontmatter
- Construct a **session bootstrap** (initial system + first user message)
- **Session bootstrap structure:**
```
System message (shared context):
- You are an AI assistant helping implement a software task
- Your task: [title from brief frontmatter]
- Success means: [success_metric]
- You are playing the role of: [roleId]
- Acceptance criteria: [from brief or inherited]
- Constraints:
- Use the [tool allowlist] only
- If you make a file change, it will be pushed to a branch
- After you succeed, claim_task + complete_task will be called
- Principles: [karpathy-guidelines from AGENTS.md]
First user message:
- [inherited agent_prompt from task frontmatter]
- [brief body content — the actual work description]
- "Here are the skills you have available: [list from .agents/skills/]"
- "Your model is: [resolved model name from omniroute]"
```
- **On session end:**
- Collect metrics: `{ model, tokens, duration, outcome }`
- Return to worker for logging
2. **Wire OpenHands integration into Worker job handler** (`apps/worker/src/handle-job.ts`)
- **Current flow:** `MinimalSession` → tool loop → checkpoint
- **New flow:**
```
1. Dequeue job from Redis
2. resolveHarnessForJob(job.roleId) → harness config
3. resolveModel(workspace, job.roleId) → model spec
4. Create OpenHands session with bootstrap (brief context + skills)
5. Run tool loop (agent interacts with codebase)
6. On success: push branch, call forge PR endpoint
7. Log complete_task + convoy_events.role_complete with model metadata
8. Return to step 1
```
- **Config:** Add env var `WORKER_SESSION_TYPE=openai-client | minimal | openhands` (default: minimal for backward compat, set to `openhands` to use new flow)
3. **Create `.agents/` integration test** (`apps/worker/src/openhands-integration.test.ts`)
- **Test:** Spawn a dry-run OpenHands session with a toy brief, verify:
- [ ] Brief context loads correctly
- [ ] Skills are discoverable
- [ ] agent_prompt is inherited
- [ ] Tool allowlist is respected
- [ ] Session logs metrics on exit
**Acceptance criteria:**
- [ ] `WORKER_SESSION_TYPE=openhands` env var enables the flow
- [ ] Integration test passes (dry-run with mock brief)
- [ ] Live E2E test (real brief, real repo) succeeds with model logging
- [ ] `agent_runs` row includes `{ model_alias, model_tier, tokens_in, tokens_out }`
- [ ] `convoy_events` has `role_complete` entry with `metadata.harnessAlias`
---
### Phase 4: End-to-End E2E Test & Dogfood (L estimate)
**Goal:** Prove one convoy executes autonomously from start to finish using OpenHands + omniroute.
**Why:** This is the acceptance test for the whole milestone. A human should be able to:
1. Define a convoy with roles mapped to omniroute profiles
2. Click "Start" in Mission Control
3. Watch OpenHands agents pick up and complete briefs without Cursor
4. See model metadata logged correctly
**Tasks:**
1. **Create a test convoy** (`.convoys/test-e2e-autonomous.md`)
- Classification: `server-only` (simple, no IA/UX/design)
- 2–3 briefs (implementer + reviewer)
- Roles: `role-conductor` (kickoff only), `role-implementer` (fix a small bug or add a minor feature), `role-reviewer` (review the PR)
- Model policy: use omniroute profiles (e.g., `role-implementer: auto/coding`, `role-reviewer: auto/coding-pro`)
- Success metric: "Bug is fixed, PR is drafted, review is complete"
2. **Run the full loop** (Manual walkthrough)
- [ ] Human creates convoy via MCP `create_convoy` or Mission Control UI
- [ ] Orchestrator picks up `role-conductor` (dry-run or light task)
- [ ] Orchestrator enqueues `role-implementer` to Redis
- [ ] Worker dequeues, spawns OpenHands session
- [ ] OpenHands loads brief, runs tool loop, fixes the bug, commits
- [ ] Worker logs `complete_task` + `role_complete`
- [ ] Orchestrator transitions convoy to `pr_review` (or next gate)
- [ ] Orchestrator enqueues `role-reviewer` brief
- [ ] Worker dequeues, spawns OpenHands session, runs two-axis review
- [ ] Worker logs `complete_task` + `role_complete` with review metadata
- [ ] Convoy reaches `merged` or `published` gate
- [ ] Check `agent_runs`: model aliases logged for both implementer + reviewer
- [ ] Check `convoy_events`: role_complete entries present with metadata
3. **Dogfood the pipeline** (Optional: use this convoy to close a real task)
- Pick a backlog task that fits the convoy (small feature or bug)
- Map task to a convoy brief
- Run the E2E flow on real code
- Validate that the fix/feature is correct and the PR is mergeable
**Acceptance criteria:**
- [ ] Convoy completes all briefs without Cursor
- [ ] `agent_runs` has entries for implementer + reviewer with model aliases
- [ ] `convoy_events` has `role_complete` entries
- [ ] PR is draft or merged (depending on gates)
- [ ] No manual intervention after "Start Convoy"
- [ ] Logs are clean (no auth errors, model resolution errors)
---
## Dependencies & Ordering
```
Phase 1 (Skills) ──┐
Phase 2 (Routing) ─┼──→ Phase 3 (Worker) ──→ Phase 4 (E2E)
Phase 3 unblocked │ (can start once 1 + 2 done)
as soon as 1 + 2 │
complete
```
**Critical path:**
- Phase 1 + 2 are sequential (skills first, then routing docs)
- Phase 3 can start once Phase 1 + 2 are done
- Phase 4 requires Phase 3 complete
---
## Agent Instructions for Each Phase
### For Agents Working on Phase 1 (Skills)
1. **Before writing:** Read the existing `.cursor/rules/*.mdc` files in this repo + the referenced SKILL.md files from mattpocock/skills, anthropics/skills
2. **Structure:** Follow the Agent Skills spec (frontmatter + markdown sections)
3. **Validation:** Each skill should be invokable by model (e.g., `/skill backlog-sync` or implied when needed) and have 3–4 substantive sections
4. **Test:** Run locally with OpenHands or Claude Code to verify skills load
5. **Acceptance:** PR reviewer checks that skills have frontmatter + clear When-to-Activate section
### For Agents Working on Phase 2 (Routing)
1. **Before writing:** Read `docs/adr-model-routing.md` fully + understand how `model_policy` works in `.convoys/*.md`
2. **Assumption:** OmniRoute cloud is the target (not local-only), and omniroute has 5 profiles (auto, auto/coding, auto/coding-pro, auto/reasoning, auto/reasoning-pro)
3. **Output:** Two markdown files (mapping + setup), both human-readable and copy-pasteable examples
4. **Validation:** Walk through the flow: convoy sets `model_policy.roles`, orchestrator calls `resolveModel`, worker receives model spec
5. **Test:** No automated test needed; human walkthrough in Phase 4
### For Agents Working on Phase 3 (Worker Integration)
1. **Before writing:** Read `apps/worker/src/session/minimal-session.ts` + `apps/worker/src/handle-job.ts` + understand the worker dequeue loop
2. **Pattern:** Don't replace MinimalSession; extend it or wrap it in an adapter (prefer wrapping for backward compat)
3. **Assumption:** OpenHands agent-server is running and reachable (via env var `AGENT_SERVER_URL`)
4. **Validation:**
- Type-check passes (`pnpm type-check`)
- New env var `WORKER_SESSION_TYPE` is optional (defaults to `minimal`)
- Integration test dry-runs without errors
5. **Test:** Phase 4 (E2E) will exercise this
### For Agents Working on Phase 4 (E2E)
1. **Before starting:** Phase 1 + 2 + 3 must be done
2. **Pre-flight:**
- [ ] `.agents/skills/` SKILL.md files exist + load correctly
- [ ] `docs/omniroute-role-mapping.md` + `docs/omniroute-setup.md` exist
- [ ] `WORKER_SESSION_TYPE=openhands` env var works
- [ ] Orchestrator + worker are running (or can dry-run)
3. **Test convoy:** Create `.convoys/test-e2e-autonomous.md` with 2 briefs (implementer + reviewer)
4. **Manual walkthrough:** Follow the flow in "Phase 4: Tasks" section, step by step
5. **Validation:** Check `agent_runs`, `convoy_events` tables for logs; verify PR created + reviewed
6. **Report:** Summarize the run (timeline, model aliases used, any errors, fixes applied)
---
## Success Criteria (Overall)
By end of Phase 4:
- [ ] One full convoy (2+ briefs) executes autonomously via OpenHands without Cursor
- [ ] Each role uses the model from omniroute (via model_policy)
- [ ] `agent_runs` + `convoy_events` log model metadata for observability
- [ ] No secrets or API keys leaked in logs
- [ ] Docs cover setup + usage (omniroute-role-mapping.md, omniroute-setup.md)
- [ ] Skills are discoverable + usable by agents (.agents/skills/*/SKILL.md)
- [ ] Worker integration test passes
- [ ] Code is linted + type-checked
---
## Phase 5: ACP Harness & Runtime Agnosticism (post-v0.1)
**Goal:** Make submatter runtime-agnostic via Agent Client Protocol (ACP).
**Why:** ACP is an emerging standard that allows OpenHands, Claude Code, Gemini CLI, and other runtimes to be swapped via a single protocol. This prevents vendor lock-in and lets operators choose the best runtime per task.
**Tasks:**
1. Add `HarnessType: "acp"` to shared types
2. Create `apps/worker/src/executor/acp-executor.ts` — implements ACP client (connects to any ACP-compatible server)
3. Support ACP discovery via `/.well-known/agent.json` (runtime metadata)
4. Add `acp` harness type to executor registry
5. Support sub-agents via ACP Task Tool Set
**ACP-compatible runtimes:**
- OpenHands (primary)
- Claude Code (Anthropic)
- Gemini CLI (Google)
- Cursor (experimental)
- Custom (any ACP server)
**This is NOT required for v0.1 testing.** Ships after v0.1 ships and proves out.
---
## Rollout & Scaling
Once Phase 4 succeeds:
1. **Publish to v0.1** — Mark the orchestrator + worker as production-ready for autonomous convoys
2. **Deploy OpenHands Agent Server** — Docker container on Dokploy (CT 102), worker connects via internal Docker DNS
3. **Scale briefs** — Extend to 5–6 briefs per convoy (architect → implementer → reviewer → ops-copilot → etc.)
4. **Scale roles** — Add `role-docs-writer`, `role-security-auditor`, `role-performance-reviewer` as needed
5. **Scale models** — Extend omniroute integration to support Claude/OpenAI/Gemini APIs side-by-side
6. **Migrate Cursor convoys** — For convoys that have historically run in Cursor, run them via OpenHands instead (backward compat: human-initiated, not automated)
7. **ACP harness** — After v0.1 ships, add ACP support to enable runtime-agnostic agent execution
---
## Risk Mitigations
| Risk | Mitigation |
|------|-----------|
| Model tier mismatches (expensive model for simple task) | OmniRoute profiles + explicit role mapping in docs |
| Agent hallucinations in brief context | Agent_prompt inheritance from task → epic → plan keeps context tight; karpathy-guidelines skill |
| Network issues between worker + OpenHands | Checkpoint/resume logic already in place; retry logic in orchestrator |
| Secrets leaked in logs | Review `agent_runs.metadata` + `worker_checkpoints.tool_history` before logging; never log full API responses |
| Convoy state desync (DB vs `.convoys/*.md`) | `reconcile_from_files` MCP tool keeps them in sync; convoy_events is the source of truth |
---
## References
- **Agent Skills Spec:** https://agentskills.io/specification
- **Mattpocock Skills (code-review, tdd):** https://github.com/mattpocock/skills
- **Superpowers (bootstrap pattern):** https://github.com/obra/superpowers
- **Karpathy Guidelines (4 behavioral rules):** https://github.com/multica-ai/andrej-karpathy-skills
- **Echodo Convoys:** `.convoys/README.md`
- **Model Routing ADR:** `docs/adr-model-routing.md`
- **Worker Docs:** `docs/worker.md`
- **Orchestrator Docs:** `docs/orchestrator.md`
---
## Questions for Review
Before agents start Phase 1, answer these:
1. **OmniRoute target:** Are we assuming omniroute cloud endpoint, or should we support local Ollama + MLX as a fallback?
2. **Model fallback:** If omniroute is down, should the worker use a hardcoded fallback model, or fail closed?
3. **Role coverage:** Are 6 roles (conductor, architect, implementer, reviewer, doc-writer, gate-copilot) enough, or do we need more?
4. **Cost tracking:** Should Phase 3 also wire up `cost_budget` checks from `model_providers`, or leave that for a follow-on?
5. **Backward compat:** Should Phase 3 preserve the MinimalSession adapter, or fully replace it with OpenHands?
6. **v0.1 scope:** Phase 4 tests one convoy. Should we aim for 2+ convoys running in parallel by end of Phase 4, or save that for post-v0.1?
---
**Document Version:** 1.0
**Last Updated:** 2026-09-07
**Next Review:** After Phase 1 completion

65
docs/copilot-prompt.md Normal file
View file

@ -0,0 +1,65 @@
# Copilot prompts — versioned system prompts for `gate-copilot` and ops copilot
Version: `v1` — 2026-08-25. Source of truth for harness portability (DB, worker, web).
If you change a prompt, bump version and update both implementations in one commit.
## `gate-copilot` — gate co-pilot (`apps/web/server/routers/convoys.ts:gut`)
Used by `convoys.gateCopilot` tRPC. Resolved via `resolveModel({ roleId: "gate-copilot" })` with `convoyPolicy` overlay.
```
You are the gate co-pilot for an agent-pipeline convoy.
Summarize the plan, highlight risks, and recommend approve / revise / request changes.
Be concise and actionable.
Convoy: <convoySlug or title>
Status: <ConvoyStatus>
Classification: <classification>
Success metric: <successMetric>
Plan:
<convoy.description — full plan markdown>
```
- User prompt defaults to: `Summarize this convoy for the operator and suggest the best gate action.` Override via `input.question`.
- Model params: `temperature 0.4`, `maxTokens 1500`.
- Grounding: convoy row only; no backlog or fleet tools.
## `ops copilot` — ops copilot (`apps/web/server/routers/copilot.ts`)
Used by `opsCopilot.generateText` / `copilot.generateText`. Resolved via `resolveModel({ roleId: "gate-copilot" })` (shared route alias). Read-only, workspace-scoped.
System prompt:
```
You are ops copilot for second brain OS. You help owners triage "what needs me?" across fleet, convoys and backlog. Use the provided tools (fleetStatus, convoyGet, backlogListTree) to ground answers. Always respond with links in form [workspaceSlug/convoySlug] for convoys and [workspaceSlug/planSlug/epicSlug/taskSlug] for backlog items. Show fleet → needs you citations when answering gate questions. Be concise, actionable, no secrets.
Current workspace: <workspace.name> (<workspace.slug>)
Workspace id: <workspace.id>
```
Tools (bounded, workspace-scoped, read-only):
- `fleetStatus()` — gate inbox (`GATE_INBOX_STATUSES`) up to 10, returns `totalNeedingYou` + items with `link: workspaceSlug/convoySlug`.
- `convoyGet({ convoyId: uuid })` — one convoy, returns `link: workspaceSlug/convoySlug`.
- `backlogListTree()` — `markdown_backlog_items` for workspace, bounded 20, returns `link: workspaceSlug/planSlug/epicSlug/taskSlug`.
Model params: `temperature 0.4`, `maxTokens 1200`, `maxSteps 3`.
## Rules (both copilots)
- Workspace-scoped — never read across `workspace_id`.
- Never emit secrets, API keys, or `FORGE_WEBHOOK_SECRET`.
- Link format is mandatory for grounding: `workspaceSlug/convoySlug` and `workspaceSlug/planSlug/epicSlug/taskSlug`.
- Concise, actionable; cite `fleetStatus` when answering gate questions.
## Versioning
| Version | Date | Change |
|---------|------|--------|
| v1 | 2026-08-25 | Initial versioned snapshot from `convoys.ts:gateCopilot` + `copilot.ts:OPS_SYSTEM_PROMPT`. |
## References
- Gate copilot impl: `apps/web/server/routers/convoys.ts`
- Ops copilot impl: `apps/web/server/routers/copilot.ts`
- Model routing: `packages/shared/src/model-routing/constants.ts` (`gate-copilot → claude-planning` default), `docs/adr-model-routing.md`
- Convoy states: `docs/convoy-lifecycle.md`, `packages/shared/src/convoy-lifecycle/state-machine.ts`

View file

@ -0,0 +1,169 @@
# Echodo / Submatter — Production Deployment
Production runs on **CT 116** (`submatter`, `192.168.68.116`) as plain Docker Compose — not Dokploy.
| Doc | Purpose |
|-----|---------|
| This file | Operator checklist, env vars, troubleshooting |
| [`docs/forgejo-migration.md`](forgejo-migration.md) | GitHub → Forgejo + CT 116 cutover |
| [`proxmox/ct116/README.md`](../proxmox/ct116/README.md) | CT creation, Traefik, auto-deploy, Dokploy removal |
| `AGENT-DEPLOY.md` (local, gitignored) | Homelab credentials + CT map — add CT 116 row after cutover |
---
## Why a dedicated CT (not Dokploy)?
Echodo builds **five Docker images** from one monorepo; the Next.js compile alone reserves a 5 GB V8 heap. On Dokploy (CT 112), that competes with Zest, Umami, Comms, etc. and OOMs with exit 255.
**CT 116** gets the full 8 GB for serialized builds. CT 107 is not an alternative — it still hosts Coolify, Echo Board, Resonance, and other dev apps.
| Resource | CT 116 spec |
|----------|-------------|
| RAM | 8 GB |
| CPU | 2 cores |
| Disk | 32 GB rootfs |
| Runtime (Phase A) | ~3–4 GB |
| Phase B (+ OpenHands) | +2 GB |
External: Postgres/Redis on **CT 102**, TLS on **CT 100**, OmniRoute on **CT 108**.
---
## Stack files
| File | Use |
|------|-----|
| `docker/docker-compose.prod.yml` | Core stack |
| `docker/docker-compose.prod.standalone.yml` | Publish `:3000` / `:1234` for CT 100 Traefik |
| `scripts/build-prod.sh` | Serialized image build |
| `scripts/deploy-prod.sh` | Build + up + health wait |
```bash
docker compose -f docker/docker-compose.prod.yml \
-f docker/docker-compose.prod.standalone.yml up -d
```
---
## Phase A — First deploy on CT 116
1. **Create CT 116** — see [`proxmox/ct116/README.md`](../proxmox/ct116/README.md) (`pct create` one-liner).
2. **Bootstrap** — `bootstrap.sh` installs Docker, clones to `/opt/submatter`.
3. **Configure `.env`** — copy from `.env.example`; use `echodo` database on CT 102, Redis `/11`.
4. **Deploy** — `bash scripts/deploy-prod.sh build`
5. **Traefik** — apply `proxmox/ct116/traefik-ct100.example.yml` on CT 100; remove old Dokploy/Coolify Echodo routes.
6. **Verify** — `https://echodo.stillwell.cloud`, `wss://collab.echodo.stillwell.cloud`
### Required env vars
| Variable | Production example | Notes |
|----------|-------------------|-------|
| `DATABASE_URL` | `postgresql://echodo:***@192.168.68.102:5432/echodo` | |
| `REDIS_URL` | `redis://:***@192.168.68.102:6379/11` | |
| `NEXTAUTH_URL` | `https://echodo.stillwell.cloud` | |
| `NEXTAUTH_SECRET` / `AUTH_SECRET` | `openssl rand -base64 32` | |
| `AUTH_AUTHENTIK_*` | | Authentik OIDC app `echodo` |
| `COLLAB_SERVER_URL` | `wss://collab.echodo.stillwell.cloud` | Server-side |
| `NEXT_PUBLIC_COLLAB_SERVER_URL` | `wss://collab.echodo.stillwell.cloud` | **Build arg** — requires image rebuild |
| `OMNIROUTE_BASE_URL` | `https://omniroute.stillwell.cloud/v1` | Web + worker |
| `OMNIROUTE_API_KEY` | (your key) | + register in Settings → AI → Providers |
| `FORGEJO_TOKEN` / `GITHUB_TOKEN` | PAT values | Referenced by name in Connected repos settings |
| `NEXT_PUBLIC_UMAMI_*` | | Analytics (CT 112 Umami) |
Changing any `NEXT_PUBLIC_*` value requires `bash scripts/deploy-prod.sh build`, not just `docker compose restart`.
---
## Auto-deploy on push
**CI** (every PR): `.github/workflows/ci.yml` — lint, type-check, test on GitHub-hosted or PR runner.
**CD** (merge to `main`): `.github/workflows/deploy-homelab.yml` and `.forgejo/workflows/deploy-homelab.yml` — SSH from CT 111 `axiom` runner → CT 116 → `deploy-prod.sh build`.
### GitHub / Forgejo repo secrets
| Secret | Value |
|--------|-------|
| `HOMELAB_DEPLOY_HOST` | `192.168.68.116` |
| `HOMELAB_DEPLOY_USER` | `root` |
| `HOMELAB_DEPLOY_SSH_KEY` | SSH private key (ed25519) |
| `HOMELAB_DEPLOY_PATH` | `/opt/submatter` (optional) |
Generate deploy key on CT 116:
```bash
ssh-keygen -t ed25519 -f /root/.ssh/github_deploy -N ""
cat /root/.ssh/github_deploy.pub >> /root/.ssh/authorized_keys
# Paste private key into HOMELAB_DEPLOY_SSH_KEY secret
```
Until secrets are set, the deploy workflow skips gracefully (CI still runs).
### Forgejo webhook (optional second trigger)
If Forgejo is canonical git and you want push deploy without Actions:
1. Install [webhook](https://github.com/adhocore/webhook) on CT 116.
2. Copy `proxmox/ct116/webhook.hooks.json.example` → `/etc/webhook.conf`.
3. Forgejo repo → Webhook → POST push events → `http://192.168.68.116:9000/hooks/submatter-deploy`.
---
## Remove from Dokploy
1. Dokploy UI → Submatter/Echodo compose app → Stop → Delete.
2. CT 100 — remove Traefik routes to Dokploy for `echodo.stillwell.cloud` / collab hostname.
3. Update local `AGENT-DEPLOY.md`: add CT 116 row, note Echodo removed from CT 112.
---
## Phase B — OpenHands (optional)
```bash
export COMPOSE_PROFILES=agent-server
bash scripts/deploy-prod.sh build
```
| Variable | Notes |
|----------|-------|
| `LOCAL_BACKEND_API_KEY` | `openssl rand -hex 32` |
| `SESSION_API_KEY` | Same as above (worker) |
| `WORKER_SESSION_TYPE` | `openhands` |
| `OMNIROUTE_BASE_URL` | `https://omniroute.stillwell.cloud/v1` |
| `OMNIROUTE_API_KEY` | OmniRoute bearer key |
Requires Docker socket on CT 116 (`nesting=1` on LXC).
---
## Troubleshooting
| Symptom | Fix |
|---------|-----|
| Build exit 255 ~60–90s | Ensure `deploy-prod.sh` uses `build-prod.sh` (serialized) |
| OAuth broken | DB must be `echodo`, not legacy `tasks` |
| Deploy workflow skipped | Set `HOMELAB_DEPLOY_*` secrets |
| Collab websocket fails | Traefik route to `:1234`, `wss://` in public env vars |
| OpenHands unavailable | `SESSION_API_KEY` must match `LOCAL_BACKEND_API_KEY` |
Local build verify: `pnpm docker:build:prod`
---
## Future: registry pull deploy (no build on CT 116)
Build images on CT 111 CI, push to Forgejo container registry, then on CT 116:
```bash
bash scripts/deploy-prod.sh pull
```
Requires compose `image:` tags instead of `build:` — not implemented yet; track as follow-on if CT 116 builds remain too slow.
---
## Related
- `docs/AGENT-AUTONOMOUS-CONVOYS.md` — convoy + OpenHands roadmap
- `docs/omniroute-setup.md` — OmniRoute operator guide
- `.env.example` — full variable list

View file

@ -0,0 +1,55 @@
# SOP published flow — `Task-write-sop → doc_review → published → artifact gallery` e2e
Fixture for `role-doc-writer` via harness `doc-writer` (`apps/worker/src/harness.ts`, `packages/shared/src/convoy-lifecycle/state-machine.ts:9`).
## Objective
Standard path for a doc-only SOP: write markdown under `docs/`, pass `doc_review`, publish, and verify artifact gallery.
## Prerequisites
- Convoy in `ready` or `in_progress` with a doc brief (`role-doc-writer`).
- Workspace has `harness_providers alias: doc-writer` (`minimal`, `needsWorktree: false`, `allowedCommands: []`) and `role_model_routes role-doc-writer → qwen-coder` (local).
- `MARKDOWN_BACKLOG_WORKSPACE_ID` set if backlog import needed.
## Steps
1. **Claim** — orchestrator assigns `role-doc-writer`; worker resolves `doc-writer` harness (`needsWorktree: false` — no worktree under `/tmp/echodo-worker-worktrees`).
2. **Write** — worker tool loop uses only `read_file`, `grep`, `write_file` (no `run_command`). Write target is `docs/...` or SOP file:
```ts
write_file({ path: "docs/operations/webhook-secret-rotation.md", content: "..." })
```
3. **Gate: `in_progress → doc_review`** — automation or `role-doc-writer` transitions via `transitionConvoy` (`packages/shared/src/convoy-lifecycle/state-machine.ts:104`). Mission Control shows `doc_review`.
4. **Gate: `doc_review → published`** — `human-gate-2` / `doc-approver` approves (`docs/convoy-lifecycle.md:27`, `state-machine.ts:104`). Event `status_change` with `actor: human-gate-2` or `doc-approver`.
5. **Gallery: `published → released` + artifact** — `convoys.complete` (`apps/web/server/routers/convoys.ts:467`) transitions `published → released` via `human-gate-3`, creates `completionArtifacts` row (`summaryMd` + optional `screenshotUrl`). Listed by `convoys.listArtifacts`.
## Verification
- [ ] Worker trace shows only `read_file`, `grep`, `write_file`; no `run_command` / `pnpm`.
- [ ] `convoy_events` contains `doc_review → published` by `human-gate-2` / `doc-approver`.
- [ ] File exists at `docs/...` with expected frontmatter/content.
- [ ] `convoys.listArtifacts` returns row with `summaryMd` and `screenshotUrl` (placeholder ok) and timeline events.
## Screenshots (placeholder)
Replace placeholders before marking `published → released` demo complete.
- `docs/examples/screenshots/sop-doc-review-gate.png` — Mission Control detail showing `doc_review` gate with Approve / Request changes.
- `docs/examples/screenshots/sop-published-badge.png` — Convoy kanban / detail showing `published` status.
- `docs/examples/screenshots/sop-artifact-gallery.png` — Gallery (`convoys.listArtifacts`) showing summary + screenshot.
> Capture via app at `/[workspaceSlug]/mission-control` and `/[workspaceSlug]/.../artifacts` or equivalent gallery route. Store originals at `docs/examples/screenshots/` (git-ignored if binary-heavy; link anyway).
## Counterparts
- Code flow mirror: `in_progress → pr_review → merged → released` via Forge webhook `POST /api/webhooks/forge` (`apps/web/app/api/webhooks/forge/route.ts`, `docs/operations/webhook-secret-rotation.md`).
- Template: `docs/templates/doc-sop.md`.
- Research fixture: `docs/examples/research-pipeline.md`.
## References
- Lifecycle: `docs/convoy-lifecycle.md`, `packages/shared/src/convoy-lifecycle/state-machine.ts`
- Worker doc harness: `docs/worker.md`, `apps/worker/src/harness.ts`
- Artifacts tRPC: `apps/web/server/routers/convoys.ts:complete`, `convoys.ts:listArtifacts`

168
docs/forgejo-migration.md Normal file
View file

@ -0,0 +1,168 @@
# Git remote migration — GitHub → Forgejo
Canonical repo: **https://forgejo.stillwell.cloud/rstillwell/Submatter.git**
Production host (CT 116) clones from Forgejo `main`. CI/CD runs via Forgejo Actions (`.forgejo/workflows/`) on the axiom self-hosted runner.
---
## 1. Local machine — point origin at Forgejo
From your dev checkout:
```bash
cd /path/to/submatter
bash scripts/migrate-origin-to-forgejo.sh
```
This renames the old GitHub remote to `github` and sets `origin` → Forgejo.
Push all branches you care about:
```bash
git push -u origin main
git push origin --tags
# feature branches:
git push origin feat/brief-3-harness-worker-dispatch
```
Use a Forgejo personal access token when prompted (Settings → Applications → Generate token, `repo` scope).
---
## 2. Merge deploy fixes to `main`
The CT 116 collab URL fix, Forgejo wiring, and prod compose live on your current branch. Before CT 116 can pull them:
```bash
git checkout main
git merge feat/brief-3-harness-worker-dispatch # or your branch name
git push origin main
```
Or open a PR on Forgejo and merge there.
---
## 3. CT 116 — switch remote + redeploy
SSH to the production host (from a machine on your LAN):
```bash
ssh root@192.168.68.116
cd /opt/submatter
```
### 3a. Update `.env` (critical for doc/whiteboard fix)
```bash
nano .env
```
Ensure these lines exist and use **wss://** (not ws://):
```env
NEXT_PUBLIC_COLLAB_SERVER_URL=wss://collab.echodo.stillwell.cloud
COLLAB_SERVER_URL=wss://collab.echodo.stillwell.cloud
```
Add OmniRoute + Forgejo token (for Connected repos + worker):
```env
OMNIROUTE_BASE_URL=https://omniroute.stillwell.cloud/v1
OMNIROUTE_API_KEY=<your-key>
FORGEJO_TOKEN=<forgejo-pat-with-repo-scope>
```
`NEXT_PUBLIC_*` changes require a **full rebuild**, not just restart.
### 3b. Migrate git remote + deploy
After `main` on Forgejo contains the deploy fixes:
```bash
bash scripts/migrate-origin-to-forgejo.sh
bash scripts/ct116-post-migration.sh
```
Or one-liner from your laptop:
```bash
ssh root@192.168.68.116 'cd /opt/submatter && bash scripts/migrate-origin-to-forgejo.sh && bash scripts/ct116-post-migration.sh'
```
Build takes **15–30 minutes** on the 8 GB CT (serialized image build).
---
## 4. Forgejo Actions secrets
In Forgejo → **Submatter** → Settings → Actions → Secrets, add:
| Secret | Value |
|--------|-------|
| `HOMELAB_DEPLOY_HOST` | `192.168.68.116` |
| `HOMELAB_DEPLOY_USER` | `root` |
| `HOMELAB_DEPLOY_SSH_KEY` | private key allowed to SSH to CT 116 |
| `HOMELAB_DEPLOY_PATH` | `/opt/submatter` |
Generate deploy key on CT 116:
```bash
ssh-keygen -t ed25519 -f /root/.ssh/forgejo_deploy -N ""
cat /root/.ssh/forgejo_deploy.pub >> /root/.ssh/authorized_keys
# paste private key into Forgejo secret HOMELAB_DEPLOY_SSH_KEY
```
Enable Actions on the repo. Workflows:
- `.forgejo/workflows/ci.yml` — lint, type-check, test on PR/push
- `.forgejo/workflows/deploy-homelab.yml` — deploy to CT 116 on push to `main`
Register the axiom runner in Forgejo (same CT 111 runner pool as GitHub, if configured for Forgejo).
---
## 5. Optional — Forgejo push webhook (instant deploy)
Alternative to Actions deploy:
1. Install [webhook](https://github.com/adhocore/webhook) on CT 116
2. Configure `proxmox/ct116/webhook.hooks.json.example`
3. Forgejo repo → Webhooks → POST push to `main`
Set `DEPLOY_WEBHOOK_SECRET` in webhook config and Forgejo webhook secret.
---
## 6. Connected repos (in-app)
Settings → Connected repos:
| Field | Value |
|-------|-------|
| Remote URL | `https://forgejo.stillwell.cloud/rstillwell/Submatter.git` |
| Token env var | `FORGEJO_TOKEN` |
Token value lives in CT 116 `.env` only — not in the database.
---
## 7. Decommission GitHub (when ready)
- Archive or delete `stwl-labs/ubiquitous-invention` on GitHub
- Remove GitHub deploy secrets if unused
- Update any external links to point at Forgejo
Keep `github` remote locally for reference: `git fetch github`
---
## Verify after deploy
1. Create a document and whiteboard — no client-side exception
2. Browser devtools → Network → WS connects to `wss://collab.echodo.stillwell.cloud`
3. Settings → AI → Providers → Test on OmniRoute alias
4. Settings → Connected repos → Test connection on Forgejo repo
See also: [`docs/deployment-production.md`](deployment-production.md), [`proxmox/ct116/README.md`](../proxmox/ct116/README.md)

View file

@ -0,0 +1,313 @@
# OmniRoute Role Mapping
**Status:** Draft
**Date:** 2026-09-07
**Purpose:** Map Echodo pipeline roles to OmniRoute profiles so the orchestrator/worker can route each role to the right model automatically.
---
## Overview
OmniRoute is an OpenAI-compatible gateway that fronts **929+ models** with a small set of **dynamic profiles**. Instead of configuring individual model IDs per role, we configure **profiles** that OmniRoute resolves to a live model endpoint:
| Profile | Purpose | Typical Resolves To |
|---------|---------|---------------------|
| `auto` | Fastest general-purpose | Small/fast model |
| `auto/coding` | Code-specialized | Coding-tuned model |
| `auto/coding-pro` | Complex code tasks, reviews | High-quality coding model |
| `auto/reasoning` | Planning, reasoning, architecture | Reasoning-tuned model |
| `auto/reasoning-pro` | Complex reasoning, escalation | Top reasoning model |
OmniRoute is integrated into Echodo as an **`openai_compatible` provider** — the same way Ollama and MLX are, but pointing at the OmniRoute base URL and using profile names as `modelId`.
---
## Profile → Role Matrix
### Recommended Defaults
| Role ID | OmniRoute Profile | Why |
|---------|-------------------|-----|
| `role-conductor` | `auto/reasoning` | Convoy kickoff: classify, skip flags, success metric |
| `role-architect` | `auto/reasoning` | Design, brief decomposition, architecture |
| `role-architect` (escalation) | `auto/reasoning-pro` | Complex/hard design problems |
| `role-ia-architect` | `auto/reasoning` | Information architecture planning |
| `role-implementer` | `auto/coding` | Default code implementation |
| `role-implementer` (escalation) | `auto/coding-pro` | After 2 failed attempts |
| `role-reviewer` | `auto/coding-pro` | Two-axis code review needs quality |
| `role-ux-reviewer` | `auto/coding` | UI review |
| `role-doc-writer` | `auto/coding` | Docs are simpler than code |
| `role-design-system-auditor` | `auto/coding` | Design system review |
| `role-a11y-auditor` | `auto/coding` | Accessibility review |
| `gate-copilot` | `auto/reasoning` | Summarize for human gate |
| `chat-default` | `auto` | Web chat default |
### OmniRoute-Only Workspace (Simple Setup)
If a workspace does **not** use local models (no Ollama/MLX), just create **5 OmniRoute providers** (one per profile) and route every role to the relevant profile:
```yaml
# Workspace model providers (via Settings → AI → Providers)
- alias: omni-auto
provider_type: openai_compatible
model_id: auto
base_url: ${OMNIROUTE_BASE_URL} # e.g. https://omniroute.example.com/v1
api_key_env_var: OMNIROUTE_API_KEY
metadata: { tier: auto, provider: omniroute }
- alias: omni-coding
provider_type: openai_compatible
model_id: auto/coding
base_url: ${OMNIROUTE_BASE_URL}
api_key_env_var: OMNIROUTE_API_KEY
metadata: { tier: auto, provider: omniroute }
- alias: omni-coding-pro
provider_type: openai_compatible
model_id: auto/coding-pro
base_url: ${OMNIROUTE_BASE_URL}
api_key_env_var: OMNIROUTE_API_KEY
metadata: { tier: auto, provider: omniroute }
- alias: omni-reasoning
provider_type: openai_compatible
model_id: auto/reasoning
base_url: ${OMNIROUTE_BASE_URL}
api_key_env_var: OMNIROUTE_API_KEY
metadata: { tier: auto, provider: omniroute }
- alias: omni-reasoning-pro
provider_type: openai_compatible
model_id: auto/reasoning-pro
base_url: ${OMNIROUTE_BASE_URL}
api_key_env_var: OMNIROUTE_API_KEY
metadata: { tier: auto, provider: omniroute }
```
Then map roles:
| Role ID | Alias |
|---------|-------|
| `role-conductor` | `omni-reasoning` |
| `role-architect` | `omni-reasoning` |
| `role-ia-architect` | `omni-reasoning` |
| `role-implementer` | `omni-coding` |
| `role-reviewer` | `omni-coding-pro` |
| `role-ux-reviewer` | `omni-coding` |
| `role-doc-writer` | `omni-coding` |
| `role-design-system-auditor` | `omni-coding` |
| `role-a11y-auditor` | `omni-coding` |
| `gate-copilot` | `omni-reasoning` |
| `chat-default` | `omni-auto` |
### Hybrid Setup (Local + OmniRoute)
For a homelab that wants free local inference AND cloud fallback:
| Role ID | Default | Escalation |
|---------|---------|------------|
| `role-conductor` | `omni-reasoning` (cloud) | — |
| `role-architect` | `omni-reasoning-pro` (cloud) | — |
| `role-implementer` | `qwen-coder-mlx` (local) | `omni-coding-pro` (cloud) |
| `role-reviewer` | `qwen-coder-mlx` (local) | `omni-coding-pro` (cloud) |
| `role-ux-reviewer` | `qwen-coder-mlx` (local) | `omni-coding` |
| `role-doc-writer` | `qwen-coder-mlx` (local) | `omni-coding` |
| `gate-copilot` | `omni-reasoning` (cloud) | — |
| `chat-default` | `omni-auto` (cloud) | — |
Hardcoded in convoy `model_policy`:
```yaml
model_policy:
roles:
role-conductor: omni-reasoning
role-architect: omni-reasoning-pro
role-implementer: qwen-coder-mlx
role-reviewer: omni-coding-pro
role-doc-writer: qwen-coder-mlx
escalate_to: omni-coding-pro
```
---
## How Resolution Works
Echodo's `resolveModel(workspaceId, roleId, ctx?)` (pure function in `packages/shared/src/model-routing/resolve-model.ts`) applies this order:
```
1. Brief override → recommended_model in .convoys/<slug>/brief-*.md
2. Convoy override → model_policy.roles[roleId] in .convoys/<slug>.md
3. Workspace route → role_model_routes row in DB
4. Pipeline template → PIPELINE_DEFAULT_ROLE_ROUTES (shipped defaults)
```
The resolved `ResolvedModel` object is passed to the worker. The worker creates a Vercel AI SDK `LanguageModel` via `createLanguageModelFromResolved` (`packages/ai/src/providers/resolve-client.ts`).
**Because OmniRoute is `openai_compatible`:** it flows through the existing `case "openai_compatible"` branch, so **no code change is required** in `resolve-client.ts` — it uses `createOpenAI({ apiKey, baseURL })` with `modelId` = profile name.
---
## Resolved Model Example
For `role-implementer` → `omni-coding`:
```typescript
{
alias: "omni-coding",
providerType: "openai_compatible",
modelId: "auto/coding",
baseUrl: "https://omniroute.example.com/v1",
apiKey: "sk-...", // read from OMNIROUTE_API_KEY at runtime
tier: "auto"
}
```
The worker's `createLanguageModelFromResolved` builds:
```typescript
const openai = createOpenAI({
apiKey: "sk-...",
baseURL: "https://omniroute.example.com/v1",
});
const model = openai("auto/coding");
```
OmniRoute then routes `auto/coding` to an appropriate live model per its own logic.
---
## Convoy Frontmatter
### OmniRoute-Only Convoy
```yaml
---
name: feature-auth
classification: server-only
success_metric: POST /auth/register works and returns 201
skip:
- ia
- ux
- design
- a11y
- visual
- smoke
status: draft
created: 2026-09-07
model_policy:
roles:
role-conductor: omni-reasoning
role-architect: omni-reasoning
role-implementer: omni-coding
role-reviewer: omni-coding-pro
escalate_to: omni-coding-pro
---
```
### Hybrid Convoy
```yaml
---
name: feature-local-first
model_policy:
roles:
role-conductor: omni-reasoning
role-architect: omni-reasoning
role-implementer: qwen-coder-mlx
role-reviewer: qwen-coder-mlx
escalate_to: omni-coding-pro
---
```
### Brief Override
```yaml
---
brief: complex-db-migration
recommended_model: omni-reasoning-pro # overrides convoy + workspace
model_tier: auto
---
```
---
## Environment Variables
| Variable | Where | Purpose |
|----------|-------|---------|
| `OMNIROUTE_BASE_URL` | Worker + web process env | OmniRoute endpoint (must end in `/v1`) |
| `OMNIROUTE_API_KEY` | Worker + web process env | Autogenerated by OpenHands/OmniRoute |
These names are **referenced** in `model_providers.apiKeyEnvVar` — the actual values live in deploy env (Coolify/Dokploy/Mac), never in the DB or committed files.
---
## Testing the Mapping
### Via tRPC
```typescript
// Debug endpoint: resolve a role to a model
resolveForRole({ roleId: "role-implementer" })
// → { ok: true, model: { alias: "omni-coding", modelId: "auto/coding", ... } }
```
### Via MCP testProvider
```typescript
testProvider({ alias: "omni-coding" })
// → { success: true, latency_ms: 320, model: "auto/coding" }
```
### Via direct OpenAI-compatible call
```bash
curl ${OMNIROUTE_BASE_URL}/models \
-H "Authorization: Bearer ${OMNIROUTE_API_KEY}"
# → List of available models (929+ for OmniRoute)
```
---
## Default Pipeline Template After OmniRoute
Update `PIPELINE_DEFAULT_ROLE_ROUTES` in `packages/shared/src/model-routing/constants.ts` if OmniRoute is the default for new workspaces:
```typescript
export const PIPELINE_DEFAULT_ROLE_ROUTES: Readonly<Record<string, string>> = {
"role-conductor": "omni-reasoning",
"role-architect": "omni-reasoning",
"role-ia-architect": "omni-reasoning",
"role-ux-reviewer": "omni-coding",
"role-implementer": "omni-coding",
"role-reviewer": "omni-coding-pro",
"role-design-system-auditor": "omni-coding",
"role-a11y-auditor": "omni-coding",
"role-doc-writer": "omni-coding",
"gate-copilot": "omni-reasoning",
"chat-default": "omni-auto",
};
```
*(Note: changing the template only affects new workspaces — existing workspace routes in the DB take precedence.)*
---
## Open Questions
1. **Should the template switch to OmniRoute aliases by default, or stay Claude/Qwen and let operators pick OmniRoute?** Recommend: keep Claude/Qwen seed, document OmniRoute as an option until it's proven.
2. **Does OmniRoute support streaming + function calling?** Needed for the worker tool loop. Verify via a live test before routing implementer roles to OmniRoute.
3. **Rate limits / cost:** OmniRoute `auto/*` profiles rotate models — need token/cost tracking in `agent_runs` to monitor spend.
---
## References
- **Resolver:** `packages/shared/src/model-routing/resolve-model.ts`
- **Schemas:** `packages/shared/src/model-routing/schemas.ts`
- **Constants:** `packages/shared/src/model-routing/constants.ts`
- **Client builder:** `packages/ai/src/providers/resolve-client.ts`
- **Provider schema:** `packages/database/src/schema/model_providers.ts`
- **Route schema:** `packages/database/src/schema/role_model_routes.ts`
- **ADR:** `docs/adr-model-routing.md`
- **Skill:** `.agents/skills/model-routing/SKILL.md`

384
docs/omniroute-setup.md Normal file
View file

@ -0,0 +1,384 @@
# OmniRoute Setup Guide
**Status:** Draft
**Date:** 2026-09-07
**Purpose:** Step-by-step guide for operators to register OmniRoute as a model provider in Echodo, configure role routes, and test that everything works.
---
## What is OmniRoute?
OmniRoute is an OpenAI-compatible model routing gateway. It provides **dynamic model profiles** (`auto`, `auto/coding`, `auto/coding-pro`, `auto/reasoning`, `auto/reasoning-pro`) that automatically resolve to the best available model based on your OmniRoute plan and current load.
**Benefits for Echodo:**
- Single API key, 929+ models available
- Auto-routing: no need to pick individual models per role
- Fallback/resilience: if one model is down, OmniRoute switches automatically
- Works through Echodo's existing `openai_compatible` provider type — zero code changes required
---
## Prerequisites
1. Echodo is running locally (`pnpm dev`) or deployed (Coolify/Dokploy)
2. Database is initialized (`pnpm db:push` or `pnpm db:migrate`)
3. You have an OmniRoute account/API key (or plan to use the local instance)
---
## Step 1: Get OmniRoute Credentials
### Option A: OmniRoute Cloud (recommended)
1. Sign up at [omniroute.example.com](https://omniroute.example.com)
2. Create an API key in Settings → API Keys
3. Note your:
- **Base URL:** `https://omniroute.example.com/v1` (check your dashboard)
- **API Key:** `sk-...` (save securely — not committed to git)
### Option B: Self-hosted OmniRoute (local)
If running OmniRoute locally (e.g., via Docker):
1. Clone/run the OmniRoute server
2. Note your:
- **Base URL:** `http://localhost:11434/v1` (or whatever port)
- **API Key:** `any-non-empty-string` (self-hosted often ignores auth)
---
## Step 2: Set Environment Variables
Add to your `.env` (or Coolify environment):
```bash
# OmniRoute credentials
OMNIROUTE_BASE_URL=https://omniroute.example.com/v1
OMNIROUTE_API_KEY=sk-your-key-here
# (Already set by default) Database
DATABASE_URL=postgres://echodo:echodo@localhost:5432/echodo
# (Already set by default) Redis (for orchestrator + worker)
REDIS_URL=redis://localhost:6379
```
**Important:** These env vars must be available to:
- The **web app** (`apps/web`) — for Settings UI
- The **worker** (`apps/worker`) — for inference
- The **orchestrator** (`apps/orchestrator`) — for dispatch
---
## Step 3: Register OmniRoute Providers in Echodo
### Via Settings UI (recommended)
1. Open `http://localhost:3001` (web app)
2. Go to **Settings → AI → Providers**
3. Click **Add Provider** and create one for each profile:
**Provider 1: omni-auto**
| Field | Value |
|-------|-------|
| Display Name | `OmniRoute Auto` |
| Alias | `omni-auto` |
| Provider Type | `openai_compatible` |
| Model ID | `auto` |
| Base URL | `${OMNIROUTE_BASE_URL}` (your actual URL) |
| API Key Env Var | `OMNIROUTE_API_KEY` |
| Metadata | `{"tier": "auto", "provider": "omniroute"}` |
| Enabled | ✅ |
**Provider 2: omni-coding**
| Field | Value |
|-------|-------|
| Display Name | `OmniRoute Coding` |
| Alias | `omni-coding` |
| Provider Type | `openai_compatible` |
| Model ID | `auto/coding` |
| Base URL | (same as above) |
| API Key Env Var | `OMNIROUTE_API_KEY` |
| Metadata | `{"tier": "auto", "provider": "omniroute"}` |
| Enabled | ✅ |
**Provider 3: omni-coding-pro**
| Field | Value |
|-------|-------|
| Display Name | `OmniRoute Coding Pro` |
| Alias | `omni-coding-pro` |
| Provider Type | `openai_compatible` |
| Model ID | `auto/coding-pro` |
| Base URL | (same) |
| API Key Env Var | `OMNIROUTE_API_KEY` |
| Metadata | `{"tier": "auto", "provider": "omniroute"}` |
| Enabled | ✅ |
**Provider 4: omni-reasoning**
| Field | Value |
|-------|-------|
| Display Name | `OmniRoute Reasoning` |
| Alias | `omni-reasoning` |
| Provider Type | `openai_compatible` |
| Model ID | `auto/reasoning` |
| Base URL | (same) |
| API Key Env Var | `OMNIROUTE_API_KEY` |
| Metadata | `{"tier": "auto", "provider": "omniroute"}` |
| Enabled | ✅ |
**Provider 5: omni-reasoning-pro**
| Field | Value |
|-------|-------|
| Display Name | `OmniRoute Reasoning Pro` |
| Alias | `omni-reasoning-pro` |
| Provider Type | `openai_compatible` |
| Model ID | `auto/reasoning-pro` |
| Base URL | (same) |
| API Key Env Var | `OMNIROUTE_API_KEY` |
| Metadata | `{"tier": "auto", "provider": "omniroute"}` |
| Enabled | ✅ |
### Via tRPC (for automation)
```typescript
// In a script or test
await trpc.ai.models.createProvider.mutate({
alias: "omni-coding",
displayName: "OmniRoute Coding",
providerType: "openai_compatible",
modelId: "auto/coding",
baseUrl: "https://omniroute.example.com/v1",
apiKeyEnvVar: "OMNIROUTE_API_KEY",
metadata: { tier: "auto", provider: "omniroute" }
});
```
---
## Step 4: Map Roles to Profiles
### Via Settings UI
1. Go to **Settings → AI → Role Routing**
2. For each role, click **Edit** and select the provider:
| Role ID | Select Provider |
|---------|-----------------|
| `role-conductor` | `omni-reasoning` |
| `role-architect` | `omni-reasoning` |
| `role-ia-architect` | `omni-reasoning` |
| `role-implementer` | `omni-coding` |
| `role-reviewer` | `omni-coding-pro` |
| `role-ux-reviewer` | `omni-coding` |
| `role-doc-writer` | `omni-coding` |
| `role-design-system-auditor` | `omni-coding` |
| `role-a11y-auditor` | `omni-coding` |
| `gate-copilot` | `omni-reasoning` |
| `chat-default` | `omni-auto` |
### Via tRPC
```typescript
// Example: route implementer to omni-coding
await trpc.ai.models.upsertRoleRoute.mutate({
roleId: "role-implementer",
providerAliasId: "omni-coding-provider-uuid" // get from listProviders
});
```
---
## Step 5: Test Connectivity
### Via Settings UI
1. Go to **Settings → AI → Providers**
2. Click the **Test** button next to `omni-coding`
3. You should see: ✅ `Success — latency: 320ms`
### Via MCP (if worker is running)
```typescript
// testProvider
{
"tool": "testProvider",
"arguments": { "alias": "omni-coding" }
}
```
### Via direct curl
```bash
curl ${OMNIROUTE_BASE_URL}/chat/completions \
-H "Authorization: Bearer ${OMNIROUTE_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"model": "auto/coding",
"messages": [{"role": "user", "content": "Say hello in 3 words"}],
"max_tokens": 20
}'
```
Expected: A response like `{"choices": [{"message": {"content": "Hello world!"}}]}`
---
## Step 6: Test Model Resolution
### Verify a specific role resolves correctly
Use the debug endpoint (or MCP `resolveForRole`):
```typescript
resolveForRole({ roleId: "role-implementer" })
// Expected output:
// {
// ok: true,
// model: {
// alias: "omni-coding",
// providerType: "openai_compatible",
// modelId: "auto/coding",
// baseUrl: "https://omniroute.example.com/v1",
// apiKey: "<redacted>",
// tier: "auto"
// },
// source: "workspace",
// resolvedAlias: "omni-coding"
// }
```
### Verify resolution order works
Create a test convoy with a model override:
```yaml
# .convoys/test-resolver.md
---
name: test-resolver
model_policy:
roles:
role-implementer: omni-reasoning-pro # overrides workspace route
---
```
Then `resolveForRole` with `convoyContext`:
```typescript
resolveForRole({
roleId: "role-implementer",
convoyContext: { model_policy: { "role-implementer": "omni-reasoning-pro" } }
})
// → source: "convoy", alias: "omni-reasoning-pro"
```
---
## Step 7: Verify Worker Picks Up the Model
1. **Start the worker:**
```bash
pnpm --filter @tasks/worker dev
# or
WORKER_DRY_RUN=0 ORCHESTRATOR_DRY_RUN=0 REDIS_URL=redis://localhost:6379 pnpm --filter @tasks/worker dev
```
2. **Start the orchestrator:**
```bash
pnpm --filter @tasks/orchestrator dev
```
3. **Enqueue a test job** (via MCP `create_convoy` → transition to `ready`)
4. **Watch worker logs:**
- Should see: `Dequeued job for role-implementer, resolved model: omni-coding (auto/coding)`
- Should see: inference call to OmniRoute endpoint
5. **Verify model metadata in `agent_runs`:**
```sql
SELECT model_alias, provider_type, model_id, model_tier
FROM agent_runs
ORDER BY created_at DESC
LIMIT 5;
```
Expected: `omni-coding | openai_compatible | auto/coding | auto`
---
## Troubleshooting
### "Provider alias 'omni-coding' is not registered in this workspace"
**Cause:** Provider not created yet, or `alias` doesn't match.
**Fix:**
1. Check Settings → AI → Providers for the alias name
2. Re-run `createProvider` with exact alias
### "Environment variable OMNIROUTE_API_KEY is not set"
**Cause:** Worker process doesn't have the env var loaded.
**Fix:**
1. Verify `OMNIROUTE_API_KEY` is in `.env` at repo root
2. Restart the worker process (env vars aren't hot-reloaded)
3. Check: `echo $OMNIROUTE_API_KEY` in the worker terminal
### "Connection refused" or "ECONNREFUSED"
**Cause:** OmniRoute server not reachable at `base_url`.
**Fix:**
1. Verify OmniRoute is running: `curl ${OMNIROUTE_BASE_URL}/models`
2. Check base URL ends with `/v1`
3. For local OmniRoute: check port (11434 vs 8000 vs other)
### "Unsupported provider type"
**Cause:** `providerType` value not in `providerTypeSchema`.
**Fix:** Must be one of: `anthropic`, `openai`, `google`, `openai_compatible`, `azure_openai`. For OmniRoute, use `openai_compatible`.
### Model returns empty or unexpected response
**Cause:** OmniRoute profile not available or rate-limited.
**Fix:**
1. Check OmniRoute dashboard for rate limits
2. Try a different profile (e.g., `auto` instead of `auto/coding`)
3. Check OmniRoute status page
### Resolution returns wrong source
**Cause:** Convoy or brief override taking precedence unexpectedly.
**Fix:** Remember the priority order:
1. Brief `recommended_model` (highest)
2. Convoy `model_policy.roles[roleId]`
3. Workspace `role_model_routes` row
4. Pipeline defaults (lowest)
If you want workspace route to win, remove `model_policy` from the convoy.
---
## Security Notes
- **API key never in DB:** `api_key_env_var` stores the *name* of the env var, not the value
- **API key never sent to browser:** Settings UI shows env var name only
- **Worker reads env var at dispatch time:** Not stored in job payload
- **Test endpoint runs server-side:** Rate-limited per workspace
---
## Related Docs
- **Role mapping:** `docs/omniroute-role-mapping.md` (profile → role matrix, resolution flow)
- **ADR:** `docs/adr-model-routing.md` (architecture decision record)
- **Skill:** `.agents/skills/model-routing/SKILL.md` (developer reference)
- **Provider schema:** `packages/database/src/schema/model_providers.ts`
- **Route schema:** `packages/database/src/schema/role_model_routes.ts`

View file

@ -19,6 +19,7 @@ This is the **operator** checklist for `plans/Plan-automation-foundation/Epic-ap
5. Settings → Runs (`/[workspaceSlug]/settings/runs`) shows row with `model_alias` + `tokensTotal` for that `runId`. 5. Settings → Runs (`/[workspaceSlug]/settings/runs`) shows row with `model_alias` + `tokensTotal` for that `runId`.
6. Mission Control detail `apps/web/app/(app)/[workspaceSlug]/mission-control/page.tsx:12` shows `role_complete` event within 5s (SSE `/api/fleet/events` or poll fallback). 6. Mission Control detail `apps/web/app/(app)/[workspaceSlug]/mission-control/page.tsx:12` shows `role_complete` event within 5s (SSE `/api/fleet/events` or poll fallback).
7. (Preferred, after `Task-worker-draft-pr`) Draft PR exists with marker `echodo-convoy: <convoyId>` consumable by Epic 8 webhook; for doc tasks `doc_review → published` without PR but `published` visible. 7. (Preferred, after `Task-worker-draft-pr`) Draft PR exists with marker `echodo-convoy: <convoyId>` consumable by Epic 8 webhook; for doc tasks `doc_review → published` without PR but `published` visible.
8. (Epic 8 — forge webhook) Merge PR triggers `POST /api/webhooks/forge` with `X-Hub-Signature-256`/`X-Forgejo-Signature` HMAC `FORGE_WEBHOOK_SECRET` → convoy `pr_review→merged` (`automation`, `convoy_events` `status_change`) and Mission Control shows `merged` within 5s; invalid signature returns 401, missing marker returns 200 no-op.
## Record ## Record
@ -36,6 +37,12 @@ This is the **operator** checklist for `plans/Plan-automation-foundation/Epic-ap
- Forge webhooks (Epic 8) — verified separately - Forge webhooks (Epic 8) — verified separately
- New worker features — use sibling tasks - New worker features — use sibling tasks
## Docs
- Webhook rotation: [`docs/operations/webhook-secret-rotation.md`](./webhook-secret-rotation.md)
- Copilot prompts (gate + ops): [`docs/copilot-prompt.md`](../copilot-prompt.md)
- SOP published flow: [`docs/examples/sop-published-flow.md`](../examples/sop-published-flow.md)
## Links ## Links
- Task: `plans/Plan-automation-foundation/Epic-apps-worker-v1/Task-worker-live-e2e-dogfood.md` - Task: `plans/Plan-automation-foundation/Epic-apps-worker-v1/Task-worker-live-e2e-dogfood.md`

View file

@ -0,0 +1,78 @@
# Webhook secret rotation — `FORGE_WEBHOOK_SECRET`
One-page rotation SOP for `POST /api/webhooks/forge` HMAC secret.
## Where it lives
- **Coolify (prod)**: `web` service env `FORGE_WEBHOOK_SECRET` in `docker/docker-compose.coolify.yml:web.environment` → Coolify UI → Environment Variables. Not committed; value lives only in Coolify env store and Forgejo webhook config.
- **Local dev**: `.env` at repo root (`FORGE_WEBHOOK_SECRET=...`). Copy from `.env.example` pattern — never commit real value.
- **Consumer**: `apps/web/app/api/webhooks/forge/route.ts:62` reads `process.env.FORGE_WEBHOOK_SECRET` and verifies `X-Hub-Signature-256` (GitHub) or `X-Forgejo-Signature` / `X-Hub-Signature` HMAC-SHA256 with `timingSafeEqual`. Rate-limit 60/min per IP. Missing secret → 500 `webhook_not_configured`.
## Rotate
1. Generate new secret (64 hex chars = 32 bytes):
```bash
openssl rand -hex 32
```
Copy output → new `FORGE_WEBHOOK_SECRET`.
2. Update Forgejo / GitHub webhook:
- Forgejo: Repo → Settings → Webhooks → target webhook → **Secret** → paste new value → Save.
- GitHub: Repo → Settings → Webhooks → target webhook → **Secret** → paste new value → Save / Update webhook.
3. Update Coolify:
- Coolify → `echodo` service `web` → Environment Variables → set `FORGE_WEBHOOK_SECRET=<new-hex>` → Save.
- Redeploy `web` (Coolify Deploy). Verify `docker/docker-compose.coolify.yml` not edited directly for prod; Coolify env overlay wins.
4. For local dev, update `.env`:
```bash
FORGE_WEBHOOK_SECRET=<new-hex> pnpm --filter @tasks/web dev
```
5. Keep old secret for 5 min overlap if Forgejo allows dual-secret testing; otherwise coordinate deploy + webhook save within same minute.
## Test
Generate signature and POST to local or prod URL. Route accepts `sha256=<hex>` or bare `<hex>`.
```bash
SECRET="<new-hex>"
BODY='{"action":"closed","pull_request":{"merged":true,"body":"echodo-convoy: 00000000-0000-0000-0000-000000000000","number":123}}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')
# Local
curl -i -X POST http://localhost:3000/api/webhooks/forge \
-H 'Content-Type: application/json' \
-H "X-Hub-Signature-256: sha256=$SIG" \
-d "$BODY"
# Forgejo variant (same HMAC, different header)
curl -i -X POST http://localhost:3000/api/webhooks/forge \
-H 'Content-Type: application/json' \
-H "X-Forgejo-Signature: sha256=$SIG" \
-d "$BODY"
# Prod
curl -i -X POST https://echodo.stillwell.cloud/api/webhooks/forge \
-H 'Content-Type: application/json' \
-H "X-Hub-Signature-256: sha256=$SIG" \
-d "$BODY"
```
## Verify
- Valid HMAC + `action=closed` + `pull_request.merged=true` + `body` contains `echodo-convoy: <uuid>` matching a convoy in `pr_review` → 200 `{"ok":true}` and convoy transitions `pr_review → merged` (`automation`, `pr#N merged`) visible in `convoy_events` and Mission Control within 5s.
- Invalid signature → 401 `{"ok":false,"error":"invalid_signature"}`.
- Missing `echodo-convoy:` marker or `merged=false` or `status !== pr_review` → 200 `{"ok":true}` no-op (no leak).
- Missing `FORGE_WEBHOOK_SECRET` env → 500 `webhook_not_configured`.
## References
- Route: `apps/web/app/api/webhooks/forge/route.ts`
- State machine: `packages/shared/src/convoy-lifecycle/state-machine.ts:90`
- Checklist: `docs/operations/live-e2e-checklist.md`

15
logs/app.log Normal file
View file

@ -0,0 +1,15 @@
2026-07-07 13:44:42 | INFO | Starting server on 0.0.0.0:8000
2026-07-07 13:44:42 | INFO | Multi-handler server configuration complete.
2026-07-07 13:44:42 | INFO | Starting Uvicorn server...
2026-07-07 13:44:42 | INFO | Model registry initialized
2026-07-07 13:44:42 | INFO | Spawning handler process for model 'mlx-community/Qwen2.5-Coder-7B-Instruct-4bit' (type=lm, path=mlx-community/Qwen2.5-Coder-7B-Instruct-4bit)
2026-07-07 13:44:42 | INFO | Spawned handler process for 'mlx-community/Qwen2.5-Coder-7B-Instruct-4bit' (pid=5116)
2026-07-07 13:44:43 | INFO | 'mlx-community/Qwen2.5-Coder-7B-Instruct-4bit': Fetching 'mlx-community/Qwen2.5-Coder-7B-Instruct-4bit' from HuggingFace...
2026-07-07 13:45:26 | INFO | 'mlx-community/Qwen2.5-Coder-7B-Instruct-4bit': Fetched 'mlx-community/Qwen2.5-Coder-7B-Instruct-4bit'
2026-07-07 13:45:33 | INFO | Handler process for 'mlx-community/Qwen2.5-Coder-7B-Instruct-4bit' is ready
2026-07-07 13:45:33 | INFO | Registered model: mlx-community/Qwen2.5-Coder-7B-Instruct-4bit (type=lm, context_length=None)
2026-07-07 13:45:33 | INFO | Model 'mlx-community/Qwen2.5-Coder-7B-Instruct-4bit' spawned and registered successfully
2026-07-07 13:45:33 | INFO | Multi-handler initialization complete. 1 model(s) spawned.
2026-07-07 15:22:19 | INFO | Shutting down multi-handler application
2026-07-07 15:22:19 | INFO | Cleaned up handler for model: mlx-community/Qwen2.5-Coder-7B-Instruct-4bit
2026-07-07 15:22:19 | INFO | All models unregistered and cleaned up

View file

@ -11,7 +11,8 @@
"db:migrate": "turbo db:migrate --filter=@tasks/database", "db:migrate": "turbo db:migrate --filter=@tasks/database",
"db:push": "turbo db:push --filter=@tasks/database", "db:push": "turbo db:push --filter=@tasks/database",
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,css,md}\"", "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,css,md}\"",
"docker:build:coolify": "bash scripts/build-coolify.sh" "docker:build:prod": "bash scripts/build-prod.sh",
"docker:build:coolify": "bash scripts/build-prod.sh"
}, },
"devDependencies": { "devDependencies": {
"@vitest/coverage-v8": "^2", "@vitest/coverage-v8": "^2",

View file

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

File diff suppressed because it is too large Load diff

View file

@ -120,6 +120,13 @@
"when": 1787850000000, "when": 1787850000000,
"tag": "0016_noble_operator_notes", "tag": "0016_noble_operator_notes",
"breakpoints": true "breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1787698170280,
"tag": "0017_lively_squadron_supreme",
"breakpoints": true
} }
] ]
} }

View file

@ -0,0 +1,59 @@
import {
boolean,
index,
numeric,
pgTable,
timestamp,
uniqueIndex,
uuid,
varchar,
} from "drizzle-orm/pg-core";
import { workspaces } from "./workspaces";
export const budgetCaps = pgTable(
"budget_caps",
{
id: uuid("id").primaryKey().defaultRandom(),
workspaceId: uuid("workspace_id")
.notNull()
.references(() => workspaces.id, { onDelete: "cascade" }),
capUsd: numeric("cap_usd", { precision: 12, scale: 2 }).notNull(),
period: varchar("period", { length: 20 }).notNull().default("monthly"),
enabled: boolean("enabled").notNull().default(true),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
workspaceIdIdx: index("budget_caps_workspace_id_idx").on(table.workspaceId),
workspacePeriodUnique: uniqueIndex("budget_caps_workspace_id_period_unique").on(
table.workspaceId,
table.period,
),
}),
);
export const spendLedger = pgTable(
"spend_ledger",
{
id: uuid("id").primaryKey().defaultRandom(),
workspaceId: uuid("workspace_id")
.notNull()
.references(() => workspaces.id, { onDelete: "cascade" }),
modelAlias: varchar("model_alias", { length: 120 }).notNull(),
providerType: varchar("provider_type", { length: 40 }).notNull(),
modelTier: varchar("model_tier", { length: 20 }),
estimatedCostUsd: numeric("estimated_cost_usd", { precision: 12, scale: 6 }).notNull(),
occurredAt: timestamp("occurred_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
workspaceOccurredIdx: index("spend_ledger_workspace_id_occurred_at_idx").on(
table.workspaceId,
table.occurredAt.desc(),
),
modelOccurredIdx: index("spend_ledger_model_alias_occurred_at_idx").on(
table.modelAlias,
table.occurredAt.desc(),
),
}),
);

View file

@ -0,0 +1,27 @@
import { index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { objects } from "./objects";
import { users } from "./users";
import { workspaces } from "./workspaces";
export const completionArtifacts = pgTable(
"completion_artifacts",
{
id: uuid("id").primaryKey().defaultRandom(),
workspaceId: uuid("workspace_id")
.notNull()
.references(() => workspaces.id, { onDelete: "cascade" }),
convoyId: uuid("convoy_id")
.notNull()
.references(() => objects.id, { onDelete: "cascade" }),
summaryMd: text("summary_md").notNull(),
screenshotUrl: text("screenshot_url"),
createdBy: uuid("created_by").references(() => users.id, { onDelete: "set null" }),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
workspaceIdIdx: index("completion_artifacts_workspace_id_idx").on(table.workspaceId),
convoyIdIdx: index("completion_artifacts_convoy_id_idx").on(table.convoyId),
createdAtIdx: index("completion_artifacts_created_at_idx").on(table.createdAt),
}),
);

View file

@ -20,3 +20,5 @@ export * from "./convoy_events";
export * from "./worker_checkpoints"; export * from "./worker_checkpoints";
export * from "./harness_providers"; export * from "./harness_providers";
export * from "./convoy_operator_messages"; export * from "./convoy_operator_messages";
export * from "./completion_artifacts";
export * from "./budget_caps";

View file

@ -34,3 +34,8 @@ export {
humanGateActorForStatus, humanGateActorForStatus,
type KanbanColumn, type KanbanColumn,
} from "./kanban"; } from "./kanban";
export {
automationTierSchema,
type AutomationTier,
} from "./schemas";

View file

@ -50,6 +50,10 @@ export const convoyEventTypeSchema = z.enum([
export type ConvoyEventType = z.infer<typeof convoyEventTypeSchema>; export type ConvoyEventType = z.infer<typeof convoyEventTypeSchema>;
export const automationTierSchema = z.enum(["manual", "assist", "auto"]);
export type AutomationTier = z.infer<typeof automationTierSchema>;
export const convoyObjectContentSchema = z.object({ export const convoyObjectContentSchema = z.object({
convoySlug: z.string().min(1).max(120), convoySlug: z.string().min(1).max(120),
classification: convoyClassificationSchema, classification: convoyClassificationSchema,
@ -63,6 +67,7 @@ export const convoyObjectContentSchema = z.object({
briefFiles: z.array(z.string()).optional(), briefFiles: z.array(z.string()).optional(),
prUrl: z.string().url().optional(), prUrl: z.string().url().optional(),
prNumber: z.number().int().optional(), prNumber: z.number().int().optional(),
automationTier: automationTierSchema.default("assist"),
}); });
export type ConvoyObjectContent = z.infer<typeof convoyObjectContentSchema>; export type ConvoyObjectContent = z.infer<typeof convoyObjectContentSchema>;

View file

@ -87,7 +87,7 @@ export const ALLOWED_TRANSITIONS: readonly TransitionRule[] = [
actors: ["human-gate-1", "role-conductor"], actors: ["human-gate-1", "role-conductor"],
requiresReason: true, requiresReason: true,
}, },
{ from: "pr_review", to: "merged", actors: ["human-gate-2"] }, { from: "pr_review", to: "merged", actors: ["human-gate-2", "automation"] },
{ from: "pr_review", to: "in_progress", actors: ["human-gate-2", "role-implementer"] }, { from: "pr_review", to: "in_progress", actors: ["human-gate-2", "role-implementer"] },
{ {
from: "pr_review", from: "pr_review",

View file

@ -1,4 +1,4 @@
import type { ConvoyStatus } from "../convoy-lifecycle/schemas"; import type { ConvoyStatus, AutomationTier } from "../convoy-lifecycle/schemas";
export type BriefSummary = { export type BriefSummary = {
id: string; id: string;
@ -84,7 +84,17 @@ export function planOrchestratorAction(input: {
briefs: BriefSummary[]; briefs: BriefSummary[];
completedRoles: Set<string>; completedRoles: Set<string>;
completedBriefIds: Set<string>; completedBriefIds: Set<string>;
automationTier?: AutomationTier;
}): OrchestratorAction { }): OrchestratorAction {
// Manual tier pauses dispatch
if (input.automationTier === "manual") {
return {
type: "pause",
reason: "Manual automation tier — dispatch paused",
gate: null,
};
}
const gate = GATE_STATUSES[input.status]; const gate = GATE_STATUSES[input.status];
if (gate) { if (gate) {
return { return {

View file

@ -0,0 +1,34 @@
import type { ConvoyClassification } from "../convoy-lifecycle/schemas";
export type CompletionSummaryInput = {
slug: string;
classification: ConvoyClassification;
successMetric: string;
briefs: Array<{ title: string; status: string | null }>;
modelAlias?: string | null;
harnessAlias?: string | null;
prUrl?: string | null;
};
export function generateCompletionSummary(input: CompletionSummaryInput): string {
const briefCount = input.briefs.length;
const briefTitles =
briefCount === 0
? "No briefs"
: input.briefs.map((b) => `${b.title} (${b.status ?? "—"})`).join(", ");
const modelLine = input.modelAlias ? `Model: ${input.modelAlias}` : "Model: n/a";
const harnessLine = input.harnessAlias ? `Harness: ${input.harnessAlias}` : "Harness: n/a";
const prLine = input.prUrl ? `PR: ${input.prUrl}` : "PR: n/a";
const lines: string[] = [
"## Summary",
`Convoy ${input.slug} ${input.classification} — ${input.successMetric}`,
`Briefs: ${briefCount} — ${briefTitles}`,
modelLine,
harnessLine,
prLine,
];
return lines.join("\n");
}

View file

@ -115,12 +115,8 @@ export type ResolvedModelLike = {
alias: string; alias: string;
}; };
export interface WorkerExecutor { /** Options passed to WorkerExecutor.run — extracted for reuse across executors. */
id: string; export type WorkerExecutorOptions = {
harnessType: HarnessType;
run(
ctx: WorkerJobContext,
opts: {
resolvedModel: ResolvedModelLike; resolvedModel: ResolvedModelLike;
resolvedAlias: string; resolvedAlias: string;
rolePromptPath: string; rolePromptPath: string;
@ -135,6 +131,35 @@ export interface WorkerExecutor {
allowedCommands?: readonly string[] | null; allowedCommands?: readonly string[] | null;
onCheckpoint?: (input: unknown) => Promise<void>; onCheckpoint?: (input: unknown) => Promise<void>;
buildPrompt: (ctx: WorkerJobContext, title: string) => string; buildPrompt: (ctx: WorkerJobContext, title: string) => string;
}, /** Optional: used by remote executors that need the convoy title for prompt building. */
): Promise<{ summary: string; toolCalls: number; tokensTotal?: number }>; convoyTitle?: string;
};
export interface WorkerExecutor {
id: string;
harnessType: HarnessType;
run(
ctx: WorkerJobContext,
opts: WorkerExecutorOptions,
): Promise<{ summary: string; toolCalls: number; tokensTotal?: number; outcome?: "succeeded" | "failed" }>;
} }
// --- WorkerExecutor types (used by worker dispatch) ---
export type WorkerRunResult = {
summary: string;
toolCalls: number;
tokensTotal?: number;
outcome: "succeeded" | "failed";
};
export type RemoteRunOptions = {
rolePromptPath: string;
workflowPrompt?: string;
briefAcceptance?: string;
briefFiles?: string[];
maxSteps: number;
allowedCommands?: readonly string[] | null;
dryRun?: boolean;
convoyTitle?: string;
};

View file

@ -18,6 +18,10 @@ export {
type ResolveHarnessResult, type ResolveHarnessResult,
type ResolvedHarness, type ResolvedHarness,
type WorkerExecutor, type WorkerExecutor,
type WorkerExecutorOptions,
type WorkerRunResult,
type RemoteRunOptions,
type ResolvedModelLike,
} from "./executor"; } from "./executor";
export { export {
@ -31,3 +35,8 @@ export {
type WorkerCheckpointSummary, type WorkerCheckpointSummary,
type WorkerCheckpointToolStep, type WorkerCheckpointToolStep,
} from "./checkpoint"; } from "./checkpoint";
export {
generateCompletionSummary,
type CompletionSummaryInput,
} from "./completion-summary";

133
proxmox/ct116/README.md Normal file
View file

@ -0,0 +1,133 @@
# CT 116 — Submatter / Echodo (production)
Dedicated LXC for the multitenant task app + convoy worker stack. Replaces Dokploy (CT 112) and eventually decommissions the Echodo slice on CT 107 Coolify.
## Spec (from AGENT-DEPLOY.md sizing)
| Setting | Value | Why |
|---------|-------|-----|
| **CT ID** | **116** | Next free slot (114 planned Nextcloud, 115 Forgejo) |
| **Hostname** | `submatter` | Product name; DNS stays `echodo.stillwell.cloud` |
| **IP** | `192.168.68.116/22` | Sequential homelab LAN |
| **RAM** | **8192 MB** | Web build needs ~5 GB V8 heap + BuildKit headroom |
| **CPU** | 2 cores | Same as former CT 107 Echodo deploy |
| **Disk** | **32 GB** | Five images + layer cache (AGENT-DEPLOY: 30–32 GB for compose-heavy CTs) |
| **Features** | `nesting=1` | Required for Docker-in-Docker (Phase B OpenHands sandboxes) |
**Do not reuse CT 107** for this — it still runs Coolify, Echo Board, Resonance, pipeline dashboard, Amazon scraper, etc. Competing for RAM during builds was the original OOM problem.
## External services (unchanged)
| Service | Host |
|---------|------|
| Postgres `echodo` DB | CT 102 `:5432` |
| Redis DB `/11` | CT 102 `:6379` |
| TLS / public routing | CT 100 Traefik |
| OmniRoute (Phase B) | CT 108 |
| Umami analytics | CT 112 (script URL only) |
## Clone URL
Production checkout: **https://forgejo.stillwell.cloud/rstillwell/Submatter.git** (`main`).
Migration from GitHub: [`docs/forgejo-migration.md`](../../docs/forgejo-migration.md).
## Bootstrap
On Proxmox host:
```bash
pct create 116 local:vztmpl/debian-13-standard_13.0-1_amd64.tar.zst \
--hostname submatter --cores 2 --memory 8192 --swap 512 \
--rootfs local-lvm:32 \
--net0 name=eth0,bridge=vmbr0,ip=192.168.68.116/22,gw=192.168.68.1 \
--unprivileged 1 --features nesting=1,keyctl=1
pct start 116
./proxmox/scripts/sync.sh push 116 # when homelab sync script includes this tree
pct exec 116 -- bash /root/bootstrap.sh
```
Edit `/opt/submatter/.env`, then:
```bash
pct exec 116 -- bash -lc 'cd /opt/submatter && bash scripts/deploy-prod.sh build'
```
## Routing
1. Merge `traefik-ct100.example.yml` into CT 100 dynamic config.
2. Remove old Echodo routes pointing at CT 107 Coolify or CT 112 Dokploy once cutover is verified.
3. AdGuard wildcard `*.stillwell.cloud` → CT 100 (unchanged).
## Delete from Dokploy (CT 112)
In Dokploy UI:
1. Open the Submatter/Echodo compose application.
2. Stop containers → Delete application (keep CT 112 shared services like Umami).
3. Remove any CT 100 Traefik routes that pointed at Dokploy for `echodo.stillwell.cloud`.
## Auto-deploy on push
Three options (can combine GitHub + Forgejo):
### A. Self-hosted runner (recommended)
Uses CT 111 `axiom` runners (already org-scoped to `stwl-labs`).
Repo secrets:
| Secret | Value |
|--------|-------|
| `HOMELAB_DEPLOY_HOST` | `192.168.68.116` |
| `HOMELAB_DEPLOY_USER` | `root` (or dedicated deploy user) |
| `HOMELAB_DEPLOY_SSH_KEY` | private key allowed to SSH to CT 116 |
| `HOMELAB_DEPLOY_PATH` | `/opt/submatter` (optional) |
Workflows:
- `.github/workflows/deploy-homelab.yml` — GitHub pushes to `main`
- `.forgejo/workflows/deploy-homelab.yml` — Forgejo Actions (same file)
CI (lint/type-check/test) still runs on every PR via `.github/workflows/ci.yml`. Deploy runs **after** merge to `main`.
### B. Forgejo webhook (no Actions)
On Forgejo → repo → Webhooks:
- URL: `http://192.168.68.116:9000/hooks/deploy` (front with Caddy/`webhook` tool)
- Secret: set `DEPLOY_WEBHOOK_SECRET` on CT 116
- Events: Push
Handler: `deploy-webhook.sh`
### C. Manual
```bash
ssh root@192.168.68.116 'cd /opt/submatter && git pull && bash scripts/deploy-prod.sh build'
```
## Phase B (OpenHands)
```bash
export COMPOSE_PROFILES=agent-server
bash scripts/deploy-prod.sh build
```
Set `WORKER_SESSION_TYPE=openhands`, `SESSION_API_KEY`, `LOCAL_BACKEND_API_KEY`, OmniRoute vars in `.env`.
## AGENT-DEPLOY.md handoff
After CT is live, add to your local `AGENT-DEPLOY.md` (gitignored):
```markdown
| 116 | LXC | submatter | 192.168.68.116 | 8 GB | 2 | 32 GB | ACTIVE | Submatter/Echodo production (docker compose) |
```
Move Echodo row off CT 107 / CT 112 sections.
## Related
- [`docs/deployment-production.md`](../../docs/deployment-production.md) — env vars, troubleshooting
- [`docker/docker-compose.prod.yml`](../../docker/docker-compose.prod.yml) — stack definition

56
proxmox/ct116/bootstrap.sh Executable file
View file

@ -0,0 +1,56 @@
#!/usr/bin/env bash
# First-time bootstrap for CT 116 (Submatter / Echodo production host).
# Run as root on a fresh Debian 13 LXC after `pct create`.
#
# Example (on Proxmox host — adjust storage/template to your install):
# pct create 116 local:vztmpl/debian-13-standard_13.0-1_amd64.tar.zst \
# --hostname submatter --cores 2 --memory 8192 --swap 512 \
# --rootfs local-lvm:32 --net0 name=eth0,bridge=vmbr0,ip=192.168.68.116/22,gw=192.168.68.1 \
# --unprivileged 1 --features nesting=1,keyctl=1
# pct start 116
# pct push 116 proxmox/ct116/bootstrap.sh /root/bootstrap.sh
# pct exec 116 -- bash /root/bootstrap.sh
set -euo pipefail
APP_DIR="${APP_DIR:-/opt/submatter}"
REPO_URL="${REPO_URL:-https://forgejo.stillwell.cloud/rstillwell/Submatter.git}"
DEPLOY_BRANCH="${DEPLOY_BRANCH:-main}"
echo "[bootstrap] installing docker..."
apt-get update
apt-get install -y ca-certificates curl git rsync
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
> /etc/apt/sources.list.d/docker.list
apt-get update
apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
echo "[bootstrap] cloning repo to ${APP_DIR}..."
mkdir -p "$(dirname "$APP_DIR")"
if [[ ! -d "${APP_DIR}/.git" ]]; then
git clone --branch "$DEPLOY_BRANCH" "$REPO_URL" "$APP_DIR"
fi
cd "$APP_DIR"
if [[ ! -f .env ]]; then
cp .env.example .env
echo "[bootstrap] Wrote ${APP_DIR}/.env from .env.example — edit before first deploy."
fi
chmod +x scripts/build-prod.sh scripts/deploy-prod.sh docker/worker-entrypoint.sh 2>/dev/null || true
mkdir -p /opt/appdata/submatter
cat <<'EOF'
[bootstrap] Next steps (manual):
1. Edit /opt/submatter/.env with CT 102 DATABASE_URL, REDIS_URL, auth secrets, public URLs.
2. bash /opt/submatter/scripts/deploy-prod.sh build
3. Add CT 100 Traefik routes (see proxmox/ct116/traefik-ct100.example.yml).
4. Install Dozzle + Beszel agents per CT 101 runbook.
5. Configure deploy automation:
- GitHub/Forgejo repo secrets for HOMELAB_DEPLOY_* (see docs/deployment-production.md)
- OR Forgejo push webhook → proxmox/ct116/deploy-webhook.sh
EOF

43
proxmox/ct116/deploy-webhook.sh Executable file
View file

@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Forgejo/GitHub push webhook target — validates HMAC and redeploys main.
#
# Install on CT 116 with almquist/webhook or Caddy reverse_proxy to this script.
# Forgejo → Repository → Webhooks → POST → http://192.168.68.116:9000/hooks/deploy
#
# Env:
# DEPLOY_WEBHOOK_SECRET — shared secret (same as Forgejo webhook secret)
# APP_DIR=/opt/submatter
set -euo pipefail
APP_DIR="${APP_DIR:-/opt/submatter}"
SECRET="${DEPLOY_WEBHOOK_SECRET:-}"
read -r payload || payload=""
if [[ -z "$payload" ]]; then
payload="$(cat)"
fi
signature="${HTTP_X_HUB_SIGNATURE_256:-${HTTP_X_FORGEJO_SIGNATURE:-}}"
if [[ -n "$SECRET" ]]; then
if [[ -z "$signature" ]]; then
echo "missing signature" >&2
exit 403
fi
sig="${signature#sha256=}"
expected="$(printf '%s' "$payload" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')"
if [[ "$sig" != "$expected" ]]; then
echo "invalid signature" >&2
exit 403
fi
fi
ref="$(printf '%s' "$payload" | sed -n 's/.*"ref":"\([^"]*\)".*/\1/p')"
if [[ "$ref" != "refs/heads/main" ]]; then
echo "ignored ref=${ref}"
exit 0
fi
echo "[deploy-webhook] push to main — deploying"
cd "$APP_DIR"
bash scripts/git-sync-deploy.sh
exec bash scripts/deploy-prod.sh build

View file

@ -0,0 +1,35 @@
# CT 100 Traefik dynamic config snippet — add to `/opt/appdata/traefik/config/dynamic/`
# as `ct116-submatter.yml` (or merge into your homelab Traefik dynamic dir).
#
# CT 116 publishes web:3000 and collab:1234 directly (standalone compose override).
# TLS terminates on CT 100; backends are plain HTTP.
#
# Remove stale routes after cutover (edit on CT 100, then push-traefik):
# - ct107-dev.yml: echodo / collab routers pointing at 192.168.68.107:80 (Coolify)
# - Any Dokploy CT 112 echodo routes in dynamic config
# Keep only the CT 116 backends below for echodo.stillwell.cloud hostnames.
http:
routers:
echodo:
rule: Host(`echodo.stillwell.cloud`)
entryPoints: [websecure]
service: echodo-web
tls:
certResolver: letsencrypt
echodo-collab:
rule: Host(`collab.echodo.stillwell.cloud`)
entryPoints: [websecure]
service: echodo-collab
tls:
certResolver: letsencrypt
services:
echodo-web:
loadBalancer:
servers:
- url: "http://192.168.68.116:3000"
echodo-collab:
loadBalancer:
servers:
- url: "http://192.168.68.116:1234"

View file

@ -0,0 +1,25 @@
[
{
"id": "submatter-deploy",
"execute-command": "/opt/submatter/proxmox/ct116/deploy-webhook.sh",
"command-working-directory": "/opt/submatter",
"pass-arguments-to-command": [],
"pass-environment-to-command": [
{ "source": "string", "name": "DEPLOY_WEBHOOK_SECRET", "envname": "DEPLOY_WEBHOOK_SECRET" },
{ "source": "string", "name": "APP_DIR", "envname": "APP_DIR" }
],
"trigger-http-method": "POST",
"include-request-headers-in-environment": true,
"incoming-payload-content-type": "application/json",
"trigger-rule": {
"match": {
"type": "payload-hash-sha256",
"secret": "REPLACE_WITH_FORGEJO_WEBHOOK_SECRET",
"parameter": {
"source": "header",
"name": "X-Hub-Signature-256"
}
}
}
}
]

View file

@ -1,26 +1,3 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Coolify custom build command for docker-compose.coolify.yml. # Backward-compatible alias — use scripts/build-prod.sh for new deploys.
# exec "$(dirname "$0")/build-prod.sh" "$@"
# Set in Coolify → Resource → Advanced → Custom Build Command:
# bash scripts/build-coolify.sh
#
# Coolify auto-injects -f and --env-file; this script only serializes builds so
# an 8 GB host does not OOM (exit 255) while exporting five runtime images.
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repo_root"
export DOCKER_BUILDKIT=1
compose() {
docker compose --project-directory "$repo_root" \
-f docker/docker-compose.coolify.yml \
"$@"
}
# Build one service at a time so an 8 GB host does not OOM during export
# (Coolify default runs all five targets in parallel → exit 255).
for service in collab mcp orchestrator worker web; do
compose build --pull "$service"
done

29
scripts/build-prod.sh Executable file
View file

@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Production custom build for docker/docker-compose.prod.yml.
#
# Dokploy → Compose resource → Advanced → Custom Build Command:
# bash scripts/build-prod.sh
#
# Serializes image builds so an 8 GB host does not OOM during export
# (parallel export of five targets → kernel exit 255, no Docker error).
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repo_root"
export DOCKER_BUILDKIT=1
compose_file="${COMPOSE_FILE:-docker/docker-compose.prod.yml}"
compose() {
docker compose --project-directory "$repo_root" \
-f "$compose_file" \
"$@"
}
for service in collab mcp orchestrator worker web; do
echo "[build-prod] building ${service}..."
compose build --pull "$service"
done
echo "[build-prod] done."

46
scripts/ct116-post-migration.sh Executable file
View file

@ -0,0 +1,46 @@
#!/usr/bin/env bash
# Operator runbook — run ON CT 116 after Forgejo migration + env updates.
# Copy-paste from your laptop:
# ssh root@192.168.68.116 'bash -s' < scripts/ct116-post-migration.sh
#
# Or on CT 116 directly:
# cd /opt/submatter && bash scripts/ct116-post-migration.sh
set -euo pipefail
APP_DIR="${APP_DIR:-/opt/submatter}"
cd "$APP_DIR"
echo "=== CT 116 post-migration ==="
if [[ -f scripts/migrate-origin-to-forgejo.sh ]]; then
bash scripts/migrate-origin-to-forgejo.sh
else
echo "WARN: migrate-origin-to-forgejo.sh missing — pull latest main first"
git fetch origin main && git checkout main && git reset --hard origin/main
fi
if grep -q '^NEXT_PUBLIC_COLLAB_SERVER_URL=ws://' .env 2>/dev/null; then
echo ""
echo "ACTION REQUIRED: update .env collab URLs to wss:// before deploy:"
echo " NEXT_PUBLIC_COLLAB_SERVER_URL=wss://collab.echodo.stillwell.cloud"
echo " COLLAB_SERVER_URL=wss://collab.echodo.stillwell.cloud"
echo ""
fi
missing=()
for var in OMNIROUTE_BASE_URL OMNIROUTE_API_KEY FORGEJO_TOKEN; do
if ! grep -q "^${var}=" .env 2>/dev/null; then
missing+=("$var")
fi
done
if ((${#missing[@]} > 0)); then
echo "Optional .env vars not set (add when ready): ${missing[*]}"
fi
echo ""
echo "=== deploying (serialized build — may take 15–30 min) ==="
bash scripts/deploy-prod.sh build
echo ""
echo "=== health ==="
docker compose -f docker/docker-compose.prod.yml -f docker/docker-compose.prod.standalone.yml ps

66
scripts/deploy-prod.sh Executable file
View file

@ -0,0 +1,66 @@
#!/usr/bin/env bash
# Deploy Submatter / Echodo production stack on a dedicated host (CT 116).
#
# Run on the CT after git sync, or via SSH from CI:
# ssh root@192.168.68.116 'cd /opt/submatter && bash scripts/deploy-prod.sh'
#
# Modes:
# build — serialized image build + compose up (default; needs ~8 GB RAM)
# up — compose up only (images already built)
# pull — docker compose pull + up (when using a registry; future)
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repo_root"
mode="${1:-build}"
compose_files=(-f docker/docker-compose.prod.yml -f docker/docker-compose.prod.standalone.yml)
# Optional Phase B: export COMPOSE_PROFILES=agent-server before deploy
if [[ -n "${COMPOSE_PROFILES:-}" ]]; then
echo "[deploy-prod] profiles=${COMPOSE_PROFILES}"
fi
if [[ ! -f .env ]]; then
echo "[deploy-prod] ERROR: missing ${repo_root}/.env — copy from .env.example and fill production values." >&2
exit 1
fi
export DOCKER_BUILDKIT=1
compose() {
docker compose --project-directory "$repo_root" "${compose_files[@]}" "$@"
}
case "$mode" in
build)
bash scripts/build-prod.sh
compose up -d --remove-orphans
;;
up)
compose up -d --remove-orphans
;;
pull)
compose pull
compose up -d --remove-orphans
;;
*)
echo "Usage: $0 [build|up|pull]" >&2
exit 1
;;
esac
echo "[deploy-prod] waiting for web health..."
deadline=$((SECONDS + 120))
while (( SECONDS < deadline )); do
if compose ps web 2>/dev/null | grep -q "(healthy)"; then
echo "[deploy-prod] web is healthy"
compose ps
exit 0
fi
sleep 5
done
echo "[deploy-prod] WARN: web not healthy within 120s — check logs:" >&2
compose logs web --tail 40
exit 1

20
scripts/git-sync-deploy.sh Executable file
View file

@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Sync production checkout to the deploy branch tip (used by deploy webhook + CI over SSH).
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repo_root"
branch="${DEPLOY_BRANCH:-main}"
remote="${DEPLOY_GIT_REMOTE:-origin}"
if [[ ! -d .git ]]; then
echo "[git-sync-deploy] ERROR: ${repo_root} is not a git repository" >&2
exit 1
fi
echo "[git-sync-deploy] fetching ${remote}/${branch}..."
git fetch "$remote" "$branch"
git checkout "$branch"
git reset --hard "${remote}/${branch}"
echo "[git-sync-deploy] at $(git rev-parse --short HEAD) ($(git log -1 --format='%s'))"

View file

@ -0,0 +1,52 @@
#!/usr/bin/env bash
# Point git origin at Forgejo Submatter (local dev machine or CT 116 production host).
#
# Usage:
# bash scripts/migrate-origin-to-forgejo.sh
# FORGEJO_REPO_URL=https://forgejo.stillwell.cloud/rstillwell/Submatter.git bash scripts/migrate-origin-to-forgejo.sh
#
# On CT 116:
# ssh root@192.168.68.116 'cd /opt/submatter && bash scripts/migrate-origin-to-forgejo.sh'
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repo_root"
forgejo_url="${FORGEJO_REPO_URL:-https://forgejo.stillwell.cloud/rstillwell/Submatter.git}"
branch="${DEPLOY_BRANCH:-main}"
if [[ ! -d .git ]]; then
echo "[migrate-origin] ERROR: not a git repo: ${repo_root}" >&2
exit 1
fi
old_url="$(git remote get-url origin 2>/dev/null || true)"
if [[ -n "$old_url" && "$old_url" != "$forgejo_url" ]]; then
echo "[migrate-origin] renaming old origin → github (was ${old_url})"
git remote rename origin github 2>/dev/null || true
if ! git remote get-url forgejo &>/dev/null; then
git remote add forgejo "$old_url"
fi
fi
if git remote get-url origin &>/dev/null; then
git remote set-url origin "$forgejo_url"
else
git remote add origin "$forgejo_url"
fi
echo "[migrate-origin] origin → ${forgejo_url}"
git remote -v
echo "[migrate-origin] fetching ${branch}..."
git fetch origin "$branch" || {
echo "[migrate-origin] WARN: fetch failed — check Forgejo credentials (git config credential.helper)" >&2
exit 1
}
current_branch="$(git branch --show-current 2>/dev/null || true)"
if [[ "$current_branch" != "$branch" ]]; then
git checkout "$branch" 2>/dev/null || git checkout -b "$branch" "origin/${branch}"
fi
echo "[migrate-origin] done. HEAD=$(git rev-parse --short HEAD)"