Compare commits
No commits in common. "9bde5960bfa8b5d58135c1a4015a11760383df29" and "6e17936b7f9c6305b482f0500665da0b89899c66" have entirely different histories.
9bde5960bf
...
6e17936b7f
83 changed files with 330 additions and 12232 deletions
|
|
@ -1,297 +0,0 @@
|
|||
---
|
||||
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
|
||||
|
|
@ -1,243 +0,0 @@
|
|||
---
|
||||
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
|
||||
|
|
@ -1,440 +0,0 @@
|
|||
---
|
||||
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
|
||||
|
|
@ -1,463 +0,0 @@
|
|||
---
|
||||
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
|
||||
|
|
@ -1,343 +0,0 @@
|
|||
---
|
||||
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`
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
---
|
||||
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)
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
---
|
||||
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.
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
---
|
||||
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.
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
---
|
||||
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.
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
---
|
||||
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.
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
---
|
||||
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.
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
---
|
||||
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.
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
---
|
||||
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.
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
---
|
||||
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.
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
---
|
||||
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.
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
---
|
||||
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.
|
||||
55
.env.example
55
.env.example
|
|
@ -2,9 +2,11 @@
|
|||
# ECHODO — Environment Variables
|
||||
# =====================================================================
|
||||
# Local dev: copy this file to .env and adjust values.
|
||||
# Production (CT 116 standalone LXC): paste into /opt/submatter/.env on the host.
|
||||
# See docs/deployment-production.md and proxmox/ct116/README.md.
|
||||
# See AGENT-DEPLOY.md for shared CT 102 service credentials and Traefik routing.
|
||||
# Coolify (CT 107): paste these into the resource's "Environment Variables"
|
||||
# tab. Coolify also accepts a .env file uploaded via the UI.
|
||||
#
|
||||
# See AGENT-DEPLOY.md for shared CT 102 service credentials and the
|
||||
# CT 100 Traefik routing pattern.
|
||||
# =====================================================================
|
||||
|
||||
# ----- Database (CT 102 shared Postgres) -----
|
||||
|
|
@ -42,16 +44,6 @@ REDIS_URL="redis://:CHANGE_ME@192.168.68.102:6379/11"
|
|||
# WORKER_CHECKPOINT_EVERY_N_STEPS=1
|
||||
# WORKER_REPO_ROOT= # defaults to monorepo root (pnpm-workspace.yaml walk-up)
|
||||
# WORKER_WORKTREE_BASE=/tmp/echodo-worker-worktrees
|
||||
#
|
||||
# Phase B — OpenHands agent-server (optional; requires --profile agent-server)
|
||||
# WORKER_SESSION_TYPE=minimal # set to `openhands` when agent-server is running
|
||||
# AGENT_SERVER_URL=http://agent-server:8001
|
||||
# WORKER_OPENHANDS_WORKSPACE=/workspace/repo
|
||||
# SESSION_API_KEY= # must match LOCAL_BACKEND_API_KEY on agent-server
|
||||
# LOCAL_BACKEND_API_KEY= # generate: openssl rand -hex 32
|
||||
# OMNIROUTE_BASE_URL=https://omniroute.stillwell.cloud/v1
|
||||
# OMNIROUTE_API_KEY=
|
||||
# OMNIROUTE_MODEL=auto/coding
|
||||
|
||||
# ----- Auth (NextAuth v5) -----
|
||||
# Local: http://localhost:3000 • Prod: https://echodo.stillwell.cloud
|
||||
|
|
@ -80,11 +72,10 @@ AUTH_AUTHENTIK_ISSUER="https://auth.stillwell.cloud/application/o/echodo/"
|
|||
# ----- Realtime collab server (Hocuspocus / Yjs) -----
|
||||
# Server-side reference (used by Next.js server actions, if any)
|
||||
COLLAB_SERVER_URL="ws://localhost:1234"
|
||||
# Browser-facing — must be wss:// in production because the page is https.
|
||||
# Baked into the web image at BUILD time (docker build args) — change requires rebuild.
|
||||
# Browser-facing — must be wss:// in production because the page is https
|
||||
NEXT_PUBLIC_COLLAB_SERVER_URL="ws://localhost:1234"
|
||||
|
||||
# ----- Umami Analytics (CT 112 — analytics.stillwell.cloud) -----
|
||||
# ----- Umami Analytics (CT 107 — analytics.stillwell.cloud) -----
|
||||
# In Umami: Settings → Websites → Add website. Copy the website UUID.
|
||||
NEXT_PUBLIC_UMAMI_SCRIPT="https://analytics.stillwell.cloud/script.js"
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=""
|
||||
|
|
@ -93,7 +84,7 @@ NEXT_PUBLIC_UMAMI_WEBSITE_ID=""
|
|||
NEXT_PUBLIC_LIBREDESK_URL="https://support.stillwell.cloud"
|
||||
NEXT_PUBLIC_LIBREDESK_WIDGET_ID=""
|
||||
|
||||
# ----- Directus Feedback (archived — was CT 107) -----
|
||||
# ----- Directus Feedback (CT 107 — directus.stillwell.cloud) -----
|
||||
# Static token scoped write-only on the `feedback` collection
|
||||
NEXT_PUBLIC_DIRECTUS_URL="https://directus.stillwell.cloud"
|
||||
DIRECTUS_FEEDBACK_TOKEN=""
|
||||
|
|
@ -119,40 +110,20 @@ ANTHROPIC_API_KEY=""
|
|||
GOOGLE_GENERATIVE_AI_API_KEY=""
|
||||
NVIDIA_API_KEY=""
|
||||
|
||||
# ----- OmniRoute (CT 108 — omniroute.stillwell.cloud) -----
|
||||
# Used by worker + web (Settings → AI provider tests) + Phase B agent-server.
|
||||
OMNIROUTE_BASE_URL="https://omniroute.stillwell.cloud/v1"
|
||||
OMNIROUTE_API_KEY=""
|
||||
OMNIROUTE_MODEL=auto/coding
|
||||
|
||||
# ----- Connected repos (Settings → Connected repos) -----
|
||||
# Token values live here; Settings stores only the env var NAME (e.g. FORGEJO_TOKEN).
|
||||
# Forgejo self-hosted:
|
||||
# Remote URL: https://forgejo.stillwell.cloud/rstillwell/Submatter.git
|
||||
# Token env var name in UI: FORGEJO_TOKEN
|
||||
FORGEJO_TOKEN=""
|
||||
# GitHub.com:
|
||||
# Remote URL: https://github.com/org/repo.git
|
||||
# Token env var name in UI: GITHUB_TOKEN
|
||||
GITHUB_TOKEN=""
|
||||
|
||||
# ----- Forge webhooks (PR merged → convoy transition) -----
|
||||
FORGE_WEBHOOK_SECRET=""
|
||||
|
||||
# ----- MCP Server -----
|
||||
MCP_SERVER_PORT=3001
|
||||
|
||||
# =====================================================================
|
||||
# Production Docker Compose build (8 GB hosts)
|
||||
# Coolify Docker Compose build (8 GB hosts)
|
||||
# =====================================================================
|
||||
# Serialize image builds to avoid kernel OOM (exit 255) during export.
|
||||
# Dokploy / standalone: set Custom Build Command to `bash scripts/build-prod.sh`
|
||||
# Local verify: pnpm docker:build:prod
|
||||
# Legacy alias: pnpm docker:build:coolify
|
||||
# Coolify (8 GB host): set Custom Build Command to `bash scripts/build-coolify.sh`
|
||||
# so compose builds collab → mcp → orchestrator → worker → web sequentially.
|
||||
# COMPOSE_PARALLEL_LIMIT is documented for reference but the script is required.
|
||||
|
||||
# =====================================================================
|
||||
# Local Docker Compose only (docker/docker-compose.yml)
|
||||
# Not needed for production (uses CT 102 shared Postgres/Redis).
|
||||
# Not needed for Coolify (Coolify uses CT 102's shared Postgres/Redis).
|
||||
# =====================================================================
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=postgres
|
||||
|
|
|
|||
|
|
@ -1,41 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
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
41
.github/workflows/deploy-homelab.yml
vendored
|
|
@ -1,41 +0,0 @@
|
|||
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"
|
||||
|
|
@ -12,8 +12,6 @@ import type { BriefSummary } from "@tasks/shared/orchestrator";
|
|||
|
||||
type Db = typeof dbInstance;
|
||||
|
||||
import type { AutomationTier } from "@tasks/shared/convoy-lifecycle";
|
||||
|
||||
export type LoadedConvoy = {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
|
|
@ -23,7 +21,6 @@ export type LoadedConvoy = {
|
|||
briefs: BriefSummary[];
|
||||
completedRoles: Set<string>;
|
||||
completedBriefIds: Set<string>;
|
||||
automationTier?: AutomationTier;
|
||||
};
|
||||
|
||||
function parseStatus(raw: string | null): ConvoyStatus {
|
||||
|
|
@ -88,7 +85,6 @@ export async function listActiveConvoys(db: Db): Promise<LoadedConvoy[]> {
|
|||
briefs,
|
||||
completedRoles,
|
||||
completedBriefIds,
|
||||
automationTier: content.data.automationTier ?? "assist",
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import {
|
|||
type OrchestratorAction,
|
||||
type OrchestratorJob,
|
||||
} from "@tasks/shared/orchestrator";
|
||||
import type { AutomationTier } from "@tasks/shared/convoy-lifecycle";
|
||||
|
||||
import { listActiveConvoys, transitionConvoyInDb, type LoadedConvoy } from "./db/convoy.js";
|
||||
import { enqueueJob } from "./queue/redis-queue.js";
|
||||
|
|
@ -114,7 +113,6 @@ export async function runOrchestratorTick(
|
|||
briefs: convoy.briefs,
|
||||
completedRoles: convoy.completedRoles,
|
||||
completedBriefIds: convoy.completedBriefIds,
|
||||
automationTier: convoy.automationTier,
|
||||
});
|
||||
|
||||
results.push(await applyAction(db, redis, env, convoy, action));
|
||||
|
|
|
|||
|
|
@ -1,204 +0,0 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
|
|
@ -73,7 +73,6 @@ export default function ConnectedReposSettingsPage() {
|
|||
setParsedPreview(null);
|
||||
},
|
||||
});
|
||||
const [connectError, setConnectError] = React.useState<string | null>(null);
|
||||
|
||||
const disconnectMut = api.connectedRepos.disconnect.useMutation({
|
||||
onSuccess: () => void utils.connectedRepos.list.invalidate(),
|
||||
|
|
@ -99,7 +98,6 @@ export default function ConnectedReposSettingsPage() {
|
|||
const openCreate = () => {
|
||||
setForm(EMPTY_FORM);
|
||||
setParsedPreview(null);
|
||||
setConnectError(null);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
|
|
@ -114,18 +112,12 @@ export default function ConnectedReposSettingsPage() {
|
|||
if (!workspaceSlug || !form.remoteUrl.trim()) {
|
||||
return;
|
||||
}
|
||||
setConnectError(null);
|
||||
connectMut.mutate(
|
||||
{
|
||||
workspace: workspaceSlug,
|
||||
remoteUrl: form.remoteUrl.trim(),
|
||||
displayName: form.displayName.trim() || undefined,
|
||||
tokenEnvVar: form.tokenEnvVar.trim() || null,
|
||||
},
|
||||
{
|
||||
onError: (err) => setConnectError(err.message),
|
||||
},
|
||||
);
|
||||
connectMut.mutate({
|
||||
workspace: workspaceSlug,
|
||||
remoteUrl: form.remoteUrl.trim(),
|
||||
displayName: form.displayName.trim() || undefined,
|
||||
tokenEnvVar: form.tokenEnvVar.trim() || null,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -134,27 +126,9 @@ export default function ConnectedReposSettingsPage() {
|
|||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Connected repos</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Link a git remote for worker automation (draft PRs, forge webhooks). Paste the clone
|
||||
URL — OAuth repo pickers are planned; today you set the API token on the server and
|
||||
reference its env var name here.
|
||||
Paste a git remote URL to link Forgejo, GitHub, or other forges. API tokens are
|
||||
referenced by env var name — never stored in the database.
|
||||
</p>
|
||||
<details className="mt-3 text-sm text-muted-foreground">
|
||||
<summary className="cursor-pointer font-medium text-foreground">Examples</summary>
|
||||
<ul className="mt-2 list-disc space-y-2 pl-5">
|
||||
<li>
|
||||
<span className="font-medium text-foreground">Forgejo (self-hosted):</span>{" "}
|
||||
<code className="text-xs">https://forgejo.stillwell.cloud/rstillwell/Submatter.git</code>
|
||||
{" — "}token env var <code className="text-xs">FORGEJO_TOKEN</code> in{" "}
|
||||
<code className="text-xs">/opt/submatter/.env</code>, then click Test connection.
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-medium text-foreground">GitHub.com:</span>{" "}
|
||||
<code className="text-xs">https://github.com/org/repo.git</code>
|
||||
{" — "}token env var <code className="text-xs">GITHUB_TOKEN</code> (PAT with repo
|
||||
scope).
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
{canManage && (
|
||||
<Button type="button" onClick={openCreate}>
|
||||
|
|
@ -295,24 +269,16 @@ export default function ConnectedReposSettingsPage() {
|
|||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium" htmlFor="token-env">
|
||||
API token env var
|
||||
API token env var (optional)
|
||||
</label>
|
||||
<Input
|
||||
id="token-env"
|
||||
placeholder="FORGEJO_TOKEN or GITHUB_TOKEN"
|
||||
placeholder="GITHUB_TOKEN"
|
||||
value={form.tokenEnvVar}
|
||||
onChange={(e) => setForm((f) => ({ ...f, tokenEnvVar: e.target.value }))}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Name of the variable in the server <code className="text-xs">.env</code> — not the
|
||||
secret itself. Required for Test connection to succeed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{connectError && (
|
||||
<p className="text-sm text-destructive">{connectError}</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button type="button" variant="outline">
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import type { ConvoyStatus } from "@tasks/shared/convoy-lifecycle";
|
|||
import { Loader2, Radar } from "lucide-react";
|
||||
|
||||
import { ConvoyDetailPanel } from "@/components/mission-control/convoy-detail-panel";
|
||||
import { OpsCopilotFab } from "@/components/copilot/ops-copilot-panel";
|
||||
import { FleetActivityFeed } from "@/components/fleet/fleet-activity-feed";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/lib/trpc";
|
||||
|
|
@ -42,8 +41,6 @@ export default function FleetPage() {
|
|||
const data = fleetQuery.data;
|
||||
const byWorkspace = data?.byWorkspace ?? [];
|
||||
const gateInboxAcross = data?.gateInboxAcross ?? [];
|
||||
const defaultWorkspaceSlug =
|
||||
selected?.workspaceSlug ?? byWorkspace[0]?.workspace.slug ?? gateInboxAcross[0]?.workspaceSlug ?? "";
|
||||
|
||||
// Group gate inbox by status for "× pr_review 2d" count display
|
||||
const gateByStatus = new Map<string, typeof gateInboxAcross>();
|
||||
|
|
@ -188,7 +185,6 @@ export default function FleetPage() {
|
|||
)}
|
||||
</aside>
|
||||
</div>
|
||||
<OpsCopilotFab workspaceSlug={defaultWorkspaceSlug} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,143 +0,0 @@
|
|||
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 });
|
||||
}
|
||||
|
|
@ -1,375 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Loader2, Send, Sparkles, X } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/lib/trpc";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
} from "@/components/ui/sheet";
|
||||
|
||||
type ChatRole = "user" | "assistant";
|
||||
|
||||
type ChatMessage = {
|
||||
id: string;
|
||||
role: ChatRole;
|
||||
content: string;
|
||||
};
|
||||
|
||||
function TypingDots() {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 px-1 py-2" aria-hidden>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="inline-block h-2 w-2 animate-bounce rounded-full bg-muted-foreground/70"
|
||||
style={{ animationDelay: `${i * 0.15}s` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OpsCopilotPanel({
|
||||
workspaceSlug,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
workspaceSlug: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const [messages, setMessages] = React.useState<ChatMessage[]>([]);
|
||||
const [input, setInput] = React.useState("");
|
||||
const [draftOpen, setDraftOpen] = React.useState(false);
|
||||
const [draftPlanSlug, setDraftPlanSlug] = React.useState("");
|
||||
const [draftEpicSlug, setDraftEpicSlug] = React.useState("");
|
||||
const [draftSlug, setDraftSlug] = React.useState("");
|
||||
const [draftTitle, setDraftTitle] = React.useState("");
|
||||
const [draftBody, setDraftBody] = React.useState("");
|
||||
const bottomRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const copilotMut = api.opsCopilot.generateText.useMutation({
|
||||
onSuccess: (data) => {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ id: crypto.randomUUID(), role: "assistant", content: data.text },
|
||||
]);
|
||||
},
|
||||
});
|
||||
|
||||
const createTaskMut = api.backlog.createTask.useMutation({
|
||||
onSuccess: () => {
|
||||
setDraftOpen(false);
|
||||
setDraftPlanSlug("");
|
||||
setDraftEpicSlug("");
|
||||
setDraftSlug("");
|
||||
setDraftTitle("");
|
||||
setDraftBody("");
|
||||
},
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages, copilotMut.isPending]);
|
||||
|
||||
const send = React.useCallback(() => {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed || copilotMut.isPending) return;
|
||||
if (!workspaceSlug) return;
|
||||
const userMsg: ChatMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: "user",
|
||||
content: trimmed,
|
||||
};
|
||||
const next = [...messages, userMsg];
|
||||
setMessages(next);
|
||||
setInput("");
|
||||
const history = next.slice(0, -1).map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
}));
|
||||
copilotMut.mutate({
|
||||
workspace: workspaceSlug,
|
||||
prompt: trimmed,
|
||||
messages: history.length > 0 ? history : undefined,
|
||||
});
|
||||
}, [input, copilotMut, messages, workspaceSlug]);
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
send();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDraftTask = () => {
|
||||
if (!workspaceSlug) return;
|
||||
if (!draftPlanSlug.trim() || !draftEpicSlug.trim() || !draftSlug.trim() || !draftTitle.trim()) {
|
||||
return;
|
||||
}
|
||||
createTaskMut.mutate({
|
||||
workspace: workspaceSlug,
|
||||
planSlug: draftPlanSlug.trim().toLowerCase(),
|
||||
epicSlug: draftEpicSlug.trim().toLowerCase(),
|
||||
slug: draftSlug.trim().toLowerCase(),
|
||||
title: draftTitle.trim(),
|
||||
bodyMarkdown: draftBody.trim() || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const isBusy = copilotMut.isPending;
|
||||
const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant");
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="flex w-full max-w-md flex-col p-0 sm:max-w-md">
|
||||
<SheetHeader className="shrink-0 border-b px-4 py-3 text-left">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="size-5 text-primary" />
|
||||
<SheetTitle className="text-base">Ops copilot</SheetTitle>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => onOpenChange(false)}
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<SheetDescription className="text-xs">
|
||||
Ask "what needs me?" — grounded in fleet, convoys and backlog. Links use [workspace/slug].
|
||||
{workspaceSlug ? ` · ${workspaceSlug}` : " · no workspace"}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-3 py-3">
|
||||
{messages.length === 0 && !isBusy ? (
|
||||
<div className="space-y-3 px-1 py-6 text-center">
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
|
||||
<Sparkles className="size-6 text-primary" />
|
||||
</div>
|
||||
<p className="text-sm font-medium">What needs you?</p>
|
||||
<p className="mx-auto max-w-[22rem] text-xs text-muted-foreground">
|
||||
Try "what needs my approval?" — fleet → needs you will be cited with [fleet] links.
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{["What needs me?", "What's ready to start?", "Show my gate inbox"].map((chip) => (
|
||||
<button
|
||||
key={chip}
|
||||
type="button"
|
||||
onClick={() => setInput(chip)}
|
||||
className="rounded-full border bg-card px-3 py-1.5 text-xs hover:bg-muted"
|
||||
>
|
||||
{chip}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{!workspaceSlug && (
|
||||
<p className="text-xs text-destructive">No workspace selected — create or select one first.</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-3">
|
||||
{messages.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className={cn("flex gap-2", m.role === "user" ? "justify-end" : "justify-start")}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[85%] rounded-lg px-3 py-2 text-sm whitespace-pre-wrap",
|
||||
m.role === "user"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "border bg-muted",
|
||||
)}
|
||||
>
|
||||
{m.content}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{isBusy && (
|
||||
<div className="flex gap-2">
|
||||
<div className="rounded-lg border bg-card px-3 py-2">
|
||||
<TypingDots />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{copilotMut.error && (
|
||||
<p className="rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
{copilotMut.error.message}
|
||||
</p>
|
||||
)}
|
||||
{copilotMut.data && (
|
||||
<p className="px-1 text-[10px] text-muted-foreground">
|
||||
Model: {copilotMut.data.resolvedAlias} ({copilotMut.data.modelId})
|
||||
</p>
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 border-t bg-muted/20 px-3 py-3">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{lastAssistant ? "Need to create work from this answer?" : "Draft a Task from chat"}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setDraftOpen((v) => !v)}
|
||||
>
|
||||
Draft Task
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{draftOpen && (
|
||||
<div className="mb-3 rounded-md border bg-background p-3">
|
||||
<p className="mb-2 text-xs font-medium">Create backlog task via create-backlog-item shape</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-medium uppercase text-muted-foreground">Plan slug</label>
|
||||
<Input
|
||||
value={draftPlanSlug}
|
||||
onChange={(e) => setDraftPlanSlug(e.target.value)}
|
||||
placeholder="automation-foundation"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-medium uppercase text-muted-foreground">Epic slug</label>
|
||||
<Input
|
||||
value={draftEpicSlug}
|
||||
onChange={(e) => setDraftEpicSlug(e.target.value)}
|
||||
placeholder="ops-copilot"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-medium uppercase text-muted-foreground">Task slug</label>
|
||||
<Input
|
||||
value={draftSlug}
|
||||
onChange={(e) => setDraftSlug(e.target.value)}
|
||||
placeholder="triage-bug"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-medium uppercase text-muted-foreground">Title</label>
|
||||
<Input
|
||||
value={draftTitle}
|
||||
onChange={(e) => setDraftTitle(e.target.value)}
|
||||
placeholder="Triage bug from copilot"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1">
|
||||
<label className="text-[10px] font-medium uppercase text-muted-foreground">Body markdown</label>
|
||||
<textarea
|
||||
value={draftBody}
|
||||
onChange={(e) => setDraftBody(e.target.value)}
|
||||
placeholder="One or two sentences. Will become Task-*.md"
|
||||
rows={3}
|
||||
className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setDraftOpen(false)}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
disabled={
|
||||
createTaskMut.isPending ||
|
||||
!draftPlanSlug.trim() ||
|
||||
!draftEpicSlug.trim() ||
|
||||
!draftSlug.trim() ||
|
||||
!draftTitle.trim() ||
|
||||
!workspaceSlug
|
||||
}
|
||||
onClick={handleDraftTask}
|
||||
>
|
||||
{createTaskMut.isPending ? <Loader2 className="size-3 animate-spin" /> : "Create Task"}
|
||||
</Button>
|
||||
</div>
|
||||
{createTaskMut.error && (
|
||||
<p className="mt-2 text-xs text-destructive">{createTaskMut.error.message}</p>
|
||||
)}
|
||||
{createTaskMut.isSuccess && (
|
||||
<p className="mt-2 text-xs text-green-600">Task created — check Plans tree.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder={workspaceSlug ? "What needs me?" : "Select workspace first"}
|
||||
disabled={isBusy || !workspaceSlug}
|
||||
rows={2}
|
||||
className={cn(
|
||||
"min-h-[44px] flex-1 resize-none rounded-md border border-input bg-background px-3 py-2 text-sm",
|
||||
"placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
className="h-[44px] w-[44px] shrink-0"
|
||||
onClick={send}
|
||||
disabled={isBusy || !input.trim() || !workspaceSlug}
|
||||
aria-label="Send"
|
||||
>
|
||||
{isBusy ? <Loader2 className="size-4 animate-spin" /> : <Send className="size-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
export function OpsCopilotFab({ workspaceSlug }: { workspaceSlug: string }) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="fixed bottom-6 right-6 z-40 h-12 w-12 rounded-full shadow-lg"
|
||||
size="icon"
|
||||
aria-label="Open ops copilot"
|
||||
>
|
||||
<Sparkles className="size-5" />
|
||||
</Button>
|
||||
<OpsCopilotPanel workspaceSlug={workspaceSlug} open={open} onOpenChange={setOpen} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
"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,
|
||||
};
|
||||
}
|
||||
|
|
@ -83,6 +83,10 @@ 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).
|
||||
*/
|
||||
|
|
@ -94,19 +98,12 @@ 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 {
|
||||
const fromEnv =
|
||||
(typeof process !== "undefined" && process.env.NEXT_PUBLIC_COLLAB_SERVER_URL) ||
|
||||
(typeof process !== "undefined" && process.env.NEXT_PUBLIC_COLLAB_URL);
|
||||
if (fromEnv) {
|
||||
return fromEnv;
|
||||
if (typeof process !== "undefined" && process.env.NEXT_PUBLIC_COLLAB_URL) {
|
||||
return process.env.NEXT_PUBLIC_COLLAB_URL;
|
||||
}
|
||||
if (typeof process !== "undefined" && process.env.COLLAB_SERVER_URL) {
|
||||
return process.env.COLLAB_SERVER_URL;
|
||||
}
|
||||
return "ws://localhost:1234";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,9 +17,7 @@ import { runsRouter } from "@/server/routers/runs";
|
|||
import { backlogRouter } from "@/server/routers/backlog";
|
||||
import { connectedReposRouter } from "@/server/routers/connected-repos";
|
||||
import { convoysRouter } from "@/server/routers/convoys";
|
||||
import { copilotRouter, opsCopilotRouter } from "@/server/routers/copilot";
|
||||
import { harnessesRouter } from "@/server/routers/harnesses";
|
||||
import { budgetsRouter } from "@/server/routers/budgets";
|
||||
|
||||
export const appRouter = router({
|
||||
health: healthRouter,
|
||||
|
|
@ -41,9 +39,6 @@ export const appRouter = router({
|
|||
connectedRepos: connectedReposRouter,
|
||||
convoys: convoysRouter,
|
||||
harnesses: harnessesRouter,
|
||||
copilot: copilotRouter,
|
||||
opsCopilot: opsCopilotRouter,
|
||||
budgets: budgetsRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
|
|
|||
|
|
@ -1,143 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
import { createLanguageModelFromResolved } from "@tasks/ai";
|
||||
import {
|
||||
completionArtifacts,
|
||||
convoyOperatorMessages,
|
||||
modelProviders,
|
||||
objects,
|
||||
|
|
@ -9,7 +8,6 @@ import {
|
|||
workspaces,
|
||||
} from "@tasks/database/schema";
|
||||
import {
|
||||
automationTierSchema,
|
||||
convoyClassificationSchema,
|
||||
convoyObjectContentSchema,
|
||||
ConvoyTransitionError,
|
||||
|
|
@ -24,10 +22,9 @@ import {
|
|||
type ModelProvider,
|
||||
type RoleModelRoute,
|
||||
} from "@tasks/shared/model-routing";
|
||||
import { generateCompletionSummary } from "@tasks/shared/worker";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { generateText } from "ai";
|
||||
import { and, asc, desc, eq, inArray } from "drizzle-orm";
|
||||
import { and, asc, eq, inArray } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
|
|
@ -215,7 +212,6 @@ export const convoysRouter = router({
|
|||
backlogItemId: z.string().uuid().optional(),
|
||||
repoPath: z.string().min(1).optional(),
|
||||
briefFiles: z.array(z.string()).optional(),
|
||||
automationTier: z.enum(["manual", "assist", "auto"]).default("assist"),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
|
|
@ -239,7 +235,6 @@ export const convoysRouter = router({
|
|||
backlogItemId: input.backlogItemId,
|
||||
repoPath: input.repoPath,
|
||||
briefFiles: input.briefFiles ?? (input.repoPath ? [input.repoPath] : undefined),
|
||||
automationTier: input.automationTier,
|
||||
});
|
||||
|
||||
const [created] = await ctx.db
|
||||
|
|
@ -467,162 +462,6 @@ 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 }) => {
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,271 +0,0 @@
|
|||
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;
|
||||
|
|
@ -33,26 +33,6 @@ const envSchema = z.object({
|
|||
.string()
|
||||
.optional()
|
||||
.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>;
|
||||
|
|
|
|||
|
|
@ -1,147 +0,0 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,119 +0,0 @@
|
|||
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 });
|
||||
}
|
||||
|
|
@ -1,160 +0,0 @@
|
|||
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",
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -87,10 +87,6 @@ const env = {
|
|||
WORKER_MAX_TOOL_STEPS: 12,
|
||||
WORKER_CHECKPOINT_EVERY_N_STEPS: 1,
|
||||
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",
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -30,11 +30,11 @@ import {
|
|||
} from "./git/worktree.js";
|
||||
import { openDraftPR } from "./git/pr.js";
|
||||
import { resolveHarnessForJob, getHarnessExecutor } from "./harness.js";
|
||||
import { getExecutor, createOpenHandsExecutor, type HarnessExecutorConfig } from "./executor/index.js";
|
||||
import { resolveHarnessForRoleDb } from "./db/harness.js";
|
||||
import { resolveRepoRoot } from "./lib/repo-root.js";
|
||||
import {
|
||||
buildSessionPrompt,
|
||||
createMinimalSession,
|
||||
resolveRolePromptPath,
|
||||
} from "./session/minimal-session.js";
|
||||
import { connectedRepos, convoyEvents, objects } from "@tasks/database/schema";
|
||||
|
|
@ -88,9 +88,9 @@ export async function handleOrchestratorJob(
|
|||
} catch (e) {
|
||||
console.warn("[worker] harness DB resolve failed, using local fallback:", e);
|
||||
}
|
||||
const executorMeta = getHarnessExecutor(harness.alias);
|
||||
const executor = getHarnessExecutor(harness.alias);
|
||||
const needsWorktree = harness.needsWorktree;
|
||||
void executorMeta;
|
||||
void executor;
|
||||
|
||||
let repoRoot = monorepoRoot;
|
||||
let worktreePath: string | null = null;
|
||||
|
|
@ -213,8 +213,7 @@ export async function handleOrchestratorJob(
|
|||
repoRoot,
|
||||
};
|
||||
|
||||
// WorkerAdapter dispatch — registry routes to the correct executor based on
|
||||
// harnessType. "minimal" runs locally; "remote-ag-ui" POSTs to the remote agent.
|
||||
// WorkerAdapter dispatch — minimal adapter delegates to createMinimalSession; remote adapters would POST to AG-UI.
|
||||
const operatorNotesProvider = {
|
||||
harnessAlias: harness.alias,
|
||||
poll: async () => {
|
||||
|
|
@ -231,96 +230,52 @@ export async function handleOrchestratorJob(
|
|||
},
|
||||
};
|
||||
|
||||
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;
|
||||
const session = createMinimalSession(sessionContext, {
|
||||
resolvedModel: modelResult.model,
|
||||
resolvedAlias: modelResult.resolvedAlias,
|
||||
rolePromptPath: resolveRolePromptPath(monorepoRoot, job.roleId),
|
||||
workflowPrompt,
|
||||
briefAcceptance: ctx.briefAcceptance,
|
||||
briefFiles: ctx.briefFiles,
|
||||
maxSteps: env.WORKER_MAX_TOOL_STEPS,
|
||||
dryRun,
|
||||
checkpointEveryNSteps: env.WORKER_CHECKPOINT_EVERY_N_STEPS,
|
||||
resumeToolHistory,
|
||||
resumeFilesTouched,
|
||||
allowedCommands: harness.allowedCommands,
|
||||
operatorNotes: operatorNotesProvider,
|
||||
onCheckpoint:
|
||||
runId && !dryRun
|
||||
? 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,
|
||||
});
|
||||
|
||||
// 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,
|
||||
try {
|
||||
const result = await session.run(buildSessionPrompt(sessionContext, ctx.convoyTitle));
|
||||
summary = result.summary;
|
||||
tokensTotal = result.tokensTotal;
|
||||
console.info(
|
||||
`[worker] job=${job.id} role=${job.roleId} tools=${result.toolCalls} dryRun=${dryRun}`,
|
||||
);
|
||||
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) {
|
||||
} catch (error) {
|
||||
outcome = "failed";
|
||||
summary = `No executor available for harness type: ${harness.harnessType}`;
|
||||
console.error(`[worker] job=${job.id} ${summary}`);
|
||||
} else {
|
||||
const sessionOptions = {
|
||||
resolvedModel: modelResult.model,
|
||||
resolvedAlias: modelResult.resolvedAlias,
|
||||
rolePromptPath: resolveRolePromptPath(monorepoRoot, job.roleId),
|
||||
workflowPrompt,
|
||||
briefAcceptance: ctx.briefAcceptance,
|
||||
briefFiles: ctx.briefFiles,
|
||||
maxSteps: env.WORKER_MAX_TOOL_STEPS,
|
||||
dryRun,
|
||||
checkpointEveryNSteps: env.WORKER_CHECKPOINT_EVERY_N_STEPS,
|
||||
resumeToolHistory,
|
||||
resumeFilesTouched,
|
||||
allowedCommands: harness.allowedCommands,
|
||||
onCheckpoint: onCheckpointSink,
|
||||
operatorNotes: operatorNotesProvider,
|
||||
buildPrompt: buildSessionPrompt,
|
||||
convoyTitle: ctx.convoyTitle,
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await executor.run(sessionContext, sessionOptions as import("@tasks/shared/worker").WorkerExecutorOptions);
|
||||
summary = result.summary;
|
||||
tokensTotal = result.tokensTotal;
|
||||
console.info(
|
||||
`[worker] job=${job.id} role=${job.roleId} harness=${executor.id} tools=${result.toolCalls} dryRun=${dryRun}`,
|
||||
);
|
||||
} catch (error) {
|
||||
outcome = "failed";
|
||||
summary = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[worker] job=${job.id} session failed:`, error);
|
||||
}
|
||||
summary = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[worker] job=${job.id} session failed:`, error);
|
||||
}
|
||||
|
||||
// Draft PR via forge adapter after successful implementer session, before teardown
|
||||
|
|
|
|||
|
|
@ -1,105 +0,0 @@
|
|||
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}`;
|
||||
}
|
||||
|
|
@ -19,7 +19,6 @@ import {
|
|||
runCommandTool,
|
||||
writeFileTool,
|
||||
} from "../tools/runner.js";
|
||||
import { loadAgentSkills, buildSkillFullContext } from "./load-skills.js";
|
||||
|
||||
export type CheckpointSink = (input: {
|
||||
checkpointSeq: number;
|
||||
|
|
@ -135,18 +134,6 @@ export function createMinimalSession(
|
|||
const checkpointEvery = Math.max(1, options.checkpointEveryNSteps ?? 1);
|
||||
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 = [
|
||||
rolePrompt.trim(),
|
||||
options.workflowPrompt ? `\n\n## Workflow\n${options.workflowPrompt.trim()}` : "",
|
||||
|
|
@ -156,7 +143,6 @@ export function createMinimalSession(
|
|||
options.briefAcceptance
|
||||
? `\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.",
|
||||
buildResumeAppendix(resumeHistory),
|
||||
].join("");
|
||||
|
|
|
|||
|
|
@ -1,283 +0,0 @@
|
|||
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,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -129,21 +129,6 @@ ENV NODE_OPTIONS=--max-old-space-size=5120
|
|||
# Local dev uses a gitignored apps/web/.env symlink; drop it so Next.js does not
|
||||
# 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
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
|
|
@ -292,11 +277,8 @@ COPY --chown=worker:nodejs packages ./repo/packages
|
|||
COPY --chown=worker:nodejs plans ./repo/plans
|
||||
COPY --chown=worker:nodejs .cursor/mcp.json ./repo/.cursor/mcp.json
|
||||
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
|
||||
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.
|
||||
USER root
|
||||
|
|
@ -313,4 +295,4 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
|||
CMD pgrep -f "node.*dist/index\\.js" > /dev/null || exit 1
|
||||
|
||||
WORKDIR /app/apps/worker
|
||||
ENTRYPOINT ["/app/worker-entrypoint.sh"]
|
||||
CMD ["node", "dist/index.js"]
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
# 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>
|
||||
|
|
@ -1,7 +1,206 @@
|
|||
# DEPRECATED — use docker/docker-compose.prod.yml
|
||||
# Docker Compose for Coolify deployment of ECHODO.
|
||||
#
|
||||
# Kept so existing Coolify resources that still point at this path keep working.
|
||||
# New Dokploy / standalone deploys should use docker-compose.prod.yml.
|
||||
# Usage in Coolify:
|
||||
# - New Resource → Docker Compose → connect this repo
|
||||
# - 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
|
||||
|
||||
include:
|
||||
- docker-compose.prod.yml
|
||||
services:
|
||||
web:
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
# 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"
|
||||
|
|
@ -1,244 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
#!/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
|
||||
|
|
@ -1,517 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
# 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`
|
||||
|
|
@ -1,169 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
# 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`
|
||||
|
|
@ -1,168 +0,0 @@
|
|||
# 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)
|
||||
|
|
@ -1,313 +0,0 @@
|
|||
# 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`
|
||||
|
|
@ -1,384 +0,0 @@
|
|||
# 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`
|
||||
|
|
@ -19,7 +19,6 @@ 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`.
|
||||
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.
|
||||
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
|
||||
|
||||
|
|
@ -37,12 +36,6 @@ This is the **operator** checklist for `plans/Plan-automation-foundation/Epic-ap
|
|||
- Forge webhooks (Epic 8) — verified separately
|
||||
- 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
|
||||
|
||||
- Task: `plans/Plan-automation-foundation/Epic-apps-worker-v1/Task-worker-live-e2e-dogfood.md`
|
||||
|
|
|
|||
|
|
@ -1,78 +0,0 @@
|
|||
# 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
15
logs/app.log
|
|
@ -1,15 +0,0 @@
|
|||
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
|
||||
|
|
@ -11,8 +11,7 @@
|
|||
"db:migrate": "turbo db:migrate --filter=@tasks/database",
|
||||
"db:push": "turbo db:push --filter=@tasks/database",
|
||||
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,css,md}\"",
|
||||
"docker:build:prod": "bash scripts/build-prod.sh",
|
||||
"docker:build:coolify": "bash scripts/build-prod.sh"
|
||||
"docker:build:coolify": "bash scripts/build-coolify.sh"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/coverage-v8": "^2",
|
||||
|
|
|
|||
|
|
@ -1,35 +0,0 @@
|
|||
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
|
|
@ -120,13 +120,6 @@
|
|||
"when": 1787850000000,
|
||||
"tag": "0016_noble_operator_notes",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "7",
|
||||
"when": 1787698170280,
|
||||
"tag": "0017_lively_squadron_supreme",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
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(),
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
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),
|
||||
}),
|
||||
);
|
||||
|
|
@ -20,5 +20,3 @@ export * from "./convoy_events";
|
|||
export * from "./worker_checkpoints";
|
||||
export * from "./harness_providers";
|
||||
export * from "./convoy_operator_messages";
|
||||
export * from "./completion_artifacts";
|
||||
export * from "./budget_caps";
|
||||
|
|
|
|||
|
|
@ -34,8 +34,3 @@ export {
|
|||
humanGateActorForStatus,
|
||||
type KanbanColumn,
|
||||
} from "./kanban";
|
||||
|
||||
export {
|
||||
automationTierSchema,
|
||||
type AutomationTier,
|
||||
} from "./schemas";
|
||||
|
|
|
|||
|
|
@ -50,10 +50,6 @@ export const convoyEventTypeSchema = z.enum([
|
|||
|
||||
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({
|
||||
convoySlug: z.string().min(1).max(120),
|
||||
classification: convoyClassificationSchema,
|
||||
|
|
@ -67,7 +63,6 @@ export const convoyObjectContentSchema = z.object({
|
|||
briefFiles: z.array(z.string()).optional(),
|
||||
prUrl: z.string().url().optional(),
|
||||
prNumber: z.number().int().optional(),
|
||||
automationTier: automationTierSchema.default("assist"),
|
||||
});
|
||||
|
||||
export type ConvoyObjectContent = z.infer<typeof convoyObjectContentSchema>;
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ export const ALLOWED_TRANSITIONS: readonly TransitionRule[] = [
|
|||
actors: ["human-gate-1", "role-conductor"],
|
||||
requiresReason: true,
|
||||
},
|
||||
{ from: "pr_review", to: "merged", actors: ["human-gate-2", "automation"] },
|
||||
{ from: "pr_review", to: "merged", actors: ["human-gate-2"] },
|
||||
{ from: "pr_review", to: "in_progress", actors: ["human-gate-2", "role-implementer"] },
|
||||
{
|
||||
from: "pr_review",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { ConvoyStatus, AutomationTier } from "../convoy-lifecycle/schemas";
|
||||
import type { ConvoyStatus } from "../convoy-lifecycle/schemas";
|
||||
|
||||
export type BriefSummary = {
|
||||
id: string;
|
||||
|
|
@ -84,17 +84,7 @@ export function planOrchestratorAction(input: {
|
|||
briefs: BriefSummary[];
|
||||
completedRoles: Set<string>;
|
||||
completedBriefIds: Set<string>;
|
||||
automationTier?: AutomationTier;
|
||||
}): 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];
|
||||
if (gate) {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,34 +0,0 @@
|
|||
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");
|
||||
}
|
||||
|
|
@ -115,51 +115,26 @@ export type ResolvedModelLike = {
|
|||
alias: string;
|
||||
};
|
||||
|
||||
/** Options passed to WorkerExecutor.run — extracted for reuse across executors. */
|
||||
export type WorkerExecutorOptions = {
|
||||
resolvedModel: ResolvedModelLike;
|
||||
resolvedAlias: string;
|
||||
rolePromptPath: string;
|
||||
workflowPrompt?: string;
|
||||
briefAcceptance?: string;
|
||||
briefFiles?: string[];
|
||||
maxSteps: number;
|
||||
dryRun?: boolean;
|
||||
checkpointEveryNSteps?: number;
|
||||
resumeToolHistory?: unknown[];
|
||||
resumeFilesTouched?: unknown[];
|
||||
allowedCommands?: readonly string[] | null;
|
||||
onCheckpoint?: (input: unknown) => Promise<void>;
|
||||
buildPrompt: (ctx: WorkerJobContext, title: string) => string;
|
||||
/** Optional: used by remote executors that need the convoy title for prompt building. */
|
||||
convoyTitle?: string;
|
||||
};
|
||||
|
||||
export interface WorkerExecutor {
|
||||
id: string;
|
||||
harnessType: HarnessType;
|
||||
run(
|
||||
ctx: WorkerJobContext,
|
||||
opts: WorkerExecutorOptions,
|
||||
): Promise<{ summary: string; toolCalls: number; tokensTotal?: number; outcome?: "succeeded" | "failed" }>;
|
||||
opts: {
|
||||
resolvedModel: ResolvedModelLike;
|
||||
resolvedAlias: string;
|
||||
rolePromptPath: string;
|
||||
workflowPrompt?: string;
|
||||
briefAcceptance?: string;
|
||||
briefFiles?: string[];
|
||||
maxSteps: number;
|
||||
dryRun?: boolean;
|
||||
checkpointEveryNSteps?: number;
|
||||
resumeToolHistory?: unknown[];
|
||||
resumeFilesTouched?: unknown[];
|
||||
allowedCommands?: readonly string[] | null;
|
||||
onCheckpoint?: (input: unknown) => Promise<void>;
|
||||
buildPrompt: (ctx: WorkerJobContext, title: string) => string;
|
||||
},
|
||||
): Promise<{ summary: string; toolCalls: number; tokensTotal?: number }>;
|
||||
}
|
||||
|
||||
// --- 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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -18,10 +18,6 @@ export {
|
|||
type ResolveHarnessResult,
|
||||
type ResolvedHarness,
|
||||
type WorkerExecutor,
|
||||
type WorkerExecutorOptions,
|
||||
type WorkerRunResult,
|
||||
type RemoteRunOptions,
|
||||
type ResolvedModelLike,
|
||||
} from "./executor";
|
||||
|
||||
export {
|
||||
|
|
@ -35,8 +31,3 @@ export {
|
|||
type WorkerCheckpointSummary,
|
||||
type WorkerCheckpointToolStep,
|
||||
} from "./checkpoint";
|
||||
|
||||
export {
|
||||
generateCompletionSummary,
|
||||
type CompletionSummaryInput,
|
||||
} from "./completion-summary";
|
||||
|
|
|
|||
|
|
@ -1,133 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
#!/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
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
#!/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
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
# 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"
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
[
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -1,3 +1,26 @@
|
|||
#!/usr/bin/env bash
|
||||
# Backward-compatible alias — use scripts/build-prod.sh for new deploys.
|
||||
exec "$(dirname "$0")/build-prod.sh" "$@"
|
||||
# Coolify custom build command for docker-compose.coolify.yml.
|
||||
#
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -1,29 +0,0 @@
|
|||
#!/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."
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
#!/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
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
#!/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
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
#!/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'))"
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
#!/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)"
|
||||
Loading…
Reference in a new issue