merge feat/second-brain-os-v01 → main (second brain OS v0.1)
This commit is contained in:
commit
38d91d4749
120 changed files with 16497 additions and 287 deletions
69
.convoys/second-brain-os-v01.md
Normal file
69
.convoys/second-brain-os-v01.md
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
---
|
||||||
|
name: second-brain-os-v01
|
||||||
|
classification: server-only
|
||||||
|
success_metric: Web-first second brain OS publishable via docker compose up — any agent (OpenCode/Cursor) can create Plan/Epic/Task via MCP, Plans tree CRUD works without CLI, Fleet shows all workspaces, harness-agnostic worker runs doc + code tasks with BYO LLMs
|
||||||
|
skip:
|
||||||
|
- ia
|
||||||
|
- ux
|
||||||
|
- design
|
||||||
|
- a11y
|
||||||
|
- visual
|
||||||
|
- smoke
|
||||||
|
status: in-progress
|
||||||
|
created: 2026-08-25
|
||||||
|
model_policy:
|
||||||
|
roles:
|
||||||
|
role-conductor: claude-planning
|
||||||
|
role-architect: claude-planning
|
||||||
|
role-implementer: qwen-coder
|
||||||
|
role-reviewer: qwen-coder
|
||||||
|
escalate_to: claude-planning
|
||||||
|
---
|
||||||
|
|
||||||
|
# Second Brain OS v0.1 — open self-hosted Obsidian for crews
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Echodo pivots per `docs/decisions/2026-08-25-second-brain-os-pivot.md` from closed code-cockpit to open "Obsidian for agent crews": `docker compose up`, BYO LLMs (Ollama 11434 / MLX 8000 / NIM), any harness via AG-UI/MCP. Current `plans/` is hand-edited markdown + CLI import; Fleet is per-workspace kanban only; worker is code-only with hard worktree + 4-tool allowlist. This convoy makes the homelab dogfoodable + publishable for stars.
|
||||||
|
|
||||||
|
See `plans/Plan-automation-foundation/Plan-automation-foundation.md` v0.1 order (2026-08-25 pivot).
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
1. MCP `create-backlog-item` — natural language `Plan/Epic/Task` creation (0.5d)
|
||||||
|
2. Worker non-code: optional worktree + per-harness allowlist + `doc_review→published` (1d)
|
||||||
|
3. Harness registry + WorkerAdapter: `harness_providers` + `role_harness_routes`, `AG-UI /api/ag-ui`, `Settings → Harnesses` (2-3d)
|
||||||
|
4. Plans tree: `apps/web/app/(app)/[workspaceSlug]/plans/page.tsx` + `backlog.createPlan/createEpic/createTask` + `Start→Convoy` (1w) — Epic 10
|
||||||
|
5. Fleet rollup: `/fleet` + SSE `convoy_events` feed (3-4d) — Epic 9
|
||||||
|
|
||||||
|
Out of scope: Epic 8 webhooks, Epic 11 artifacts, Epic 12 copilot (post-v0.1); LiteLLM required, per-user overrides.
|
||||||
|
|
||||||
|
## Roles invoked
|
||||||
|
|
||||||
|
Conductor → Architect (briefs = 5 above, 1:1 to roadmap) → Implementer per brief (sequential, each needs prior) → Reviewer (PR).
|
||||||
|
|
||||||
|
## Todos
|
||||||
|
|
||||||
|
- [ ] Brief 1 MCP create
|
||||||
|
- [ ] Brief 2 worker non-code
|
||||||
|
- [ ] Brief 3 harness registry — depends on 2
|
||||||
|
- [ ] Brief 4 Plans tree — depends on 1,3
|
||||||
|
- [ ] Brief 5 Fleet — depends on 3
|
||||||
|
|
||||||
|
## IA
|
||||||
|
|
||||||
|
Skipped (`ia` in skip). No new IA — `plans/` hierarchy `docs/Glossary.md:50` + `objects` `packages/database/src/schema/objects.ts:18` unchanged.
|
||||||
|
|
||||||
|
## UX
|
||||||
|
|
||||||
|
Skipped (`ux` in skip). Reuses existing `Mission control` kanban `apps/web/app/(app)/[workspaceSlug]/mission-control/page.tsx:12` + `Settings` patterns.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
See decision log + `docs/adr-model-routing.md`. Key new:
|
||||||
|
- `packages/database/src/schema/harness_providers.ts` (mirror `model_providers` `docs/adr-model-routing.md:78`)
|
||||||
|
- `packages/shared/src/worker/executor.ts` `WorkerAdapter`
|
||||||
|
- `apps/worker/src/handle-job.ts:40` dispatch via adapter
|
||||||
|
- `apps/web/app/api/ag-ui/[workspace]/route.ts` AG-UI
|
||||||
|
- `apps/mcp-server/src/tools/create-backlog-item.ts`
|
||||||
|
- `apps/web/server/routers/backlog.ts:29` add CRUD for `plans/*.md`
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
---
|
||||||
|
brief: 1
|
||||||
|
convoy: second-brain-os-v01
|
||||||
|
role: role-implementer
|
||||||
|
title: MCP create-backlog-item tool for natural language Plan/Epic/Task authoring
|
||||||
|
status: ready
|
||||||
|
priority: P0
|
||||||
|
slice_dependencies: []
|
||||||
|
recommended_model: qwen-coder
|
||||||
|
model_tier: local
|
||||||
|
workspace_id: global
|
||||||
|
---
|
||||||
|
|
||||||
|
# Brief 1 — MCP `create-backlog-item` (0.5d)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Any agent (OpenCode/Cursor/Codex) via MCP can say "create an Epic for offline mode with 3 tasks" and it writes `plans/Plan-*/Epic-*/Task-*.md` + re-imports to `markdown_backlog_items` `packages/database/src/markdown-backlog/sync.ts:38`.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `apps/mcp-server/src/tools/create-backlog-item.ts` NEW — zod schema `kind: plan|epic|task`, `slug`, `title`, `planSlug`, `epicSlug`, `status`, `priority`, `bodyMarkdown`, `agent_prompt`; validates frontmatter per `docs/templates/`, writes file via `exportBacklogItemToMarkdown` inverse, calls `syncMarkdownBacklogScan` or direct DB insert + `contentHash` `packages/database/src/markdown-backlog/parse.ts:1`.
|
||||||
|
- `apps/mcp-server/src/tools/index.ts` register
|
||||||
|
- `packages/database/src/markdown-backlog/parse.test.ts` add case
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
- `mcp call create-backlog-item {workspace:"maya-workspace", kind:"task", slug:"research-prompt", planSlug:"clarity", epicSlug:"ai-tags", title:"Research prompt", bodyMarkdown:"..."}` creates `plans/Plan-clarity/Epic-ai-tags/Task-research-prompt.md` with YAML frontmatter + body, row appears in `markdown_backlog_items` without manual `pnpm import`.
|
||||||
|
- Duplicate slug under same epic → error `CONFLICT`.
|
||||||
|
- `pnpm lint && pnpm type-check && pnpm --filter @tasks/mcp-server test` (if exists) pass; `apps/mcp-server` still builds Docker.
|
||||||
|
|
||||||
|
## Prompt for implementer
|
||||||
|
|
||||||
|
You are `role-implementer` for `second-brain-os-v01` brief 1. Read `docs/decisions/2026-08-25-second-brain-os-pivot.md`, `packages/database/src/markdown-backlog/parse.ts`, `apps/mcp-server/src/tools/claim-task.ts:1` as reference. Implement exactly this brief, keep changes small, run `pnpm lint && pnpm type-check`.
|
||||||
|
|
||||||
|
## Dogfood
|
||||||
|
|
||||||
|
`claim_task` on the matching `plans/Plan-automation-foundation/.../Task-*.md` if exists (or ad-hoc) → `complete_task outcome:succeeded tokensTotal:... modelAlias:qwen-coder`.
|
||||||
40
.convoys/second-brain-os-v01/brief-2-worker-non-code.md
Normal file
40
.convoys/second-brain-os-v01/brief-2-worker-non-code.md
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
---
|
||||||
|
brief: 2
|
||||||
|
convoy: second-brain-os-v01
|
||||||
|
role: role-implementer
|
||||||
|
title: Worker non-code — optional worktree + per-harness allowlist + doc_review→published
|
||||||
|
status: ready
|
||||||
|
priority: P0
|
||||||
|
slice_dependencies: []
|
||||||
|
recommended_model: qwen-coder
|
||||||
|
model_tier: local
|
||||||
|
---
|
||||||
|
|
||||||
|
# Brief 2 — Worker non-code (1d)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
`book/course/research/SOPs` docs don't need git worktree or `pnpm`. Worker must handle doc tasks writing straight to `docs/`/`plans/` no branch, with allowlist per harness.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `apps/worker/src/git/worktree.ts:1` — add `needsWorktree: boolean` param; when false skip `ensureWorktree`, use `monorepoRoot` directly. Keep existing path for code briefs `job.briefId` present.
|
||||||
|
- `apps/worker/src/tools/allowlist.ts:1` — make `allowlist` configurable per harness `harness.config.allowedCommands` (or `minimal: [read,write,grep]` only). Pass `allowedCommands` into `runCommandTool`.
|
||||||
|
- `packages/shared/src/convoy-lifecycle/state-machine.ts:1` — add `doc_review → published` transition (mirror `pr_review → merged`), allow `human-gate-2` analogue `doc-approver`.
|
||||||
|
- `apps/worker/src/handle-job.ts:40` — thread `harness.needsWorktree` + `harness.allowlist` through; log `harnessAlias` to `convoy_events`.
|
||||||
|
- `docs/worker.md:1` document optional worktree.
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
- `Task-write-manifesto.md` (doc task) with `harness: doc-writer` creates `docs/manifesto.md` via `write_file` tool without worktree directory under `/tmp/echodo-worker-worktrees`.
|
||||||
|
- Code task `Task-fix-lint` still creates worktree per prior behavior.
|
||||||
|
- `transition_convoy_status` `doc_review → published` succeeds; existing `pr_review → merged` still works.
|
||||||
|
- `pnpm lint && pnpm type-check && pnpm --filter @tasks/worker test` pass.
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
You are `role-implementer` brief 2. Read `docs/convoy-lifecycle.md`, `packages/shared/src/convoy-lifecycle/state-machine.ts`, `apps/worker/src/handle-job.ts`, `docs/decisions/2026-08-25-second-brain-os-pivot.md`. Implement small, gated.
|
||||||
|
|
||||||
|
## Depends
|
||||||
|
|
||||||
|
None — can run in parallel with brief 1.
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
---
|
||||||
|
brief: 3
|
||||||
|
convoy: second-brain-os-v01
|
||||||
|
role: role-implementer
|
||||||
|
title: Harness registry + WorkerAdapter + AG-UI + Settings → Harnesses
|
||||||
|
status: ready
|
||||||
|
priority: P0
|
||||||
|
slice_dependencies: [1, 2]
|
||||||
|
recommended_model: qwen-coder
|
||||||
|
model_tier: local
|
||||||
|
---
|
||||||
|
|
||||||
|
# Brief 3 — Harness registry + WorkerAdapter (2-3d) — THE big one
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Workspace admins CRUD harness providers (`minimal|crewai|langgraph|remote-ag-ui`) + map `role_id → harness_alias`, worker dispatches via adapter, any harness via `AG-UI /api/ag-ui/[workspace]`, test button proves reachability.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `packages/database/src/schema/harness_providers.ts` NEW — mirror `model_providers` `docs/adr-model-routing.md:78`: `id, workspace_id fk, alias unique per workspace, display_name, harness_type enum, endpoint_url nullable, auth_env_var varchar nullable, config jsonb, enabled bool, created_at/updated_at`. + `role_harness_routes` `workspace_id, role_id varchar, harness_provider_id fk, enabled, sort_order`, unique `(workspace_id, role_id)`.
|
||||||
|
- `packages/database/migrations/0015_*` via `pnpm db:generate`.
|
||||||
|
- `packages/shared/src/worker/executor.ts` NEW — `WorkerExecutor {id, run(ctx, model):Promise<...>}`, `resolveHarnessForRole()` pure like `resolveModel` `packages/shared/src/model-routing/index.ts`.
|
||||||
|
- `apps/worker/src/handle-job.ts:40` — replace hard `createMinimalSession` with `registry.get(harnessAlias).run(...)`; keep `MinimalSession` as `minimal` adapter.
|
||||||
|
- `apps/web/server/routers/harnesses.ts` NEW — `list/create/update/delete/testHarness` (admin only, never return `auth` value, `test` does fetch `health` on endpoint with `Authorization: Bearer ${process.env[AUTH_ENV_VAR]}`).
|
||||||
|
- `apps/web/app/(app)/[workspaceSlug]/settings/harnesses/page.tsx` NEW — table + add/edit dialog + test.
|
||||||
|
- `apps/web/app/api/ag-ui/[workspace]/route.ts` NEW — POST ag-ui envelope, validate `workspace_id`, write `convoy_events`/`agent_runs`, enforce tenant fence.
|
||||||
|
- `packages/database/src/schema/index.ts` exports.
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
- Migration applies `pnpm db:generate && pnpm db:migrate` locally (no drift).
|
||||||
|
- `Settings → Harnesses` CRUD works; `Test` on `remote-ag-ui https://crew.example.com/ag-ui` with `HARNESS_AUTH_TOKEN` env shows latency or error detail, never leaks token to browser.
|
||||||
|
- `resolveHarnessForRole(workspaceId, "role-implementer")` returns `crewai` when mapped, `minimal` fallback.
|
||||||
|
- Worker dispatched `role-implementer` via `crewai` adapter still `claim_task`/`complete_task` and writes `harness_alias` to `convoy_events.harness_alias` + `agent_runs`.
|
||||||
|
- `pnpm lint && pnpm type-check && pnpm test` pass.
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
You are `role-implementer` brief 3. Read `docs/adr-model-routing.md`, `docs/decisions/2026-08-25-second-brain-os-pivot.md`, `packages/database/src/schema/model_providers.ts`, `packages/shared/src/model-routing/index.ts`. Keep diff minimal, follow existing Drizzle/tRPC patterns in `server/routers/ai-models.ts`.
|
||||||
|
|
||||||
|
## Depends
|
||||||
|
|
||||||
|
Briefs 1,2 — needs optional worktree done.
|
||||||
44
.convoys/second-brain-os-v01/brief-4-plans-tree.md
Normal file
44
.convoys/second-brain-os-v01/brief-4-plans-tree.md
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
---
|
||||||
|
brief: 4
|
||||||
|
convoy: second-brain-os-v01
|
||||||
|
role: role-implementer
|
||||||
|
title: Plans tree — web CRUD for plans/ without CLI, Start→Convoy
|
||||||
|
status: ready
|
||||||
|
priority: P0
|
||||||
|
slice_dependencies: [1, 3]
|
||||||
|
recommended_model: qwen-coder
|
||||||
|
model_tier: local
|
||||||
|
---
|
||||||
|
|
||||||
|
# Brief 4 — Plans tree (1w) — Epic 10
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Web `Plans` is Obsidian-like tree — read/write `plans/Plan-*/Epic-*/Task-*.md` without `pnpm import:markdown-backlog` CLI; click `Start → Convoy`.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `apps/web/server/routers/backlog.ts:29` — add `listTree` (returns hierarchical `Plan → Epic → Task` with `status/priority/repoPath`), `createPlan/createEpic/createTask` (`slug/title/bodyMarkdown/agent_prompt` → writes `.md` via `join(repoRoot, repoPath)` atomically `.tmp→rename`, then direct DB upsert or `syncMarkdownBacklogScan`), `updateTaskBody`, `getBacklogItem` already exists. Enforce slug `^[a-z0-9-]+$`, `workspace_id` fence, `updated_at` bump.
|
||||||
|
- `apps/web/app/(app)/[workspaceSlug]/plans/page.tsx` NEW — tree component (collapsible `Radix Collapsible` + `ScrollArea`), status chips, search, `New Plan/Epic/Task` dialogs, `Start` button → calls `convoys.create` `apps/web/server/routers/convoys.ts:173` with `planSlug/epicSlug`.
|
||||||
|
- Reuse `resolveWorkflowPrompt` preview in task detail drawer.
|
||||||
|
- `docs/Glossary.md:50` hierarchy respected; `config/CursorSync.md:33` export allow-list preserved (web writes go through same export path).
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
- `Plans` page shows `Plan-clarity → Epic-ai-tags → Task-local-tag-prompt [ready]` from DB without CLI after DB seed.
|
||||||
|
- `New Task` dialog `title: Research prompt` `slug: research-prompt` creates `plans/Plan-clarity/Epic-ai-tags/Task-research-prompt.md` on disk + row in `markdown_backlog_items` visible after refresh without manual import.
|
||||||
|
- `Start` on a `ready` task creates convoy `clarity-research-prompt` at `draft` and links `briefFiles: [repoPath]`.
|
||||||
|
- `MCP create-backlog-item` brief 1 and web create produce same file format (round-trip parse test).
|
||||||
|
- `pnpm lint && pnpm type-check` pass.
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
You are `role-implementer` brief 4. Read `plans/README.md`, `packages/database/src/markdown-backlog/parse.ts`, `apps/web/server/routers/backlog.ts`, `docs/decisions/2026-08-25-second-brain-os-pivot.md`. Follow existing tRPC patterns `ai-models.ts`. Keep UI to `components/ui/*` + Tailwind.
|
||||||
|
|
||||||
|
## Depends
|
||||||
|
|
||||||
|
Briefs 1,3 — needs MCP tool shape + harness registry for Start.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
Full Notion drag-n-drop, breadcrumb perf — keep tree simple.
|
||||||
38
.convoys/second-brain-os-v01/brief-5-fleet-rollup.md
Normal file
38
.convoys/second-brain-os-v01/brief-5-fleet-rollup.md
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
---
|
||||||
|
brief: 5
|
||||||
|
convoy: second-brain-os-v01
|
||||||
|
role: role-implementer
|
||||||
|
title: Fleet rollup /fleet + SSE convoy_events feed
|
||||||
|
status: ready
|
||||||
|
priority: P1
|
||||||
|
slice_dependencies: [3]
|
||||||
|
recommended_model: qwen-coder
|
||||||
|
model_tier: local
|
||||||
|
---
|
||||||
|
|
||||||
|
# Brief 5 — Fleet rollup `/fleet` + SSE (3-4d) — Epic 9
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
`echodo.stillwell.cloud/fleet` shows *all* workspaces + `connected_repos` projects needing you — the "land and see everything" you asked for. Live `convoy_events` tail without refresh.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `apps/web/server/routers/convoys.ts:119` — add `fleet` procedure (no `workspace` arg, uses `ctx.session.user.id` to gather all `workspaceMembers` workspaces → `listConvoyProjects` across them, return `byWorkspace, byStatus, gateInboxAcross`). Reuse `GATE_INBOX_STATUSES`.
|
||||||
|
- `apps/web/app/(app)/fleet/page.tsx` NEW — top `Needs you` list, grid `byWorkspace` cards, reuse `ConvoyKanban` `apps/web/app/(app)/[workspaceSlug]/mission-control/page.tsx:12` + `ConvoyDetailPanel` but workspace-agnostic.
|
||||||
|
- `apps/web/app/api/fleet/events/route.ts` NEW — SSE `text/event-stream` tailing `convoy_events` `packages/database/src/schema/convoy_events.ts:1` filtered to user's workspaces, `occurredAt desc` 30 rows + live publish via `pg_notify` or polling 5s fallback (reuse orchestrator poll).
|
||||||
|
- `apps/web/components/fleet/fleet-activity-feed.tsx` NEW — auto-append without refresh.
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
- `/fleet` lists convoys from `maya-workspace` + `studio-internal` + `bakery-coop` together; `Needs you` shows `× pr_review 2d` count.
|
||||||
|
- Clicking fleet card opens detail with correct `workspaceSlug` context for `gateAction`.
|
||||||
|
- SSE feed shows `role_complete` event within 5s of worker `logConvoyEvent` `apps/mcp-server/src/tools/log-convoy-event.ts`.
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
You are `role-implementer` brief 5. Read `apps/web/server/routers/convoys.ts:173`, `apps/web/server/lib/convoy/convoy-service.ts`, `plans/Plan-automation-foundation/Epic-fleet-dashboard/Epic-fleet-dashboard.md`, `docs/decisions/2026-08-25-second-brain-os-pivot.md`. Keep polling fallback simple, no Redis pubsub v1.
|
||||||
|
|
||||||
|
## Depends
|
||||||
|
|
||||||
|
Brief 3 — needs harness field in events for display.
|
||||||
|
|
@ -2,8 +2,15 @@
|
||||||
"_comment": "Echodo's own MCP server. Spawned per Cursor session over stdio. The 'mcp' script in apps/mcp-server/package.json invokes tsx with --env-file=../../.env so DATABASE_URL is read from the repo's .env without leaking it into this committed file. The -s/--silent flag on pnpm suppresses its progress output that would otherwise corrupt the MCP stdio protocol. If you fork the repo, copy .env.example → .env and fill in DATABASE_URL.",
|
"_comment": "Echodo's own MCP server. Spawned per Cursor session over stdio. The 'mcp' script in apps/mcp-server/package.json invokes tsx with --env-file=../../.env so DATABASE_URL is read from the repo's .env without leaking it into this committed file. The -s/--silent flag on pnpm suppresses its progress output that would otherwise corrupt the MCP stdio protocol. If you fork the repo, copy .env.example → .env and fill in DATABASE_URL.",
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"echodo": {
|
"echodo": {
|
||||||
"command": "pnpm",
|
"command": "/Users/rstillw/.nvm/versions/node/v22.14.0/bin/pnpm",
|
||||||
"args": ["-s", "--filter", "@tasks/mcp-server", "mcp"]
|
"args": [
|
||||||
|
"-s",
|
||||||
|
"-C",
|
||||||
|
"/Users/rstillw/Documents/Personal Coding Projects/tasks",
|
||||||
|
"--filter",
|
||||||
|
"@tasks/mcp-server",
|
||||||
|
"mcp"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ REDIS_URL="redis://:CHANGE_ME@192.168.68.102:6379/11"
|
||||||
# WORKER_DRY_RUN=1 skips Redis dequeue loop
|
# WORKER_DRY_RUN=1 skips Redis dequeue loop
|
||||||
# WORKER_BLPOP_TIMEOUT_S=5
|
# WORKER_BLPOP_TIMEOUT_S=5
|
||||||
# WORKER_MAX_TOOL_STEPS=12
|
# WORKER_MAX_TOOL_STEPS=12
|
||||||
|
# WORKER_CHECKPOINT_EVERY_N_STEPS=1
|
||||||
# WORKER_REPO_ROOT= # defaults to monorepo root (pnpm-workspace.yaml walk-up)
|
# WORKER_REPO_ROOT= # defaults to monorepo root (pnpm-workspace.yaml walk-up)
|
||||||
# WORKER_WORKTREE_BASE=/tmp/echodo-worker-worktrees
|
# WORKER_WORKTREE_BASE=/tmp/echodo-worker-worktrees
|
||||||
|
|
||||||
|
|
|
||||||
123
apps/mcp-server/src/lib/resolve-backlog-item.ts
Normal file
123
apps/mcp-server/src/lib/resolve-backlog-item.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { and, eq, isNull } from "../drizzle.js";
|
||||||
|
import { db } from "../db.js";
|
||||||
|
import { markdownBacklogItems } from "../schema.js";
|
||||||
|
|
||||||
|
export const backlogKindSchema = z.enum(["plan", "epic", "task"]);
|
||||||
|
|
||||||
|
export const backlogItemLookupFieldsSchema = z.object({
|
||||||
|
repoPath: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Repo-relative path under plans/, e.g. plans/Plan-automation-foundation/Epic-apps-worker-v1/Task-worker-minimal-tool-loop.md",
|
||||||
|
),
|
||||||
|
slug: z.string().min(1).optional(),
|
||||||
|
planSlug: z.string().min(1).optional(),
|
||||||
|
epicSlug: z.string().min(1).optional(),
|
||||||
|
kind: backlogKindSchema.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const backlogItemLookupSchema = backlogItemLookupFieldsSchema.superRefine(
|
||||||
|
(input, ctx) => {
|
||||||
|
const hasRepoPath = Boolean(input.repoPath?.trim());
|
||||||
|
const hasSlugKeys = Boolean(input.slug?.trim() && input.planSlug?.trim());
|
||||||
|
if (!hasRepoPath && !hasSlugKeys) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message:
|
||||||
|
"Provide repoPath, or slug + planSlug (add epicSlug for tasks/epics).",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export type BacklogItemLookup = z.infer<typeof backlogItemLookupFieldsSchema>;
|
||||||
|
|
||||||
|
export type ResolvedBacklogItem = {
|
||||||
|
id: string;
|
||||||
|
workspaceId: string;
|
||||||
|
kind: string;
|
||||||
|
slug: string;
|
||||||
|
planSlug: string;
|
||||||
|
epicSlug: string | null;
|
||||||
|
repoPath: string;
|
||||||
|
title: string;
|
||||||
|
bodyMarkdown: string;
|
||||||
|
status: string | null;
|
||||||
|
priority: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BACKLOG_ITEM_NOT_FOUND_MESSAGE =
|
||||||
|
"Backlog item not found. Run `pnpm --filter @tasks/database import:markdown-backlog` (with MARKDOWN_BACKLOG_WORKSPACE_ID set) if the plans/ file exists but has not been imported yet.";
|
||||||
|
|
||||||
|
export function normalizeRepoPath(repoPath: string): string {
|
||||||
|
return repoPath.trim().replace(/^\.\/+/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLookupConditions(
|
||||||
|
workspaceId: string,
|
||||||
|
lookup: BacklogItemLookup,
|
||||||
|
) {
|
||||||
|
const conditions = [
|
||||||
|
eq(markdownBacklogItems.workspaceId, workspaceId),
|
||||||
|
isNull(markdownBacklogItems.archivedAt),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (lookup.repoPath?.trim()) {
|
||||||
|
conditions.push(
|
||||||
|
eq(markdownBacklogItems.repoPath, normalizeRepoPath(lookup.repoPath)),
|
||||||
|
);
|
||||||
|
return conditions;
|
||||||
|
}
|
||||||
|
|
||||||
|
conditions.push(eq(markdownBacklogItems.slug, lookup.slug!.trim()));
|
||||||
|
conditions.push(eq(markdownBacklogItems.planSlug, lookup.planSlug!.trim()));
|
||||||
|
if (lookup.epicSlug?.trim()) {
|
||||||
|
conditions.push(eq(markdownBacklogItems.epicSlug, lookup.epicSlug.trim()));
|
||||||
|
}
|
||||||
|
if (lookup.kind) {
|
||||||
|
conditions.push(eq(markdownBacklogItems.kind, lookup.kind));
|
||||||
|
}
|
||||||
|
|
||||||
|
return conditions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const backlogItemSelect = {
|
||||||
|
id: markdownBacklogItems.id,
|
||||||
|
workspaceId: markdownBacklogItems.workspaceId,
|
||||||
|
kind: markdownBacklogItems.kind,
|
||||||
|
slug: markdownBacklogItems.slug,
|
||||||
|
planSlug: markdownBacklogItems.planSlug,
|
||||||
|
epicSlug: markdownBacklogItems.epicSlug,
|
||||||
|
repoPath: markdownBacklogItems.repoPath,
|
||||||
|
title: markdownBacklogItems.title,
|
||||||
|
bodyMarkdown: markdownBacklogItems.bodyMarkdown,
|
||||||
|
status: markdownBacklogItems.status,
|
||||||
|
priority: markdownBacklogItems.priority,
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function resolveBacklogItemById(
|
||||||
|
backlogItemId: string,
|
||||||
|
): Promise<ResolvedBacklogItem | null> {
|
||||||
|
const [row] = await db
|
||||||
|
.select(backlogItemSelect)
|
||||||
|
.from(markdownBacklogItems)
|
||||||
|
.where(eq(markdownBacklogItems.id, backlogItemId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return row ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveBacklogItemsByLookup(
|
||||||
|
workspaceId: string,
|
||||||
|
lookup: BacklogItemLookup,
|
||||||
|
): Promise<ResolvedBacklogItem[]> {
|
||||||
|
return db
|
||||||
|
.select(backlogItemSelect)
|
||||||
|
.from(markdownBacklogItems)
|
||||||
|
.where(and(...buildLookupConditions(workspaceId, lookup)))
|
||||||
|
.limit(5);
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,12 @@ import {
|
||||||
auditLog,
|
auditLog,
|
||||||
markdownBacklogItems,
|
markdownBacklogItems,
|
||||||
} from "../schema.js";
|
} from "../schema.js";
|
||||||
|
import {
|
||||||
|
BACKLOG_ITEM_NOT_FOUND_MESSAGE,
|
||||||
|
backlogItemLookupFieldsSchema,
|
||||||
|
resolveBacklogItemById,
|
||||||
|
resolveBacklogItemsByLookup,
|
||||||
|
} from "../lib/resolve-backlog-item.js";
|
||||||
import { resolveWorkspaceHandle } from "../lib/resolve-workspace.js";
|
import { resolveWorkspaceHandle } from "../lib/resolve-workspace.js";
|
||||||
import { resolveWorkflowPrompt } from "../../../../packages/database/src/markdown-backlog/resolve-prompt.ts";
|
import { resolveWorkflowPrompt } from "../../../../packages/database/src/markdown-backlog/resolve-prompt.ts";
|
||||||
import { exportBacklogItemToMarkdown } from "../../../../packages/database/src/markdown-backlog/export.ts";
|
import { exportBacklogItemToMarkdown } from "../../../../packages/database/src/markdown-backlog/export.ts";
|
||||||
|
|
@ -40,7 +46,8 @@ import { toolCatch, toolErr, toolOk } from "./tool-result.js";
|
||||||
* `agent_runs` separately.
|
* `agent_runs` separately.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const claimTaskInputSchema = z.object({
|
const claimTaskInputSchema = z
|
||||||
|
.object({
|
||||||
workspace: z
|
workspace: z
|
||||||
.string()
|
.string()
|
||||||
.min(1)
|
.min(1)
|
||||||
|
|
@ -48,7 +55,10 @@ const claimTaskInputSchema = z.object({
|
||||||
backlogItemId: z
|
backlogItemId: z
|
||||||
.string()
|
.string()
|
||||||
.uuid()
|
.uuid()
|
||||||
.describe("UUID of the markdown_backlog_items row being claimed."),
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"UUID of the markdown_backlog_items row being claimed. Optional when repoPath or slug keys are provided.",
|
||||||
|
),
|
||||||
actorUserId: z
|
actorUserId: z
|
||||||
.string()
|
.string()
|
||||||
.uuid()
|
.uuid()
|
||||||
|
|
@ -78,7 +88,20 @@ const claimTaskInputSchema = z.object({
|
||||||
.describe(
|
.describe(
|
||||||
"Model handle driving the session — e.g. 'claude-4.6-sonnet', 'gpt-5-codex'. Free-form because new models ship faster than we can rev a zod enum.",
|
"Model handle driving the session — e.g. 'claude-4.6-sonnet', 'gpt-5-codex'. Free-form because new models ship faster than we can rev a zod enum.",
|
||||||
),
|
),
|
||||||
});
|
})
|
||||||
|
.merge(backlogItemLookupFieldsSchema)
|
||||||
|
.superRefine((input, ctx) => {
|
||||||
|
const hasId = Boolean(input.backlogItemId);
|
||||||
|
const hasRepoPath = Boolean(input.repoPath?.trim());
|
||||||
|
const hasSlugKeys = Boolean(input.slug?.trim() && input.planSlug?.trim());
|
||||||
|
if (!hasId && !hasRepoPath && !hasSlugKeys) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message:
|
||||||
|
"Provide backlogItemId, repoPath, or slug + planSlug (add epicSlug for tasks/epics).",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the metadata object stored on agent_runs at claim time. Returns
|
* Build the metadata object stored on agent_runs at claim time. Returns
|
||||||
|
|
@ -100,7 +123,7 @@ export function registerClaimTaskTool(mcp: McpServer): void {
|
||||||
"claim_task",
|
"claim_task",
|
||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"Open an agent_runs row against a backlog item, transition it to in_progress (if eligible), and return the effective workflow prompt for the session.",
|
"Open an agent_runs row against a backlog item, transition it to in_progress (if eligible), and return the effective workflow prompt for the session. Identify the item by backlogItemId, repoPath, or slug + planSlug.",
|
||||||
inputSchema: claimTaskInputSchema,
|
inputSchema: claimTaskInputSchema,
|
||||||
},
|
},
|
||||||
async (args) => {
|
async (args) => {
|
||||||
|
|
@ -108,27 +131,42 @@ export function registerClaimTaskTool(mcp: McpServer): void {
|
||||||
const input = claimTaskInputSchema.parse(args);
|
const input = claimTaskInputSchema.parse(args);
|
||||||
const ws = await resolveWorkspaceHandle(input.workspace);
|
const ws = await resolveWorkspaceHandle(input.workspace);
|
||||||
|
|
||||||
const [item] = await db
|
let item;
|
||||||
.select({
|
let backlogItemId: string;
|
||||||
id: markdownBacklogItems.id,
|
|
||||||
workspaceId: markdownBacklogItems.workspaceId,
|
|
||||||
title: markdownBacklogItems.title,
|
|
||||||
bodyMarkdown: markdownBacklogItems.bodyMarkdown,
|
|
||||||
status: markdownBacklogItems.status,
|
|
||||||
})
|
|
||||||
.from(markdownBacklogItems)
|
|
||||||
.where(eq(markdownBacklogItems.id, input.backlogItemId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!item) {
|
if (input.backlogItemId) {
|
||||||
|
const found = await resolveBacklogItemById(input.backlogItemId);
|
||||||
|
if (!found) {
|
||||||
return toolErr(`Backlog item ${input.backlogItemId} not found`);
|
return toolErr(`Backlog item ${input.backlogItemId} not found`);
|
||||||
}
|
}
|
||||||
|
item = found;
|
||||||
|
backlogItemId = found.id;
|
||||||
|
} else {
|
||||||
|
const matches = await resolveBacklogItemsByLookup(ws.id, {
|
||||||
|
repoPath: input.repoPath,
|
||||||
|
slug: input.slug,
|
||||||
|
planSlug: input.planSlug,
|
||||||
|
epicSlug: input.epicSlug,
|
||||||
|
kind: input.kind,
|
||||||
|
});
|
||||||
|
if (matches.length === 0) {
|
||||||
|
return toolErr(BACKLOG_ITEM_NOT_FOUND_MESSAGE);
|
||||||
|
}
|
||||||
|
if (matches.length > 1) {
|
||||||
|
return toolErr(
|
||||||
|
`Ambiguous backlog lookup (${matches.length} matches). Add epicSlug/kind or use repoPath.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
item = matches[0]!;
|
||||||
|
backlogItemId = item.id;
|
||||||
|
}
|
||||||
|
|
||||||
if (item.workspaceId !== ws.id) {
|
if (item.workspaceId !== ws.id) {
|
||||||
// Tenancy fence: the backlog item exists but in a different
|
// Tenancy fence: the backlog item exists but in a different
|
||||||
// workspace than the resolved handle. Refuse rather than 404 so
|
// workspace than the resolved handle. Refuse rather than 404 so
|
||||||
// the caller can't fish for cross-tenant existence.
|
// the caller can't fish for cross-tenant existence.
|
||||||
return toolErr(
|
return toolErr(
|
||||||
`Backlog item ${input.backlogItemId} does not belong to workspace ${ws.slug}`,
|
`Backlog item ${backlogItemId} does not belong to workspace ${ws.slug}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -150,7 +188,7 @@ export function registerClaimTaskTool(mcp: McpServer): void {
|
||||||
.from(agentRuns)
|
.from(agentRuns)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(agentRuns.backlogItemId, input.backlogItemId),
|
eq(agentRuns.backlogItemId, backlogItemId),
|
||||||
isNull(agentRuns.finishedAt),
|
isNull(agentRuns.finishedAt),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -190,7 +228,7 @@ export function registerClaimTaskTool(mcp: McpServer): void {
|
||||||
|
|
||||||
const resolved = await resolveWorkflowPrompt(db, {
|
const resolved = await resolveWorkflowPrompt(db, {
|
||||||
workspaceId: ws.id,
|
workspaceId: ws.id,
|
||||||
backlogItemId: input.backlogItemId,
|
backlogItemId,
|
||||||
});
|
});
|
||||||
|
|
||||||
return toolOk({
|
return toolOk({
|
||||||
|
|
@ -203,6 +241,7 @@ export function registerClaimTaskTool(mcp: McpServer): void {
|
||||||
title: item.title,
|
title: item.title,
|
||||||
bodyMarkdown: item.bodyMarkdown,
|
bodyMarkdown: item.bodyMarkdown,
|
||||||
status: item.status,
|
status: item.status,
|
||||||
|
repoPath: item.repoPath,
|
||||||
},
|
},
|
||||||
workspace: { id: ws.id, slug: ws.slug, name: ws.name },
|
workspace: { id: ws.id, slug: ws.slug, name: ws.name },
|
||||||
});
|
});
|
||||||
|
|
@ -219,7 +258,7 @@ export function registerClaimTaskTool(mcp: McpServer): void {
|
||||||
.insert(agentRuns)
|
.insert(agentRuns)
|
||||||
.values({
|
.values({
|
||||||
workspaceId: ws.id,
|
workspaceId: ws.id,
|
||||||
backlogItemId: input.backlogItemId,
|
backlogItemId,
|
||||||
actorUserId,
|
actorUserId,
|
||||||
notes: input.notes ?? null,
|
notes: input.notes ?? null,
|
||||||
metadata: claimMeta,
|
metadata: claimMeta,
|
||||||
|
|
@ -230,7 +269,7 @@ export function registerClaimTaskTool(mcp: McpServer): void {
|
||||||
await tx
|
await tx
|
||||||
.update(markdownBacklogItems)
|
.update(markdownBacklogItems)
|
||||||
.set({ status: "in_progress", updatedAt: new Date() })
|
.set({ status: "in_progress", updatedAt: new Date() })
|
||||||
.where(eq(markdownBacklogItems.id, input.backlogItemId));
|
.where(eq(markdownBacklogItems.id, backlogItemId));
|
||||||
}
|
}
|
||||||
|
|
||||||
await tx.insert(auditLog).values({
|
await tx.insert(auditLog).values({
|
||||||
|
|
@ -240,7 +279,7 @@ export function registerClaimTaskTool(mcp: McpServer): void {
|
||||||
targetType: "agent_run",
|
targetType: "agent_run",
|
||||||
targetId: created.id,
|
targetId: created.id,
|
||||||
metadata: {
|
metadata: {
|
||||||
backlog_item_id: input.backlogItemId,
|
backlog_item_id: backlogItemId,
|
||||||
prior_status: priorStatus,
|
prior_status: priorStatus,
|
||||||
transitioned: shouldTransition,
|
transitioned: shouldTransition,
|
||||||
...(claimMeta ?? {}),
|
...(claimMeta ?? {}),
|
||||||
|
|
@ -253,7 +292,7 @@ export function registerClaimTaskTool(mcp: McpServer): void {
|
||||||
|
|
||||||
const resolved = await resolveWorkflowPrompt(db, {
|
const resolved = await resolveWorkflowPrompt(db, {
|
||||||
workspaceId: ws.id,
|
workspaceId: ws.id,
|
||||||
backlogItemId: input.backlogItemId,
|
backlogItemId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Only export when the row's status actually changed. On a
|
// Only export when the row's status actually changed. On a
|
||||||
|
|
@ -269,7 +308,7 @@ export function registerClaimTaskTool(mcp: McpServer): void {
|
||||||
try {
|
try {
|
||||||
const exportResult = await exportBacklogItemToMarkdown(db, {
|
const exportResult = await exportBacklogItemToMarkdown(db, {
|
||||||
workspaceId: ws.id,
|
workspaceId: ws.id,
|
||||||
backlogItemId: input.backlogItemId,
|
backlogItemId,
|
||||||
repoRootAbs: repoRoot,
|
repoRootAbs: repoRoot,
|
||||||
});
|
});
|
||||||
if (exportResult.ok) {
|
if (exportResult.ok) {
|
||||||
|
|
@ -302,6 +341,7 @@ export function registerClaimTaskTool(mcp: McpServer): void {
|
||||||
title: item.title,
|
title: item.title,
|
||||||
bodyMarkdown: item.bodyMarkdown,
|
bodyMarkdown: item.bodyMarkdown,
|
||||||
status: shouldTransition ? "in_progress" : priorStatus,
|
status: shouldTransition ? "in_progress" : priorStatus,
|
||||||
|
repoPath: item.repoPath,
|
||||||
},
|
},
|
||||||
workspace: { id: ws.id, slug: ws.slug, name: ws.name },
|
workspace: { id: ws.id, slug: ws.slug, name: ws.name },
|
||||||
export: exportSummary,
|
export: exportSummary,
|
||||||
|
|
|
||||||
362
apps/mcp-server/src/tools/create-backlog-item.ts
Normal file
362
apps/mcp-server/src/tools/create-backlog-item.ts
Normal file
|
|
@ -0,0 +1,362 @@
|
||||||
|
import { mkdir, writeFile, rename, access } from "node:fs/promises";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { parseDocument, Scalar } from "yaml";
|
||||||
|
|
||||||
|
import { eq, and } from "../drizzle.js";
|
||||||
|
import { db } from "../db.js";
|
||||||
|
import { markdownBacklogItems } from "../schema.js";
|
||||||
|
import { resolveWorkspaceHandle } from "../lib/resolve-workspace.js";
|
||||||
|
import { resolveRepoRoot } from "../lib/repo-root.js";
|
||||||
|
import { parentRepoPath } from "../../../../packages/database/src/markdown-backlog/paths.ts";
|
||||||
|
import { hashFileContents } from "../../../../packages/database/src/markdown-backlog/parse.ts";
|
||||||
|
import { toolCatch, toolErr, toolOk } from "./tool-result.js";
|
||||||
|
|
||||||
|
const slugRe = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||||
|
|
||||||
|
const kindSchema = z.enum(["plan", "epic", "task"]);
|
||||||
|
const statusSchema = z.enum(["draft", "ready", "in_progress", "blocked", "done", "cancelled"]);
|
||||||
|
const prioritySchema = z.enum(["P0", "P1", "P2", "P3"]);
|
||||||
|
|
||||||
|
const createBacklogItemInputSchema = z
|
||||||
|
.object({
|
||||||
|
workspace: z.string().min(1).describe("Workspace UUID or slug (e.g. 'acme' or '550e8400-...')"),
|
||||||
|
kind: kindSchema,
|
||||||
|
slug: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(200)
|
||||||
|
.regex(slugRe, "slug must be lowercase-hyphenated (a-z, 0-9, hyphen)"),
|
||||||
|
title: z.string().min(1).max(500),
|
||||||
|
planSlug: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(200)
|
||||||
|
.regex(slugRe, "planSlug must be lowercase-hyphenated")
|
||||||
|
.optional(),
|
||||||
|
epicSlug: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(200)
|
||||||
|
.regex(slugRe, "epicSlug must be lowercase-hyphenated")
|
||||||
|
.optional(),
|
||||||
|
status: statusSchema.optional(),
|
||||||
|
priority: prioritySchema.optional(),
|
||||||
|
bodyMarkdown: z.string().optional(),
|
||||||
|
agent_prompt: z.string().nullable().optional().describe("Optional per-item agent prompt (multiline supported)"),
|
||||||
|
})
|
||||||
|
.superRefine((input, ctx) => {
|
||||||
|
if (input.kind === "plan") {
|
||||||
|
if (input.epicSlug) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "epicSlug must not be provided for kind=plan",
|
||||||
|
path: ["epicSlug"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (input.planSlug && input.planSlug !== input.slug) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "planSlug for kind=plan must equal slug or be omitted",
|
||||||
|
path: ["planSlug"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (input.kind === "epic") {
|
||||||
|
if (!input.planSlug?.trim()) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "planSlug is required for kind=epic",
|
||||||
|
path: ["planSlug"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (input.kind === "task") {
|
||||||
|
if (!input.planSlug?.trim()) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "planSlug is required for kind=task",
|
||||||
|
path: ["planSlug"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!input.epicSlug?.trim()) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "epicSlug is required for kind=task",
|
||||||
|
path: ["epicSlug"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function todayIso(): string {
|
||||||
|
return new Date().toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultStatusForKind(kind: z.infer<typeof kindSchema>): string {
|
||||||
|
if (kind === "task") return "ready";
|
||||||
|
return "draft";
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultBodyForKind(kind: string, title: string): string {
|
||||||
|
if (kind === "plan") {
|
||||||
|
return `# ${title}\n\nShort summary of the initiative and why it exists.\n`;
|
||||||
|
}
|
||||||
|
if (kind === "epic") {
|
||||||
|
return `# ${title}\n\nOne paragraph: what milestone this epic delivers.\n`;
|
||||||
|
}
|
||||||
|
return `# ${title}\n\nOne or two sentences.\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function repoPathFor(input: {
|
||||||
|
kind: z.infer<typeof kindSchema>;
|
||||||
|
slug: string;
|
||||||
|
planSlug?: string;
|
||||||
|
epicSlug?: string;
|
||||||
|
}): string {
|
||||||
|
if (input.kind === "plan") {
|
||||||
|
return `plans/Plan-${input.slug}/Plan-${input.slug}.md`;
|
||||||
|
}
|
||||||
|
if (input.kind === "epic") {
|
||||||
|
return `plans/Plan-${input.planSlug}/Epic-${input.slug}/Epic-${input.slug}.md`;
|
||||||
|
}
|
||||||
|
return `plans/Plan-${input.planSlug}/Epic-${input.epicSlug}/Task-${input.slug}.md`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFrontmatterYaml(input: {
|
||||||
|
kind: string;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
planSlug: string | null;
|
||||||
|
epicSlug: string | null;
|
||||||
|
status: string;
|
||||||
|
priority: string;
|
||||||
|
agentPrompt: string | null;
|
||||||
|
}): string {
|
||||||
|
const doc = parseDocument("");
|
||||||
|
// Ensure we start from empty map
|
||||||
|
if (!doc.contents) {
|
||||||
|
// parseDocument("") yields empty; ensure contents is mapping
|
||||||
|
}
|
||||||
|
doc.set("kind", input.kind);
|
||||||
|
doc.set("slug", input.slug);
|
||||||
|
doc.set("title", input.title);
|
||||||
|
if (input.planSlug) doc.set("plan_slug", input.planSlug);
|
||||||
|
if (input.epicSlug) doc.set("epic_slug", input.epicSlug);
|
||||||
|
doc.set("status", input.status);
|
||||||
|
doc.set("priority", input.priority);
|
||||||
|
doc.set("updated_at", todayIso());
|
||||||
|
if (input.agentPrompt !== null && input.agentPrompt.trim() !== "") {
|
||||||
|
const isMultiline = input.agentPrompt.includes("\n");
|
||||||
|
const node = doc.createNode(input.agentPrompt);
|
||||||
|
if (node instanceof Scalar) {
|
||||||
|
node.type = isMultiline ? Scalar.BLOCK_LITERAL : Scalar.PLAIN;
|
||||||
|
}
|
||||||
|
doc.set("agent_prompt", node);
|
||||||
|
}
|
||||||
|
const yamlText = String(doc).replace(/\n$/, "");
|
||||||
|
return yamlText;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fileExists(absPath: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await access(absPath);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function atomicWrite(absPath: string, contents: string): Promise<void> {
|
||||||
|
const dir = dirname(absPath);
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
const tmp = join(dir, `.${randomUUID()}.tmp`);
|
||||||
|
await writeFile(tmp, contents, "utf8");
|
||||||
|
await rename(tmp, absPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerCreateBacklogItemTool(mcp: McpServer): void {
|
||||||
|
mcp.registerTool(
|
||||||
|
"create-backlog-item",
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
"Create a new Plan/Epic/Task markdown file under plans/ and upsert markdown_backlog_items. Validates frontmatter per docs/templates/. Duplicate slug under same epic → CONFLICT.",
|
||||||
|
inputSchema: createBacklogItemInputSchema,
|
||||||
|
},
|
||||||
|
async (args) => {
|
||||||
|
try {
|
||||||
|
const input = createBacklogItemInputSchema.parse(args);
|
||||||
|
const ws = await resolveWorkspaceHandle(input.workspace);
|
||||||
|
|
||||||
|
const effectivePlanSlug =
|
||||||
|
input.kind === "plan" ? input.slug : (input.planSlug?.trim() ?? null);
|
||||||
|
const effectiveEpicSlug =
|
||||||
|
input.kind === "task"
|
||||||
|
? (input.epicSlug?.trim() ?? null)
|
||||||
|
: input.kind === "epic"
|
||||||
|
? null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const status = input.status ?? defaultStatusForKind(input.kind);
|
||||||
|
const priority = input.priority ?? "P2";
|
||||||
|
const agentPrompt =
|
||||||
|
input.agent_prompt !== undefined && input.agent_prompt !== null
|
||||||
|
? input.agent_prompt
|
||||||
|
: null;
|
||||||
|
const bodyMarkdown = input.bodyMarkdown ?? defaultBodyForKind(input.kind, input.title);
|
||||||
|
|
||||||
|
const repoPath = repoPathFor({
|
||||||
|
kind: input.kind,
|
||||||
|
slug: input.slug,
|
||||||
|
planSlug: effectivePlanSlug ?? undefined,
|
||||||
|
epicSlug: effectiveEpicSlug ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const repoRoot = resolveRepoRoot();
|
||||||
|
if (!repoRoot) {
|
||||||
|
return toolErr("MARKDOWN_BACKLOG_REPO_ROOT is disabled; cannot write plans/ file");
|
||||||
|
}
|
||||||
|
const absPath = join(repoRoot, ...repoPath.split("/"));
|
||||||
|
|
||||||
|
// Duplicate check: file exists
|
||||||
|
if (await fileExists(absPath)) {
|
||||||
|
return toolErr(`CONFLICT: backlog item already exists at ${repoPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duplicate check: DB row exists for same workspace+repoPath
|
||||||
|
const [existingRow] = await db
|
||||||
|
.select({ id: markdownBacklogItems.id })
|
||||||
|
.from(markdownBacklogItems)
|
||||||
|
.where(and(eq(markdownBacklogItems.workspaceId, ws.id), eq(markdownBacklogItems.repoPath, repoPath)))
|
||||||
|
.limit(1);
|
||||||
|
if (existingRow) {
|
||||||
|
return toolErr(`CONFLICT: backlog item already exists at ${repoPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build frontmatter + raw markdown (exportBacklogItemToMarkdown inverse)
|
||||||
|
// Plan files per template do not carry plan_slug/epic_slug
|
||||||
|
const yamlPlanSlug = input.kind === "plan" ? null : effectivePlanSlug;
|
||||||
|
const yamlEpicSlug = input.kind === "task" ? effectiveEpicSlug : null;
|
||||||
|
const yamlText = buildFrontmatterYaml({
|
||||||
|
kind: input.kind,
|
||||||
|
slug: input.slug,
|
||||||
|
title: input.title,
|
||||||
|
planSlug: yamlPlanSlug,
|
||||||
|
epicSlug: yamlEpicSlug,
|
||||||
|
status,
|
||||||
|
priority,
|
||||||
|
agentPrompt,
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = bodyMarkdown.startsWith("\n") ? bodyMarkdown : `\n${bodyMarkdown}`;
|
||||||
|
// Ensure body ends with newline
|
||||||
|
const normalizedBody = body.endsWith("\n") ? body : `${body}\n`;
|
||||||
|
const raw = `---\n${yamlText}\n---${normalizedBody}`;
|
||||||
|
|
||||||
|
const contentHash = hashFileContents(raw);
|
||||||
|
|
||||||
|
// Frontmatter stored in DB (mirrors sync's parsed frontmatter)
|
||||||
|
const frontmatter: Record<string, unknown> = {
|
||||||
|
kind: input.kind,
|
||||||
|
slug: input.slug,
|
||||||
|
title: input.title,
|
||||||
|
status,
|
||||||
|
priority,
|
||||||
|
updated_at: todayIso(),
|
||||||
|
};
|
||||||
|
if (yamlPlanSlug) frontmatter.plan_slug = yamlPlanSlug;
|
||||||
|
if (yamlEpicSlug) frontmatter.epic_slug = yamlEpicSlug;
|
||||||
|
if (agentPrompt !== null && agentPrompt.trim() !== "") {
|
||||||
|
frontmatter.agent_prompt = agentPrompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomic write .tmp → rename
|
||||||
|
await atomicWrite(absPath, raw);
|
||||||
|
|
||||||
|
// Upsert markdown_backlog_items with contentHash
|
||||||
|
// Compute parentId by looking up parent repo path if any
|
||||||
|
const parentPath = parentRepoPath(repoPath);
|
||||||
|
let parentId: string | null = null;
|
||||||
|
if (parentPath) {
|
||||||
|
const [parentRow] = await db
|
||||||
|
.select({ id: markdownBacklogItems.id })
|
||||||
|
.from(markdownBacklogItems)
|
||||||
|
.where(and(eq(markdownBacklogItems.workspaceId, ws.id), eq(markdownBacklogItems.repoPath, parentPath)))
|
||||||
|
.limit(1);
|
||||||
|
parentId = parentRow?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let insertedId: string | null = null;
|
||||||
|
try {
|
||||||
|
const [inserted] = await db
|
||||||
|
.insert(markdownBacklogItems)
|
||||||
|
.values({
|
||||||
|
workspaceId: ws.id,
|
||||||
|
kind: input.kind,
|
||||||
|
slug: input.slug,
|
||||||
|
planSlug: effectivePlanSlug ?? input.slug,
|
||||||
|
epicSlug: effectiveEpicSlug,
|
||||||
|
parentId,
|
||||||
|
repoPath,
|
||||||
|
title: input.title,
|
||||||
|
status,
|
||||||
|
priority,
|
||||||
|
owner: null,
|
||||||
|
frontmatter,
|
||||||
|
bodyMarkdown: normalizedBody.replace(/^\n/, ""),
|
||||||
|
contentHash,
|
||||||
|
workflowPrompt: agentPrompt?.trim() ? agentPrompt.trim() : null,
|
||||||
|
})
|
||||||
|
.returning({ id: markdownBacklogItems.id });
|
||||||
|
insertedId = inserted?.id ?? null;
|
||||||
|
} catch (e) {
|
||||||
|
// Unique violation → CONFLICT (race with concurrent writer)
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
if (msg.includes("duplicate") || msg.includes("unique") || msg.includes("CONFLICT")) {
|
||||||
|
return toolErr(`CONFLICT: backlog item already exists at ${repoPath}`);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we had a parent but it was inserted concurrently after our lookup,
|
||||||
|
// refresh parentId (best-effort)
|
||||||
|
if (parentPath && !parentId) {
|
||||||
|
const [parentRow] = await db
|
||||||
|
.select({ id: markdownBacklogItems.id })
|
||||||
|
.from(markdownBacklogItems)
|
||||||
|
.where(and(eq(markdownBacklogItems.workspaceId, ws.id), eq(markdownBacklogItems.repoPath, parentPath)))
|
||||||
|
.limit(1);
|
||||||
|
if (parentRow) {
|
||||||
|
await db
|
||||||
|
.update(markdownBacklogItems)
|
||||||
|
.set({ parentId: parentRow.id })
|
||||||
|
.where(eq(markdownBacklogItems.id, insertedId!));
|
||||||
|
parentId = parentRow.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return toolOk({
|
||||||
|
workspace: { id: ws.id, slug: ws.slug, name: ws.name },
|
||||||
|
repoPath,
|
||||||
|
absPath,
|
||||||
|
contentHash,
|
||||||
|
backlogItem: {
|
||||||
|
id: insertedId,
|
||||||
|
kind: input.kind,
|
||||||
|
slug: input.slug,
|
||||||
|
planSlug: effectivePlanSlug,
|
||||||
|
epicSlug: effectiveEpicSlug,
|
||||||
|
title: input.title,
|
||||||
|
status,
|
||||||
|
priority,
|
||||||
|
parentId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return toolCatch(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
70
apps/mcp-server/src/tools/find-backlog-item.ts
Normal file
70
apps/mcp-server/src/tools/find-backlog-item.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import {
|
||||||
|
BACKLOG_ITEM_NOT_FOUND_MESSAGE,
|
||||||
|
backlogItemLookupFieldsSchema,
|
||||||
|
resolveBacklogItemsByLookup,
|
||||||
|
} from "../lib/resolve-backlog-item.js";
|
||||||
|
import { resolveWorkspaceHandle } from "../lib/resolve-workspace.js";
|
||||||
|
import { toolCatch, toolErr, toolOk } from "./tool-result.js";
|
||||||
|
|
||||||
|
const findBacklogItemInputSchema = z
|
||||||
|
.object({
|
||||||
|
workspace: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.describe("Workspace UUID or slug (e.g. 'convoys-tasks')."),
|
||||||
|
})
|
||||||
|
.merge(backlogItemLookupFieldsSchema)
|
||||||
|
.superRefine((input, ctx) => {
|
||||||
|
const hasRepoPath = Boolean(input.repoPath?.trim());
|
||||||
|
const hasSlugKeys = Boolean(input.slug?.trim() && input.planSlug?.trim());
|
||||||
|
if (!hasRepoPath && !hasSlugKeys) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message:
|
||||||
|
"Provide repoPath, or slug + planSlug (add epicSlug for tasks/epics).",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export function registerFindBacklogItemTool(mcp: McpServer): void {
|
||||||
|
mcp.registerTool(
|
||||||
|
"find_backlog_item",
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
"Resolve a markdown backlog item to its backlogItemId by repo path or slug keys. Optional when claim_task is called with repoPath directly.",
|
||||||
|
inputSchema: findBacklogItemInputSchema,
|
||||||
|
},
|
||||||
|
async (args) => {
|
||||||
|
try {
|
||||||
|
const input = findBacklogItemInputSchema.parse(args);
|
||||||
|
const ws = await resolveWorkspaceHandle(input.workspace);
|
||||||
|
const rows = await resolveBacklogItemsByLookup(ws.id, input);
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return toolErr(BACKLOG_ITEM_NOT_FOUND_MESSAGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rows.length > 1) {
|
||||||
|
return toolOk({
|
||||||
|
workspace: { id: ws.id, slug: ws.slug, name: ws.name },
|
||||||
|
ambiguous: true,
|
||||||
|
matches: rows,
|
||||||
|
count: rows.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = rows[0]!;
|
||||||
|
return toolOk({
|
||||||
|
workspace: { id: ws.id, slug: ws.slug, name: ws.name },
|
||||||
|
backlogItem: item,
|
||||||
|
backlogItemId: item.id,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return toolCatch(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,8 @@ import { registerSearchObjectsTool } from "./search-objects.js";
|
||||||
import { registerUpdateObjectTool } from "./update-object.js";
|
import { registerUpdateObjectTool } from "./update-object.js";
|
||||||
import { registerClaimTaskTool } from "./claim-task.js";
|
import { registerClaimTaskTool } from "./claim-task.js";
|
||||||
import { registerCompleteTaskTool } from "./complete-task.js";
|
import { registerCompleteTaskTool } from "./complete-task.js";
|
||||||
|
import { registerCreateBacklogItemTool } from "./create-backlog-item.js";
|
||||||
|
import { registerFindBacklogItemTool } from "./find-backlog-item.js";
|
||||||
import { registerCreateConvoyTool } from "./create-convoy.js";
|
import { registerCreateConvoyTool } from "./create-convoy.js";
|
||||||
import { registerCreateBriefTool } from "./create-brief.js";
|
import { registerCreateBriefTool } from "./create-brief.js";
|
||||||
import { registerTransitionConvoyStatusTool } from "./transition-convoy-status.js";
|
import { registerTransitionConvoyStatusTool } from "./transition-convoy-status.js";
|
||||||
|
|
@ -21,6 +23,8 @@ export function registerTools(mcp: McpServer): void {
|
||||||
registerManageObjectTool(mcp);
|
registerManageObjectTool(mcp);
|
||||||
registerClaimTaskTool(mcp);
|
registerClaimTaskTool(mcp);
|
||||||
registerCompleteTaskTool(mcp);
|
registerCompleteTaskTool(mcp);
|
||||||
|
registerCreateBacklogItemTool(mcp);
|
||||||
|
registerFindBacklogItemTool(mcp);
|
||||||
registerCreateConvoyTool(mcp);
|
registerCreateConvoyTool(mcp);
|
||||||
registerCreateBriefTool(mcp);
|
registerCreateBriefTool(mcp);
|
||||||
registerTransitionConvoyStatusTool(mcp);
|
registerTransitionConvoyStatusTool(mcp);
|
||||||
|
|
@ -36,6 +40,8 @@ export { registerListObjectsTool } from "./list-objects.js";
|
||||||
export { registerManageObjectTool } from "./manage-object.js";
|
export { registerManageObjectTool } from "./manage-object.js";
|
||||||
export { registerClaimTaskTool } from "./claim-task.js";
|
export { registerClaimTaskTool } from "./claim-task.js";
|
||||||
export { registerCompleteTaskTool } from "./complete-task.js";
|
export { registerCompleteTaskTool } from "./complete-task.js";
|
||||||
|
export { registerCreateBacklogItemTool } from "./create-backlog-item.js";
|
||||||
|
export { registerFindBacklogItemTool } from "./find-backlog-item.js";
|
||||||
export { registerCreateConvoyTool } from "./create-convoy.js";
|
export { registerCreateConvoyTool } from "./create-convoy.js";
|
||||||
export { registerCreateBriefTool } from "./create-brief.js";
|
export { registerCreateBriefTool } from "./create-brief.js";
|
||||||
export { registerTransitionConvoyStatusTool } from "./transition-convoy-status.js";
|
export { registerTransitionConvoyStatusTool } from "./transition-convoy-status.js";
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { and, desc, eq, inArray, isNull, lt, sql } from "drizzle-orm";
|
import { and, desc, eq, inArray, isNull, lt, sql } from "drizzle-orm";
|
||||||
|
|
||||||
import type { db as dbInstance } from "@tasks/database";
|
import type { db as dbInstance } from "@tasks/database";
|
||||||
import { agentRuns, convoyEvents, objects } from "@tasks/database/schema";
|
import { agentRuns, convoyEvents, objects, workerCheckpoints } from "@tasks/database/schema";
|
||||||
import {
|
import {
|
||||||
briefObjectContentSchema,
|
briefObjectContentSchema,
|
||||||
convoyObjectContentSchema,
|
convoyObjectContentSchema,
|
||||||
|
|
@ -145,10 +145,28 @@ export async function transitionConvoyInDb(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function detectStalledConvoys(db: Db, stallMinutes: number): Promise<string[]> {
|
export type StalledRunDispatch = {
|
||||||
|
workspaceId: string;
|
||||||
|
agentRunId: string;
|
||||||
|
backlogItemId: string;
|
||||||
|
convoyId: string;
|
||||||
|
roleId: string;
|
||||||
|
briefId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open agent runs past the stall threshold that have a workspace-scoped
|
||||||
|
* checkpoint (enough context to re-dispatch with resume_from_checkpoint).
|
||||||
|
*/
|
||||||
|
export async function detectStalledRunsForResume(
|
||||||
|
db: Db,
|
||||||
|
stallMinutes: number,
|
||||||
|
): Promise<StalledRunDispatch[]> {
|
||||||
const cutoff = new Date(Date.now() - stallMinutes * 60_000);
|
const cutoff = new Date(Date.now() - stallMinutes * 60_000);
|
||||||
const openRuns = await db
|
const openRuns = await db
|
||||||
.select({
|
.select({
|
||||||
|
id: agentRuns.id,
|
||||||
|
workspaceId: agentRuns.workspaceId,
|
||||||
backlogItemId: agentRuns.backlogItemId,
|
backlogItemId: agentRuns.backlogItemId,
|
||||||
startedAt: agentRuns.startedAt,
|
startedAt: agentRuns.startedAt,
|
||||||
})
|
})
|
||||||
|
|
@ -158,5 +176,41 @@ export async function detectStalledConvoys(db: Db, stallMinutes: number): Promis
|
||||||
|
|
||||||
if (openRuns.length === 0) return [];
|
if (openRuns.length === 0) return [];
|
||||||
|
|
||||||
return openRuns.map((run) => run.backlogItemId);
|
const dispatches: StalledRunDispatch[] = [];
|
||||||
|
for (const run of openRuns) {
|
||||||
|
const [checkpoint] = await db
|
||||||
|
.select({
|
||||||
|
convoyId: workerCheckpoints.convoyId,
|
||||||
|
briefId: workerCheckpoints.briefId,
|
||||||
|
roleId: workerCheckpoints.roleId,
|
||||||
|
})
|
||||||
|
.from(workerCheckpoints)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(workerCheckpoints.workspaceId, run.workspaceId),
|
||||||
|
eq(workerCheckpoints.agentRunId, run.id),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(desc(workerCheckpoints.checkpointSeq))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!checkpoint) continue;
|
||||||
|
|
||||||
|
dispatches.push({
|
||||||
|
workspaceId: run.workspaceId,
|
||||||
|
agentRunId: run.id,
|
||||||
|
backlogItemId: run.backlogItemId,
|
||||||
|
convoyId: checkpoint.convoyId,
|
||||||
|
roleId: checkpoint.roleId,
|
||||||
|
briefId: checkpoint.briefId ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return dispatches;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Prefer detectStalledRunsForResume — kept for log-only callers. */
|
||||||
|
export async function detectStalledConvoys(db: Db, stallMinutes: number): Promise<string[]> {
|
||||||
|
const stalled = await detectStalledRunsForResume(db, stallMinutes);
|
||||||
|
return stalled.map((run) => run.backlogItemId);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { db } from "@tasks/database";
|
import { db } from "@tasks/database";
|
||||||
import IoRedis from "ioredis";
|
import IoRedis from "ioredis";
|
||||||
|
|
||||||
import { detectStalledConvoys } from "./db/convoy.js";
|
import { detectStalledRunsForResume } from "./db/convoy.js";
|
||||||
import { loadEnv } from "./env.js";
|
import { loadEnv } from "./env.js";
|
||||||
|
import { enqueueJob } from "./queue/redis-queue.js";
|
||||||
import { runOrchestratorTick } from "./tick.js";
|
import { runOrchestratorTick } from "./tick.js";
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
|
|
@ -24,11 +25,32 @@ async function main(): Promise<void> {
|
||||||
ORCHESTRATOR_DRY_RUN: env.ORCHESTRATOR_DRY_RUN || !redis,
|
ORCHESTRATOR_DRY_RUN: env.ORCHESTRATOR_DRY_RUN || !redis,
|
||||||
});
|
});
|
||||||
|
|
||||||
const stalled = await detectStalledConvoys(db, env.ORCHESTRATOR_STALL_MINUTES);
|
const stalled = await detectStalledRunsForResume(db, env.ORCHESTRATOR_STALL_MINUTES);
|
||||||
if (stalled.length > 0) {
|
if (stalled.length > 0) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`[orchestrator] stall detector: ${stalled.length} open agent_runs older than ${env.ORCHESTRATOR_STALL_MINUTES}m`,
|
`[orchestrator] stall detector: ${stalled.length} open agent_runs older than ${env.ORCHESTRATOR_STALL_MINUTES}m`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
for (const run of stalled) {
|
||||||
|
if (env.ORCHESTRATOR_DRY_RUN || !redis) {
|
||||||
|
console.info(
|
||||||
|
`[orchestrator] DRY-RUN resume_from_checkpoint convoy=${run.convoyId} role=${run.roleId} run=${run.agentRunId}`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const job = await enqueueJob(redis, {
|
||||||
|
type: "role_dispatch",
|
||||||
|
workspaceId: run.workspaceId,
|
||||||
|
convoyId: run.convoyId,
|
||||||
|
roleId: run.roleId,
|
||||||
|
briefId: run.briefId,
|
||||||
|
resumeFromCheckpoint: true,
|
||||||
|
});
|
||||||
|
console.info(
|
||||||
|
`[orchestrator] re-dispatched job=${job.id} resume_from_checkpoint run=${run.agentRunId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (results.length === 0) {
|
if (results.length === 0) {
|
||||||
|
|
|
||||||
655
apps/web/app/(app)/[workspaceSlug]/plans/page.tsx
Normal file
655
apps/web/app/(app)/[workspaceSlug]/plans/page.tsx
Normal file
|
|
@ -0,0 +1,655 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
|
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
|
||||||
|
import { ChevronDown, ChevronRight, FileText, Folder, FolderKanban, Loader2, Plus, Search, Rocket, X } from "lucide-react";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from "@/components/ui/sheet";
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const STATUS_VARIANT: Record<string, string> = {
|
||||||
|
draft: "bg-muted text-muted-foreground",
|
||||||
|
ready: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
|
||||||
|
in_progress: "bg-amber-500/15 text-amber-600 dark:text-amber-400 border-amber-500/20",
|
||||||
|
blocked: "bg-destructive/10 text-destructive border-destructive/20",
|
||||||
|
done: "bg-sky-500/15 text-sky-600 dark:text-sky-400 border-sky-500/20",
|
||||||
|
cancelled: "bg-muted text-muted-foreground",
|
||||||
|
};
|
||||||
|
|
||||||
|
function StatusChip({ status }: { status: string | null }) {
|
||||||
|
const s = status ?? "unknown";
|
||||||
|
const cls = STATUS_VARIANT[s] ?? STATUS_VARIANT.draft;
|
||||||
|
return (
|
||||||
|
<span className={cn("inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium", cls)}>{s}</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(value: string): string {
|
||||||
|
return value
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.replace(/--+/g, "-");
|
||||||
|
}
|
||||||
|
|
||||||
|
type DialogState =
|
||||||
|
| { kind: "plan" }
|
||||||
|
| { kind: "epic"; planSlug: string }
|
||||||
|
| { kind: "task"; planSlug: string; epicSlug: string }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
export default function PlansPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const workspaceSlug = params?.workspaceSlug as string | undefined;
|
||||||
|
|
||||||
|
const [search, setSearch] = React.useState("");
|
||||||
|
const [expandedPlans, setExpandedPlans] = React.useState<Set<string>>(new Set());
|
||||||
|
const [expandedEpics, setExpandedEpics] = React.useState<Set<string>>(new Set());
|
||||||
|
const [selectedId, setSelectedId] = React.useState<string | null>(null);
|
||||||
|
const [dialog, setDialog] = React.useState<DialogState>(null);
|
||||||
|
const [formSlug, setFormSlug] = React.useState("");
|
||||||
|
const [formTitle, setFormTitle] = React.useState("");
|
||||||
|
const [formBody, setFormBody] = React.useState("");
|
||||||
|
const [formAgentPrompt, setFormAgentPrompt] = React.useState("");
|
||||||
|
const [formError, setFormError] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
const treeQuery = api.backlog.listTree.useQuery({ workspace: workspaceSlug! }, { enabled: Boolean(workspaceSlug) });
|
||||||
|
const detailQuery = api.backlog.getBacklogItem.useQuery(
|
||||||
|
{ workspace: workspaceSlug!, backlogItemId: selectedId! },
|
||||||
|
{ enabled: Boolean(workspaceSlug && selectedId) },
|
||||||
|
);
|
||||||
|
const promptQuery = api.backlog.getWorkflowPrompt.useQuery(
|
||||||
|
{ workspace: workspaceSlug!, backlogItemId: selectedId! },
|
||||||
|
{ enabled: Boolean(workspaceSlug && selectedId) },
|
||||||
|
);
|
||||||
|
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
const createPlan = api.backlog.createPlan.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
void utils.backlog.listTree.invalidate();
|
||||||
|
closeDialog();
|
||||||
|
},
|
||||||
|
onError: (e) => setFormError(e.message),
|
||||||
|
});
|
||||||
|
const createEpic = api.backlog.createEpic.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
void utils.backlog.listTree.invalidate();
|
||||||
|
closeDialog();
|
||||||
|
},
|
||||||
|
onError: (e) => setFormError(e.message),
|
||||||
|
});
|
||||||
|
const createTask = api.backlog.createTask.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
void utils.backlog.listTree.invalidate();
|
||||||
|
closeDialog();
|
||||||
|
},
|
||||||
|
onError: (e) => setFormError(e.message),
|
||||||
|
});
|
||||||
|
const startConvoy = api.backlog.startConvoy.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
void utils.convoys.kanban.invalidate();
|
||||||
|
if (workspaceSlug) router.push(`/${workspaceSlug}/mission-control`);
|
||||||
|
},
|
||||||
|
onError: (e) => setFormError(e.message),
|
||||||
|
});
|
||||||
|
// Direct convoy.create path required by brief 4 — Start → Convoy via convoys.create with planSlug/epicSlug
|
||||||
|
const createConvoyDirect = api.convoys.create.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
void utils.convoys.kanban.invalidate();
|
||||||
|
if (workspaceSlug) router.push(`/${workspaceSlug}/mission-control`);
|
||||||
|
},
|
||||||
|
onError: (e) => setFormError(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
function closeDialog() {
|
||||||
|
setDialog(null);
|
||||||
|
setFormSlug("");
|
||||||
|
setFormTitle("");
|
||||||
|
setFormBody("");
|
||||||
|
setFormAgentPrompt("");
|
||||||
|
setFormError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPlanDialog() {
|
||||||
|
setFormError(null);
|
||||||
|
setFormSlug("");
|
||||||
|
setFormTitle("");
|
||||||
|
setFormBody("");
|
||||||
|
setFormAgentPrompt("");
|
||||||
|
setDialog({ kind: "plan" });
|
||||||
|
}
|
||||||
|
function openEpicDialog(planSlug: string) {
|
||||||
|
setFormError(null);
|
||||||
|
setFormSlug("");
|
||||||
|
setFormTitle("");
|
||||||
|
setFormBody("");
|
||||||
|
setFormAgentPrompt("");
|
||||||
|
setDialog({ kind: "epic", planSlug });
|
||||||
|
}
|
||||||
|
function openTaskDialog(planSlug: string, epicSlug: string) {
|
||||||
|
setFormError(null);
|
||||||
|
setFormSlug("");
|
||||||
|
setFormTitle("");
|
||||||
|
setFormBody("");
|
||||||
|
setFormAgentPrompt("");
|
||||||
|
setDialog({ kind: "task", planSlug, epicSlug });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tree = React.useMemo(() => treeQuery.data?.plans ?? [], [treeQuery.data?.plans]);
|
||||||
|
|
||||||
|
const filtered = React.useMemo(() => {
|
||||||
|
if (!search.trim()) return tree;
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
return tree
|
||||||
|
.map((plan) => {
|
||||||
|
const planMatches = plan.title.toLowerCase().includes(q) || plan.slug.toLowerCase().includes(q);
|
||||||
|
const epics = plan.epics
|
||||||
|
.map((epic) => {
|
||||||
|
const epicMatches = epic.title.toLowerCase().includes(q) || epic.slug.toLowerCase().includes(q);
|
||||||
|
const tasks = epic.tasks.filter(
|
||||||
|
(t) =>
|
||||||
|
t.title.toLowerCase().includes(q) ||
|
||||||
|
t.slug.toLowerCase().includes(q) ||
|
||||||
|
(t.status ?? "").toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
if (epicMatches || tasks.length > 0 || planMatches) {
|
||||||
|
return { ...epic, tasks: epicMatches && tasks.length === 0 ? epic.tasks : tasks };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.filter(Boolean) as typeof plan.epics;
|
||||||
|
if (planMatches) return { ...plan, epics: epics.length ? epics : plan.epics };
|
||||||
|
if (epics.length) return { ...plan, epics };
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.filter(Boolean) as typeof tree;
|
||||||
|
}, [tree, search]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (tree.length && expandedPlans.size === 0) {
|
||||||
|
setExpandedPlans(new Set(tree.slice(0, 2).map((p) => p.slug)));
|
||||||
|
}
|
||||||
|
}, [tree, expandedPlans.size]);
|
||||||
|
|
||||||
|
if (!workspaceSlug) return null;
|
||||||
|
|
||||||
|
const isMutating = createPlan.isPending || createEpic.isPending || createTask.isPending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0 flex-col">
|
||||||
|
<header className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-border px-6 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
|
||||||
|
<FolderKanban className="size-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold">Plans</h1>
|
||||||
|
<p className="text-xs text-muted-foreground">Plan → Epic → Task tree from plans/ markdown</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input placeholder="Search plans, epics, tasks…" value={search} onChange={(e) => setSearch(e.target.value)} className="w-64 pl-8" />
|
||||||
|
</div>
|
||||||
|
<Button type="button" size="sm" onClick={openPlanDialog}>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
New Plan
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1">
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col border-r border-border">
|
||||||
|
{treeQuery.isLoading ? (
|
||||||
|
<div className="flex items-center gap-2 p-6 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
Loading plans…
|
||||||
|
</div>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-8 text-center">
|
||||||
|
<FolderKanban className="size-8 text-muted-foreground" />
|
||||||
|
<p className="text-sm font-medium">No plans yet</p>
|
||||||
|
<p className="max-w-sm text-xs text-muted-foreground">
|
||||||
|
{search.trim()
|
||||||
|
? "No results for your search."
|
||||||
|
: "Create your first plan or run pnpm --filter @tasks/database watch:markdown-backlog to import plans/ after seeding."}
|
||||||
|
</p>
|
||||||
|
{!search.trim() && (
|
||||||
|
<Button size="sm" onClick={openPlanDialog}>
|
||||||
|
<Plus className="mr-1 size-4" />
|
||||||
|
New Plan
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ScrollArea className="h-full">
|
||||||
|
<div className="space-y-1 p-3">
|
||||||
|
{filtered.map((plan) => {
|
||||||
|
const planOpen = expandedPlans.has(plan.slug);
|
||||||
|
return (
|
||||||
|
<CollapsiblePrimitive.Root
|
||||||
|
key={plan.id}
|
||||||
|
open={planOpen}
|
||||||
|
onOpenChange={(open) =>
|
||||||
|
setExpandedPlans((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (open) next.add(plan.slug);
|
||||||
|
else next.delete(plan.slug);
|
||||||
|
return next;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="group flex items-center gap-1 rounded-md px-1 py-1 hover:bg-muted/50">
|
||||||
|
<CollapsiblePrimitive.Trigger asChild>
|
||||||
|
<button type="button" className="flex items-center gap-1 rounded p-1 hover:bg-muted" aria-label={planOpen ? "Collapse" : "Expand"}>
|
||||||
|
{planOpen ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}
|
||||||
|
</button>
|
||||||
|
</CollapsiblePrimitive.Trigger>
|
||||||
|
<Folder className="size-4 shrink-0 text-primary" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedId(plan.id.startsWith("placeholder-") ? null : plan.id)}
|
||||||
|
className={cn(
|
||||||
|
"min-w-0 flex-1 truncate text-left text-sm font-medium hover:underline",
|
||||||
|
selectedId === plan.id && "text-primary",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{plan.title || plan.slug}
|
||||||
|
</button>
|
||||||
|
<StatusChip status={plan.status} />
|
||||||
|
<Badge variant="outline" className="text-[10px]">{plan.slug}</Badge>
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => openEpicDialog(plan.slug)}>
|
||||||
|
<Plus className="size-3" />
|
||||||
|
Epic
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<CollapsiblePrimitive.Content className="ml-5 border-l border-border pl-3">
|
||||||
|
<div className="space-y-1 py-1">
|
||||||
|
{plan.epics.length === 0 ? (
|
||||||
|
<p className="px-2 py-1 text-xs text-muted-foreground">No epics — add one.</p>
|
||||||
|
) : (
|
||||||
|
plan.epics.map((epic) => {
|
||||||
|
const epicKey = `${plan.slug}/${epic.slug}`;
|
||||||
|
const epicOpen = expandedEpics.has(epicKey);
|
||||||
|
return (
|
||||||
|
<CollapsiblePrimitive.Root
|
||||||
|
key={epic.id}
|
||||||
|
open={epicOpen}
|
||||||
|
onOpenChange={(open) =>
|
||||||
|
setExpandedEpics((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (open) next.add(epicKey);
|
||||||
|
else next.delete(epicKey);
|
||||||
|
return next;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1 rounded-md px-1 py-1 hover:bg-muted/50">
|
||||||
|
<CollapsiblePrimitive.Trigger asChild>
|
||||||
|
<button type="button" className="flex items-center rounded p-1 hover:bg-muted">
|
||||||
|
{epicOpen ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}
|
||||||
|
</button>
|
||||||
|
</CollapsiblePrimitive.Trigger>
|
||||||
|
<FolderKanban className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedId(epic.id.startsWith("placeholder-") ? null : epic.id)}
|
||||||
|
className={cn(
|
||||||
|
"min-w-0 flex-1 truncate text-left text-sm hover:underline",
|
||||||
|
selectedId === epic.id && "text-primary",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{epic.title || epic.slug}
|
||||||
|
</button>
|
||||||
|
<StatusChip status={epic.status} />
|
||||||
|
<Badge variant="outline" className="hidden text-[10px] sm:inline-flex">{epic.slug}</Badge>
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => openTaskDialog(plan.slug, epic.slug)}>
|
||||||
|
<Plus className="size-3" />
|
||||||
|
Task
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<CollapsiblePrimitive.Content className="ml-5 border-l border-border pl-3">
|
||||||
|
<ul className="space-y-0.5 py-1">
|
||||||
|
{epic.tasks.length === 0 ? (
|
||||||
|
<li className="px-2 py-1 text-xs text-muted-foreground">No tasks.</li>
|
||||||
|
) : (
|
||||||
|
epic.tasks.map((task) => (
|
||||||
|
<li
|
||||||
|
key={task.id}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 rounded-md px-2 py-1.5 hover:bg-muted",
|
||||||
|
selectedId === task.id && "bg-muted ring-1 ring-primary/20",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedId(task.id)}
|
||||||
|
className="min-w-0 flex-1 truncate text-left text-sm"
|
||||||
|
>
|
||||||
|
<span className="font-medium">{task.title || task.slug}</span>
|
||||||
|
<span className="ml-1 text-xs text-muted-foreground">{task.slug}</span>
|
||||||
|
</button>
|
||||||
|
<StatusChip status={task.status} />
|
||||||
|
{task.priority && (
|
||||||
|
<Badge variant="outline" className="hidden text-[10px] sm:inline-flex">
|
||||||
|
{task.priority}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<span className="hidden max-w-[140px] truncate text-[10px] text-muted-foreground sm:inline">{task.repoPath}</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={task.status === "ready" ? "default" : "outline"}
|
||||||
|
className="h-7 shrink-0 px-2 text-xs"
|
||||||
|
disabled={startConvoy.isPending || createConvoyDirect.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedId(task.id);
|
||||||
|
// Primary: backlog.startConvoy (idempotent, creates briefFiles: [repoPath]); fallback/additional: convoys.create with planSlug/epicSlug per brief
|
||||||
|
const convoySlug = `${task.planSlug}-${task.slug}`.toLowerCase();
|
||||||
|
if (task.status === "ready") {
|
||||||
|
startConvoy.mutate({ workspace: workspaceSlug, backlogItemId: task.id });
|
||||||
|
} else {
|
||||||
|
createConvoyDirect.mutate({
|
||||||
|
workspace: workspaceSlug,
|
||||||
|
slug: convoySlug,
|
||||||
|
classification: "feature",
|
||||||
|
successMetric: task.title || task.slug,
|
||||||
|
ideaMarkdown: task.bodyMarkdown || `# ${task.title}`,
|
||||||
|
planSlug: task.planSlug,
|
||||||
|
epicSlug: task.epicSlug ?? undefined,
|
||||||
|
backlogItemId: task.id,
|
||||||
|
repoPath: task.repoPath,
|
||||||
|
briefFiles: [task.repoPath],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Rocket className="mr-1 size-3" />
|
||||||
|
Start
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</CollapsiblePrimitive.Content>
|
||||||
|
</CollapsiblePrimitive.Root>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CollapsiblePrimitive.Content>
|
||||||
|
</CollapsiblePrimitive.Root>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Sheet open={Boolean(selectedId)} onOpenChange={(open) => !open && setSelectedId(null)}>
|
||||||
|
<SheetContent className="flex w-[480px] max-w-[90vw] flex-col sm:max-w-[480px]">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle className="pr-6">{detailQuery.data?.title ?? "Details"}</SheetTitle>
|
||||||
|
<SheetDescription className="break-all text-xs">
|
||||||
|
{detailQuery.data?.repoPath ?? ""}
|
||||||
|
{detailQuery.data ? ` · ${detailQuery.data.kind}` : ""}
|
||||||
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
<div className="space-y-4 p-1 pr-4">
|
||||||
|
{detailQuery.isLoading ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
Loading…
|
||||||
|
</div>
|
||||||
|
) : detailQuery.data ? (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<StatusChip status={detailQuery.data.status} />
|
||||||
|
{detailQuery.data.priority && <Badge variant="outline">{detailQuery.data.priority}</Badge>}
|
||||||
|
<Badge variant="secondary">{detailQuery.data.kind}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border border-border bg-muted/20 p-3">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">Repo path</p>
|
||||||
|
<p className="mt-1 break-all font-mono text-xs">{detailQuery.data.repoPath}</p>
|
||||||
|
<p className="mt-2 text-xs font-medium text-muted-foreground">Slugs</p>
|
||||||
|
<p className="mt-1 text-xs">
|
||||||
|
Plan: <code className="rounded bg-muted px-1 py-0.5">{detailQuery.data.planSlug}</code>
|
||||||
|
{detailQuery.data.epicSlug ? (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
Epic: <code className="rounded bg-muted px-1 py-0.5">{detailQuery.data.epicSlug}</code>
|
||||||
|
</>
|
||||||
|
) : null}{" "}
|
||||||
|
Slug: <code className="rounded bg-muted px-1 py-0.5">{detailQuery.data.slug}</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold">Body</h3>
|
||||||
|
<pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-md border border-border bg-muted/20 p-3 text-xs leading-relaxed">
|
||||||
|
{detailQuery.data.bodyMarkdown || "(empty)"}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold">Effective prompt</h3>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">Source: {promptQuery.data?.source ?? "—"}</p>
|
||||||
|
<pre className="mt-2 max-h-48 overflow-auto whitespace-pre-wrap break-words rounded-md border border-border bg-muted/20 p-3 text-xs leading-relaxed">
|
||||||
|
{promptQuery.data?.effectivePrompt ?? promptQuery.data?.ownOverride ?? "(no prompt)"}
|
||||||
|
</pre>
|
||||||
|
{promptQuery.data?.ownOverride && <p className="mt-1 text-[10px] text-muted-foreground">Own override present</p>}
|
||||||
|
</div>
|
||||||
|
{detailQuery.data.kind === "task" && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
disabled={startConvoy.isPending || createConvoyDirect.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
const d = detailQuery.data!;
|
||||||
|
const convoySlug = `${d.planSlug}-${d.slug}`.toLowerCase();
|
||||||
|
// Use convoys.create with planSlug/epicSlug as required by brief, via createConvoyDirect
|
||||||
|
createConvoyDirect.mutate({
|
||||||
|
workspace: workspaceSlug,
|
||||||
|
slug: convoySlug,
|
||||||
|
classification: "feature",
|
||||||
|
successMetric: d.title || d.slug,
|
||||||
|
ideaMarkdown: d.bodyMarkdown || `# ${d.title}`,
|
||||||
|
planSlug: d.planSlug,
|
||||||
|
epicSlug: d.epicSlug ?? undefined,
|
||||||
|
backlogItemId: d.id,
|
||||||
|
repoPath: d.repoPath,
|
||||||
|
briefFiles: [d.repoPath],
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(startConvoy.isPending || createConvoyDirect.isPending) ? <Loader2 className="size-4 animate-spin" /> : <Rocket className="size-4" />}
|
||||||
|
Start → Convoy
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => setSelectedId(null)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(startConvoy.error || createConvoyDirect.error) && (
|
||||||
|
<p className="text-xs text-destructive">{(startConvoy.error ?? createConvoyDirect.error)?.message}</p>
|
||||||
|
)}
|
||||||
|
{(startConvoy.isSuccess || createConvoyDirect.isSuccess) && (
|
||||||
|
<p className="text-xs text-emerald-600">
|
||||||
|
Convoy {(startConvoy.data?.convoySlug ?? createConvoyDirect.data?.convoyId) ?? ""} created — check Mission Control.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">Select an item to see details.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogPrimitive.Root open={Boolean(dialog)} onOpenChange={(open) => !open && closeDialog()}>
|
||||||
|
<DialogPrimitive.Portal>
|
||||||
|
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/50" />
|
||||||
|
<DialogPrimitive.Content className="fixed left-1/2 top-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-background p-6 shadow-lg">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<DialogPrimitive.Title className="text-lg font-semibold">
|
||||||
|
{dialog?.kind === "plan" ? "New Plan" : dialog?.kind === "epic" ? "New Epic" : dialog?.kind === "task" ? "New Task" : "New"}
|
||||||
|
</DialogPrimitive.Title>
|
||||||
|
<DialogPrimitive.Close asChild>
|
||||||
|
<button type="button" className="rounded p-1 hover:bg-muted" aria-label="Close">
|
||||||
|
<X className="size-4" />
|
||||||
|
</button>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{dialog?.kind === "epic" && (
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium">Plan</label>
|
||||||
|
<div className="rounded-md border border-input bg-muted px-3 py-2 text-sm">{dialog.planSlug}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{dialog?.kind === "task" && (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium">Plan</label>
|
||||||
|
<div className="rounded-md border border-input bg-muted px-3 py-2 text-sm">{dialog.planSlug}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium">Epic</label>
|
||||||
|
<div className="rounded-md border border-input bg-muted px-3 py-2 text-sm">{dialog.epicSlug}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium" htmlFor="plans-slug">
|
||||||
|
Slug <span className="font-normal text-muted-foreground">(a-z 0-9 -)</span>
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="plans-slug"
|
||||||
|
placeholder="my-slug"
|
||||||
|
value={formSlug}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value.toLowerCase();
|
||||||
|
setFormSlug(v);
|
||||||
|
if (!formTitle.trim() || slugify(formTitle) === formSlug) {
|
||||||
|
// keep title in sync helper? no auto
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-[11px] text-muted-foreground">Must match ^[a-z0-9-]+$</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium" htmlFor="plans-title">
|
||||||
|
Title
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="plans-title"
|
||||||
|
placeholder="Human readable title"
|
||||||
|
value={formTitle}
|
||||||
|
onChange={(e) => setFormTitle(e.target.value)}
|
||||||
|
onBlur={() => {
|
||||||
|
if (!formSlug.trim() && formTitle.trim()) setFormSlug(slugify(formTitle));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium" htmlFor="plans-body">
|
||||||
|
Body markdown (optional)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="plans-body"
|
||||||
|
className="min-h-[90px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||||
|
placeholder="# Summary…"
|
||||||
|
value={formBody}
|
||||||
|
onChange={(e) => setFormBody(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium" htmlFor="plans-prompt">
|
||||||
|
Agent prompt (optional)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="plans-prompt"
|
||||||
|
className="min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||||
|
placeholder="Custom prompt for agents…"
|
||||||
|
value={formAgentPrompt}
|
||||||
|
onChange={(e) => setFormAgentPrompt(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formError && <p className="text-xs text-destructive">{formError}</p>}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<DialogPrimitive.Close asChild>
|
||||||
|
<Button type="button" variant="outline" onClick={closeDialog}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={isMutating || !formSlug.trim() || !formTitle.trim()}
|
||||||
|
onClick={() => {
|
||||||
|
setFormError(null);
|
||||||
|
const slug = formSlug.trim().toLowerCase();
|
||||||
|
if (!SLUG_RE.test(slug)) {
|
||||||
|
setFormError("slug must match ^[a-z0-9-]+$");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (dialog?.kind === "plan") {
|
||||||
|
createPlan.mutate({
|
||||||
|
workspace: workspaceSlug,
|
||||||
|
slug,
|
||||||
|
title: formTitle.trim(),
|
||||||
|
bodyMarkdown: formBody.trim() || undefined,
|
||||||
|
agentPrompt: formAgentPrompt.trim() || null,
|
||||||
|
});
|
||||||
|
} else if (dialog?.kind === "epic") {
|
||||||
|
createEpic.mutate({
|
||||||
|
workspace: workspaceSlug,
|
||||||
|
planSlug: dialog.planSlug,
|
||||||
|
slug,
|
||||||
|
title: formTitle.trim(),
|
||||||
|
bodyMarkdown: formBody.trim() || undefined,
|
||||||
|
agentPrompt: formAgentPrompt.trim() || null,
|
||||||
|
});
|
||||||
|
} else if (dialog?.kind === "task") {
|
||||||
|
createTask.mutate({
|
||||||
|
workspace: workspaceSlug,
|
||||||
|
planSlug: dialog.planSlug,
|
||||||
|
epicSlug: dialog.epicSlug,
|
||||||
|
slug,
|
||||||
|
title: formTitle.trim(),
|
||||||
|
bodyMarkdown: formBody.trim() || undefined,
|
||||||
|
agentPrompt: formAgentPrompt.trim() || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isMutating ? <Loader2 className="size-4 animate-spin" /> : <Plus className="size-4" />}
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPrimitive.Portal>
|
||||||
|
</DialogPrimitive.Root>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SLUG_RE = /^[a-z0-9-]+$/;
|
||||||
165
apps/web/app/(app)/[workspaceSlug]/settings/harnesses/page.tsx
Normal file
165
apps/web/app/(app)/[workspaceSlug]/settings/harnesses/page.tsx
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
|
import { Loader2, Plus, Wrench, X } from "lucide-react";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
import type { HarnessType } from "@tasks/shared/worker";
|
||||||
|
|
||||||
|
const HARNESS_TYPES: HarnessType[] = ["minimal", "crewai", "langgraph", "remote-ag-ui"];
|
||||||
|
|
||||||
|
type HarnessForm = {
|
||||||
|
alias: string;
|
||||||
|
displayName: string;
|
||||||
|
harnessType: HarnessType;
|
||||||
|
endpointUrl: string;
|
||||||
|
authEnvVar: string;
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY_FORM: HarnessForm = {
|
||||||
|
alias: "",
|
||||||
|
displayName: "",
|
||||||
|
harnessType: "minimal",
|
||||||
|
endpointUrl: "",
|
||||||
|
authEnvVar: "",
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function HarnessesSettingsPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const workspaceSlug = params?.workspaceSlug as string | undefined;
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const listQuery = api.harnesses.list.useQuery({ workspace: workspaceSlug ?? "" }, { enabled: Boolean(workspaceSlug) });
|
||||||
|
const routesQuery = api.harnesses.listRoutes.useQuery({ workspace: workspaceSlug ?? "" }, { enabled: Boolean(workspaceSlug) });
|
||||||
|
|
||||||
|
const [dialogOpen, setDialogOpen] = React.useState(false);
|
||||||
|
const [editingId, setEditingId] = React.useState<string | null>(null);
|
||||||
|
const [form, setForm] = React.useState<HarnessForm>(EMPTY_FORM);
|
||||||
|
const [testResult, setTestResult] = React.useState<Record<string, string>>({});
|
||||||
|
const [newRoleId, setNewRoleId] = React.useState("");
|
||||||
|
const [newHarnessId, setNewHarnessId] = React.useState("");
|
||||||
|
|
||||||
|
const createMut = api.harnesses.create.useMutation({ onSuccess: () => { void utils.harnesses.list.invalidate(); setDialogOpen(false); } });
|
||||||
|
const updateMut = api.harnesses.update.useMutation({ onSuccess: () => { void utils.harnesses.list.invalidate(); setDialogOpen(false); } });
|
||||||
|
const deleteMut = api.harnesses.delete.useMutation({ onSuccess: () => void utils.harnesses.list.invalidate() });
|
||||||
|
const testMut = api.harnesses.testHarness.useMutation({
|
||||||
|
onSuccess: (result, vars) => {
|
||||||
|
setTestResult((prev) => ({ ...prev, [vars.harnessId]: result.ok ? `OK in ${result.latencyMs}ms` : `Failed: ${result.error ?? "unknown"}` }));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const upsertRouteMut = api.harnesses.upsertRoute.useMutation({ onSuccess: () => { void utils.harnesses.listRoutes.invalidate(); setNewRoleId(""); setNewHarnessId(""); } });
|
||||||
|
const deleteRouteMut = api.harnesses.deleteRoute.useMutation({ onSuccess: () => void utils.harnesses.listRoutes.invalidate() });
|
||||||
|
|
||||||
|
const canManage = listQuery.data?.canManage ?? false;
|
||||||
|
const harnesses = listQuery.data?.harnesses ?? [];
|
||||||
|
const routes = routesQuery.data?.routes ?? [];
|
||||||
|
|
||||||
|
const openCreate = () => { setEditingId(null); setForm(EMPTY_FORM); setDialogOpen(true); };
|
||||||
|
const openEdit = (h: (typeof harnesses)[number]) => {
|
||||||
|
setEditingId(h.id);
|
||||||
|
setForm({ alias: h.alias, displayName: h.displayName, harnessType: h.harnessType as HarnessType, endpointUrl: h.endpointUrl ?? "", authEnvVar: h.authEnvVar ?? "", enabled: h.enabled });
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
const submit = () => {
|
||||||
|
if (!workspaceSlug) return;
|
||||||
|
const payload = { workspace: workspaceSlug, alias: form.alias, displayName: form.displayName, harnessType: form.harnessType, endpointUrl: form.endpointUrl.trim() || null, authEnvVar: form.authEnvVar.trim() || null, enabled: form.enabled };
|
||||||
|
if (editingId) updateMut.mutate({ ...payload, id: editingId });
|
||||||
|
else createMut.mutate(payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!workspaceSlug) return <p className="p-8 text-sm text-muted-foreground">No workspace selected.</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-5xl px-8 py-10">
|
||||||
|
<div className="mb-4 flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10"><Wrench className="size-5 text-primary" /></div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold">Harnesses</h1>
|
||||||
|
<p className="text-xs text-muted-foreground">Providers for <code className="text-[10px]">minimal|crewai|langgraph|remote-ag-ui</code> + role routing. Auth tokens stay in server env.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{canManage ? <Button size="sm" className="gap-1.5" onClick={openCreate}><Plus className="size-4" />Add harness</Button> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{listQuery.isLoading ? <div className="flex justify-center py-16"><Loader2 className="size-6 animate-spin text-muted-foreground" /></div>
|
||||||
|
: listQuery.error ? <p className="text-sm text-destructive">{listQuery.error.message}</p>
|
||||||
|
: harnesses.length === 0 ? <div className="rounded-lg border border-dashed px-6 py-12 text-center text-sm text-muted-foreground">No harnesses yet. Add minimal or a remote AG-UI endpoint.</div>
|
||||||
|
: (
|
||||||
|
<div className="overflow-hidden rounded-lg border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-muted/40 text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
<tr><th className="px-4 py-2 text-left font-medium">Alias</th><th className="px-4 py-2 text-left font-medium">Type</th><th className="px-4 py-2 text-left font-medium">Endpoint</th><th className="px-4 py-2 text-left font-medium">On</th>{canManage ? <th className="px-4 py-2 text-right font-medium">Actions</th> : null}</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{harnesses.map((h) => (
|
||||||
|
<tr key={h.id} className="hover:bg-muted/20">
|
||||||
|
<td className="px-4 py-2"><div className="font-medium">{h.alias}</div><div className="text-xs text-muted-foreground">{h.displayName}</div></td>
|
||||||
|
<td className="px-4 py-2 font-mono text-xs">{h.harnessType}</td>
|
||||||
|
<td className="max-w-[24ch] truncate px-4 py-2 font-mono text-xs text-muted-foreground">{h.endpointUrl ?? "—"}</td>
|
||||||
|
<td className="px-4 py-2 text-xs">{h.enabled ? "yes" : "no"}</td>
|
||||||
|
{canManage ? (
|
||||||
|
<td className="space-x-2 px-4 py-2 text-right">
|
||||||
|
<Button variant="outline" size="sm" disabled={testMut.isPending} onClick={() => testMut.mutate({ workspace: workspaceSlug, harnessId: h.id })}>Test</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => openEdit(h)}>Edit</Button>
|
||||||
|
<Button variant="ghost" size="sm" className="text-destructive" onClick={() => { if (window.confirm(`Delete harness "${h.alias}"?`)) deleteMut.mutate({ workspace: workspaceSlug, id: h.id }); }}>Delete</Button>
|
||||||
|
{testResult[h.id] ? <div className="mt-1 text-[10px] text-muted-foreground">{testResult[h.id]}</div> : null}
|
||||||
|
</td>
|
||||||
|
) : null}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-10 rounded-lg border">
|
||||||
|
<div className="border-b px-4 py-3 text-sm font-medium">Role → harness routing</div>
|
||||||
|
{routesQuery.isLoading ? <div className="flex justify-center py-8"><Loader2 className="size-5 animate-spin text-muted-foreground" /></div>
|
||||||
|
: (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-muted/40 text-xs uppercase tracking-wide text-muted-foreground"><tr><th className="px-4 py-2 text-left font-medium">Role</th><th className="px-4 py-2 text-left font-medium">Harness</th><th className="px-4 py-2 text-left font-medium">Type</th>{canManage ? <th className="px-4 py-2 text-right font-medium">Actions</th> : null}</tr></thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{routes.length === 0 ? <tr><td colSpan={canManage ? 4 : 3} className="px-4 py-8 text-center text-muted-foreground">No harness routes. Map role-implementer to crewai etc.</td></tr>
|
||||||
|
: routes.map((r) => (
|
||||||
|
<tr key={r.id} className="hover:bg-muted/20">
|
||||||
|
<td className="px-4 py-2 font-mono text-xs">{r.roleId}</td>
|
||||||
|
<td className="px-4 py-2 font-mono text-xs">{r.harnessAlias}</td>
|
||||||
|
<td className="px-4 py-2 font-mono text-xs">{r.harnessType}</td>
|
||||||
|
{canManage ? <td className="px-4 py-2 text-right"><select className="mr-2 rounded border border-input bg-background px-2 py-1 text-xs" value={r.harnessProviderId} onChange={(e) => upsertRouteMut.mutate({ workspace: workspaceSlug!, roleId: r.roleId, harnessProviderId: e.target.value })}>{harnesses.filter((h) => h.enabled).map((h) => <option key={h.id} value={h.id}>{h.alias}</option>)}</select><Button variant="ghost" size="sm" className="text-destructive" onClick={() => { if (window.confirm(`Remove harness route for ${r.roleId}?`)) deleteRouteMut.mutate({ workspace: workspaceSlug!, id: r.id }); }}>Remove</Button></td> : null}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
{canManage && harnesses.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap items-end gap-3 border-t bg-muted/20 p-4">
|
||||||
|
<div><label className="mb-1 block text-xs text-muted-foreground">role_id</label><Input className="w-48 font-mono text-xs" placeholder="role-implementer" value={newRoleId} onChange={(e) => setNewRoleId(e.target.value)} /></div>
|
||||||
|
<div><label className="mb-1 block text-xs text-muted-foreground">Harness</label><select className="h-9 rounded-md border border-input bg-background px-3 text-sm" value={newHarnessId} onChange={(e) => setNewHarnessId(e.target.value)}><option value="">Select…</option>{harnesses.filter((h) => h.enabled).map((h) => <option key={h.id} value={h.id}>{h.alias}</option>)}</select></div>
|
||||||
|
<Button size="sm" className="gap-1" disabled={!newRoleId.trim() || !newHarnessId || upsertRouteMut.isPending} onClick={() => upsertRouteMut.mutate({ workspace: workspaceSlug!, roleId: newRoleId.trim(), harnessProviderId: newHarnessId })}><Plus className="size-3.5" />Map</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogPrimitive.Root open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogPrimitive.Portal><DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/50" /><DialogPrimitive.Content className="fixed left-1/2 top-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-6 shadow-lg">
|
||||||
|
<div className="mb-4 flex items-center justify-between"><DialogPrimitive.Title className="text-lg font-semibold">{editingId ? "Edit harness" : "Add harness"}</DialogPrimitive.Title><DialogPrimitive.Close asChild><button type="button" className="rounded-md p-1 hover:bg-muted"><X className="size-4" /></button></DialogPrimitive.Close></div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Field label="Alias"><Input value={form.alias} onChange={(e) => setForm((f) => ({ ...f, alias: e.target.value }))} /></Field>
|
||||||
|
<Field label="Display name"><Input value={form.displayName} onChange={(e) => setForm((f) => ({ ...f, displayName: e.target.value }))} /></Field>
|
||||||
|
<Field label="Harness type"><select className="flex h-9 w-full rounded-md border border-input bg-background px-3 text-sm" value={form.harnessType} onChange={(e) => setForm((f) => ({ ...f, harnessType: e.target.value as HarnessType }))}>{HARNESS_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}</select></Field>
|
||||||
|
<Field label="Endpoint URL (nullable)"><Input placeholder="https://crew.example.com/ag-ui" value={form.endpointUrl} onChange={(e) => setForm((f) => ({ ...f, endpointUrl: e.target.value }))} /></Field>
|
||||||
|
<Field label="Auth env var (write-only)"><Input placeholder="HARNESS_AUTH_TOKEN" value={form.authEnvVar} onChange={(e) => setForm((f) => ({ ...f, authEnvVar: e.target.value }))} /><p className="mt-1 text-[10px] text-muted-foreground">Token lives in server env, never returned to browser.</p></Field>
|
||||||
|
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={form.enabled} onChange={(e) => setForm((f) => ({ ...f, enabled: e.target.checked }))} /> Enabled</label>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 flex justify-end gap-2"><Button variant="outline" onClick={() => setDialogOpen(false)}>Cancel</Button><Button onClick={submit} disabled={createMut.isPending || updateMut.isPending}>{createMut.isPending || updateMut.isPending ? <Loader2 className="size-4 animate-spin" /> : "Save"}</Button></div>
|
||||||
|
</DialogPrimitive.Content></DialogPrimitive.Portal>
|
||||||
|
</DialogPrimitive.Root>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function Field({ label, children }: { label: string; children: React.ReactNode }) { return <div><label className="mb-1 block text-xs font-medium text-muted-foreground">{label}</label>{children}</div>; }
|
||||||
190
apps/web/app/(app)/fleet/page.tsx
Normal file
190
apps/web/app/(app)/fleet/page.tsx
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { gateLabelForStatus } from "@tasks/shared/convoy-lifecycle";
|
||||||
|
import type { ConvoyStatus } from "@tasks/shared/convoy-lifecycle";
|
||||||
|
import { Loader2, Radar } from "lucide-react";
|
||||||
|
|
||||||
|
import { ConvoyDetailPanel } from "@/components/mission-control/convoy-detail-panel";
|
||||||
|
import { FleetActivityFeed } from "@/components/fleet/fleet-activity-feed";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
|
function formatAge(date: Date | string): string {
|
||||||
|
const d = new Date(date);
|
||||||
|
const diff = Date.now() - d.getTime();
|
||||||
|
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||||
|
if (days > 0) return `${days}d`;
|
||||||
|
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||||
|
if (hours > 0) return `${hours}h`;
|
||||||
|
const mins = Math.floor(diff / (1000 * 60));
|
||||||
|
return `${mins}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FleetPage() {
|
||||||
|
const fleetQuery = api.convoys.fleet.useQuery();
|
||||||
|
const [selected, setSelected] = React.useState<{ convoyId: string; workspaceSlug: string } | null>(null);
|
||||||
|
|
||||||
|
if (fleetQuery.isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 p-6 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
Loading fleet…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fleetQuery.error) {
|
||||||
|
return <p className="p-6 text-sm text-destructive">{fleetQuery.error.message}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = fleetQuery.data;
|
||||||
|
const byWorkspace = data?.byWorkspace ?? [];
|
||||||
|
const gateInboxAcross = data?.gateInboxAcross ?? [];
|
||||||
|
|
||||||
|
// Group gate inbox by status for "× pr_review 2d" count display
|
||||||
|
const gateByStatus = new Map<string, typeof gateInboxAcross>();
|
||||||
|
for (const c of gateInboxAcross) {
|
||||||
|
const arr = gateByStatus.get(c.status) ?? [];
|
||||||
|
arr.push(c);
|
||||||
|
gateByStatus.set(c.status, arr);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0 flex-col">
|
||||||
|
<header className="flex shrink-0 items-center gap-3 border-b border-border px-6 py-4">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
|
||||||
|
<Radar className="size-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold">Fleet</h1>
|
||||||
|
<p className="text-xs text-muted-foreground">All workspaces · gates needing you · live convoy events</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="grid min-h-0 flex-1 grid-cols-1 gap-4 overflow-y-auto p-4 lg:grid-cols-[260px_1fr_340px]">
|
||||||
|
{/* Needs you */}
|
||||||
|
<div className="flex min-h-0 flex-col gap-4">
|
||||||
|
<section className="rounded-lg border border-border bg-card">
|
||||||
|
<div className="border-b border-border p-4">
|
||||||
|
<h2 className="text-sm font-semibold">Needs you</h2>
|
||||||
|
<p className="text-xs text-muted-foreground">Gate inbox across all workspaces</p>
|
||||||
|
{gateInboxAcross.length > 0 && (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2 text-xs">
|
||||||
|
{[...gateByStatus.entries()].map(([status, list]) => (
|
||||||
|
<span key={status} className="rounded bg-amber-500/10 px-2 py-0.5 text-amber-700 dark:text-amber-300">
|
||||||
|
× {status} {list.length} · {formatAge(list[0]?.updatedAt ?? new Date())}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-3">
|
||||||
|
{gateInboxAcross.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No pending gates.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{gateInboxAcross.map((convoy) => (
|
||||||
|
<li key={`${convoy.workspaceId}-${convoy.id}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelected({ convoyId: convoy.id, workspaceSlug: convoy.workspaceSlug })}
|
||||||
|
className={cn(
|
||||||
|
"w-full rounded-md border border-amber-500/30 bg-amber-500/5 p-3 text-left text-sm hover:bg-amber-500/10",
|
||||||
|
selected?.convoyId === convoy.id && "ring-2 ring-amber-500",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p className="font-medium">{convoy.content?.convoySlug ?? convoy.title}</p>
|
||||||
|
<p className="text-xs text-amber-700 dark:text-amber-300">{gateLabelForStatus(convoy.status as ConvoyStatus) ?? convoy.status}</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
{convoy.workspaceName} · {convoy.workspaceSlug} · {formatAge(convoy.updatedAt)} ago
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<FleetActivityFeed />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* byWorkspace cards grid */}
|
||||||
|
<div className="min-h-0 space-y-4">
|
||||||
|
{byWorkspace.length === 0 ? (
|
||||||
|
<p className="rounded-lg border border-dashed p-6 text-sm text-muted-foreground">No workspaces or no convoys yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||||
|
{byWorkspace.map(({ workspace, convoys }) => {
|
||||||
|
const workspaceGateCount = gateInboxAcross.filter((c) => c.workspaceId === workspace.id).length;
|
||||||
|
return (
|
||||||
|
<div key={workspace.id} className="rounded-lg border border-border bg-card">
|
||||||
|
<div className="border-b border-border px-4 py-3">
|
||||||
|
<h3 className="text-sm font-semibold">{workspace.name}</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{workspace.slug} · {convoys.length} convoys
|
||||||
|
{workspaceGateCount > 0 ? ` · ${workspaceGateCount} needing you` : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-2 p-3">
|
||||||
|
{convoys.length === 0 ? (
|
||||||
|
<li className="text-xs text-muted-foreground">No convoys.</li>
|
||||||
|
) : (
|
||||||
|
convoys.slice(0, 8).map((convoy) => (
|
||||||
|
<li key={convoy.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelected({ convoyId: convoy.id, workspaceSlug: convoy.workspaceSlug })}
|
||||||
|
className={cn(
|
||||||
|
"w-full rounded-md border border-border bg-background p-2 text-left text-sm transition-colors hover:bg-muted",
|
||||||
|
selected?.convoyId === convoy.id && "ring-2 ring-primary",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p className="font-medium">{convoy.content?.convoySlug ?? convoy.title}</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
{convoy.status} · {convoy.content?.classification ?? ""} · {formatAge(convoy.updatedAt)} ago
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
{convoys.length > 8 && (
|
||||||
|
<li className="text-center text-xs text-muted-foreground">+{convoys.length - 8} more</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* byStatus summary */}
|
||||||
|
{data?.byStatus && Object.keys(data.byStatus).length > 0 && (
|
||||||
|
<div className="rounded-lg border border-border bg-muted/10 p-3">
|
||||||
|
<p className="mb-2 text-xs font-semibold uppercase text-muted-foreground">By status</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{Object.entries(data.byStatus).map(([status, list]) => (
|
||||||
|
<span key={status} className="rounded border border-border bg-background px-2 py-1 text-xs">
|
||||||
|
{status}: {(list as unknown[]).length}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Detail panel workspace-agnostic */}
|
||||||
|
<aside className="min-h-0 border border-border bg-background lg:rounded-lg">
|
||||||
|
{selected ? (
|
||||||
|
<ConvoyDetailPanel workspaceSlug={selected.workspaceSlug} convoyId={selected.convoyId} />
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center p-6 text-sm text-muted-foreground">
|
||||||
|
Select a convoy to review gates and co-pilot suggestions.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
apps/web/app/(marketing)/landing/page.tsx
Normal file
30
apps/web/app/(marketing)/landing/page.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
import { CtaSection } from "@/components/marketing/cta-section";
|
||||||
|
import { FeaturesSection } from "@/components/marketing/features-section";
|
||||||
|
import { HeroSection } from "@/components/marketing/hero-section";
|
||||||
|
import { HowItWorksSection } from "@/components/marketing/how-it-works-section";
|
||||||
|
import { ManifestSection } from "@/components/marketing/manifest-section";
|
||||||
|
import { SiteFooter } from "@/components/marketing/site-footer";
|
||||||
|
import { SiteHeader } from "@/components/marketing/site-header";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Subagents. Frontmatter. One workspace.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LandingPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SiteHeader />
|
||||||
|
<main>
|
||||||
|
<HeroSection />
|
||||||
|
<div className="marketing-hard-rule" aria-hidden />
|
||||||
|
<FeaturesSection />
|
||||||
|
<HowItWorksSection />
|
||||||
|
<ManifestSection />
|
||||||
|
<CtaSection />
|
||||||
|
</main>
|
||||||
|
<SiteFooter />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
99
apps/web/app/(marketing)/landing/pricing/page.tsx
Normal file
99
apps/web/app/(marketing)/landing/pricing/page.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ArrowRight } from "lucide-react";
|
||||||
|
|
||||||
|
import { Reveal, Stagger, StaggerItem } from "@/components/marketing/reveal";
|
||||||
|
import { SiteFooter } from "@/components/marketing/site-footer";
|
||||||
|
import { SiteHeader } from "@/components/marketing/site-header";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Pricing",
|
||||||
|
};
|
||||||
|
|
||||||
|
const tiers = [
|
||||||
|
{
|
||||||
|
name: "Solo",
|
||||||
|
price: "Free",
|
||||||
|
period: "beta",
|
||||||
|
description: "Personal workspace for agent workflow experiments.",
|
||||||
|
features: ["1 workspace", "Markdown sync", "MCP tools", "Docs & planner"],
|
||||||
|
href: "/sign-up",
|
||||||
|
highlight: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Team",
|
||||||
|
price: "$12",
|
||||||
|
period: "seat / mo",
|
||||||
|
description: "Convoys, run history, and routing for builder teams.",
|
||||||
|
features: ["Unlimited workspaces", "Agent runs", "Model routing", "Audit log"],
|
||||||
|
href: "/sign-up",
|
||||||
|
highlight: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Fleet",
|
||||||
|
price: "Custom",
|
||||||
|
period: "contact",
|
||||||
|
description: "Mission control for repos, tenants, and orchestration.",
|
||||||
|
features: ["Connected repos", "Fleet dashboard", "Webhook gates", "SSO"],
|
||||||
|
href: "mailto:hello@submatter.dev",
|
||||||
|
highlight: false,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export default function PricingPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SiteHeader />
|
||||||
|
<main className="marketing-paper-grain min-h-screen bg-background">
|
||||||
|
<div className="mx-auto max-w-6xl px-4 py-20 sm:px-6 sm:py-28">
|
||||||
|
<Reveal>
|
||||||
|
<p className="font-mono-brand text-[11px] uppercase tracking-[0.24em] text-muted-foreground">Pricing</p>
|
||||||
|
<h1 className="mt-4 font-marketing text-[clamp(2.5rem,6vw,4rem)] font-semibold leading-[1.05] tracking-[-0.03em]">
|
||||||
|
Seats, not tokens.
|
||||||
|
</h1>
|
||||||
|
<p className="mt-4 max-w-xl font-marketing text-muted-foreground">
|
||||||
|
Beta numbers for feedback. Bring your own model keys always.
|
||||||
|
</p>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
|
<Stagger className="mt-16 grid gap-0 border border-foreground lg:grid-cols-3">
|
||||||
|
{tiers.map((tier, i) => (
|
||||||
|
<StaggerItem
|
||||||
|
key={tier.name}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col border-foreground p-8",
|
||||||
|
i < tiers.length - 1 && "border-b lg:border-b-0 lg:border-r",
|
||||||
|
tier.highlight && "bg-[hsl(var(--hero))] text-[hsl(var(--hero-foreground))]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p className="font-mono-brand text-[11px] uppercase tracking-[0.2em] opacity-70">{tier.name}</p>
|
||||||
|
<p className="mt-4 flex items-baseline gap-2">
|
||||||
|
<span className="font-marketing text-4xl font-semibold tracking-[-0.03em]">{tier.price}</span>
|
||||||
|
<span className="font-mono-brand text-[11px] uppercase tracking-widest opacity-60">{tier.period}</span>
|
||||||
|
</p>
|
||||||
|
<p className="mt-4 font-marketing text-sm opacity-80">{tier.description}</p>
|
||||||
|
<ul className="mt-8 flex-1 space-y-2 font-mono-brand text-[11px] uppercase tracking-wide">
|
||||||
|
{tier.features.map((f) => (
|
||||||
|
<li key={f}>{f}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<Link
|
||||||
|
href={tier.href}
|
||||||
|
className={cn(
|
||||||
|
"mt-10 inline-flex items-center gap-2 font-mono-brand text-[11px] uppercase tracking-[0.18em]",
|
||||||
|
tier.highlight ? "text-[hsl(var(--matter))]" : "text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tier.name === "Fleet" ? "Contact" : "Start"}
|
||||||
|
<ArrowRight className="size-3.5" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
</StaggerItem>
|
||||||
|
))}
|
||||||
|
</Stagger>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<SiteFooter />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
apps/web/app/(marketing)/layout.tsx
Normal file
47
apps/web/app/(marketing)/layout.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Barlow_Condensed, IBM_Plex_Sans, JetBrains_Mono } from "next/font/google";
|
||||||
|
|
||||||
|
import "@/styles/marketing.css";
|
||||||
|
|
||||||
|
const barlowCondensed = Barlow_Condensed({
|
||||||
|
subsets: ["latin"],
|
||||||
|
weight: ["700", "800"],
|
||||||
|
variable: "--font-display",
|
||||||
|
});
|
||||||
|
|
||||||
|
const ibmPlexSans = IBM_Plex_Sans({
|
||||||
|
subsets: ["latin"],
|
||||||
|
weight: ["400", "500", "600"],
|
||||||
|
variable: "--font-marketing-sans",
|
||||||
|
});
|
||||||
|
|
||||||
|
const jetbrainsMono = JetBrains_Mono({
|
||||||
|
subsets: ["latin"],
|
||||||
|
weight: ["400", "500"],
|
||||||
|
variable: "--font-mono-brand",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: {
|
||||||
|
default: "Submatter — Subagents. Frontmatter. One workspace.",
|
||||||
|
template: "%s · Submatter",
|
||||||
|
},
|
||||||
|
description:
|
||||||
|
"Agent-native workspace with markdown backlog sync, MCP task lifecycle, and convoy pipelines for Cursor subagents.",
|
||||||
|
openGraph: {
|
||||||
|
title: "SBMTR — Submatter",
|
||||||
|
description: "Subagents. Frontmatter. One workspace.",
|
||||||
|
siteName: "Submatter",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MarketingLayout({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`marketing-theme min-h-screen bg-background font-marketing text-foreground antialiased ${barlowCondensed.variable} ${ibmPlexSans.variable} ${jetbrainsMono.variable}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
105
apps/web/app/api/ag-ui/[workspace]/route.ts
Normal file
105
apps/web/app/api/ag-ui/[workspace]/route.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@tasks/database";
|
||||||
|
import { agentRuns, convoyEvents, objects } from "@tasks/database/schema";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { resolveWorkspace } from "@/server/lib/resolve-workspace";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const agUiEnvelopeSchema = z.object({
|
||||||
|
convoyId: z.string().uuid().optional(),
|
||||||
|
briefId: z.string().uuid().nullable().optional(),
|
||||||
|
roleId: z.string().min(1).max(120).optional(),
|
||||||
|
eventType: z.string().min(1).max(64).optional(),
|
||||||
|
outcome: z.string().min(1).max(20).optional(),
|
||||||
|
harnessAlias: z.string().min(1).max(120).optional(),
|
||||||
|
summary: z.string().max(5000).optional(),
|
||||||
|
tokensTotal: z.number().int().nonnegative().optional(),
|
||||||
|
metadata: z.record(z.unknown()).optional(),
|
||||||
|
// Backlog lifecycle via ag-ui envelope (for remote harness that ran a task externally)
|
||||||
|
backlogItemId: z.string().uuid().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
function json(status: number, body: unknown): Response {
|
||||||
|
return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request, { params }: { params: Promise<{ workspace: string }> }) {
|
||||||
|
const { workspace: handle } = await params;
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) return json(401, { error: "Unauthorized" });
|
||||||
|
|
||||||
|
let workspace;
|
||||||
|
try {
|
||||||
|
workspace = await resolveWorkspace({ handle, userId: session.user.id });
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const msg = e instanceof Error ? e.message : "Workspace not found";
|
||||||
|
const code = (e as { code?: string })?.code;
|
||||||
|
const status = code === "FORBIDDEN" ? 403 : code === "NOT_FOUND" ? 404 : 400;
|
||||||
|
return json(status, { error: msg });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: z.infer<typeof agUiEnvelopeSchema>;
|
||||||
|
try {
|
||||||
|
body = agUiEnvelopeSchema.parse(await req.json());
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof z.ZodError) return json(400, { error: e.issues[0]?.message ?? "Invalid body" });
|
||||||
|
return json(400, { error: "Invalid JSON" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tenant fence: if convoyId provided, ensure it belongs to this workspace
|
||||||
|
if (body.convoyId) {
|
||||||
|
const [convoy] = await db.select({ id: objects.id, workspaceId: objects.workspaceId }).from(objects).where(and(eq(objects.id, body.convoyId), eq(objects.workspaceId, workspace.id))).limit(1);
|
||||||
|
if (!convoy) return json(403, { error: "convoyId not in workspace" });
|
||||||
|
}
|
||||||
|
if (body.briefId) {
|
||||||
|
const [brief] = await db.select({ id: objects.id, workspaceId: objects.workspaceId }).from(objects).where(and(eq(objects.id, body.briefId), eq(objects.workspaceId, workspace.id))).limit(1);
|
||||||
|
if (!brief) return json(403, { error: "briefId not in workspace" });
|
||||||
|
}
|
||||||
|
if (body.backlogItemId) {
|
||||||
|
// validate backlog item belongs to workspace via agent_runs path? We check objects? backlog stored in markdown_backlog_items — skip heavy check, just allow if workspace matches via conduct?
|
||||||
|
// For now allow but ensure if we can find agent run path.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write convoy event if we have convoy context
|
||||||
|
if (body.convoyId && body.roleId) {
|
||||||
|
await db.insert(convoyEvents).values({
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
convoyId: body.convoyId,
|
||||||
|
briefId: body.briefId ?? null,
|
||||||
|
role: body.roleId,
|
||||||
|
actor: body.roleId,
|
||||||
|
eventType: body.eventType ?? "ag_ui",
|
||||||
|
outcome: body.outcome ?? null,
|
||||||
|
harnessAlias: body.harnessAlias ?? null,
|
||||||
|
metadata: {
|
||||||
|
source: "ag-ui",
|
||||||
|
summary: body.summary ?? null,
|
||||||
|
tokensTotal: body.tokensTotal ?? null,
|
||||||
|
...(body.metadata ?? {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optionally touch agent run if backlogItemId provided — create or update latest open run
|
||||||
|
if (body.backlogItemId && body.convoyId) {
|
||||||
|
// best-effort: insert completed run for observability; if harness already claimed, this will be additive
|
||||||
|
try {
|
||||||
|
await db.insert(agentRuns).values({
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
backlogItemId: body.backlogItemId,
|
||||||
|
harnessAlias: body.harnessAlias ?? null,
|
||||||
|
outcome: body.outcome ?? null,
|
||||||
|
tokensTotal: body.tokensTotal ?? null,
|
||||||
|
notes: body.summary?.slice(0, 2000) ?? null,
|
||||||
|
metadata: { source: "ag-ui", ...(body.metadata ?? {}) },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// ignore duplicate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return json(200, { ok: true });
|
||||||
|
}
|
||||||
165
apps/web/app/api/fleet/events/route.ts
Normal file
165
apps/web/app/api/fleet/events/route.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
import { desc, eq, inArray } from "drizzle-orm";
|
||||||
|
|
||||||
|
import { db } from "@tasks/database";
|
||||||
|
import { convoyEvents, workspaceMembers, workspaces } from "@tasks/database/schema";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
async function getUserWorkspaceIds(userId: string): Promise<string[]> {
|
||||||
|
const memberRows = await db
|
||||||
|
.select({ workspaceId: workspaceMembers.workspaceId })
|
||||||
|
.from(workspaceMembers)
|
||||||
|
.where(eq(workspaceMembers.userId, userId));
|
||||||
|
const ownedRows = await db
|
||||||
|
.select({ id: workspaces.id })
|
||||||
|
.from(workspaces)
|
||||||
|
.where(eq(workspaces.ownerUserId, userId));
|
||||||
|
const ids = new Set<string>([...memberRows.map((r) => r.workspaceId), ...ownedRows.map((r) => r.id)]);
|
||||||
|
return [...ids];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return new Response("Unauthorized", { status: 401 });
|
||||||
|
}
|
||||||
|
const userId = session.user.id;
|
||||||
|
const workspaceIds = await getUserWorkspaceIds(userId);
|
||||||
|
|
||||||
|
if (workspaceIds.length === 0) {
|
||||||
|
// No workspaces: return empty SSE that stays open with keepalive
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
controller.enqueue(encoder.encode(`: no workspaces\n\n`));
|
||||||
|
const iv = setInterval(() => {
|
||||||
|
try {
|
||||||
|
controller.enqueue(encoder.encode(`: keepalive\n\n`));
|
||||||
|
} catch {
|
||||||
|
clearInterval(iv);
|
||||||
|
}
|
||||||
|
}, 15000);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/event-stream",
|
||||||
|
"Cache-Control": "no-cache, no-transform",
|
||||||
|
Connection: "keep-alive",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
let closed = false;
|
||||||
|
let pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
const fetchRecent = async () =>
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(convoyEvents)
|
||||||
|
.where(inArray(convoyEvents.workspaceId, workspaceIds))
|
||||||
|
.orderBy(desc(convoyEvents.occurredAt))
|
||||||
|
.limit(30);
|
||||||
|
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
async start(controller) {
|
||||||
|
const send = (data: unknown) => {
|
||||||
|
if (closed) return;
|
||||||
|
try {
|
||||||
|
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
|
||||||
|
} catch {
|
||||||
|
closed = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendComment = (comment: string) => {
|
||||||
|
if (closed) return;
|
||||||
|
try {
|
||||||
|
controller.enqueue(encoder.encode(`: ${comment}\n\n`));
|
||||||
|
} catch {
|
||||||
|
closed = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let initial: Awaited<ReturnType<typeof fetchRecent>> = [];
|
||||||
|
try {
|
||||||
|
initial = await fetchRecent();
|
||||||
|
} catch {
|
||||||
|
initial = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const ev of [...initial].reverse()) {
|
||||||
|
send({
|
||||||
|
id: ev.id,
|
||||||
|
workspaceId: ev.workspaceId,
|
||||||
|
convoyId: ev.convoyId,
|
||||||
|
briefId: ev.briefId,
|
||||||
|
role: ev.role,
|
||||||
|
actor: ev.actor,
|
||||||
|
eventType: ev.eventType,
|
||||||
|
harnessAlias: ev.harnessAlias,
|
||||||
|
occurredAt: ev.occurredAt.toISOString(),
|
||||||
|
metadata: ev.metadata,
|
||||||
|
classification: ev.classification,
|
||||||
|
durationS: ev.durationS,
|
||||||
|
outcome: ev.outcome,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const seen = new Set(initial.map((e) => e.id));
|
||||||
|
|
||||||
|
const poll = async () => {
|
||||||
|
if (closed) return;
|
||||||
|
try {
|
||||||
|
const rows = await fetchRecent();
|
||||||
|
const fresh = rows.filter((r) => !seen.has(r.id));
|
||||||
|
for (const ev of [...fresh].reverse()) {
|
||||||
|
seen.add(ev.id);
|
||||||
|
send({
|
||||||
|
id: ev.id,
|
||||||
|
workspaceId: ev.workspaceId,
|
||||||
|
convoyId: ev.convoyId,
|
||||||
|
briefId: ev.briefId,
|
||||||
|
role: ev.role,
|
||||||
|
actor: ev.actor,
|
||||||
|
eventType: ev.eventType,
|
||||||
|
harnessAlias: ev.harnessAlias,
|
||||||
|
occurredAt: ev.occurredAt.toISOString(),
|
||||||
|
metadata: ev.metadata,
|
||||||
|
classification: ev.classification,
|
||||||
|
durationS: ev.durationS,
|
||||||
|
outcome: ev.outcome,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (seen.size > 400) {
|
||||||
|
const toKeep = new Set(rows.map((r) => r.id));
|
||||||
|
for (const id of seen) {
|
||||||
|
if (!toKeep.has(id) && seen.size > 200) seen.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore poll errors
|
||||||
|
}
|
||||||
|
if (!closed) sendComment("keepalive");
|
||||||
|
};
|
||||||
|
|
||||||
|
pollInterval = setInterval(poll, 5000);
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
closed = true;
|
||||||
|
if (pollInterval) clearInterval(pollInterval);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/event-stream",
|
||||||
|
"Cache-Control": "no-cache, no-transform",
|
||||||
|
Connection: "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -6,27 +6,14 @@ import { db } from "@tasks/database/client";
|
||||||
import { workspaceMembers, workspaces } from "@tasks/database/schema";
|
import { workspaceMembers, workspaces } from "@tasks/database/schema";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Root landing route. Decides where an authenticated user goes when they
|
* Root landing route. Unauthenticated visitors go to the public marketing site;
|
||||||
* hit `/`. The post-sign-in `callbackUrl` defaults here, so this is the
|
* signed-in users land on their oldest workspace home (`/{slug}/`).
|
||||||
* single source of truth for "where does a user actually land?"
|
|
||||||
*
|
|
||||||
* Decision: land on the user's oldest workspace home (`/{slug}/`). Why oldest:
|
|
||||||
* we don't have a "last visited" column yet (deliberately deferred — see
|
|
||||||
* Task-pick-workspace-landing-route) and oldest-membership is stable across
|
|
||||||
* page loads. The workspace home is no longer a mockup; it now shows real
|
|
||||||
* stats and recent activity via `objects.stats` / `objects.listRecent`.
|
|
||||||
*
|
|
||||||
* The auth callback (`ensureUserHasWorkspace` in `apps/web/lib/auth.ts`)
|
|
||||||
* provisions a personal workspace on first sign-in, so the zero-workspace
|
|
||||||
* branch below should be effectively unreachable for fresh sign-ins. It
|
|
||||||
* survives only as a defensive fallback for sessions that were minted
|
|
||||||
* before that provisioning logic existed.
|
|
||||||
*/
|
*/
|
||||||
export default async function HomePage() {
|
export default async function HomePage() {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
redirect("/sign-in");
|
redirect("/landing");
|
||||||
}
|
}
|
||||||
|
|
||||||
const [membership] = await db
|
const [membership] = await db
|
||||||
|
|
@ -41,11 +28,5 @@ export default async function HomePage() {
|
||||||
redirect(`/${membership.slug}`);
|
redirect(`/${membership.slug}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zero-workspace fallback. See header doc — this should be unreachable
|
|
||||||
// post-`ensureUserHasWorkspace`. If we hit it anyway, bounce through
|
|
||||||
// sign-in so the auth callback re-runs and provisions a workspace.
|
|
||||||
// Track follow-up: a proper onboarding flow for users in this state
|
|
||||||
// is captured in Plan-daily-driver-finish/Epic-shipping-the-shell/
|
|
||||||
// Task-onboarding-zero-workspace-flow.md.
|
|
||||||
redirect("/sign-in?error=no_workspace");
|
redirect("/sign-in?error=no_workspace");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
101
apps/web/components/fleet/fleet-activity-feed.tsx
Normal file
101
apps/web/components/fleet/fleet-activity-feed.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
type FleetEvent = {
|
||||||
|
id: string;
|
||||||
|
workspaceId: string;
|
||||||
|
convoyId: string;
|
||||||
|
briefId: string | null;
|
||||||
|
role: string;
|
||||||
|
actor: string | null;
|
||||||
|
eventType: string;
|
||||||
|
harnessAlias: string | null;
|
||||||
|
occurredAt: string;
|
||||||
|
metadata: Record<string, unknown> | null;
|
||||||
|
classification?: string | null;
|
||||||
|
durationS?: number | null;
|
||||||
|
outcome?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatRelative(iso: string): string {
|
||||||
|
const diff = Date.now() - new Date(iso).getTime();
|
||||||
|
const s = Math.floor(diff / 1000);
|
||||||
|
if (s < 60) return `${s}s ago`;
|
||||||
|
const m = Math.floor(s / 60);
|
||||||
|
if (m < 60) return `${m}m ago`;
|
||||||
|
const h = Math.floor(m / 60);
|
||||||
|
if (h < 24) return `${h}h ago`;
|
||||||
|
return `${Math.floor(h / 24)}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FleetActivityFeed() {
|
||||||
|
const [events, setEvents] = React.useState<FleetEvent[]>([]);
|
||||||
|
const [connected, setConnected] = React.useState(false);
|
||||||
|
const seenRef = React.useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const es = new EventSource("/api/fleet/events");
|
||||||
|
|
||||||
|
es.onopen = () => setConnected(true);
|
||||||
|
es.onerror = () => setConnected(false);
|
||||||
|
|
||||||
|
es.onmessage = (e) => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(e.data) as FleetEvent | FleetEvent[];
|
||||||
|
const incoming = Array.isArray(parsed) ? parsed : [parsed];
|
||||||
|
// Filter duplicates by id
|
||||||
|
const fresh = incoming.filter((ev) => {
|
||||||
|
if (!ev?.id || seenRef.current.has(ev.id)) return false;
|
||||||
|
seenRef.current.add(ev.id);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
if (fresh.length === 0) return;
|
||||||
|
setEvents((prev) => {
|
||||||
|
const merged = [...fresh, ...prev];
|
||||||
|
// keep sort by occurredAt desc, dedup already handled, limit 50
|
||||||
|
merged.sort((a, b) => new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime());
|
||||||
|
return merged.slice(0, 100);
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// ignore malformed
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
es.close();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border bg-card">
|
||||||
|
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||||
|
<h3 className="text-sm font-semibold">Activity feed</h3>
|
||||||
|
<span className="text-[10px] text-muted-foreground">{connected ? "live" : "reconnecting…"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[360px] overflow-y-auto">
|
||||||
|
{events.length === 0 ? (
|
||||||
|
<p className="p-4 text-sm text-muted-foreground">No recent convoy events.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-border">
|
||||||
|
{events.map((ev) => (
|
||||||
|
<li key={ev.id} className="px-4 py-2.5 text-sm">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="font-medium">{ev.eventType}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{formatRelative(ev.occurredAt)}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{ev.role}
|
||||||
|
{ev.actor && ev.actor !== ev.role ? ` · ${ev.actor}` : ""}
|
||||||
|
{ev.harnessAlias ? ` · ${ev.harnessAlias}` : ""}
|
||||||
|
{ev.outcome ? ` · ${ev.outcome}` : ""}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground">convoy {ev.convoyId.slice(0, 8)}</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
apps/web/components/marketing/cta-section.tsx
Normal file
40
apps/web/components/marketing/cta-section.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ArrowRight } from "lucide-react";
|
||||||
|
|
||||||
|
import { Reveal } from "@/components/marketing/reveal";
|
||||||
|
|
||||||
|
export function CtaSection() {
|
||||||
|
return (
|
||||||
|
<section className="border-b border-foreground bg-background py-24 sm:py-32">
|
||||||
|
<div className="mx-auto max-w-6xl px-4 sm:px-6">
|
||||||
|
<Reveal className="border border-foreground bg-[hsl(var(--hero))] p-10 text-[hsl(var(--hero-foreground))] sm:p-16">
|
||||||
|
<p className="font-mono-brand text-[11px] uppercase tracking-[0.28em] text-[hsl(var(--hero-muted))]">---</p>
|
||||||
|
<h2 className="mt-6 font-display text-[clamp(3.5rem,12vw,8rem)] font-extrabold leading-[0.9] tracking-[0.08em]">
|
||||||
|
SBMTR
|
||||||
|
</h2>
|
||||||
|
<p className="mt-8 max-w-xl font-marketing text-lg text-[hsl(var(--hero-muted))]">
|
||||||
|
Open Monday morning in Submatter. Your backlog is already in git — give your agents a workspace that
|
||||||
|
respects that.
|
||||||
|
</p>
|
||||||
|
<div className="mt-10 flex flex-wrap items-center gap-8">
|
||||||
|
<Link
|
||||||
|
href="/sign-up"
|
||||||
|
className="group inline-flex items-center gap-3 font-mono-brand text-[11px] uppercase tracking-[0.18em]"
|
||||||
|
>
|
||||||
|
<span className="border-b-2 border-[hsl(var(--matter))] pb-1 text-[hsl(var(--hero-foreground))] transition-colors group-hover:text-[hsl(var(--matter))]">
|
||||||
|
Start free
|
||||||
|
</span>
|
||||||
|
<ArrowRight className="size-4" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/landing/pricing"
|
||||||
|
className="font-mono-brand text-[11px] uppercase tracking-[0.18em] text-[hsl(var(--hero-muted))] hover:text-[hsl(var(--hero-foreground))]"
|
||||||
|
>
|
||||||
|
Pricing
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
apps/web/components/marketing/features-section.tsx
Normal file
71
apps/web/components/marketing/features-section.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { Reveal, Stagger, StaggerItem } from "@/components/marketing/reveal";
|
||||||
|
|
||||||
|
const capabilities = [
|
||||||
|
{
|
||||||
|
index: "01",
|
||||||
|
title: "Markdown backlog",
|
||||||
|
body: "Plan / Epic / Task files in git. Frontmatter is the schema agents and humans both read.",
|
||||||
|
mono: "plans/Plan-*/Epic-*/Task-*.md",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: "02",
|
||||||
|
title: "Bidirectional sync",
|
||||||
|
body: "Importer mirrors YAML into Postgres. Cursor mappings keep slugs aligned with agent ids.",
|
||||||
|
mono: "watch:markdown-backlog",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: "03",
|
||||||
|
title: "MCP lifecycle",
|
||||||
|
body: "claim_task → work → complete_task. Auditable runs instead of ad-hoc object patches.",
|
||||||
|
mono: "workspace_id scoped",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: "04",
|
||||||
|
title: "Convoy gates",
|
||||||
|
body: "Role-based subagents with human approval between architect, implementer, and reviewer.",
|
||||||
|
mono: "ready → in_progress → done",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: "05",
|
||||||
|
title: "Full workspace",
|
||||||
|
body: "Docs, boards, forms, whiteboards — one object model, not a task queue bolted on.",
|
||||||
|
mono: "multitenant · workspace_id",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: "06",
|
||||||
|
title: "Model routing",
|
||||||
|
body: "Per-role tiers in convoy frontmatter — fast implementers, premium architects.",
|
||||||
|
mono: "model_policy: composer-fast",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function FeaturesSection() {
|
||||||
|
return (
|
||||||
|
<section id="features" className="marketing-paper-grain border-b border-foreground bg-background py-24 sm:py-32">
|
||||||
|
<div className="relative mx-auto max-w-6xl px-4 sm:px-6">
|
||||||
|
<Reveal className="mb-16 grid gap-8 lg:grid-cols-12 lg:items-end">
|
||||||
|
<div className="lg:col-span-8">
|
||||||
|
<p className="font-mono-brand text-[11px] uppercase tracking-[0.24em] text-muted-foreground">Capabilities</p>
|
||||||
|
<h2 className="mt-4 font-marketing text-[clamp(2rem,5vw,3.5rem)] font-semibold leading-[1.05] tracking-[-0.03em]">
|
||||||
|
Built for the agent pipeline era — not the slide-deck era.
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p className="font-marketing text-sm leading-relaxed text-muted-foreground lg:col-span-4 lg:text-right">
|
||||||
|
Swiss grid. Zero decoration. Every line earns its place.
|
||||||
|
</p>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
|
<Stagger className="grid border border-foreground lg:grid-cols-2 [&>*]:border-b [&>*]:border-foreground [&>*:last-child]:border-b-0 lg:[&>*:nth-child(odd)]:border-r lg:[&>*:nth-last-child(-n+2)]:border-b-0">
|
||||||
|
{capabilities.map((item) => (
|
||||||
|
<StaggerItem key={item.index} className="p-8">
|
||||||
|
<p className="font-mono-brand text-[11px] text-matter">{item.index}</p>
|
||||||
|
<h3 className="mt-4 font-marketing text-xl font-semibold tracking-[-0.02em]">{item.title}</h3>
|
||||||
|
<p className="mt-3 font-marketing text-sm leading-relaxed text-muted-foreground">{item.body}</p>
|
||||||
|
<p className="mt-6 font-mono-brand text-[11px] text-foreground">{item.mono}</p>
|
||||||
|
</StaggerItem>
|
||||||
|
))}
|
||||||
|
</Stagger>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
apps/web/components/marketing/hero-section.tsx
Normal file
64
apps/web/components/marketing/hero-section.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ArrowRight } from "lucide-react";
|
||||||
|
|
||||||
|
import { MarqueeStrip } from "@/components/marketing/marquee-strip";
|
||||||
|
import { ProductDemo } from "@/components/marketing/product-demo";
|
||||||
|
import { Reveal } from "@/components/marketing/reveal";
|
||||||
|
import { ScrambleText } from "@/components/marketing/scramble-text";
|
||||||
|
|
||||||
|
export function HeroSection() {
|
||||||
|
return (
|
||||||
|
<section className="marketing-aurora marketing-grid-field relative min-h-[92vh] overflow-hidden text-[hsl(var(--hero-foreground))]">
|
||||||
|
<div className="relative mx-auto flex min-h-[92vh] max-w-6xl flex-col justify-between px-4 pb-0 pt-10 sm:px-6">
|
||||||
|
<div className="flex flex-1 flex-col justify-center py-12 lg:py-20">
|
||||||
|
<Reveal>
|
||||||
|
<p className="font-mono-brand text-[11px] uppercase tracking-[0.28em] text-[hsl(var(--hero-muted))]">
|
||||||
|
Submatter · agent-native workspace
|
||||||
|
</p>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
|
<Reveal delay={0.05} className="mt-8">
|
||||||
|
<h1 className="font-display text-[clamp(4.5rem,16vw,11rem)] font-extrabold leading-[0.85] tracking-[0.06em]">
|
||||||
|
<ScrambleText text="SBMTR" durationMs={1400} />
|
||||||
|
</h1>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
|
<Reveal delay={0.12} className="mt-10 max-w-2xl">
|
||||||
|
<p className="font-marketing text-xl leading-snug tracking-[-0.02em] text-[hsl(var(--hero-foreground))] sm:text-2xl">
|
||||||
|
The coordination plane between{" "}
|
||||||
|
<span className="font-mono-brand text-[hsl(var(--matter))]">plans/</span>, your team, and
|
||||||
|
Cursor subagents.
|
||||||
|
</p>
|
||||||
|
<p className="mt-4 font-marketing text-base leading-relaxed text-[hsl(var(--hero-muted))]">
|
||||||
|
Markdown frontmatter is the contract. MCP is the handshake. Convoys are how work ships.
|
||||||
|
</p>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
|
<Reveal delay={0.18} className="mt-10 flex flex-wrap items-center gap-8">
|
||||||
|
<Link
|
||||||
|
href="/sign-up"
|
||||||
|
className="group inline-flex items-center gap-3 font-marketing text-sm font-medium uppercase tracking-[0.12em] text-[hsl(var(--hero-foreground))]"
|
||||||
|
>
|
||||||
|
<span className="border-b-2 border-[hsl(var(--matter))] pb-1 transition-colors group-hover:text-[hsl(var(--matter))]">
|
||||||
|
Start free
|
||||||
|
</span>
|
||||||
|
<ArrowRight className="size-4 transition-transform group-hover:translate-x-1" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="#manifest"
|
||||||
|
className="font-mono-brand text-[11px] uppercase tracking-[0.2em] text-[hsl(var(--hero-muted))] transition-colors hover:text-[hsl(var(--hero-foreground))]"
|
||||||
|
>
|
||||||
|
Read the manifest
|
||||||
|
</Link>
|
||||||
|
</Reveal>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Reveal delay={0.22} y={40} className="pb-8">
|
||||||
|
<ProductDemo className="border-[hsl(var(--hero-border))]" />
|
||||||
|
</Reveal>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MarqueeStrip />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
apps/web/components/marketing/how-it-works-section.tsx
Normal file
58
apps/web/components/marketing/how-it-works-section.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { Reveal } from "@/components/marketing/reveal";
|
||||||
|
|
||||||
|
const logLines = [
|
||||||
|
{ time: "09:14:02", level: "sync", msg: "imported plans/Plan-automation-foundation/Epic-convoy-lifecycle-mcp/Task-mcp-claim-task-tool.md" },
|
||||||
|
{ time: "09:14:03", level: "mcp", msg: "claim_task · workspace_id=ws_8a2 · agent=cursor-ide · run=ar_8f2a" },
|
||||||
|
{ time: "09:14:04", level: "convoy", msg: "role-implementer · model=composer-fast · status=in_progress" },
|
||||||
|
{ time: "09:18:41", level: "gate", msg: "human approval required · role-reviewer · awaiting operator" },
|
||||||
|
{ time: "09:22:15", level: "sync", msg: "frontmatter status → done · cursor_todo_id mapped" },
|
||||||
|
{ time: "09:22:16", level: "mcp", msg: "complete_task · run=ar_8f2a · duration=8m14s" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const levelColor: Record<string, string> = {
|
||||||
|
sync: "text-agent",
|
||||||
|
mcp: "text-matter",
|
||||||
|
convoy: "text-foreground",
|
||||||
|
gate: "text-matter",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function HowItWorksSection() {
|
||||||
|
return (
|
||||||
|
<section id="pipeline" className="border-b border-foreground bg-[hsl(var(--hero))] py-24 text-[hsl(var(--hero-foreground))] sm:py-32">
|
||||||
|
<div className="mx-auto max-w-6xl px-4 sm:px-6">
|
||||||
|
<Reveal className="mb-12 flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-mono-brand text-[11px] uppercase tracking-[0.24em] text-[hsl(var(--hero-muted))]">
|
||||||
|
Pipeline log
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-4 font-marketing text-[clamp(2rem,4vw,3rem)] font-semibold tracking-[-0.03em]">
|
||||||
|
From frontmatter to shipped
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p className="max-w-md font-mono-brand text-[11px] leading-relaxed text-[hsl(var(--hero-muted))]">
|
||||||
|
tail -f .convoys/.metrics.jsonl
|
||||||
|
<br />
|
||||||
|
# auditable agent runs, not black-box chat
|
||||||
|
</p>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
|
<Reveal delay={0.08}>
|
||||||
|
<div className="border border-[hsl(var(--hero-border))] bg-[hsl(0_0%_6%)]">
|
||||||
|
<div className="border-b border-[hsl(var(--hero-border))] px-4 py-2 font-mono-brand text-[10px] uppercase tracking-widest text-[hsl(var(--hero-muted))]">
|
||||||
|
submatter · fleet · live
|
||||||
|
</div>
|
||||||
|
<ul className="divide-y divide-[hsl(var(--hero-border))]">
|
||||||
|
{logLines.map((line) => (
|
||||||
|
<li key={line.time} className="grid gap-2 px-4 py-3 font-mono-brand text-[12px] sm:grid-cols-[5rem_4rem_1fr] sm:gap-4">
|
||||||
|
<span className="text-[hsl(var(--hero-muted))]">{line.time}</span>
|
||||||
|
<span className={levelColor[line.level] ?? "text-[hsl(var(--hero-foreground))]"}>{line.level}</span>
|
||||||
|
<span className="text-[hsl(var(--hero-foreground)/0.9)]">{line.msg}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
apps/web/components/marketing/logo.tsx
Normal file
61
apps/web/components/marketing/logo.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type LogoProps = {
|
||||||
|
className?: string;
|
||||||
|
showTagline?: boolean;
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
variant?: "dark" | "light";
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: { rule: "w-8", word: "text-xl tracking-[0.22em]", tag: "text-[10px]" },
|
||||||
|
md: { rule: "w-10", word: "text-2xl tracking-[0.24em]", tag: "text-xs" },
|
||||||
|
lg: { rule: "w-14", word: "text-4xl tracking-[0.26em]", tag: "text-sm" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Logo({
|
||||||
|
className,
|
||||||
|
showTagline = false,
|
||||||
|
size = "md",
|
||||||
|
variant = "light",
|
||||||
|
}: LogoProps) {
|
||||||
|
const s = sizeClasses[size];
|
||||||
|
const isDark = variant === "dark";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link href="/landing" className={cn("group inline-flex flex-col gap-1", className)}>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"h-0.5 rounded-full transition-colors",
|
||||||
|
s.rule,
|
||||||
|
isDark
|
||||||
|
? "bg-[hsl(var(--hero-foreground))] group-hover:bg-[hsl(var(--matter))]"
|
||||||
|
: "bg-foreground group-hover:bg-primary",
|
||||||
|
)}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"font-display font-extrabold leading-none",
|
||||||
|
s.word,
|
||||||
|
isDark ? "text-[hsl(var(--hero-foreground))]" : "text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
SBMTR
|
||||||
|
</span>
|
||||||
|
{showTagline ? (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"font-marketing",
|
||||||
|
s.tag,
|
||||||
|
isDark ? "text-[hsl(var(--hero-muted))]" : "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Submatter
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
apps/web/components/marketing/manifest-section.tsx
Normal file
57
apps/web/components/marketing/manifest-section.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { Reveal } from "@/components/marketing/reveal";
|
||||||
|
|
||||||
|
const principles = [
|
||||||
|
{
|
||||||
|
title: "Frontmatter is law",
|
||||||
|
body: "If it is not in YAML at the top of the file, it is not part of the contract. Agents read the same fields humans edit.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Repo-native backlog",
|
||||||
|
body: "plans/ lives in git. Submatter syncs — it does not replace your source of truth.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Humans at the gates",
|
||||||
|
body: "Convoys pause for approval. Nothing merges because an agent got confident.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Tenant-scoped everything",
|
||||||
|
body: "workspace_id on every row. Multitenancy is not a migration you schedule for later.",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function ManifestSection() {
|
||||||
|
return (
|
||||||
|
<section id="manifest" className="marketing-paper-grain border-b border-foreground bg-background py-24 sm:py-32">
|
||||||
|
<div className="mx-auto max-w-6xl px-4 sm:px-6">
|
||||||
|
<Reveal className="mb-16">
|
||||||
|
<p className="font-mono-brand text-[11px] uppercase tracking-[0.24em] text-muted-foreground">Manifest</p>
|
||||||
|
<h2 className="mt-4 max-w-3xl font-marketing text-[clamp(2rem,5vw,3.25rem)] font-semibold leading-[1.08] tracking-[-0.03em]">
|
||||||
|
We are not building another chat box with a kanban view glued on.
|
||||||
|
</h2>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
|
<div className="grid gap-0 border border-foreground lg:grid-cols-2">
|
||||||
|
{principles.map((item, i) => (
|
||||||
|
<Reveal key={item.title} delay={i * 0.06}>
|
||||||
|
<article
|
||||||
|
className={`border-foreground p-8 ${i % 2 === 0 ? "lg:border-r" : ""} ${i < 2 ? "border-b" : ""}`}
|
||||||
|
>
|
||||||
|
<h3 className="font-marketing text-lg font-semibold tracking-[-0.02em]">{item.title}</h3>
|
||||||
|
<p className="mt-3 font-marketing text-sm leading-relaxed text-muted-foreground">{item.body}</p>
|
||||||
|
</article>
|
||||||
|
</Reveal>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Reveal delay={0.2} className="mt-16 border-l-4 border-matter pl-6">
|
||||||
|
<p className="font-marketing text-lg leading-relaxed text-foreground">
|
||||||
|
Subagents. Frontmatter. One workspace.
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 font-mono-brand text-[11px] text-muted-foreground">
|
||||||
|
kind: manifest · version: 2 · status: locked
|
||||||
|
</p>
|
||||||
|
</Reveal>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
apps/web/components/marketing/marquee-strip.tsx
Normal file
49
apps/web/components/marketing/marquee-strip.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
"frontmatter",
|
||||||
|
"subagents",
|
||||||
|
"claim_task",
|
||||||
|
"complete_task",
|
||||||
|
"convoy",
|
||||||
|
"workspace_id",
|
||||||
|
"plans/",
|
||||||
|
"mcp",
|
||||||
|
"markdown-backlog",
|
||||||
|
"human-gates",
|
||||||
|
"model_policy",
|
||||||
|
"role-implementer",
|
||||||
|
"role-reviewer",
|
||||||
|
"role-architect",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function MarqueeStrip({ className, variant = "dark" }: { className?: string; variant?: "dark" | "light" }) {
|
||||||
|
const track = [...items, ...items];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"overflow-hidden border-y py-3",
|
||||||
|
variant === "dark"
|
||||||
|
? "border-[hsl(var(--hero-border))] bg-[hsl(var(--hero))]"
|
||||||
|
: "border-foreground bg-background",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<div className="marketing-marquee-track flex w-max gap-8 whitespace-nowrap px-4">
|
||||||
|
{track.map((item, i) => (
|
||||||
|
<span
|
||||||
|
key={`${item}-${i}`}
|
||||||
|
className={cn(
|
||||||
|
"font-mono-brand text-[11px] uppercase tracking-[0.18em]",
|
||||||
|
variant === "dark" ? "text-[hsl(var(--hero-muted))]" : "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
apps/web/components/marketing/product-demo.tsx
Normal file
91
apps/web/components/marketing/product-demo.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion, useReducedMotion } from "motion/react";
|
||||||
|
import { Bot, CheckCircle2, GitBranch, Terminal } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const pipelineSteps = [
|
||||||
|
{ role: "architect", model: "opus", status: "done" as const },
|
||||||
|
{ role: "implementer", model: "fast", status: "active" as const },
|
||||||
|
{ role: "reviewer", model: "fast", status: "queued" as const },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ProductDemo({ className }: { className?: string }) {
|
||||||
|
const reduceMotion = useReducedMotion();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("relative overflow-hidden border bg-[hsl(0_0%_6%)]", className)}>
|
||||||
|
<div className="flex items-center justify-between border-b border-[hsl(var(--hero-border))] px-4 py-3">
|
||||||
|
<div className="flex gap-2 font-mono-brand text-[10px] uppercase tracking-widest text-[hsl(var(--hero-muted))]">
|
||||||
|
<Terminal className="size-3.5" aria-hidden />
|
||||||
|
<span>workspace · convoy</span>
|
||||||
|
</div>
|
||||||
|
<span className="inline-flex items-center gap-2 font-mono-brand text-[10px] text-[hsl(var(--agent))]">
|
||||||
|
<span className="marketing-pulse-dot size-1.5 bg-[hsl(var(--agent))]" />
|
||||||
|
mcp connected
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid lg:grid-cols-2">
|
||||||
|
<div className="border-b border-[hsl(var(--hero-border))] p-5 lg:border-b-0 lg:border-r">
|
||||||
|
<p className="mb-3 font-mono-brand text-[10px] text-[hsl(var(--hero-muted))]">plans/…/Task-*.md</p>
|
||||||
|
<pre className="font-mono-brand text-[12px] leading-[1.7] text-[hsl(var(--hero-foreground))]">
|
||||||
|
<span className="text-[hsl(var(--hero-muted))]">---{"\n"}</span>
|
||||||
|
<span className="text-[hsl(var(--agent))]">kind</span>
|
||||||
|
<span className="text-[hsl(var(--hero-muted))]">: </span>task{"\n"}
|
||||||
|
<span className="text-[hsl(var(--agent))]">status</span>
|
||||||
|
<span className="text-[hsl(var(--hero-muted))]">: </span>
|
||||||
|
<span className="text-[hsl(var(--matter))]">in_progress</span>
|
||||||
|
{"\n"}
|
||||||
|
<span className="text-[hsl(var(--agent))]">priority</span>
|
||||||
|
<span className="text-[hsl(var(--hero-muted))]">: </span>P1{"\n"}
|
||||||
|
<span className="text-[hsl(var(--agent))]">model_policy</span>
|
||||||
|
<span className="text-[hsl(var(--hero-muted))]">: </span>composer-fast{"\n"}
|
||||||
|
<span className="text-[hsl(var(--hero-muted))]">---</span>
|
||||||
|
<span className="marketing-cursor-blink text-[hsl(var(--matter))]">█</span>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-5">
|
||||||
|
<div className="mb-4 flex items-center gap-2 font-mono-brand text-[10px] uppercase tracking-widest text-[hsl(var(--hero-muted))]">
|
||||||
|
<GitBranch className="size-3.5" aria-hidden />
|
||||||
|
convoy pipeline
|
||||||
|
</div>
|
||||||
|
<ol className="space-y-2">
|
||||||
|
{pipelineSteps.map((step, index) => (
|
||||||
|
<motion.li
|
||||||
|
key={step.role}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-between border px-3 py-2.5",
|
||||||
|
step.status === "active"
|
||||||
|
? "border-[hsl(var(--matter))] bg-[hsl(var(--matter)/0.07)]"
|
||||||
|
: "border-[hsl(var(--hero-border))]",
|
||||||
|
)}
|
||||||
|
initial={reduceMotion ? false : { opacity: 0, x: 16 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: 0.2 + index * 0.1, duration: 0.4 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{step.status === "done" ? (
|
||||||
|
<CheckCircle2 className="size-3.5 text-[hsl(var(--agent))]" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<Bot
|
||||||
|
className={cn(
|
||||||
|
"size-3.5",
|
||||||
|
step.status === "active" ? "text-[hsl(var(--matter))]" : "text-[hsl(var(--hero-muted))]",
|
||||||
|
)}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="font-mono-brand text-xs">role-{step.role}</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono-brand text-[10px] text-[hsl(var(--hero-muted))]">{step.model}</span>
|
||||||
|
</motion.li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
apps/web/components/marketing/reveal.tsx
Normal file
90
apps/web/components/marketing/reveal.tsx
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { motion, useReducedMotion } from "motion/react";
|
||||||
|
|
||||||
|
type RevealProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
delay?: number;
|
||||||
|
y?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Reveal({ children, className, delay = 0, y = 24 }: RevealProps) {
|
||||||
|
const reduceMotion = useReducedMotion();
|
||||||
|
|
||||||
|
if (reduceMotion) {
|
||||||
|
return <div className={className}>{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className={className}
|
||||||
|
initial={{ opacity: 0, y }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-60px" }}
|
||||||
|
transition={{ duration: 0.55, delay, ease: [0.22, 1, 0.36, 1] }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type StaggerProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
stagger?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Stagger({ children, className, stagger = 0.08 }: StaggerProps) {
|
||||||
|
const reduceMotion = useReducedMotion();
|
||||||
|
|
||||||
|
if (reduceMotion) {
|
||||||
|
return <div className={className}>{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className={className}
|
||||||
|
initial="hidden"
|
||||||
|
whileInView="visible"
|
||||||
|
viewport={{ once: true, margin: "-40px" }}
|
||||||
|
variants={{
|
||||||
|
hidden: {},
|
||||||
|
visible: { transition: { staggerChildren: stagger } },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StaggerItem({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const reduceMotion = useReducedMotion();
|
||||||
|
|
||||||
|
if (reduceMotion) {
|
||||||
|
return <div className={className}>{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className={className}
|
||||||
|
variants={{
|
||||||
|
hidden: { opacity: 0, y: 20 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
transition: { duration: 0.5, ease: [0.22, 1, 0.36, 1] },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
apps/web/components/marketing/scramble-text.tsx
Normal file
64
apps/web/components/marketing/scramble-text.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { motion, useReducedMotion } from "motion/react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789/_-";
|
||||||
|
|
||||||
|
type ScrambleTextProps = {
|
||||||
|
text: string;
|
||||||
|
className?: string;
|
||||||
|
durationMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ScrambleText({ text, className, durationMs = 1200 }: ScrambleTextProps) {
|
||||||
|
const reduceMotion = useReducedMotion();
|
||||||
|
const [display, setDisplay] = useState(text);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (reduceMotion) {
|
||||||
|
setDisplay(text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let frame = 0;
|
||||||
|
const totalFrames = Math.ceil(durationMs / 32);
|
||||||
|
const locked = new Set<number>();
|
||||||
|
|
||||||
|
const id = window.setInterval(() => {
|
||||||
|
frame += 1;
|
||||||
|
const progress = frame / totalFrames;
|
||||||
|
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
if (text[i] === " ") continue;
|
||||||
|
if (Math.random() < progress * 0.15 + 0.05) locked.add(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = text
|
||||||
|
.split("")
|
||||||
|
.map((char, i) => {
|
||||||
|
if (char === " ") return " ";
|
||||||
|
if (locked.has(i) || progress > 0.92) return char;
|
||||||
|
return CHARSET[Math.floor(Math.random() * CHARSET.length)] ?? char;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
setDisplay(next);
|
||||||
|
|
||||||
|
if (frame >= totalFrames) {
|
||||||
|
window.clearInterval(id);
|
||||||
|
setDisplay(text);
|
||||||
|
}
|
||||||
|
}, 32);
|
||||||
|
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [text, durationMs, reduceMotion]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.span className={cn("inline-block", className)} initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
|
||||||
|
{display}
|
||||||
|
</motion.span>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
apps/web/components/marketing/site-footer.tsx
Normal file
45
apps/web/components/marketing/site-footer.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { Logo } from "@/components/marketing/logo";
|
||||||
|
import { MarqueeStrip } from "@/components/marketing/marquee-strip";
|
||||||
|
|
||||||
|
const footerLinks = [
|
||||||
|
{ href: "/landing#features", label: "Capabilities" },
|
||||||
|
{ href: "/landing#manifest", label: "Manifest" },
|
||||||
|
{ href: "/landing/pricing", label: "Pricing" },
|
||||||
|
{ href: "/sign-in", label: "Sign in" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function SiteFooter() {
|
||||||
|
return (
|
||||||
|
<footer className="bg-background">
|
||||||
|
<MarqueeStrip variant="light" />
|
||||||
|
<div className="mx-auto flex max-w-6xl flex-col gap-10 px-4 py-14 sm:px-6 md:flex-row md:items-start md:justify-between">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Logo size="sm" showTagline variant="light" />
|
||||||
|
<p className="max-w-xs font-marketing text-sm text-muted-foreground">
|
||||||
|
Subagents. Frontmatter. One workspace.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex flex-wrap gap-x-10 gap-y-3" aria-label="Footer">
|
||||||
|
{footerLinks.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className="font-mono-brand text-[11px] uppercase tracking-[0.16em] text-muted-foreground transition-colors hover:text-foreground"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="marketing-hard-rule" aria-hidden />
|
||||||
|
<div className="mx-auto flex max-w-6xl flex-col gap-2 px-4 py-5 sm:flex-row sm:items-center sm:justify-between sm:px-6">
|
||||||
|
<p className="font-mono-brand text-[11px] text-muted-foreground">© {new Date().getFullYear()} Submatter</p>
|
||||||
|
<p className="font-mono-brand text-[11px] text-muted-foreground">v2 · sbmtr.io</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
apps/web/components/marketing/site-header.tsx
Normal file
53
apps/web/components/marketing/site-header.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { Logo } from "@/components/marketing/logo";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const nav = [
|
||||||
|
{ href: "/landing#features", label: "Capabilities" },
|
||||||
|
{ href: "/landing#pipeline", label: "Pipeline" },
|
||||||
|
{ href: "/landing#manifest", label: "Manifest" },
|
||||||
|
{ href: "/landing/pricing", label: "Pricing" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function SiteHeader({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
className={cn(
|
||||||
|
"sticky top-0 z-50 border-b border-[hsl(var(--hero-border))] bg-[hsl(var(--hero)/0.92)] text-[hsl(var(--hero-foreground))] backdrop-blur-sm",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between gap-6 px-4 sm:px-6">
|
||||||
|
<Logo size="sm" variant="dark" />
|
||||||
|
|
||||||
|
<nav className="hidden items-center gap-8 md:flex" aria-label="Main">
|
||||||
|
{nav.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className="font-mono-brand text-[11px] uppercase tracking-[0.16em] text-[hsl(var(--hero-muted))] transition-colors hover:text-[hsl(var(--hero-foreground))]"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link
|
||||||
|
href="/sign-in"
|
||||||
|
className="hidden font-mono-brand text-[11px] uppercase tracking-[0.16em] text-[hsl(var(--hero-muted))] transition-colors hover:text-[hsl(var(--hero-foreground))] sm:inline"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/sign-up"
|
||||||
|
className="inline-flex h-9 items-center border border-[hsl(var(--matter))] px-4 font-mono-brand text-[11px] uppercase tracking-[0.14em] text-[hsl(var(--matter))] transition-colors hover:bg-[hsl(var(--matter))] hover:text-[hsl(var(--hero))]"
|
||||||
|
>
|
||||||
|
Start
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -93,6 +93,19 @@ export function ConvoyDetailPanel({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4 rounded-md border border-border bg-muted/30 p-3 text-sm">
|
||||||
|
<p className="mb-1 text-xs font-medium uppercase text-muted-foreground">
|
||||||
|
Worker checkpoints
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{detailQuery.data?.checkpoints.count ?? 0} checkpoint
|
||||||
|
{(detailQuery.data?.checkpoints.count ?? 0) === 1 ? "" : "s"}
|
||||||
|
{detailQuery.data?.checkpoints.lastOccurredAt
|
||||||
|
? ` · last ${new Date(detailQuery.data.checkpoints.lastOccurredAt).toLocaleString()}`
|
||||||
|
: ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{convoy.description && (
|
{convoy.description && (
|
||||||
<div className="mb-4 rounded-md border border-border bg-muted/30 p-3">
|
<div className="mb-4 rounded-md border border-border bg-muted/30 p-3">
|
||||||
<p className="mb-1 text-xs font-medium uppercase text-muted-foreground">Plan</p>
|
<p className="mb-1 text-xs font-medium uppercase text-muted-foreground">Plan</p>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ export default auth((req) => {
|
||||||
|
|
||||||
const isPublic =
|
const isPublic =
|
||||||
pathname === "/" ||
|
pathname === "/" ||
|
||||||
|
pathname.startsWith("/landing") ||
|
||||||
pathname.startsWith("/sign-in") ||
|
pathname.startsWith("/sign-in") ||
|
||||||
pathname.startsWith("/sign-up") ||
|
pathname.startsWith("/sign-up") ||
|
||||||
pathname.startsWith("/api/auth");
|
pathname.startsWith("/api/auth");
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,7 @@
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"drizzle-orm": "^0.38.0",
|
"drizzle-orm": "^0.38.0",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
|
"motion": "^12.42.2",
|
||||||
"next": "^15.1.0",
|
"next": "^15.1.0",
|
||||||
"next-auth": "^5.0.0-beta.25",
|
"next-auth": "^5.0.0-beta.25",
|
||||||
"next-themes": "^0.4.4",
|
"next-themes": "^0.4.4",
|
||||||
|
|
@ -73,6 +74,7 @@
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"tldraw": "^4.5.4",
|
"tldraw": "^4.5.4",
|
||||||
"y-prosemirror": "^1.3.7",
|
"y-prosemirror": "^1.3.7",
|
||||||
|
"yaml": "^2.8.3",
|
||||||
"yjs": "^13.6.22",
|
"yjs": "^13.6.22",
|
||||||
"zod": "^3.24.0",
|
"zod": "^3.24.0",
|
||||||
"zustand": "^5.0.0"
|
"zustand": "^5.0.0"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { and, desc, eq, sql } from "drizzle-orm";
|
import { and, desc, eq, sql } from "drizzle-orm";
|
||||||
|
|
||||||
import type { db as dbInstance } from "@tasks/database";
|
import type { db as dbInstance } from "@tasks/database";
|
||||||
import { convoyEvents, objects } from "@tasks/database/schema";
|
import { convoyEvents, objects, workerCheckpoints } from "@tasks/database/schema";
|
||||||
import {
|
import {
|
||||||
convoyObjectContentSchema,
|
convoyObjectContentSchema,
|
||||||
convoyStatusSchema,
|
convoyStatusSchema,
|
||||||
|
|
@ -196,3 +196,28 @@ export async function getRecentConvoyEvents(
|
||||||
.orderBy(desc(convoyEvents.occurredAt))
|
.orderBy(desc(convoyEvents.occurredAt))
|
||||||
.limit(limit);
|
.limit(limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Checkpoint count + last time for mission-control detail (workspace-scoped). */
|
||||||
|
export async function getConvoyCheckpointSummary(
|
||||||
|
db: Db,
|
||||||
|
workspaceId: string,
|
||||||
|
convoyId: string,
|
||||||
|
): Promise<{ count: number; lastOccurredAt: Date | null }> {
|
||||||
|
const [row] = await db
|
||||||
|
.select({
|
||||||
|
count: sql<number>`count(*)::int`,
|
||||||
|
lastOccurredAt: sql<Date | null>`max(${workerCheckpoints.occurredAt})`,
|
||||||
|
})
|
||||||
|
.from(workerCheckpoints)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(workerCheckpoints.workspaceId, workspaceId),
|
||||||
|
eq(workerCheckpoints.convoyId, convoyId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
count: row?.count ?? 0,
|
||||||
|
lastOccurredAt: row?.lastOccurredAt ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { runsRouter } from "@/server/routers/runs";
|
||||||
import { backlogRouter } from "@/server/routers/backlog";
|
import { backlogRouter } from "@/server/routers/backlog";
|
||||||
import { connectedReposRouter } from "@/server/routers/connected-repos";
|
import { connectedReposRouter } from "@/server/routers/connected-repos";
|
||||||
import { convoysRouter } from "@/server/routers/convoys";
|
import { convoysRouter } from "@/server/routers/convoys";
|
||||||
|
import { harnessesRouter } from "@/server/routers/harnesses";
|
||||||
|
|
||||||
export const appRouter = router({
|
export const appRouter = router({
|
||||||
health: healthRouter,
|
health: healthRouter,
|
||||||
|
|
@ -37,6 +38,7 @@ export const appRouter = router({
|
||||||
backlog: backlogRouter,
|
backlog: backlogRouter,
|
||||||
connectedRepos: connectedReposRouter,
|
connectedRepos: connectedReposRouter,
|
||||||
convoys: convoysRouter,
|
convoys: convoysRouter,
|
||||||
|
harnesses: harnessesRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AppRouter = typeof appRouter;
|
export type AppRouter = typeof appRouter;
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,93 @@
|
||||||
|
import { mkdir, writeFile, rename, access, readFile } from "node:fs/promises";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { parseDocument, Scalar } from "yaml";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, asc, eq } from "drizzle-orm";
|
||||||
|
|
||||||
import { markdownBacklogItems } from "@tasks/database/schema";
|
import { markdownBacklogItems, objects } from "@tasks/database/schema";
|
||||||
import {
|
import {
|
||||||
resolveWorkflowPrompt,
|
resolveWorkflowPrompt,
|
||||||
DEFAULT_AGENT_PROMPT,
|
DEFAULT_AGENT_PROMPT,
|
||||||
exportBacklogItemToMarkdown,
|
exportBacklogItemToMarkdown,
|
||||||
|
hashFileContents,
|
||||||
} from "@tasks/database/markdown-backlog";
|
} from "@tasks/database/markdown-backlog";
|
||||||
|
import { parentRepoPath } from "@tasks/database/markdown-backlog";
|
||||||
|
|
||||||
import { router, workspaceProcedure } from "@/server/trpc";
|
import { router, workspaceProcedure } from "@/server/trpc";
|
||||||
import { recordAudit } from "@/server/lib/audit";
|
import { recordAudit } from "@/server/lib/audit";
|
||||||
import { resolveRepoRoot } from "@/server/lib/repo-root";
|
import { resolveRepoRoot } from "@/server/lib/repo-root";
|
||||||
|
import { findConvoyBySlug, insertConvoyEvent } from "@/server/lib/convoy/convoy-service";
|
||||||
|
|
||||||
const MAX_PROMPT_LENGTH = 20_000;
|
const MAX_PROMPT_LENGTH = 20_000;
|
||||||
|
const SLUG_RE = /^[a-z0-9-]+$/;
|
||||||
|
const SLUG_STRICT_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||||
|
|
||||||
|
function todayIso(): string {
|
||||||
|
return new Date().toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultBodyForKind(kind: string, title: string): string {
|
||||||
|
if (kind === "plan") return `# ${title}\n\nShort summary of the initiative and why it exists.\n`;
|
||||||
|
if (kind === "epic") return `# ${title}\n\nOne paragraph: what milestone this epic delivers.\n`;
|
||||||
|
return `# ${title}\n\nOne or two sentences.\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function repoPathFor(input: { kind: string; slug: string; planSlug?: string; epicSlug?: string }): string {
|
||||||
|
if (input.kind === "plan") return `plans/Plan-${input.slug}/Plan-${input.slug}.md`;
|
||||||
|
if (input.kind === "epic") return `plans/Plan-${input.planSlug}/Epic-${input.slug}/Epic-${input.slug}.md`;
|
||||||
|
return `plans/Plan-${input.planSlug}/Epic-${input.epicSlug}/Task-${input.slug}.md`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFrontmatterYaml(input: {
|
||||||
|
kind: string;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
planSlug: string | null;
|
||||||
|
epicSlug: string | null;
|
||||||
|
status: string;
|
||||||
|
priority: string;
|
||||||
|
agentPrompt: string | null;
|
||||||
|
}): string {
|
||||||
|
const doc = parseDocument("");
|
||||||
|
doc.set("kind", input.kind);
|
||||||
|
doc.set("slug", input.slug);
|
||||||
|
doc.set("title", input.title);
|
||||||
|
if (input.planSlug) doc.set("plan_slug", input.planSlug);
|
||||||
|
if (input.epicSlug) doc.set("epic_slug", input.epicSlug);
|
||||||
|
doc.set("status", input.status);
|
||||||
|
doc.set("priority", input.priority);
|
||||||
|
doc.set("updated_at", todayIso());
|
||||||
|
if (input.agentPrompt !== null && input.agentPrompt.trim() !== "") {
|
||||||
|
const isMultiline = input.agentPrompt.includes("\n");
|
||||||
|
const node = doc.createNode(input.agentPrompt);
|
||||||
|
if (node instanceof Scalar) node.type = isMultiline ? Scalar.BLOCK_LITERAL : Scalar.PLAIN;
|
||||||
|
doc.set("agent_prompt", node);
|
||||||
|
}
|
||||||
|
const yamlText = String(doc).replace(/\n$/, "");
|
||||||
|
return yamlText;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function atomicWrite(absPath: string, contents: string): Promise<void> {
|
||||||
|
const dir = dirname(absPath);
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
const tmp = join(dir, `.${randomUUID()}.tmp`);
|
||||||
|
await writeFile(tmp, contents, "utf8");
|
||||||
|
await rename(tmp, absPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fileExists(absPath: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await access(absPath);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const slugSchema = z.string().min(1).max(200).regex(SLUG_RE, "slug must match ^[a-z0-9-]+$");
|
||||||
|
const slugStrictSchema = z.string().min(1).max(120).regex(SLUG_STRICT_RE, "slug must be kebab-case");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* tRPC procedures for managing the markdown-backlog rows in the DB,
|
* tRPC procedures for managing the markdown-backlog rows in the DB,
|
||||||
|
|
@ -144,15 +218,815 @@ export const backlogRouter = router({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(
|
console.warn("[backlog.updateWorkflowPrompt] markdown export threw:", e);
|
||||||
"[backlog.updateWorkflowPrompt] markdown export threw:",
|
|
||||||
e,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return updated;
|
return updated;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
listTree: workspaceProcedure.query(async ({ ctx }) => {
|
||||||
|
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,
|
||||||
|
bodyMarkdown: markdownBacklogItems.bodyMarkdown,
|
||||||
|
workflowPrompt: markdownBacklogItems.workflowPrompt,
|
||||||
|
frontmatter: markdownBacklogItems.frontmatter,
|
||||||
|
updatedAt: markdownBacklogItems.updatedAt,
|
||||||
|
parentId: markdownBacklogItems.parentId,
|
||||||
|
})
|
||||||
|
.from(markdownBacklogItems)
|
||||||
|
.where(eq(markdownBacklogItems.workspaceId, ctx.workspace.id))
|
||||||
|
.orderBy(asc(markdownBacklogItems.planSlug), asc(markdownBacklogItems.kind), asc(markdownBacklogItems.slug));
|
||||||
|
|
||||||
|
type Row = (typeof rows)[number];
|
||||||
|
type TaskNode = Row;
|
||||||
|
type EpicNode = Row & { tasks: TaskNode[] };
|
||||||
|
type PlanNode = Row & { epics: EpicNode[] };
|
||||||
|
|
||||||
|
const planMap = new Map<string, PlanNode>();
|
||||||
|
const epicMap = new Map<string, EpicNode>(); // key planSlug/epicSlug
|
||||||
|
|
||||||
|
for (const r of rows) {
|
||||||
|
if (r.kind === "plan") {
|
||||||
|
planMap.set(r.slug, { ...r, epics: [] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const r of rows) {
|
||||||
|
if (r.kind === "epic") {
|
||||||
|
const key = `${r.planSlug}/${r.slug}`;
|
||||||
|
const node: EpicNode = { ...r, tasks: [] };
|
||||||
|
epicMap.set(key, node);
|
||||||
|
const plan = planMap.get(r.planSlug);
|
||||||
|
if (plan) plan.epics.push(node);
|
||||||
|
else {
|
||||||
|
// Orphan epic: ensure plan placeholder exists so tree still renders
|
||||||
|
const placeholder: PlanNode = {
|
||||||
|
id: `placeholder-${r.planSlug}`,
|
||||||
|
kind: "plan",
|
||||||
|
slug: r.planSlug,
|
||||||
|
planSlug: r.planSlug,
|
||||||
|
epicSlug: null,
|
||||||
|
title: r.planSlug,
|
||||||
|
status: null,
|
||||||
|
priority: null,
|
||||||
|
repoPath: `plans/Plan-${r.planSlug}/Plan-${r.planSlug}.md`,
|
||||||
|
bodyMarkdown: "",
|
||||||
|
workflowPrompt: null,
|
||||||
|
frontmatter: null,
|
||||||
|
updatedAt: r.updatedAt,
|
||||||
|
parentId: null,
|
||||||
|
epics: [node],
|
||||||
|
};
|
||||||
|
planMap.set(r.planSlug, placeholder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const r of rows) {
|
||||||
|
if (r.kind === "task") {
|
||||||
|
const epicKey = `${r.planSlug}/${r.epicSlug ?? ""}`;
|
||||||
|
const epic = epicMap.get(epicKey);
|
||||||
|
if (epic) epic.tasks.push(r);
|
||||||
|
else {
|
||||||
|
// Orphan task: attach to epic placeholder under plan
|
||||||
|
let epicNode = epicMap.get(epicKey);
|
||||||
|
if (!epicNode && r.epicSlug) {
|
||||||
|
epicNode = {
|
||||||
|
id: `placeholder-${r.planSlug}-${r.epicSlug}`,
|
||||||
|
kind: "epic",
|
||||||
|
slug: r.epicSlug,
|
||||||
|
planSlug: r.planSlug,
|
||||||
|
epicSlug: null,
|
||||||
|
title: r.epicSlug,
|
||||||
|
status: null,
|
||||||
|
priority: null,
|
||||||
|
repoPath: `plans/Plan-${r.planSlug}/Epic-${r.epicSlug}/Epic-${r.epicSlug}.md`,
|
||||||
|
bodyMarkdown: "",
|
||||||
|
workflowPrompt: null,
|
||||||
|
frontmatter: null,
|
||||||
|
updatedAt: r.updatedAt,
|
||||||
|
parentId: null,
|
||||||
|
tasks: [],
|
||||||
|
};
|
||||||
|
epicMap.set(epicKey, epicNode);
|
||||||
|
const plan = planMap.get(r.planSlug);
|
||||||
|
if (plan) plan.epics.push(epicNode);
|
||||||
|
else {
|
||||||
|
const placeholderPlan: PlanNode = {
|
||||||
|
id: `placeholder-${r.planSlug}`,
|
||||||
|
kind: "plan",
|
||||||
|
slug: r.planSlug,
|
||||||
|
planSlug: r.planSlug,
|
||||||
|
epicSlug: null,
|
||||||
|
title: r.planSlug,
|
||||||
|
status: null,
|
||||||
|
priority: null,
|
||||||
|
repoPath: `plans/Plan-${r.planSlug}/Plan-${r.planSlug}.md`,
|
||||||
|
bodyMarkdown: "",
|
||||||
|
workflowPrompt: null,
|
||||||
|
frontmatter: null,
|
||||||
|
updatedAt: r.updatedAt,
|
||||||
|
parentId: null,
|
||||||
|
epics: [epicNode],
|
||||||
|
};
|
||||||
|
planMap.set(r.planSlug, placeholderPlan);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
epicNode?.tasks.push(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const plans = Array.from(planMap.values()).sort((a, b) => a.slug.localeCompare(b.slug));
|
||||||
|
for (const p of plans) {
|
||||||
|
p.epics.sort((a, b) => a.slug.localeCompare(b.slug));
|
||||||
|
for (const e of p.epics) e.tasks.sort((a, b) => a.slug.localeCompare(b.slug));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { plans };
|
||||||
|
}),
|
||||||
|
|
||||||
|
getBacklogItem: workspaceProcedure
|
||||||
|
.input(z.object({ backlogItemId: z.string().uuid() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const [row] = await ctx.db
|
||||||
|
.select()
|
||||||
|
.from(markdownBacklogItems)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(markdownBacklogItems.id, input.backlogItemId),
|
||||||
|
eq(markdownBacklogItems.workspaceId, ctx.workspace.id),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (!row) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: "Backlog item not found in this workspace." });
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}),
|
||||||
|
|
||||||
|
createPlan: workspaceProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
slug: slugSchema,
|
||||||
|
title: z.string().min(1).max(500),
|
||||||
|
bodyMarkdown: z.string().max(50_000).optional(),
|
||||||
|
agentPrompt: z.string().max(MAX_PROMPT_LENGTH).nullable().optional(),
|
||||||
|
status: z.enum(["draft", "ready", "in_progress", "blocked", "done", "cancelled"]).optional(),
|
||||||
|
priority: z.enum(["P0", "P1", "P2", "P3"]).optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const slug = input.slug.trim().toLowerCase();
|
||||||
|
if (!SLUG_RE.test(slug)) {
|
||||||
|
throw new TRPCError({ code: "BAD_REQUEST", message: "slug must match ^[a-z0-9-]+$" });
|
||||||
|
}
|
||||||
|
const title = input.title.trim();
|
||||||
|
const status = input.status ?? "draft";
|
||||||
|
const priority = input.priority ?? "P2";
|
||||||
|
const agentPrompt = input.agentPrompt?.trim() ? input.agentPrompt.trim() : null;
|
||||||
|
const bodyMarkdown = input.bodyMarkdown?.trim() ? input.bodyMarkdown.trim() : defaultBodyForKind("plan", title);
|
||||||
|
|
||||||
|
const repoPath = repoPathFor({ kind: "plan", slug });
|
||||||
|
const repoRoot = resolveRepoRoot();
|
||||||
|
|
||||||
|
if (repoRoot) {
|
||||||
|
const absPath = join(repoRoot, ...repoPath.split("/"));
|
||||||
|
if (await fileExists(absPath)) {
|
||||||
|
throw new TRPCError({ code: "CONFLICT", message: `Backlog item already exists at ${repoPath}` });
|
||||||
|
}
|
||||||
|
const [existingRow] = await ctx.db
|
||||||
|
.select({ id: markdownBacklogItems.id })
|
||||||
|
.from(markdownBacklogItems)
|
||||||
|
.where(and(eq(markdownBacklogItems.workspaceId, ctx.workspace.id), eq(markdownBacklogItems.repoPath, repoPath)))
|
||||||
|
.limit(1);
|
||||||
|
if (existingRow) {
|
||||||
|
throw new TRPCError({ code: "CONFLICT", message: `Backlog item already exists at ${repoPath}` });
|
||||||
|
}
|
||||||
|
const yamlText = buildFrontmatterYaml({
|
||||||
|
kind: "plan",
|
||||||
|
slug,
|
||||||
|
title,
|
||||||
|
planSlug: null,
|
||||||
|
epicSlug: null,
|
||||||
|
status,
|
||||||
|
priority,
|
||||||
|
agentPrompt,
|
||||||
|
});
|
||||||
|
const body = bodyMarkdown.startsWith("\n") ? bodyMarkdown : `\n${bodyMarkdown}`;
|
||||||
|
const normalizedBody = body.endsWith("\n") ? body : `${body}\n`;
|
||||||
|
const raw = `---\n${yamlText}\n---${normalizedBody}`;
|
||||||
|
const contentHash = hashFileContents(raw);
|
||||||
|
const frontmatter: Record<string, unknown> = {
|
||||||
|
kind: "plan",
|
||||||
|
slug,
|
||||||
|
title,
|
||||||
|
status,
|
||||||
|
priority,
|
||||||
|
updated_at: todayIso(),
|
||||||
|
};
|
||||||
|
if (agentPrompt) frontmatter.agent_prompt = agentPrompt;
|
||||||
|
await atomicWrite(absPath, raw);
|
||||||
|
const [inserted] = await ctx.db
|
||||||
|
.insert(markdownBacklogItems)
|
||||||
|
.values({
|
||||||
|
workspaceId: ctx.workspace.id,
|
||||||
|
kind: "plan",
|
||||||
|
slug,
|
||||||
|
planSlug: slug,
|
||||||
|
epicSlug: null,
|
||||||
|
parentId: null,
|
||||||
|
repoPath,
|
||||||
|
title,
|
||||||
|
status,
|
||||||
|
priority,
|
||||||
|
owner: null,
|
||||||
|
frontmatter,
|
||||||
|
bodyMarkdown: normalizedBody.replace(/^\n/, ""),
|
||||||
|
contentHash,
|
||||||
|
workflowPrompt: agentPrompt,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
if (!inserted) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to create plan" });
|
||||||
|
await recordAudit(ctx.db, {
|
||||||
|
workspaceId: ctx.workspace.id,
|
||||||
|
actorUserId: ctx.session.user.id,
|
||||||
|
action: "backlog.create_plan",
|
||||||
|
targetType: "markdown_backlog_item",
|
||||||
|
targetId: inserted.id,
|
||||||
|
metadata: { slug, repoPath },
|
||||||
|
});
|
||||||
|
return inserted;
|
||||||
|
} else {
|
||||||
|
// No repo root (production) — DB only
|
||||||
|
const body = bodyMarkdown.endsWith("\n") ? bodyMarkdown : `${bodyMarkdown}\n`;
|
||||||
|
const rawForHash = `---\n${buildFrontmatterYaml({
|
||||||
|
kind: "plan",
|
||||||
|
slug,
|
||||||
|
title,
|
||||||
|
planSlug: null,
|
||||||
|
epicSlug: null,
|
||||||
|
status,
|
||||||
|
priority,
|
||||||
|
agentPrompt,
|
||||||
|
})}\n---\n${body}`;
|
||||||
|
const contentHash = hashFileContents(rawForHash);
|
||||||
|
const frontmatter: Record<string, unknown> = {
|
||||||
|
kind: "plan",
|
||||||
|
slug,
|
||||||
|
title,
|
||||||
|
status,
|
||||||
|
priority,
|
||||||
|
updated_at: todayIso(),
|
||||||
|
};
|
||||||
|
if (agentPrompt) frontmatter.agent_prompt = agentPrompt;
|
||||||
|
const [existingRow] = await ctx.db
|
||||||
|
.select({ id: markdownBacklogItems.id })
|
||||||
|
.from(markdownBacklogItems)
|
||||||
|
.where(and(eq(markdownBacklogItems.workspaceId, ctx.workspace.id), eq(markdownBacklogItems.repoPath, repoPath)))
|
||||||
|
.limit(1);
|
||||||
|
if (existingRow) throw new TRPCError({ code: "CONFLICT", message: `Backlog item already exists at ${repoPath}` });
|
||||||
|
const [inserted] = await ctx.db
|
||||||
|
.insert(markdownBacklogItems)
|
||||||
|
.values({
|
||||||
|
workspaceId: ctx.workspace.id,
|
||||||
|
kind: "plan",
|
||||||
|
slug,
|
||||||
|
planSlug: slug,
|
||||||
|
epicSlug: null,
|
||||||
|
parentId: null,
|
||||||
|
repoPath,
|
||||||
|
title,
|
||||||
|
status,
|
||||||
|
priority,
|
||||||
|
owner: null,
|
||||||
|
frontmatter,
|
||||||
|
bodyMarkdown: body,
|
||||||
|
contentHash,
|
||||||
|
workflowPrompt: agentPrompt,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
if (!inserted) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to create plan" });
|
||||||
|
return inserted;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
createEpic: workspaceProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
planSlug: slugSchema,
|
||||||
|
slug: slugSchema,
|
||||||
|
title: z.string().min(1).max(500),
|
||||||
|
bodyMarkdown: z.string().max(50_000).optional(),
|
||||||
|
agentPrompt: z.string().max(MAX_PROMPT_LENGTH).nullable().optional(),
|
||||||
|
status: z.enum(["draft", "ready", "in_progress", "blocked", "done", "cancelled"]).optional(),
|
||||||
|
priority: z.enum(["P0", "P1", "P2", "P3"]).optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const planSlug = input.planSlug.trim().toLowerCase();
|
||||||
|
const slug = input.slug.trim().toLowerCase();
|
||||||
|
const title = input.title.trim();
|
||||||
|
const status = input.status ?? "draft";
|
||||||
|
const priority = input.priority ?? "P2";
|
||||||
|
const agentPrompt = input.agentPrompt?.trim() ? input.agentPrompt.trim() : null;
|
||||||
|
const bodyMarkdown = input.bodyMarkdown?.trim() ? input.bodyMarkdown.trim() : defaultBodyForKind("epic", title);
|
||||||
|
|
||||||
|
// Verify plan exists in DB for this workspace
|
||||||
|
const [planRow] = await ctx.db
|
||||||
|
.select({ id: markdownBacklogItems.id })
|
||||||
|
.from(markdownBacklogItems)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(markdownBacklogItems.workspaceId, ctx.workspace.id),
|
||||||
|
eq(markdownBacklogItems.kind, "plan"),
|
||||||
|
eq(markdownBacklogItems.slug, planSlug),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (!planRow) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: `Plan '${planSlug}' not found` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const repoPath = repoPathFor({ kind: "epic", slug, planSlug });
|
||||||
|
const repoRoot = resolveRepoRoot();
|
||||||
|
|
||||||
|
if (repoRoot) {
|
||||||
|
const absPath = join(repoRoot, ...repoPath.split("/"));
|
||||||
|
if (await fileExists(absPath)) {
|
||||||
|
throw new TRPCError({ code: "CONFLICT", message: `Backlog item already exists at ${repoPath}` });
|
||||||
|
}
|
||||||
|
const [existingRow] = await ctx.db
|
||||||
|
.select({ id: markdownBacklogItems.id })
|
||||||
|
.from(markdownBacklogItems)
|
||||||
|
.where(and(eq(markdownBacklogItems.workspaceId, ctx.workspace.id), eq(markdownBacklogItems.repoPath, repoPath)))
|
||||||
|
.limit(1);
|
||||||
|
if (existingRow) throw new TRPCError({ code: "CONFLICT", message: `Backlog item already exists at ${repoPath}` });
|
||||||
|
const yamlText = buildFrontmatterYaml({
|
||||||
|
kind: "epic",
|
||||||
|
slug,
|
||||||
|
title,
|
||||||
|
planSlug,
|
||||||
|
epicSlug: null,
|
||||||
|
status,
|
||||||
|
priority,
|
||||||
|
agentPrompt,
|
||||||
|
});
|
||||||
|
const body = bodyMarkdown.startsWith("\n") ? bodyMarkdown : `\n${bodyMarkdown}`;
|
||||||
|
const normalizedBody = body.endsWith("\n") ? body : `${body}\n`;
|
||||||
|
const raw = `---\n${yamlText}\n---${normalizedBody}`;
|
||||||
|
const contentHash = hashFileContents(raw);
|
||||||
|
const frontmatter: Record<string, unknown> = {
|
||||||
|
kind: "epic",
|
||||||
|
slug,
|
||||||
|
title,
|
||||||
|
plan_slug: planSlug,
|
||||||
|
status,
|
||||||
|
priority,
|
||||||
|
updated_at: todayIso(),
|
||||||
|
};
|
||||||
|
if (agentPrompt) frontmatter.agent_prompt = agentPrompt;
|
||||||
|
await atomicWrite(absPath, raw);
|
||||||
|
const parentPath = parentRepoPath(repoPath);
|
||||||
|
let parentId: string | null = null;
|
||||||
|
if (parentPath) {
|
||||||
|
const [parentRow] = await ctx.db
|
||||||
|
.select({ id: markdownBacklogItems.id })
|
||||||
|
.from(markdownBacklogItems)
|
||||||
|
.where(and(eq(markdownBacklogItems.workspaceId, ctx.workspace.id), eq(markdownBacklogItems.repoPath, parentPath)))
|
||||||
|
.limit(1);
|
||||||
|
parentId = parentRow?.id ?? planRow.id;
|
||||||
|
}
|
||||||
|
const [inserted] = await ctx.db
|
||||||
|
.insert(markdownBacklogItems)
|
||||||
|
.values({
|
||||||
|
workspaceId: ctx.workspace.id,
|
||||||
|
kind: "epic",
|
||||||
|
slug,
|
||||||
|
planSlug,
|
||||||
|
epicSlug: null,
|
||||||
|
parentId,
|
||||||
|
repoPath,
|
||||||
|
title,
|
||||||
|
status,
|
||||||
|
priority,
|
||||||
|
owner: null,
|
||||||
|
frontmatter,
|
||||||
|
bodyMarkdown: normalizedBody.replace(/^\n/, ""),
|
||||||
|
contentHash,
|
||||||
|
workflowPrompt: agentPrompt,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
if (!inserted) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to create epic" });
|
||||||
|
await recordAudit(ctx.db, {
|
||||||
|
workspaceId: ctx.workspace.id,
|
||||||
|
actorUserId: ctx.session.user.id,
|
||||||
|
action: "backlog.create_epic",
|
||||||
|
targetType: "markdown_backlog_item",
|
||||||
|
targetId: inserted.id,
|
||||||
|
metadata: { slug, planSlug, repoPath },
|
||||||
|
});
|
||||||
|
return inserted;
|
||||||
|
} else {
|
||||||
|
const body = bodyMarkdown.endsWith("\n") ? bodyMarkdown : `${bodyMarkdown}\n`;
|
||||||
|
const rawForHash = `---\n${buildFrontmatterYaml({
|
||||||
|
kind: "epic",
|
||||||
|
slug,
|
||||||
|
title,
|
||||||
|
planSlug,
|
||||||
|
epicSlug: null,
|
||||||
|
status,
|
||||||
|
priority,
|
||||||
|
agentPrompt,
|
||||||
|
})}\n---\n${body}`;
|
||||||
|
const contentHash = hashFileContents(rawForHash);
|
||||||
|
const frontmatter: Record<string, unknown> = {
|
||||||
|
kind: "epic",
|
||||||
|
slug,
|
||||||
|
title,
|
||||||
|
plan_slug: planSlug,
|
||||||
|
status,
|
||||||
|
priority,
|
||||||
|
updated_at: todayIso(),
|
||||||
|
};
|
||||||
|
if (agentPrompt) frontmatter.agent_prompt = agentPrompt;
|
||||||
|
const [existingRow] = await ctx.db
|
||||||
|
.select({ id: markdownBacklogItems.id })
|
||||||
|
.from(markdownBacklogItems)
|
||||||
|
.where(and(eq(markdownBacklogItems.workspaceId, ctx.workspace.id), eq(markdownBacklogItems.repoPath, repoPath)))
|
||||||
|
.limit(1);
|
||||||
|
if (existingRow) throw new TRPCError({ code: "CONFLICT", message: `Backlog item already exists at ${repoPath}` });
|
||||||
|
const [inserted] = await ctx.db
|
||||||
|
.insert(markdownBacklogItems)
|
||||||
|
.values({
|
||||||
|
workspaceId: ctx.workspace.id,
|
||||||
|
kind: "epic",
|
||||||
|
slug,
|
||||||
|
planSlug,
|
||||||
|
epicSlug: null,
|
||||||
|
parentId: planRow.id,
|
||||||
|
repoPath,
|
||||||
|
title,
|
||||||
|
status,
|
||||||
|
priority,
|
||||||
|
owner: null,
|
||||||
|
frontmatter,
|
||||||
|
bodyMarkdown: body,
|
||||||
|
contentHash,
|
||||||
|
workflowPrompt: agentPrompt,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
if (!inserted) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to create epic" });
|
||||||
|
return inserted;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
createTask: workspaceProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
planSlug: slugSchema,
|
||||||
|
epicSlug: slugSchema,
|
||||||
|
slug: slugSchema,
|
||||||
|
title: z.string().min(1).max(500),
|
||||||
|
bodyMarkdown: z.string().max(50_000).optional(),
|
||||||
|
agentPrompt: z.string().max(MAX_PROMPT_LENGTH).nullable().optional(),
|
||||||
|
status: z.enum(["draft", "ready", "in_progress", "blocked", "done", "cancelled"]).optional(),
|
||||||
|
priority: z.enum(["P0", "P1", "P2", "P3"]).optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const planSlug = input.planSlug.trim().toLowerCase();
|
||||||
|
const epicSlug = input.epicSlug.trim().toLowerCase();
|
||||||
|
const slug = input.slug.trim().toLowerCase();
|
||||||
|
const title = input.title.trim();
|
||||||
|
const status = input.status ?? "ready";
|
||||||
|
const priority = input.priority ?? "P2";
|
||||||
|
const agentPrompt = input.agentPrompt?.trim() ? input.agentPrompt.trim() : null;
|
||||||
|
const bodyMarkdown = input.bodyMarkdown?.trim() ? input.bodyMarkdown.trim() : defaultBodyForKind("task", title);
|
||||||
|
|
||||||
|
const [planRow] = await ctx.db
|
||||||
|
.select({ id: markdownBacklogItems.id })
|
||||||
|
.from(markdownBacklogItems)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(markdownBacklogItems.workspaceId, ctx.workspace.id),
|
||||||
|
eq(markdownBacklogItems.kind, "plan"),
|
||||||
|
eq(markdownBacklogItems.slug, planSlug),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (!planRow) throw new TRPCError({ code: "NOT_FOUND", message: `Plan '${planSlug}' not found` });
|
||||||
|
const [epicRow] = await ctx.db
|
||||||
|
.select({ id: markdownBacklogItems.id })
|
||||||
|
.from(markdownBacklogItems)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(markdownBacklogItems.workspaceId, ctx.workspace.id),
|
||||||
|
eq(markdownBacklogItems.kind, "epic"),
|
||||||
|
eq(markdownBacklogItems.slug, epicSlug),
|
||||||
|
eq(markdownBacklogItems.planSlug, planSlug),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (!epicRow) throw new TRPCError({ code: "NOT_FOUND", message: `Epic '${epicSlug}' not found under plan '${planSlug}'` });
|
||||||
|
|
||||||
|
const repoPath = repoPathFor({ kind: "task", slug, planSlug, epicSlug });
|
||||||
|
const repoRoot = resolveRepoRoot();
|
||||||
|
|
||||||
|
if (repoRoot) {
|
||||||
|
const absPath = join(repoRoot, ...repoPath.split("/"));
|
||||||
|
if (await fileExists(absPath)) {
|
||||||
|
throw new TRPCError({ code: "CONFLICT", message: `Backlog item already exists at ${repoPath}` });
|
||||||
|
}
|
||||||
|
const [existingRow] = await ctx.db
|
||||||
|
.select({ id: markdownBacklogItems.id })
|
||||||
|
.from(markdownBacklogItems)
|
||||||
|
.where(and(eq(markdownBacklogItems.workspaceId, ctx.workspace.id), eq(markdownBacklogItems.repoPath, repoPath)))
|
||||||
|
.limit(1);
|
||||||
|
if (existingRow) throw new TRPCError({ code: "CONFLICT", message: `Backlog item already exists at ${repoPath}` });
|
||||||
|
const yamlText = buildFrontmatterYaml({
|
||||||
|
kind: "task",
|
||||||
|
slug,
|
||||||
|
title,
|
||||||
|
planSlug,
|
||||||
|
epicSlug,
|
||||||
|
status,
|
||||||
|
priority,
|
||||||
|
agentPrompt,
|
||||||
|
});
|
||||||
|
const body = bodyMarkdown.startsWith("\n") ? bodyMarkdown : `\n${bodyMarkdown}`;
|
||||||
|
const normalizedBody = body.endsWith("\n") ? body : `${body}\n`;
|
||||||
|
const raw = `---\n${yamlText}\n---${normalizedBody}`;
|
||||||
|
const contentHash = hashFileContents(raw);
|
||||||
|
const frontmatter: Record<string, unknown> = {
|
||||||
|
kind: "task",
|
||||||
|
slug,
|
||||||
|
title,
|
||||||
|
plan_slug: planSlug,
|
||||||
|
epic_slug: epicSlug,
|
||||||
|
status,
|
||||||
|
priority,
|
||||||
|
updated_at: todayIso(),
|
||||||
|
};
|
||||||
|
if (agentPrompt) frontmatter.agent_prompt = agentPrompt;
|
||||||
|
await atomicWrite(absPath, raw);
|
||||||
|
const [inserted] = await ctx.db
|
||||||
|
.insert(markdownBacklogItems)
|
||||||
|
.values({
|
||||||
|
workspaceId: ctx.workspace.id,
|
||||||
|
kind: "task",
|
||||||
|
slug,
|
||||||
|
planSlug,
|
||||||
|
epicSlug,
|
||||||
|
parentId: epicRow.id,
|
||||||
|
repoPath,
|
||||||
|
title,
|
||||||
|
status,
|
||||||
|
priority,
|
||||||
|
owner: null,
|
||||||
|
frontmatter,
|
||||||
|
bodyMarkdown: normalizedBody.replace(/^\n/, ""),
|
||||||
|
contentHash,
|
||||||
|
workflowPrompt: agentPrompt,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
if (!inserted) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to create task" });
|
||||||
|
await recordAudit(ctx.db, {
|
||||||
|
workspaceId: ctx.workspace.id,
|
||||||
|
actorUserId: ctx.session.user.id,
|
||||||
|
action: "backlog.create_task",
|
||||||
|
targetType: "markdown_backlog_item",
|
||||||
|
targetId: inserted.id,
|
||||||
|
metadata: { slug, planSlug, epicSlug, repoPath },
|
||||||
|
});
|
||||||
|
return inserted;
|
||||||
|
} else {
|
||||||
|
const body = bodyMarkdown.endsWith("\n") ? bodyMarkdown : `${bodyMarkdown}\n`;
|
||||||
|
const rawForHash = `---\n${buildFrontmatterYaml({
|
||||||
|
kind: "task",
|
||||||
|
slug,
|
||||||
|
title,
|
||||||
|
planSlug,
|
||||||
|
epicSlug,
|
||||||
|
status,
|
||||||
|
priority,
|
||||||
|
agentPrompt,
|
||||||
|
})}\n---\n${body}`;
|
||||||
|
const contentHash = hashFileContents(rawForHash);
|
||||||
|
const frontmatter: Record<string, unknown> = {
|
||||||
|
kind: "task",
|
||||||
|
slug,
|
||||||
|
title,
|
||||||
|
plan_slug: planSlug,
|
||||||
|
epic_slug: epicSlug,
|
||||||
|
status,
|
||||||
|
priority,
|
||||||
|
updated_at: todayIso(),
|
||||||
|
};
|
||||||
|
if (agentPrompt) frontmatter.agent_prompt = agentPrompt;
|
||||||
|
const [existingRow] = await ctx.db
|
||||||
|
.select({ id: markdownBacklogItems.id })
|
||||||
|
.from(markdownBacklogItems)
|
||||||
|
.where(and(eq(markdownBacklogItems.workspaceId, ctx.workspace.id), eq(markdownBacklogItems.repoPath, repoPath)))
|
||||||
|
.limit(1);
|
||||||
|
if (existingRow) throw new TRPCError({ code: "CONFLICT", message: `Backlog item already exists at ${repoPath}` });
|
||||||
|
const [inserted] = await ctx.db
|
||||||
|
.insert(markdownBacklogItems)
|
||||||
|
.values({
|
||||||
|
workspaceId: ctx.workspace.id,
|
||||||
|
kind: "task",
|
||||||
|
slug,
|
||||||
|
planSlug,
|
||||||
|
epicSlug,
|
||||||
|
parentId: epicRow.id,
|
||||||
|
repoPath,
|
||||||
|
title,
|
||||||
|
status,
|
||||||
|
priority,
|
||||||
|
owner: null,
|
||||||
|
frontmatter,
|
||||||
|
bodyMarkdown: body,
|
||||||
|
contentHash,
|
||||||
|
workflowPrompt: agentPrompt,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
if (!inserted) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to create task" });
|
||||||
|
return inserted;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
updateTaskBody: workspaceProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
backlogItemId: z.string().uuid(),
|
||||||
|
bodyMarkdown: z.string().min(1).max(100_000),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const [row] = await ctx.db
|
||||||
|
.select()
|
||||||
|
.from(markdownBacklogItems)
|
||||||
|
.where(and(eq(markdownBacklogItems.id, input.backlogItemId), eq(markdownBacklogItems.workspaceId, ctx.workspace.id)))
|
||||||
|
.limit(1);
|
||||||
|
if (!row) throw new TRPCError({ code: "NOT_FOUND", message: "Backlog item not found in this workspace." });
|
||||||
|
if (row.kind !== "task") throw new TRPCError({ code: "BAD_REQUEST", message: "Only tasks support body updates via this endpoint." });
|
||||||
|
|
||||||
|
const newBody = input.bodyMarkdown.endsWith("\n") ? input.bodyMarkdown : `${input.bodyMarkdown}\n`;
|
||||||
|
const repoRoot = resolveRepoRoot();
|
||||||
|
let contentHash = row.contentHash;
|
||||||
|
|
||||||
|
if (repoRoot && row.repoPath) {
|
||||||
|
const absPath = join(repoRoot, ...row.repoPath.split("/"));
|
||||||
|
try {
|
||||||
|
const rawBefore = await readFile(absPath, "utf8");
|
||||||
|
const fmMatch = rawBefore.match(/^---\r?\n([\s\S]*?)\r?\n---(\r?\n)?/);
|
||||||
|
let newRaw: string;
|
||||||
|
if (fmMatch) {
|
||||||
|
const bodyOffset = fmMatch[0].length;
|
||||||
|
// Keep frontmatter verbatim, replace body
|
||||||
|
const frontmatterPart = rawBefore.slice(0, bodyOffset);
|
||||||
|
// Ensure body starts without duplicate frontmatter newline handling: frontmatterPart already ends with newline
|
||||||
|
const bodyPart = newBody.startsWith("\n") ? newBody.slice(1) : newBody;
|
||||||
|
// frontmatterPart ends with "" or newline; ensure separation
|
||||||
|
newRaw = frontmatterPart + bodyPart;
|
||||||
|
if (!newRaw.endsWith("\n")) newRaw += "\n";
|
||||||
|
} else {
|
||||||
|
newRaw = newBody;
|
||||||
|
}
|
||||||
|
contentHash = hashFileContents(newRaw);
|
||||||
|
await atomicWrite(absPath, newRaw);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[backlog.updateTaskBody] file write failed:", e);
|
||||||
|
// still update DB; compute hash from DB perspective
|
||||||
|
const frontmatterYaml = buildFrontmatterYaml({
|
||||||
|
kind: row.kind,
|
||||||
|
slug: row.slug,
|
||||||
|
title: row.title,
|
||||||
|
planSlug: row.planSlug,
|
||||||
|
epicSlug: row.epicSlug,
|
||||||
|
status: row.status ?? "ready",
|
||||||
|
priority: row.priority ?? "P2",
|
||||||
|
agentPrompt: row.workflowPrompt,
|
||||||
|
});
|
||||||
|
const syntheticRaw = `---\n${frontmatterYaml}\n---\n${newBody}`;
|
||||||
|
contentHash = hashFileContents(syntheticRaw);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const frontmatterYaml = buildFrontmatterYaml({
|
||||||
|
kind: row.kind,
|
||||||
|
slug: row.slug,
|
||||||
|
title: row.title,
|
||||||
|
planSlug: row.planSlug,
|
||||||
|
epicSlug: row.epicSlug,
|
||||||
|
status: row.status ?? "ready",
|
||||||
|
priority: row.priority ?? "P2",
|
||||||
|
agentPrompt: row.workflowPrompt,
|
||||||
|
});
|
||||||
|
const syntheticRaw = `---\n${frontmatterYaml}\n---\n${newBody}`;
|
||||||
|
contentHash = hashFileContents(syntheticRaw);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await ctx.db
|
||||||
|
.update(markdownBacklogItems)
|
||||||
|
.set({ bodyMarkdown: newBody, contentHash, updatedAt: new Date() })
|
||||||
|
.where(and(eq(markdownBacklogItems.id, row.id), eq(markdownBacklogItems.workspaceId, ctx.workspace.id)))
|
||||||
|
.returning();
|
||||||
|
if (!updated) throw new TRPCError({ code: "NOT_FOUND", message: "Backlog item not found in this workspace." });
|
||||||
|
|
||||||
|
await recordAudit(ctx.db, {
|
||||||
|
workspaceId: ctx.workspace.id,
|
||||||
|
actorUserId: ctx.session.user.id,
|
||||||
|
action: "backlog.update_task_body",
|
||||||
|
targetType: "markdown_backlog_item",
|
||||||
|
targetId: row.id,
|
||||||
|
metadata: { repoPath: row.repoPath },
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}),
|
||||||
|
|
||||||
|
startConvoy: workspaceProcedure
|
||||||
|
.input(z.object({ backlogItemId: z.string().uuid() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const [row] = await ctx.db
|
||||||
|
.select()
|
||||||
|
.from(markdownBacklogItems)
|
||||||
|
.where(and(eq(markdownBacklogItems.id, input.backlogItemId), eq(markdownBacklogItems.workspaceId, ctx.workspace.id)))
|
||||||
|
.limit(1);
|
||||||
|
if (!row) throw new TRPCError({ code: "NOT_FOUND", message: "Backlog item not found in this workspace." });
|
||||||
|
if (row.kind !== "task") throw new TRPCError({ code: "BAD_REQUEST", message: "Only tasks can start a convoy." });
|
||||||
|
|
||||||
|
const planSlug = row.planSlug;
|
||||||
|
const taskSlug = row.slug;
|
||||||
|
const convoySlug = `${planSlug}-${taskSlug}`.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/--+/g, "-").replace(/^-|-$/g, "");
|
||||||
|
const parsedSlug = slugStrictSchema.safeParse(convoySlug);
|
||||||
|
if (!parsedSlug.success) {
|
||||||
|
throw new TRPCError({ code: "BAD_REQUEST", message: `Derived convoy slug '${convoySlug}' is invalid` });
|
||||||
|
}
|
||||||
|
const existing = await findConvoyBySlug(ctx.db, ctx.workspace.id, convoySlug);
|
||||||
|
if (existing) {
|
||||||
|
return { convoyId: existing.id, convoySlug, alreadyExists: true as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx.workspace.role !== "owner" && ctx.workspace.role !== "admin") {
|
||||||
|
throw new TRPCError({ code: "FORBIDDEN", message: "Only owners and admins can start convoys." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const successMetric = row.title ?? taskSlug;
|
||||||
|
const ideaMarkdown = row.bodyMarkdown?.trim() ? row.bodyMarkdown.trim() : `# ${row.title}\n\nTask: ${row.repoPath}`;
|
||||||
|
const content = {
|
||||||
|
convoySlug,
|
||||||
|
classification: "feature" as const,
|
||||||
|
skipFlags: [] as string[],
|
||||||
|
successMetric,
|
||||||
|
backlogItemId: row.id,
|
||||||
|
repoPath: row.repoPath,
|
||||||
|
planSlug,
|
||||||
|
epicSlug: row.epicSlug ?? undefined,
|
||||||
|
briefFiles: [row.repoPath],
|
||||||
|
};
|
||||||
|
|
||||||
|
const [created] = await ctx.db
|
||||||
|
.insert(objects)
|
||||||
|
.values({
|
||||||
|
type: "project",
|
||||||
|
title: convoySlug,
|
||||||
|
workspaceId: ctx.workspace.id,
|
||||||
|
description: ideaMarkdown,
|
||||||
|
status: "draft",
|
||||||
|
content,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
if (!created) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to create convoy." });
|
||||||
|
|
||||||
|
await insertConvoyEvent(ctx.db, {
|
||||||
|
workspaceId: ctx.workspace.id,
|
||||||
|
convoyId: created.id,
|
||||||
|
role: "role-conductor",
|
||||||
|
actor: "role-conductor",
|
||||||
|
eventType: "status_change",
|
||||||
|
metadata: { from: null, to: "draft", source: "plans_start", backlogItemId: row.id, repoPath: row.repoPath },
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAudit(ctx.db, {
|
||||||
|
workspaceId: ctx.workspace.id,
|
||||||
|
actorUserId: ctx.session.user.id,
|
||||||
|
action: "backlog.start_convoy",
|
||||||
|
targetType: "project",
|
||||||
|
targetId: created.id,
|
||||||
|
metadata: { convoySlug, backlogItemId: row.id, repoPath: row.repoPath },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { convoyId: created.id, convoySlug, alreadyExists: false as const };
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type BacklogRouter = typeof backlogRouter;
|
export type BacklogRouter = typeof backlogRouter;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
import { createLanguageModelFromResolved } from "@tasks/ai";
|
import { createLanguageModelFromResolved } from "@tasks/ai";
|
||||||
import { modelProviders, objects, roleModelRoutes } from "@tasks/database/schema";
|
import {
|
||||||
|
modelProviders,
|
||||||
|
objects,
|
||||||
|
roleModelRoutes,
|
||||||
|
workspaceMembers,
|
||||||
|
workspaces,
|
||||||
|
} from "@tasks/database/schema";
|
||||||
import {
|
import {
|
||||||
convoyClassificationSchema,
|
convoyClassificationSchema,
|
||||||
convoyObjectContentSchema,
|
convoyObjectContentSchema,
|
||||||
|
|
@ -17,19 +23,20 @@ import {
|
||||||
} from "@tasks/shared/model-routing";
|
} from "@tasks/shared/model-routing";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { generateText } from "ai";
|
import { generateText } from "ai";
|
||||||
import { asc, eq } from "drizzle-orm";
|
import { asc, eq, inArray } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
findConvoyBySlug,
|
findConvoyBySlug,
|
||||||
getConvoyBriefs,
|
getConvoyBriefs,
|
||||||
|
getConvoyCheckpointSummary,
|
||||||
getRecentConvoyEvents,
|
getRecentConvoyEvents,
|
||||||
insertConvoyEvent,
|
insertConvoyEvent,
|
||||||
listConvoyProjects,
|
listConvoyProjects,
|
||||||
loadConvoyProject,
|
loadConvoyProject,
|
||||||
transitionConvoy,
|
transitionConvoy,
|
||||||
} from "@/server/lib/convoy/convoy-service";
|
} from "@/server/lib/convoy/convoy-service";
|
||||||
import { router, workspaceProcedure } from "@/server/trpc";
|
import { protectedProcedure, router, workspaceProcedure } from "@/server/trpc";
|
||||||
import type { WorkspaceProcedureContext } from "@/server/trpc";
|
import type { WorkspaceProcedureContext } from "@/server/trpc";
|
||||||
|
|
||||||
type ProviderRow = typeof modelProviders.$inferSelect;
|
type ProviderRow = typeof modelProviders.$inferSelect;
|
||||||
|
|
@ -103,7 +110,11 @@ function resolveGateTransition(
|
||||||
if (action === "approve") return { actor, toStatus: "merged" };
|
if (action === "approve") return { actor, toStatus: "merged" };
|
||||||
if (action === "request_changes") return { actor, toStatus: "in_progress" };
|
if (action === "request_changes") return { actor, toStatus: "in_progress" };
|
||||||
}
|
}
|
||||||
if (status === "merged" && action === "promote") {
|
if (status === "doc_review") {
|
||||||
|
if (action === "approve") return { actor, toStatus: "published" };
|
||||||
|
if (action === "request_changes") return { actor, toStatus: "in_progress" };
|
||||||
|
}
|
||||||
|
if ((status === "merged" || status === "published") && action === "promote") {
|
||||||
return { actor, toStatus: "released" };
|
return { actor, toStatus: "released" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -143,6 +154,11 @@ export const convoysRouter = router({
|
||||||
const content = convoyObjectContentSchema.safeParse(convoy.content);
|
const content = convoyObjectContentSchema.safeParse(convoy.content);
|
||||||
const briefs = await getConvoyBriefs(ctx.db, ctx.workspace.id, convoy.id);
|
const briefs = await getConvoyBriefs(ctx.db, ctx.workspace.id, convoy.id);
|
||||||
const events = await getRecentConvoyEvents(ctx.db, ctx.workspace.id, convoy.id, 30);
|
const events = await getRecentConvoyEvents(ctx.db, ctx.workspace.id, convoy.id, 30);
|
||||||
|
const checkpoints = await getConvoyCheckpointSummary(
|
||||||
|
ctx.db,
|
||||||
|
ctx.workspace.id,
|
||||||
|
convoy.id,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
convoy: {
|
convoy: {
|
||||||
|
|
@ -155,6 +171,12 @@ export const convoysRouter = router({
|
||||||
},
|
},
|
||||||
briefs,
|
briefs,
|
||||||
events,
|
events,
|
||||||
|
checkpoints: {
|
||||||
|
count: checkpoints.count,
|
||||||
|
lastOccurredAt: checkpoints.lastOccurredAt
|
||||||
|
? checkpoints.lastOccurredAt.toISOString()
|
||||||
|
: null,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|
@ -171,6 +193,21 @@ export const convoysRouter = router({
|
||||||
skipFlags: z.array(z.string()).default([]),
|
skipFlags: z.array(z.string()).default([]),
|
||||||
successMetric: z.string().min(1).max(2_000),
|
successMetric: z.string().min(1).max(2_000),
|
||||||
ideaMarkdown: z.string().min(1),
|
ideaMarkdown: z.string().min(1),
|
||||||
|
planSlug: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(200)
|
||||||
|
.regex(/^[a-z0-9-]+$/)
|
||||||
|
.optional(),
|
||||||
|
epicSlug: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(200)
|
||||||
|
.regex(/^[a-z0-9-]+$/)
|
||||||
|
.optional(),
|
||||||
|
backlogItemId: z.string().uuid().optional(),
|
||||||
|
repoPath: z.string().min(1).optional(),
|
||||||
|
briefFiles: z.array(z.string()).optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
|
@ -189,6 +226,11 @@ export const convoysRouter = router({
|
||||||
classification: input.classification,
|
classification: input.classification,
|
||||||
skipFlags: input.skipFlags,
|
skipFlags: input.skipFlags,
|
||||||
successMetric: input.successMetric,
|
successMetric: input.successMetric,
|
||||||
|
planSlug: input.planSlug,
|
||||||
|
epicSlug: input.epicSlug,
|
||||||
|
backlogItemId: input.backlogItemId,
|
||||||
|
repoPath: input.repoPath,
|
||||||
|
briefFiles: input.briefFiles ?? (input.repoPath ? [input.repoPath] : undefined),
|
||||||
});
|
});
|
||||||
|
|
||||||
const [created] = await ctx.db
|
const [created] = await ctx.db
|
||||||
|
|
@ -335,4 +377,64 @@ export const convoysRouter = router({
|
||||||
providerType: resolved.model.providerType,
|
providerType: resolved.model.providerType,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
fleet: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
const userId = ctx.session.user.id;
|
||||||
|
|
||||||
|
const memberRows = await ctx.db
|
||||||
|
.select({ workspaceId: workspaceMembers.workspaceId })
|
||||||
|
.from(workspaceMembers)
|
||||||
|
.where(eq(workspaceMembers.userId, userId));
|
||||||
|
|
||||||
|
const ownedRows = await ctx.db
|
||||||
|
.select({ id: workspaces.id })
|
||||||
|
.from(workspaces)
|
||||||
|
.where(eq(workspaces.ownerUserId, userId));
|
||||||
|
|
||||||
|
const idSet = new Set<string>([
|
||||||
|
...memberRows.map((r) => r.workspaceId),
|
||||||
|
...ownedRows.map((r) => r.id),
|
||||||
|
]);
|
||||||
|
const workspaceIds = [...idSet];
|
||||||
|
|
||||||
|
if (workspaceIds.length === 0) {
|
||||||
|
return { byWorkspace: [], byStatus: {}, gateInboxAcross: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const wsRows = await ctx.db
|
||||||
|
.select({ id: workspaces.id, slug: workspaces.slug, name: workspaces.name })
|
||||||
|
.from(workspaces)
|
||||||
|
.where(inArray(workspaces.id, workspaceIds));
|
||||||
|
|
||||||
|
const byWorkspace: Array<{
|
||||||
|
workspace: { id: string; slug: string; name: string };
|
||||||
|
convoys: Array<Awaited<ReturnType<typeof listConvoyProjects>>[number] & { workspaceId: string; workspaceSlug: string; workspaceName: string }>;
|
||||||
|
}> = [];
|
||||||
|
const allConvoys: Array<Awaited<ReturnType<typeof listConvoyProjects>>[number] & { workspaceId: string; workspaceSlug: string; workspaceName: string }> = [];
|
||||||
|
|
||||||
|
for (const ws of wsRows) {
|
||||||
|
const convoys = await listConvoyProjects(ctx.db, ws.id);
|
||||||
|
const enriched = convoys.map((c) => ({
|
||||||
|
...c,
|
||||||
|
workspaceId: ws.id,
|
||||||
|
workspaceSlug: ws.slug,
|
||||||
|
workspaceName: ws.name,
|
||||||
|
}));
|
||||||
|
byWorkspace.push({ workspace: ws, convoys: enriched });
|
||||||
|
allConvoys.push(...enriched);
|
||||||
|
}
|
||||||
|
|
||||||
|
const byStatus: Record<string, typeof allConvoys> = {};
|
||||||
|
for (const convoy of allConvoys) {
|
||||||
|
const key = convoy.status;
|
||||||
|
if (!byStatus[key]) byStatus[key] = [];
|
||||||
|
byStatus[key].push(convoy);
|
||||||
|
}
|
||||||
|
|
||||||
|
const gateInboxAcross = allConvoys.filter((c) =>
|
||||||
|
(GATE_INBOX_STATUSES as readonly string[]).includes(c.status),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { byWorkspace, byStatus, gateInboxAcross };
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
256
apps/web/server/routers/harnesses.ts
Normal file
256
apps/web/server/routers/harnesses.ts
Normal file
|
|
@ -0,0 +1,256 @@
|
||||||
|
import { and, asc, eq } from "drizzle-orm";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { harnessProviders, roleHarnessRoutes } from "@tasks/database/schema";
|
||||||
|
import { harnessTypeSchema } from "@tasks/shared/worker";
|
||||||
|
import { rateLimit } from "@/server/lib/rate-limit";
|
||||||
|
import { router, workspaceProcedure } from "@/server/trpc";
|
||||||
|
import type { WorkspaceProcedureContext } from "@/server/trpc";
|
||||||
|
|
||||||
|
type HarnessRow = typeof harnessProviders.$inferSelect;
|
||||||
|
type RouteRow = typeof roleHarnessRoutes.$inferSelect;
|
||||||
|
|
||||||
|
function assertWorkspaceAdmin(ctx: WorkspaceProcedureContext): void {
|
||||||
|
if (ctx.workspace.role !== "owner" && ctx.workspace.role !== "admin") {
|
||||||
|
throw new TRPCError({ code: "FORBIDDEN", message: "Only workspace owners and admins can manage harnesses." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function canManageWorkspace(ctx: WorkspaceProcedureContext): boolean {
|
||||||
|
return ctx.workspace.role === "owner" || ctx.workspace.role === "admin";
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapHarnessRow(row: HarnessRow) {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
workspaceId: row.workspaceId,
|
||||||
|
alias: row.alias,
|
||||||
|
displayName: row.displayName,
|
||||||
|
harnessType: harnessTypeSchema.parse(row.harnessType),
|
||||||
|
endpointUrl: row.endpointUrl,
|
||||||
|
authEnvVar: row.authEnvVar,
|
||||||
|
config: row.config,
|
||||||
|
enabled: row.enabled,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapRouteRow(row: RouteRow) {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
workspaceId: row.workspaceId,
|
||||||
|
roleId: row.roleId,
|
||||||
|
harnessProviderId: row.harnessProviderId,
|
||||||
|
enabled: row.enabled,
|
||||||
|
sortOrder: row.sortOrder,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const harnessFieldsSchema = z.object({
|
||||||
|
alias: z.string().min(1).max(120),
|
||||||
|
displayName: z.string().min(1).max(200),
|
||||||
|
harnessType: harnessTypeSchema,
|
||||||
|
endpointUrl: z.string().url().nullable().optional(),
|
||||||
|
authEnvVar: z.string().min(1).max(120).nullable().optional(),
|
||||||
|
config: z.record(z.unknown()).nullable().optional(),
|
||||||
|
enabled: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const harnessesRouter = router({
|
||||||
|
list: workspaceProcedure.query(async ({ ctx }) => {
|
||||||
|
const rows = await ctx.db
|
||||||
|
.select()
|
||||||
|
.from(harnessProviders)
|
||||||
|
.where(eq(harnessProviders.workspaceId, ctx.workspace.id))
|
||||||
|
.orderBy(asc(harnessProviders.alias));
|
||||||
|
return { harnesses: rows.map(mapHarnessRow), canManage: canManageWorkspace(ctx) };
|
||||||
|
}),
|
||||||
|
|
||||||
|
create: workspaceProcedure.input(harnessFieldsSchema).mutation(async ({ ctx, input }) => {
|
||||||
|
assertWorkspaceAdmin(ctx);
|
||||||
|
try {
|
||||||
|
const [created] = await ctx.db
|
||||||
|
.insert(harnessProviders)
|
||||||
|
.values({
|
||||||
|
workspaceId: ctx.workspace.id,
|
||||||
|
alias: input.alias.trim(),
|
||||||
|
displayName: input.displayName.trim(),
|
||||||
|
harnessType: input.harnessType,
|
||||||
|
endpointUrl: input.endpointUrl?.trim() || null,
|
||||||
|
authEnvVar: input.authEnvVar?.trim() || null,
|
||||||
|
config: input.config ?? null,
|
||||||
|
enabled: input.enabled ?? true,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
if (!created) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to create harness provider." });
|
||||||
|
return mapHarnessRow(created);
|
||||||
|
} catch (e) {
|
||||||
|
if (isUniqueViolation(e)) throw new TRPCError({ code: "CONFLICT", message: `Alias "${input.alias}" already exists in this workspace.` });
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
update: workspaceProcedure.input(harnessFieldsSchema.partial().extend({ id: z.string().uuid() })).mutation(async ({ ctx, input }) => {
|
||||||
|
assertWorkspaceAdmin(ctx);
|
||||||
|
const [existing] = await ctx.db
|
||||||
|
.select()
|
||||||
|
.from(harnessProviders)
|
||||||
|
.where(and(eq(harnessProviders.id, input.id), eq(harnessProviders.workspaceId, ctx.workspace.id)))
|
||||||
|
.limit(1);
|
||||||
|
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Harness provider not found." });
|
||||||
|
try {
|
||||||
|
const [updated] = await ctx.db
|
||||||
|
.update(harnessProviders)
|
||||||
|
.set({
|
||||||
|
...(input.alias !== undefined ? { alias: input.alias.trim() } : {}),
|
||||||
|
...(input.displayName !== undefined ? { displayName: input.displayName.trim() } : {}),
|
||||||
|
...(input.harnessType !== undefined ? { harnessType: input.harnessType } : {}),
|
||||||
|
...(input.endpointUrl !== undefined ? { endpointUrl: input.endpointUrl?.trim() || null } : {}),
|
||||||
|
...(input.authEnvVar !== undefined ? { authEnvVar: input.authEnvVar?.trim() || null } : {}),
|
||||||
|
...(input.config !== undefined ? { config: input.config } : {}),
|
||||||
|
...(input.enabled !== undefined ? { enabled: input.enabled } : {}),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(and(eq(harnessProviders.id, input.id), eq(harnessProviders.workspaceId, ctx.workspace.id)))
|
||||||
|
.returning();
|
||||||
|
if (!updated) throw new TRPCError({ code: "NOT_FOUND", message: "Harness provider not found." });
|
||||||
|
return mapHarnessRow(updated);
|
||||||
|
} catch (e) {
|
||||||
|
if (isUniqueViolation(e)) throw new TRPCError({ code: "CONFLICT", message: "Another harness already uses that alias." });
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
delete: workspaceProcedure.input(z.object({ id: z.string().uuid() })).mutation(async ({ ctx, input }) => {
|
||||||
|
assertWorkspaceAdmin(ctx);
|
||||||
|
const [deleted] = await ctx.db
|
||||||
|
.delete(harnessProviders)
|
||||||
|
.where(and(eq(harnessProviders.id, input.id), eq(harnessProviders.workspaceId, ctx.workspace.id)))
|
||||||
|
.returning({ id: harnessProviders.id });
|
||||||
|
if (!deleted) throw new TRPCError({ code: "NOT_FOUND", message: "Harness provider not found." });
|
||||||
|
return { id: deleted.id };
|
||||||
|
}),
|
||||||
|
|
||||||
|
testHarness: workspaceProcedure.input(z.object({ harnessId: z.string().uuid() })).mutation(async ({ ctx, input }) => {
|
||||||
|
assertWorkspaceAdmin(ctx);
|
||||||
|
const limit = rateLimit({ key: `harness:test:${ctx.workspace.id}`, limit: 10, windowMs: 60_000 });
|
||||||
|
if (!limit.allowed) throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: "Test rate limit exceeded. Try again shortly." });
|
||||||
|
const [row] = await ctx.db
|
||||||
|
.select()
|
||||||
|
.from(harnessProviders)
|
||||||
|
.where(and(eq(harnessProviders.id, input.harnessId), eq(harnessProviders.workspaceId, ctx.workspace.id)))
|
||||||
|
.limit(1);
|
||||||
|
if (!row) throw new TRPCError({ code: "NOT_FOUND", message: "Harness provider not found." });
|
||||||
|
if (!row.endpointUrl) {
|
||||||
|
return { ok: false as const, latencyMs: 0, error: "No endpoint_url configured for this harness." };
|
||||||
|
}
|
||||||
|
const started = Date.now();
|
||||||
|
try {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (row.authEnvVar) {
|
||||||
|
const token = process.env[row.authEnvVar]?.trim();
|
||||||
|
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
const base = row.endpointUrl.replace(/\/+$/, "");
|
||||||
|
// Try /health then base itself as fallback
|
||||||
|
let res: Response | null = null;
|
||||||
|
let lastError = "";
|
||||||
|
for (const suffix of ["/health", ""]) {
|
||||||
|
try {
|
||||||
|
res = await fetch(`${base}${suffix}`, { headers, signal: AbortSignal.timeout(8000) });
|
||||||
|
if (res.ok) break;
|
||||||
|
lastError = `HTTP ${res.status} ${res.statusText}`;
|
||||||
|
// don't break on non-ok if we have another suffix
|
||||||
|
if (suffix === "") lastError = `HTTP ${res.status} ${res.statusText}`;
|
||||||
|
} catch (e) {
|
||||||
|
lastError = e instanceof Error ? e.message : String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const latencyMs = Date.now() - started;
|
||||||
|
if (res && res.ok) return { ok: true as const, latencyMs };
|
||||||
|
return { ok: false as const, latencyMs, error: lastError || "Health check failed" };
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false as const, latencyMs: Date.now() - started, error: e instanceof Error ? e.message : String(e) };
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
listRoutes: workspaceProcedure.query(async ({ ctx }) => {
|
||||||
|
const routes = await ctx.db
|
||||||
|
.select({
|
||||||
|
route: roleHarnessRoutes,
|
||||||
|
harnessAlias: harnessProviders.alias,
|
||||||
|
harnessType: harnessProviders.harnessType,
|
||||||
|
harnessEnabled: harnessProviders.enabled,
|
||||||
|
})
|
||||||
|
.from(roleHarnessRoutes)
|
||||||
|
.innerJoin(harnessProviders, eq(roleHarnessRoutes.harnessProviderId, harnessProviders.id))
|
||||||
|
.where(eq(roleHarnessRoutes.workspaceId, ctx.workspace.id))
|
||||||
|
.orderBy(asc(roleHarnessRoutes.sortOrder), asc(roleHarnessRoutes.roleId));
|
||||||
|
return {
|
||||||
|
routes: routes.map((row) => ({
|
||||||
|
...mapRouteRow(row.route),
|
||||||
|
harnessAlias: row.harnessAlias,
|
||||||
|
harnessType: row.harnessType,
|
||||||
|
harnessEnabled: row.harnessEnabled,
|
||||||
|
})),
|
||||||
|
canManage: canManageWorkspace(ctx),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
upsertRoute: workspaceProcedure
|
||||||
|
.input(z.object({ roleId: z.string().min(1).max(120), harnessProviderId: z.string().uuid(), enabled: z.boolean().optional(), sortOrder: z.number().int().optional() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
assertWorkspaceAdmin(ctx);
|
||||||
|
const [provider] = await ctx.db
|
||||||
|
.select({ id: harnessProviders.id })
|
||||||
|
.from(harnessProviders)
|
||||||
|
.where(and(eq(harnessProviders.id, input.harnessProviderId), eq(harnessProviders.workspaceId, ctx.workspace.id)))
|
||||||
|
.limit(1);
|
||||||
|
if (!provider) throw new TRPCError({ code: "NOT_FOUND", message: "Harness provider not found." });
|
||||||
|
const [existing] = await ctx.db
|
||||||
|
.select({ id: roleHarnessRoutes.id })
|
||||||
|
.from(roleHarnessRoutes)
|
||||||
|
.where(and(eq(roleHarnessRoutes.workspaceId, ctx.workspace.id), eq(roleHarnessRoutes.roleId, input.roleId)))
|
||||||
|
.limit(1);
|
||||||
|
if (existing) {
|
||||||
|
const [updated] = await ctx.db
|
||||||
|
.update(roleHarnessRoutes)
|
||||||
|
.set({
|
||||||
|
harnessProviderId: input.harnessProviderId,
|
||||||
|
enabled: input.enabled ?? true,
|
||||||
|
...(input.sortOrder !== undefined ? { sortOrder: input.sortOrder } : {}),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(and(eq(roleHarnessRoutes.id, existing.id), eq(roleHarnessRoutes.workspaceId, ctx.workspace.id)))
|
||||||
|
.returning();
|
||||||
|
if (!updated) throw new TRPCError({ code: "NOT_FOUND", message: "Route not found." });
|
||||||
|
return mapRouteRow(updated);
|
||||||
|
}
|
||||||
|
const [created] = await ctx.db
|
||||||
|
.insert(roleHarnessRoutes)
|
||||||
|
.values({
|
||||||
|
workspaceId: ctx.workspace.id,
|
||||||
|
roleId: input.roleId,
|
||||||
|
harnessProviderId: input.harnessProviderId,
|
||||||
|
enabled: input.enabled ?? true,
|
||||||
|
sortOrder: input.sortOrder ?? 0,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
if (!created) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to create harness route." });
|
||||||
|
return mapRouteRow(created);
|
||||||
|
}),
|
||||||
|
|
||||||
|
deleteRoute: workspaceProcedure.input(z.object({ id: z.string().uuid() })).mutation(async ({ ctx, input }) => {
|
||||||
|
assertWorkspaceAdmin(ctx);
|
||||||
|
const [deleted] = await ctx.db.delete(roleHarnessRoutes).where(and(eq(roleHarnessRoutes.id, input.id), eq(roleHarnessRoutes.workspaceId, ctx.workspace.id))).returning({ id: roleHarnessRoutes.id });
|
||||||
|
if (!deleted) throw new TRPCError({ code: "NOT_FOUND", message: "Route not found." });
|
||||||
|
return { id: deleted.id };
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
function isUniqueViolation(error: unknown): boolean {
|
||||||
|
return typeof error === "object" && error !== null && "code" in error && (error as { code?: string }).code === "23505";
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,11 @@
|
||||||
--teal: 168 76% 42%;
|
--teal: 168 76% 42%;
|
||||||
--teal-foreground: 0 0% 100%;
|
--teal-foreground: 0 0% 100%;
|
||||||
|
|
||||||
|
--matter: 32 92% 50%;
|
||||||
|
--matter-foreground: 220 25% 12%;
|
||||||
|
--agent: 168 55% 36%;
|
||||||
|
--agent-foreground: 0 0% 100%;
|
||||||
|
|
||||||
--secondary: 210 40% 96%;
|
--secondary: 210 40% 96%;
|
||||||
--secondary-foreground: 222 47% 11%;
|
--secondary-foreground: 222 47% 11%;
|
||||||
|
|
||||||
|
|
|
||||||
169
apps/web/styles/marketing.css
Normal file
169
apps/web/styles/marketing.css
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
/* Submatter marketing v2 — Manifest (Exaggerated Minimal + E-Ink + Swiss grid) */
|
||||||
|
|
||||||
|
.marketing-theme {
|
||||||
|
--background: 45 20% 98%;
|
||||||
|
--foreground: 0 0% 4%;
|
||||||
|
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 0 0% 4%;
|
||||||
|
|
||||||
|
--primary: 0 0% 4%;
|
||||||
|
--primary-foreground: 45 20% 98%;
|
||||||
|
|
||||||
|
--matter: 18 100% 50%;
|
||||||
|
--matter-foreground: 0 0% 4%;
|
||||||
|
|
||||||
|
--agent: 168 80% 32%;
|
||||||
|
--agent-foreground: 0 0% 100%;
|
||||||
|
|
||||||
|
--secondary: 45 10% 94%;
|
||||||
|
--secondary-foreground: 0 0% 4%;
|
||||||
|
|
||||||
|
--muted: 45 8% 94%;
|
||||||
|
--muted-foreground: 0 0% 40%;
|
||||||
|
|
||||||
|
--accent: 45 15% 92%;
|
||||||
|
--accent-foreground: 0 0% 4%;
|
||||||
|
|
||||||
|
--border: 0 0% 4%;
|
||||||
|
--border-light: 45 8% 88%;
|
||||||
|
--input: 45 8% 88%;
|
||||||
|
--ring: 18 100% 50%;
|
||||||
|
|
||||||
|
--hero: 0 0% 4%;
|
||||||
|
--hero-foreground: 45 20% 98%;
|
||||||
|
--hero-muted: 0 0% 62%;
|
||||||
|
--hero-border: 0 0% 18%;
|
||||||
|
|
||||||
|
--radius: 0px;
|
||||||
|
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marketing-theme .font-display {
|
||||||
|
font-family: var(--font-display), system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marketing-theme .font-marketing {
|
||||||
|
font-family: var(--font-marketing-sans), system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marketing-theme .font-mono-brand {
|
||||||
|
font-family: var(--font-mono-brand), ui-monospace, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Paper grain (E-Ink texture) */
|
||||||
|
.marketing-paper-grain {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marketing-paper-grain::after {
|
||||||
|
content: "";
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
opacity: 0.035;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Line waves divider (React Bits–inspired) */
|
||||||
|
.marketing-line-waves {
|
||||||
|
height: 1px;
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
90deg,
|
||||||
|
hsl(var(--foreground)) 0,
|
||||||
|
hsl(var(--foreground)) 8px,
|
||||||
|
transparent 8px,
|
||||||
|
transparent 16px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.marketing-line-waves-light {
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
90deg,
|
||||||
|
hsl(var(--hero-foreground) / 0.35) 0,
|
||||||
|
hsl(var(--hero-foreground) / 0.35) 8px,
|
||||||
|
transparent 8px,
|
||||||
|
transparent 16px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Soft aurora — warm signal only, no purple slop */
|
||||||
|
.marketing-aurora {
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 50% 40% at 20% 30%, hsl(var(--matter) / 0.12), transparent 55%),
|
||||||
|
radial-gradient(ellipse 45% 35% at 80% 20%, hsl(var(--agent) / 0.08), transparent 50%),
|
||||||
|
hsl(var(--hero));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Marquee */
|
||||||
|
@keyframes marketing-marquee {
|
||||||
|
from {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.marketing-marquee-track {
|
||||||
|
animation: marketing-marquee 40s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grid field */
|
||||||
|
.marketing-grid-field {
|
||||||
|
background-image:
|
||||||
|
linear-gradient(hsl(var(--hero-foreground) / 0.04) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, hsl(var(--hero-foreground) / 0.04) 1px, transparent 1px);
|
||||||
|
background-size: 48px 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marketing-hard-rule {
|
||||||
|
height: 4px;
|
||||||
|
background: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.marketing-hard-rule-light {
|
||||||
|
height: 4px;
|
||||||
|
background: hsl(var(--hero-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes marketing-blink {
|
||||||
|
0%,
|
||||||
|
49% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.marketing-cursor-blink {
|
||||||
|
animation: marketing-blink 1.1s step-end infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes marketing-pulse-dot {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.marketing-pulse-dot {
|
||||||
|
animation: marketing-pulse-dot 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.marketing-marquee-track {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marketing-cursor-blink,
|
||||||
|
.marketing-pulse-dot {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -55,6 +55,14 @@ const config: Config = {
|
||||||
DEFAULT: "hsl(var(--teal))",
|
DEFAULT: "hsl(var(--teal))",
|
||||||
foreground: "hsl(var(--teal-foreground))",
|
foreground: "hsl(var(--teal-foreground))",
|
||||||
},
|
},
|
||||||
|
matter: {
|
||||||
|
DEFAULT: "hsl(var(--matter))",
|
||||||
|
foreground: "hsl(var(--matter-foreground, 0 0% 100%))",
|
||||||
|
},
|
||||||
|
agent: {
|
||||||
|
DEFAULT: "hsl(var(--agent))",
|
||||||
|
foreground: "hsl(var(--agent-foreground, 0 0% 100%))",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
borderRadius: {
|
borderRadius: {
|
||||||
lg: "var(--radius)",
|
lg: "var(--radius)",
|
||||||
|
|
|
||||||
143
apps/worker/src/db/checkpoints.ts
Normal file
143
apps/worker/src/db/checkpoints.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
|
||||||
|
import type { db as dbInstance } from "@tasks/database";
|
||||||
|
import { workerCheckpoints } from "@tasks/database/schema";
|
||||||
|
import {
|
||||||
|
WORKER_CHECKPOINT_TOOL_HISTORY_LIMIT,
|
||||||
|
workerCheckpointPayloadSchema,
|
||||||
|
type WorkerCheckpointFileTouch,
|
||||||
|
type WorkerCheckpointToolStep,
|
||||||
|
} from "@tasks/shared/worker";
|
||||||
|
import { and, desc, eq, sql } from "drizzle-orm";
|
||||||
|
|
||||||
|
type Db = typeof dbInstance;
|
||||||
|
|
||||||
|
export type SaveWorkerCheckpointInput = {
|
||||||
|
workspaceId: string;
|
||||||
|
agentRunId: string;
|
||||||
|
convoyId: string;
|
||||||
|
briefId?: string;
|
||||||
|
roleId: string;
|
||||||
|
checkpointSeq: number;
|
||||||
|
toolHistory: WorkerCheckpointToolStep[];
|
||||||
|
filesTouched: WorkerCheckpointFileTouch[];
|
||||||
|
branch: string | null;
|
||||||
|
worktreePath: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkerCheckpointRow = {
|
||||||
|
id: string;
|
||||||
|
workspaceId: string;
|
||||||
|
agentRunId: string;
|
||||||
|
convoyId: string;
|
||||||
|
briefId: string | null;
|
||||||
|
roleId: string;
|
||||||
|
checkpointSeq: number;
|
||||||
|
toolHistory: WorkerCheckpointToolStep[];
|
||||||
|
filesTouched: WorkerCheckpointFileTouch[];
|
||||||
|
branch: string | null;
|
||||||
|
worktreePath: string | null;
|
||||||
|
occurredAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function hashContent(content: string): string {
|
||||||
|
return createHash("sha256").update(content).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function digestResult(output: string, ok: boolean): string {
|
||||||
|
const prefix = ok ? "ok" : "err";
|
||||||
|
return `${prefix}:${hashContent(output).slice(0, 16)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveWorkerCheckpoint(
|
||||||
|
db: Db,
|
||||||
|
input: SaveWorkerCheckpointInput,
|
||||||
|
): Promise<WorkerCheckpointRow> {
|
||||||
|
const payload = workerCheckpointPayloadSchema.parse({
|
||||||
|
toolHistory: input.toolHistory.slice(-WORKER_CHECKPOINT_TOOL_HISTORY_LIMIT),
|
||||||
|
filesTouched: input.filesTouched.slice(-200),
|
||||||
|
branch: input.branch,
|
||||||
|
worktreePath: input.worktreePath,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [row] = await db
|
||||||
|
.insert(workerCheckpoints)
|
||||||
|
.values({
|
||||||
|
workspaceId: input.workspaceId,
|
||||||
|
agentRunId: input.agentRunId,
|
||||||
|
convoyId: input.convoyId,
|
||||||
|
briefId: input.briefId ?? null,
|
||||||
|
roleId: input.roleId,
|
||||||
|
checkpointSeq: input.checkpointSeq,
|
||||||
|
toolHistory: payload.toolHistory,
|
||||||
|
filesTouched: payload.filesTouched,
|
||||||
|
branch: payload.branch,
|
||||||
|
worktreePath: payload.worktreePath,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [workerCheckpoints.agentRunId, workerCheckpoints.checkpointSeq],
|
||||||
|
set: {
|
||||||
|
toolHistory: payload.toolHistory,
|
||||||
|
filesTouched: payload.filesTouched,
|
||||||
|
branch: payload.branch,
|
||||||
|
worktreePath: payload.worktreePath,
|
||||||
|
occurredAt: sql`now()`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
throw new Error("Failed to persist worker checkpoint");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
workspaceId: row.workspaceId,
|
||||||
|
agentRunId: row.agentRunId,
|
||||||
|
convoyId: row.convoyId,
|
||||||
|
briefId: row.briefId,
|
||||||
|
roleId: row.roleId,
|
||||||
|
checkpointSeq: row.checkpointSeq,
|
||||||
|
toolHistory: row.toolHistory,
|
||||||
|
filesTouched: row.filesTouched,
|
||||||
|
branch: row.branch,
|
||||||
|
worktreePath: row.worktreePath,
|
||||||
|
occurredAt: row.occurredAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Latest checkpoint for an agent run — always scoped by workspace_id. */
|
||||||
|
export async function getLatestCheckpointForRun(
|
||||||
|
db: Db,
|
||||||
|
workspaceId: string,
|
||||||
|
agentRunId: string,
|
||||||
|
): Promise<WorkerCheckpointRow | null> {
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(workerCheckpoints)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(workerCheckpoints.workspaceId, workspaceId),
|
||||||
|
eq(workerCheckpoints.agentRunId, agentRunId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(desc(workerCheckpoints.checkpointSeq))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!row) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
workspaceId: row.workspaceId,
|
||||||
|
agentRunId: row.agentRunId,
|
||||||
|
convoyId: row.convoyId,
|
||||||
|
briefId: row.briefId,
|
||||||
|
roleId: row.roleId,
|
||||||
|
checkpointSeq: row.checkpointSeq,
|
||||||
|
toolHistory: row.toolHistory,
|
||||||
|
filesTouched: row.filesTouched,
|
||||||
|
branch: row.branch,
|
||||||
|
worktreePath: row.worktreePath,
|
||||||
|
occurredAt: row.occurredAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -203,6 +203,7 @@ export async function logRoleComplete(
|
||||||
modelTier?: string;
|
modelTier?: string;
|
||||||
tokensTotal?: number;
|
tokensTotal?: number;
|
||||||
summary?: string;
|
summary?: string;
|
||||||
|
harnessAlias?: string | null;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
await db.insert(convoyEvents).values({
|
await db.insert(convoyEvents).values({
|
||||||
|
|
@ -218,10 +219,12 @@ export async function logRoleComplete(
|
||||||
providerType: input.providerType ?? null,
|
providerType: input.providerType ?? null,
|
||||||
modelId: input.modelId ?? null,
|
modelId: input.modelId ?? null,
|
||||||
modelTier: input.modelTier ?? null,
|
modelTier: input.modelTier ?? null,
|
||||||
|
harnessAlias: input.harnessAlias ?? null,
|
||||||
metadata: {
|
metadata: {
|
||||||
source: "worker",
|
source: "worker",
|
||||||
tokensTotal: input.tokensTotal ?? null,
|
tokensTotal: input.tokensTotal ?? null,
|
||||||
summary: input.summary ?? null,
|
summary: input.summary ?? null,
|
||||||
|
harnessAlias: input.harnessAlias ?? null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
53
apps/worker/src/db/harness.ts
Normal file
53
apps/worker/src/db/harness.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import type { db as dbInstance } from "@tasks/database";
|
||||||
|
import { harnessProviders, roleHarnessRoutes } from "@tasks/database/schema";
|
||||||
|
import { resolveHarnessForRole } from "@tasks/shared/worker/executor";
|
||||||
|
|
||||||
|
type Db = typeof dbInstance;
|
||||||
|
|
||||||
|
export async function resolveHarnessForRoleDb(
|
||||||
|
db: Db,
|
||||||
|
workspaceId: string,
|
||||||
|
roleId: string,
|
||||||
|
overrideAlias?: string | null,
|
||||||
|
): Promise<{ alias: string; harnessType: string; endpointUrl: string | null; authEnvVar: string | null }> {
|
||||||
|
if (overrideAlias) {
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(harnessProviders)
|
||||||
|
.where(and(eq(harnessProviders.workspaceId, workspaceId), eq(harnessProviders.alias, overrideAlias)))
|
||||||
|
.limit(1);
|
||||||
|
if (row && row.enabled) {
|
||||||
|
return { alias: row.alias, harnessType: row.harnessType, endpointUrl: row.endpointUrl, authEnvVar: row.authEnvVar };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const providers = await db.select().from(harnessProviders).where(eq(harnessProviders.workspaceId, workspaceId));
|
||||||
|
const routes = await db.select().from(roleHarnessRoutes).where(eq(roleHarnessRoutes.workspaceId, workspaceId));
|
||||||
|
// map to shared types
|
||||||
|
const mappedProviders = providers.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
workspaceId: p.workspaceId,
|
||||||
|
alias: p.alias,
|
||||||
|
displayName: p.displayName,
|
||||||
|
harnessType: p.harnessType as "minimal" | "crewai" | "langgraph" | "remote-ag-ui",
|
||||||
|
endpointUrl: p.endpointUrl,
|
||||||
|
authEnvVar: p.authEnvVar,
|
||||||
|
config: p.config,
|
||||||
|
enabled: p.enabled,
|
||||||
|
}));
|
||||||
|
const mappedRoutes = routes.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
workspaceId: r.workspaceId,
|
||||||
|
roleId: r.roleId,
|
||||||
|
harnessProviderId: r.harnessProviderId,
|
||||||
|
enabled: r.enabled,
|
||||||
|
sortOrder: r.sortOrder,
|
||||||
|
}));
|
||||||
|
const result = resolveHarnessForRole({ roleId, providers: mappedProviders, routes: mappedRoutes });
|
||||||
|
return {
|
||||||
|
alias: result.harness.alias,
|
||||||
|
harnessType: result.harness.harnessType,
|
||||||
|
endpointUrl: result.harness.endpointUrl ?? null,
|
||||||
|
authEnvVar: result.harness.authEnvVar ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -143,6 +143,7 @@ export async function completeTask(
|
||||||
modelTier?: string;
|
modelTier?: string;
|
||||||
providerType?: string;
|
providerType?: string;
|
||||||
modelId?: string;
|
modelId?: string;
|
||||||
|
harnessAlias?: string | null;
|
||||||
repoRoot?: string | null;
|
repoRoot?: string | null;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
|
|
@ -175,9 +176,11 @@ export async function completeTask(
|
||||||
modelTier: input.modelTier ?? null,
|
modelTier: input.modelTier ?? null,
|
||||||
providerType: input.providerType ?? null,
|
providerType: input.providerType ?? null,
|
||||||
modelId: input.modelId ?? null,
|
modelId: input.modelId ?? null,
|
||||||
|
harnessAlias: input.harnessAlias ?? null,
|
||||||
metadata: {
|
metadata: {
|
||||||
...(run.metadata ?? {}),
|
...(run.metadata ?? {}),
|
||||||
client: "echodo-worker",
|
client: "echodo-worker",
|
||||||
|
harnessAlias: input.harnessAlias ?? null,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.where(eq(agentRuns.id, input.runId));
|
.where(eq(agentRuns.id, input.runId));
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,13 @@ const envSchema = z.object({
|
||||||
const parsed = Number(value ?? "12");
|
const parsed = Number(value ?? "12");
|
||||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 12;
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : 12;
|
||||||
}),
|
}),
|
||||||
|
WORKER_CHECKPOINT_EVERY_N_STEPS: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((value) => {
|
||||||
|
const parsed = Number(value ?? "1");
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 1;
|
||||||
|
}),
|
||||||
WORKER_REPO_ROOT: z.string().optional(),
|
WORKER_REPO_ROOT: z.string().optional(),
|
||||||
WORKER_WORKTREE_BASE: z
|
WORKER_WORKTREE_BASE: z
|
||||||
.string()
|
.string()
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { access } from "node:fs/promises";
|
||||||
import { mkdir } from "node:fs/promises";
|
import { mkdir } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { execFile } from "node:child_process";
|
import { execFile } from "node:child_process";
|
||||||
|
|
@ -11,16 +12,70 @@ export type WorktreeContext = {
|
||||||
branch: string;
|
branch: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function pathExists(path: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await access(path);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reuse an existing worktree directory when present (crash resume without re-clone). */
|
||||||
|
export async function reuseWorktreeIfPresent(
|
||||||
|
repoRoot: string,
|
||||||
|
worktreePath: string,
|
||||||
|
branch: string | null,
|
||||||
|
): Promise<WorktreeContext | null> {
|
||||||
|
if (!(await pathExists(worktreePath))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileAsync("git", ["rev-parse", "--show-toplevel"], {
|
||||||
|
cwd: worktreePath,
|
||||||
|
});
|
||||||
|
if (stdout.trim() !== worktreePath && !(await pathExists(join(worktreePath, ".git")))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolvedBranch = branch ?? "HEAD";
|
||||||
|
if (!branch) {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileAsync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
|
||||||
|
cwd: worktreePath,
|
||||||
|
});
|
||||||
|
resolvedBranch = stdout.trim() || "HEAD";
|
||||||
|
} catch {
|
||||||
|
resolvedBranch = "HEAD";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { repoRoot, worktreePath, branch: resolvedBranch };
|
||||||
|
}
|
||||||
|
|
||||||
export async function ensureWorktree(
|
export async function ensureWorktree(
|
||||||
repoRoot: string,
|
repoRoot: string,
|
||||||
worktreeBase: string,
|
worktreeBase: string,
|
||||||
jobId: string,
|
worktreeKey: string,
|
||||||
branchSuffix: string,
|
branchSuffix: string,
|
||||||
): Promise<WorktreeContext> {
|
opts?: { needsWorktree?: boolean },
|
||||||
|
): Promise<WorktreeContext | null> {
|
||||||
|
if (opts?.needsWorktree === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const branch = `worker/${branchSuffix}`.replace(/[^a-zA-Z0-9/_-]/g, "-");
|
const branch = `worker/${branchSuffix}`.replace(/[^a-zA-Z0-9/_-]/g, "-");
|
||||||
const worktreePath = join(worktreeBase, jobId);
|
const worktreePath = join(worktreeBase, worktreeKey);
|
||||||
await mkdir(worktreeBase, { recursive: true });
|
await mkdir(worktreeBase, { recursive: true });
|
||||||
|
|
||||||
|
const existing = await reuseWorktreeIfPresent(repoRoot, worktreePath, branch);
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await execFileAsync("git", ["worktree", "add", "-B", branch, worktreePath], {
|
await execFileAsync("git", ["worktree", "add", "-B", branch, worktreePath], {
|
||||||
cwd: repoRoot,
|
cwd: repoRoot,
|
||||||
|
|
|
||||||
286
apps/worker/src/handle-job.test.ts
Normal file
286
apps/worker/src/handle-job.test.ts
Normal file
|
|
@ -0,0 +1,286 @@
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { OrchestratorJob } from "@tasks/shared/orchestrator";
|
||||||
|
|
||||||
|
const {
|
||||||
|
loadJobContext,
|
||||||
|
resolveModelForRole,
|
||||||
|
logRoleComplete,
|
||||||
|
claimTask,
|
||||||
|
completeTask,
|
||||||
|
findBacklogItemByRepoPath,
|
||||||
|
pickTaskRepoPath,
|
||||||
|
ensureWorktree,
|
||||||
|
removeWorktree,
|
||||||
|
reuseWorktreeIfPresent,
|
||||||
|
createMinimalSession,
|
||||||
|
buildSessionPrompt,
|
||||||
|
resolveRolePromptPath,
|
||||||
|
getLatestCheckpointForRun,
|
||||||
|
saveWorkerCheckpoint,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
loadJobContext: vi.fn(),
|
||||||
|
resolveModelForRole: vi.fn(),
|
||||||
|
logRoleComplete: vi.fn(),
|
||||||
|
claimTask: vi.fn(),
|
||||||
|
completeTask: vi.fn(),
|
||||||
|
findBacklogItemByRepoPath: vi.fn(),
|
||||||
|
pickTaskRepoPath: vi.fn(),
|
||||||
|
ensureWorktree: vi.fn(),
|
||||||
|
removeWorktree: vi.fn(),
|
||||||
|
reuseWorktreeIfPresent: vi.fn(),
|
||||||
|
createMinimalSession: vi.fn(),
|
||||||
|
buildSessionPrompt: vi.fn(),
|
||||||
|
resolveRolePromptPath: vi.fn(),
|
||||||
|
getLatestCheckpointForRun: vi.fn(),
|
||||||
|
saveWorkerCheckpoint: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./db/convoy.js", () => ({
|
||||||
|
loadJobContext,
|
||||||
|
resolveModelForRole,
|
||||||
|
logRoleComplete,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./db/task-lifecycle.js", () => ({
|
||||||
|
claimTask,
|
||||||
|
completeTask,
|
||||||
|
findBacklogItemByRepoPath,
|
||||||
|
pickTaskRepoPath,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./db/checkpoints.js", () => ({
|
||||||
|
getLatestCheckpointForRun,
|
||||||
|
saveWorkerCheckpoint,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./git/worktree.js", () => ({
|
||||||
|
ensureWorktree,
|
||||||
|
removeWorktree,
|
||||||
|
reuseWorktreeIfPresent,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./session/minimal-session.js", () => ({
|
||||||
|
createMinimalSession,
|
||||||
|
buildSessionPrompt,
|
||||||
|
resolveRolePromptPath,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { handleOrchestratorJob } from "./handle-job.js";
|
||||||
|
|
||||||
|
const db = {} as never;
|
||||||
|
|
||||||
|
const job: OrchestratorJob = {
|
||||||
|
id: "11111111-1111-4111-8111-111111111111",
|
||||||
|
type: "role_dispatch",
|
||||||
|
workspaceId: "22222222-2222-4222-8222-222222222222",
|
||||||
|
convoyId: "33333333-3333-4333-8333-333333333333",
|
||||||
|
roleId: "role-implementer",
|
||||||
|
briefId: "44444444-4444-4444-8444-444444444444",
|
||||||
|
enqueuedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const env = {
|
||||||
|
DATABASE_URL: "postgres://local/test",
|
||||||
|
WORKER_DRY_RUN: false,
|
||||||
|
WORKER_BLPOP_TIMEOUT_S: 5,
|
||||||
|
WORKER_MAX_TOOL_STEPS: 12,
|
||||||
|
WORKER_CHECKPOINT_EVERY_N_STEPS: 1,
|
||||||
|
WORKER_WORKTREE_BASE: "/tmp/echodo-worker-worktrees",
|
||||||
|
REDIS_URL: "redis://localhost:6379",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
convoyId: job.convoyId,
|
||||||
|
convoyTitle: "Test convoy",
|
||||||
|
convoySlug: "test-convoy",
|
||||||
|
workspaceId: job.workspaceId,
|
||||||
|
roleId: job.roleId,
|
||||||
|
briefId: job.briefId,
|
||||||
|
briefFiles: [".convoys/test-convoy/brief-1-implementer.md"],
|
||||||
|
briefAcceptance: "Ship the feature",
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolvedModel = {
|
||||||
|
alias: "qwen-coder",
|
||||||
|
providerType: "openai_compatible" as const,
|
||||||
|
modelId: "qwen2.5-coder",
|
||||||
|
baseUrl: "http://localhost:11434/v1",
|
||||||
|
apiKey: "test-key",
|
||||||
|
tier: "local" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
loadJobContext.mockResolvedValue(ctx);
|
||||||
|
pickTaskRepoPath.mockReturnValue(ctx.briefFiles[0]);
|
||||||
|
findBacklogItemByRepoPath.mockResolvedValue({
|
||||||
|
id: "55555555-5555-4555-8555-555555555555",
|
||||||
|
title: "Brief task",
|
||||||
|
status: "ready",
|
||||||
|
repoPath: ctx.briefFiles[0],
|
||||||
|
});
|
||||||
|
claimTask.mockResolvedValue({
|
||||||
|
runId: "66666666-6666-4666-8666-666666666666",
|
||||||
|
workflowPrompt: "Implement the brief.",
|
||||||
|
});
|
||||||
|
resolveModelForRole.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
model: resolvedModel,
|
||||||
|
resolvedAlias: "qwen-coder",
|
||||||
|
modelTier: "local",
|
||||||
|
});
|
||||||
|
ensureWorktree.mockResolvedValue({
|
||||||
|
repoRoot: "/repo",
|
||||||
|
worktreePath: "/tmp/echodo-worker-worktrees/job-1",
|
||||||
|
branch: "worker/test-convoy-brief-44444444",
|
||||||
|
});
|
||||||
|
reuseWorktreeIfPresent.mockResolvedValue(null);
|
||||||
|
getLatestCheckpointForRun.mockResolvedValue(null);
|
||||||
|
saveWorkerCheckpoint.mockResolvedValue(undefined);
|
||||||
|
removeWorktree.mockResolvedValue(undefined);
|
||||||
|
buildSessionPrompt.mockReturnValue("Run the brief.");
|
||||||
|
resolveRolePromptPath.mockReturnValue("/repo/.cursor/agents/role-implementer.md");
|
||||||
|
logRoleComplete.mockResolvedValue(undefined);
|
||||||
|
completeTask.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("handleOrchestratorJob", () => {
|
||||||
|
it("completes a brief with model alias and token totals", async () => {
|
||||||
|
const run = vi.fn().mockResolvedValue({
|
||||||
|
summary: "Implemented brief changes.",
|
||||||
|
toolCalls: 3,
|
||||||
|
tokensTotal: 1280,
|
||||||
|
});
|
||||||
|
createMinimalSession.mockReturnValue({ context: {}, run });
|
||||||
|
|
||||||
|
await handleOrchestratorJob(db, null, env, job);
|
||||||
|
|
||||||
|
expect(ensureWorktree).toHaveBeenCalledOnce();
|
||||||
|
expect(run).toHaveBeenCalledOnce();
|
||||||
|
expect(completeTask).toHaveBeenCalledWith(
|
||||||
|
db,
|
||||||
|
expect.objectContaining({
|
||||||
|
runId: "66666666-6666-4666-8666-666666666666",
|
||||||
|
outcome: "succeeded",
|
||||||
|
tokensTotal: 1280,
|
||||||
|
modelAlias: "qwen-coder",
|
||||||
|
modelTier: "local",
|
||||||
|
providerType: "openai_compatible",
|
||||||
|
modelId: "qwen2.5-coder",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(removeWorktree).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks the run failed and tears down the worktree when the session throws", async () => {
|
||||||
|
createMinimalSession.mockReturnValue({
|
||||||
|
context: {},
|
||||||
|
run: vi.fn().mockRejectedValue(new Error("model unavailable")),
|
||||||
|
});
|
||||||
|
|
||||||
|
await handleOrchestratorJob(db, null, env, job);
|
||||||
|
|
||||||
|
expect(completeTask).toHaveBeenCalledWith(
|
||||||
|
db,
|
||||||
|
expect.objectContaining({
|
||||||
|
outcome: "failed",
|
||||||
|
error: "model unavailable",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(removeWorktree).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks the run failed when model resolution fails after claim", async () => {
|
||||||
|
resolveModelForRole.mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
reason: "No provider route for role-implementer",
|
||||||
|
});
|
||||||
|
|
||||||
|
await handleOrchestratorJob(db, null, env, job);
|
||||||
|
|
||||||
|
expect(createMinimalSession).not.toHaveBeenCalled();
|
||||||
|
expect(completeTask).toHaveBeenCalledWith(
|
||||||
|
db,
|
||||||
|
expect.objectContaining({
|
||||||
|
outcome: "failed",
|
||||||
|
error: "No provider route for role-implementer",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(removeWorktree).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips worktree setup in dry-run mode", async () => {
|
||||||
|
createMinimalSession.mockReturnValue({
|
||||||
|
context: {},
|
||||||
|
run: vi.fn().mockResolvedValue({
|
||||||
|
summary: "[DRY-RUN] Would run role-implementer",
|
||||||
|
toolCalls: 0,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await handleOrchestratorJob(db, null, { ...env, WORKER_DRY_RUN: true }, job);
|
||||||
|
|
||||||
|
expect(ensureWorktree).not.toHaveBeenCalled();
|
||||||
|
expect(removeWorktree).not.toHaveBeenCalled();
|
||||||
|
expect(createMinimalSession).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
expect.objectContaining({ dryRun: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resumes from checkpoint without re-cloning when worktree is present", async () => {
|
||||||
|
const resumeJob = { ...job, resumeFromCheckpoint: true };
|
||||||
|
getLatestCheckpointForRun.mockResolvedValue({
|
||||||
|
id: "cp-1",
|
||||||
|
workspaceId: job.workspaceId,
|
||||||
|
agentRunId: "66666666-6666-4666-8666-666666666666",
|
||||||
|
convoyId: job.convoyId,
|
||||||
|
briefId: job.briefId,
|
||||||
|
roleId: job.roleId,
|
||||||
|
checkpointSeq: 2,
|
||||||
|
toolHistory: [
|
||||||
|
{
|
||||||
|
seq: 1,
|
||||||
|
tool: "read_file",
|
||||||
|
args: { path: "README.md" },
|
||||||
|
ok: true,
|
||||||
|
resultDigest: "ok:abc",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
filesTouched: [],
|
||||||
|
branch: "worker/test-convoy-brief-44444444",
|
||||||
|
worktreePath: "/tmp/echodo-worker-worktrees/existing",
|
||||||
|
occurredAt: new Date(),
|
||||||
|
});
|
||||||
|
reuseWorktreeIfPresent.mockResolvedValue({
|
||||||
|
repoRoot: "/repo",
|
||||||
|
worktreePath: "/tmp/echodo-worker-worktrees/existing",
|
||||||
|
branch: "worker/test-convoy-brief-44444444",
|
||||||
|
});
|
||||||
|
const run = vi.fn().mockResolvedValue({
|
||||||
|
summary: "Resumed and finished.",
|
||||||
|
toolCalls: 3,
|
||||||
|
tokensTotal: 400,
|
||||||
|
});
|
||||||
|
createMinimalSession.mockReturnValue({ context: {}, run });
|
||||||
|
|
||||||
|
await handleOrchestratorJob(db, null, env, resumeJob);
|
||||||
|
|
||||||
|
expect(reuseWorktreeIfPresent).toHaveBeenCalledWith(
|
||||||
|
expect.any(String),
|
||||||
|
"/tmp/echodo-worker-worktrees/existing",
|
||||||
|
"worker/test-convoy-brief-44444444",
|
||||||
|
);
|
||||||
|
expect(ensureWorktree).not.toHaveBeenCalled();
|
||||||
|
expect(createMinimalSession).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ repoRoot: "/tmp/echodo-worker-worktrees/existing" }),
|
||||||
|
expect.objectContaining({
|
||||||
|
resumeToolHistory: expect.arrayContaining([
|
||||||
|
expect.objectContaining({ tool: "read_file", seq: 1 }),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(removeWorktree).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -2,7 +2,15 @@ import type IoRedis from "ioredis";
|
||||||
|
|
||||||
import type { db as dbInstance } from "@tasks/database";
|
import type { db as dbInstance } from "@tasks/database";
|
||||||
import type { OrchestratorJob } from "@tasks/shared/orchestrator";
|
import type { OrchestratorJob } from "@tasks/shared/orchestrator";
|
||||||
|
import type {
|
||||||
|
WorkerCheckpointFileTouch,
|
||||||
|
WorkerCheckpointToolStep,
|
||||||
|
} from "@tasks/shared/worker";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getLatestCheckpointForRun,
|
||||||
|
saveWorkerCheckpoint,
|
||||||
|
} from "./db/checkpoints.js";
|
||||||
import {
|
import {
|
||||||
loadJobContext,
|
loadJobContext,
|
||||||
logRoleComplete,
|
logRoleComplete,
|
||||||
|
|
@ -15,7 +23,13 @@ import {
|
||||||
pickTaskRepoPath,
|
pickTaskRepoPath,
|
||||||
} from "./db/task-lifecycle.js";
|
} from "./db/task-lifecycle.js";
|
||||||
import type { WorkerEnv } from "./env.js";
|
import type { WorkerEnv } from "./env.js";
|
||||||
import { ensureWorktree, removeWorktree } from "./git/worktree.js";
|
import {
|
||||||
|
ensureWorktree,
|
||||||
|
removeWorktree,
|
||||||
|
reuseWorktreeIfPresent,
|
||||||
|
} from "./git/worktree.js";
|
||||||
|
import { resolveHarnessForJob, getHarnessExecutor } from "./harness.js";
|
||||||
|
import { resolveHarnessForRoleDb } from "./db/harness.js";
|
||||||
import { resolveRepoRoot } from "./lib/repo-root.js";
|
import { resolveRepoRoot } from "./lib/repo-root.js";
|
||||||
import {
|
import {
|
||||||
buildSessionPrompt,
|
buildSessionPrompt,
|
||||||
|
|
@ -39,6 +53,7 @@ export async function handleOrchestratorJob(
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
const dryRun = env.WORKER_DRY_RUN || !env.REDIS_URL;
|
const dryRun = env.WORKER_DRY_RUN || !env.REDIS_URL;
|
||||||
const monorepoRoot = resolveRepoRoot(env.WORKER_REPO_ROOT);
|
const monorepoRoot = resolveRepoRoot(env.WORKER_REPO_ROOT);
|
||||||
|
const resumeRequested = job.resumeFromCheckpoint === true;
|
||||||
|
|
||||||
const ctx = await loadJobContext(db, {
|
const ctx = await loadJobContext(db, {
|
||||||
workspaceId: job.workspaceId,
|
workspaceId: job.workspaceId,
|
||||||
|
|
@ -52,49 +67,41 @@ export async function handleOrchestratorJob(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const modelResult = await resolveModelForRole(db, {
|
// Resolve harness via DB registry with fallback to local alias map (mirrors resolveHarnessForRole pure logic)
|
||||||
workspaceId: job.workspaceId,
|
let harness = resolveHarnessForJob(job as { roleId: string; harnessAlias?: string | null });
|
||||||
|
try {
|
||||||
|
const dbHarness = await resolveHarnessForRoleDb(db, job.workspaceId, job.roleId, (job as { harnessAlias?: string | null }).harnessAlias ?? null);
|
||||||
|
harness = resolveHarnessForJob({
|
||||||
roleId: job.roleId,
|
roleId: job.roleId,
|
||||||
convoyId: job.convoyId,
|
harnessAlias: dbHarness.alias,
|
||||||
|
harnessType: dbHarness.harnessType,
|
||||||
|
endpointUrl: dbHarness.endpointUrl,
|
||||||
|
authEnvVar: dbHarness.authEnvVar,
|
||||||
});
|
});
|
||||||
|
} catch (e) {
|
||||||
if (!modelResult.ok) {
|
console.warn("[worker] harness DB resolve failed, using local fallback:", e);
|
||||||
console.error(`[worker] job=${job.id} model resolve failed: ${modelResult.reason}`);
|
|
||||||
await logRoleComplete(db, {
|
|
||||||
workspaceId: job.workspaceId,
|
|
||||||
convoyId: job.convoyId,
|
|
||||||
roleId: job.roleId,
|
|
||||||
briefId: job.briefId,
|
|
||||||
outcome: "failed",
|
|
||||||
durationS: Math.round((Date.now() - started) / 1000),
|
|
||||||
summary: modelResult.reason,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
const executor = getHarnessExecutor(harness.alias);
|
||||||
|
const needsWorktree = harness.needsWorktree;
|
||||||
|
void executor;
|
||||||
|
|
||||||
let repoRoot = monorepoRoot;
|
let repoRoot = monorepoRoot;
|
||||||
let worktreePath: string | null = null;
|
let worktreePath: string | null = null;
|
||||||
|
let worktreeBranch: string | null = null;
|
||||||
if (!dryRun && job.briefId) {
|
|
||||||
try {
|
|
||||||
const worktree = await ensureWorktree(
|
|
||||||
monorepoRoot,
|
|
||||||
env.WORKER_WORKTREE_BASE,
|
|
||||||
job.id,
|
|
||||||
`${ctx.convoySlug}-brief-${job.briefId.slice(0, 8)}`,
|
|
||||||
);
|
|
||||||
repoRoot = worktree.worktreePath;
|
|
||||||
worktreePath = worktree.worktreePath;
|
|
||||||
console.info(`[worker] worktree=${worktree.worktreePath} branch=${worktree.branch}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("[worker] worktree setup failed, using monorepo root:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let runId: string | null = null;
|
let runId: string | null = null;
|
||||||
let workflowPrompt: string | undefined;
|
let workflowPrompt: string | undefined;
|
||||||
const taskRepoPath = pickTaskRepoPath(ctx.briefFiles);
|
let outcome: "succeeded" | "failed" = "succeeded";
|
||||||
|
let summary = "";
|
||||||
|
let tokensTotal: number | undefined;
|
||||||
|
let modelAlias: string | undefined;
|
||||||
|
let modelTier: string | undefined;
|
||||||
|
let providerType: string | undefined;
|
||||||
|
let modelId: string | undefined;
|
||||||
|
let wroteCheckpoint = false;
|
||||||
|
let resumeToolHistory: WorkerCheckpointToolStep[] | undefined;
|
||||||
|
let resumeFilesTouched: WorkerCheckpointFileTouch[] | undefined;
|
||||||
|
|
||||||
|
const taskRepoPath = pickTaskRepoPath(ctx.briefFiles);
|
||||||
if (taskRepoPath) {
|
if (taskRepoPath) {
|
||||||
const backlogItem = await findBacklogItemByRepoPath(db, job.workspaceId, taskRepoPath);
|
const backlogItem = await findBacklogItemByRepoPath(db, job.workspaceId, taskRepoPath);
|
||||||
if (backlogItem) {
|
if (backlogItem) {
|
||||||
|
|
@ -103,7 +110,6 @@ export async function handleOrchestratorJob(
|
||||||
workspaceId: job.workspaceId,
|
workspaceId: job.workspaceId,
|
||||||
backlogItemId: backlogItem.id,
|
backlogItemId: backlogItem.id,
|
||||||
client: "echodo-worker",
|
client: "echodo-worker",
|
||||||
model: modelResult.resolvedAlias,
|
|
||||||
});
|
});
|
||||||
runId = claim.runId;
|
runId = claim.runId;
|
||||||
workflowPrompt = claim.workflowPrompt;
|
workflowPrompt = claim.workflowPrompt;
|
||||||
|
|
@ -116,6 +122,82 @@ export async function handleOrchestratorJob(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (runId) {
|
||||||
|
const latest = await getLatestCheckpointForRun(db, job.workspaceId, runId);
|
||||||
|
if (latest) {
|
||||||
|
resumeToolHistory = latest.toolHistory;
|
||||||
|
resumeFilesTouched = latest.filesTouched;
|
||||||
|
console.info(
|
||||||
|
`[worker] resuming from checkpoint seq=${latest.checkpointSeq} tools=${latest.toolHistory.length}${
|
||||||
|
resumeRequested ? " (resume_from_checkpoint)" : ""
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (latest.worktreePath && !dryRun) {
|
||||||
|
const reused = await reuseWorktreeIfPresent(
|
||||||
|
monorepoRoot,
|
||||||
|
latest.worktreePath,
|
||||||
|
latest.branch,
|
||||||
|
);
|
||||||
|
if (reused) {
|
||||||
|
repoRoot = reused.worktreePath;
|
||||||
|
worktreePath = reused.worktreePath;
|
||||||
|
worktreeBranch = reused.branch;
|
||||||
|
console.info(
|
||||||
|
`[worker] reused worktree=${reused.worktreePath} branch=${reused.branch} (no re-clone)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (resumeRequested) {
|
||||||
|
console.info(`[worker] resume requested but no checkpoint for run=${runId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!dryRun && job.briefId && !worktreePath) {
|
||||||
|
if (!needsWorktree) {
|
||||||
|
console.info(`[worker] harness=${harness.alias} needsWorktree=false using monorepoRoot directly`);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
// Stable key by brief so crash resume lands on the same directory without re-clone.
|
||||||
|
const worktreeKey = `${job.convoyId.slice(0, 8)}-${job.briefId.slice(0, 8)}`;
|
||||||
|
const worktree = await ensureWorktree(
|
||||||
|
monorepoRoot,
|
||||||
|
env.WORKER_WORKTREE_BASE,
|
||||||
|
worktreeKey,
|
||||||
|
`${ctx.convoySlug}-brief-${job.briefId.slice(0, 8)}`,
|
||||||
|
{ needsWorktree },
|
||||||
|
);
|
||||||
|
if (worktree) {
|
||||||
|
repoRoot = worktree.worktreePath;
|
||||||
|
worktreePath = worktree.worktreePath;
|
||||||
|
worktreeBranch = worktree.branch;
|
||||||
|
console.info(`[worker] worktree=${worktree.worktreePath} branch=${worktree.branch} harness=${harness.alias}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[worker] worktree setup failed, using monorepo root:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelResult = await resolveModelForRole(db, {
|
||||||
|
workspaceId: job.workspaceId,
|
||||||
|
roleId: job.roleId,
|
||||||
|
convoyId: job.convoyId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!modelResult.ok) {
|
||||||
|
outcome = "failed";
|
||||||
|
summary = modelResult.reason;
|
||||||
|
console.error(`[worker] job=${job.id} model resolve failed: ${summary}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
modelAlias = modelResult.resolvedAlias;
|
||||||
|
modelTier = modelResult.modelTier;
|
||||||
|
providerType = modelResult.model.providerType;
|
||||||
|
modelId = modelResult.model.modelId;
|
||||||
|
|
||||||
const sessionContext = {
|
const sessionContext = {
|
||||||
workspaceId: job.workspaceId,
|
workspaceId: job.workspaceId,
|
||||||
convoyId: job.convoyId,
|
convoyId: job.convoyId,
|
||||||
|
|
@ -124,6 +206,7 @@ export async function handleOrchestratorJob(
|
||||||
repoRoot,
|
repoRoot,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// WorkerAdapter dispatch — minimal adapter delegates to createMinimalSession; remote adapters would POST to AG-UI.
|
||||||
const session = createMinimalSession(sessionContext, {
|
const session = createMinimalSession(sessionContext, {
|
||||||
resolvedModel: modelResult.model,
|
resolvedModel: modelResult.model,
|
||||||
resolvedAlias: modelResult.resolvedAlias,
|
resolvedAlias: modelResult.resolvedAlias,
|
||||||
|
|
@ -133,11 +216,30 @@ export async function handleOrchestratorJob(
|
||||||
briefFiles: ctx.briefFiles,
|
briefFiles: ctx.briefFiles,
|
||||||
maxSteps: env.WORKER_MAX_TOOL_STEPS,
|
maxSteps: env.WORKER_MAX_TOOL_STEPS,
|
||||||
dryRun,
|
dryRun,
|
||||||
|
checkpointEveryNSteps: env.WORKER_CHECKPOINT_EVERY_N_STEPS,
|
||||||
|
resumeToolHistory,
|
||||||
|
resumeFilesTouched,
|
||||||
|
allowedCommands: harness.allowedCommands,
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
let outcome: "succeeded" | "failed" = "succeeded";
|
|
||||||
let summary = "";
|
|
||||||
let tokensTotal: number | undefined;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await session.run(buildSessionPrompt(sessionContext, ctx.convoyTitle));
|
const result = await session.run(buildSessionPrompt(sessionContext, ctx.convoyTitle));
|
||||||
|
|
@ -151,7 +253,7 @@ export async function handleOrchestratorJob(
|
||||||
summary = error instanceof Error ? error.message : String(error);
|
summary = error instanceof Error ? error.message : String(error);
|
||||||
console.error(`[worker] job=${job.id} session failed:`, error);
|
console.error(`[worker] job=${job.id} session failed:`, error);
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
if (runId) {
|
if (runId) {
|
||||||
try {
|
try {
|
||||||
await completeTask(db, {
|
await completeTask(db, {
|
||||||
|
|
@ -160,10 +262,11 @@ export async function handleOrchestratorJob(
|
||||||
notes: summary.slice(0, 2000),
|
notes: summary.slice(0, 2000),
|
||||||
error: outcome === "failed" ? summary.slice(0, 2000) : undefined,
|
error: outcome === "failed" ? summary.slice(0, 2000) : undefined,
|
||||||
tokensTotal,
|
tokensTotal,
|
||||||
modelAlias: modelResult.resolvedAlias,
|
modelAlias,
|
||||||
modelTier: modelResult.modelTier,
|
modelTier,
|
||||||
providerType: modelResult.model.providerType,
|
providerType,
|
||||||
modelId: modelResult.model.modelId,
|
modelId,
|
||||||
|
harnessAlias: harness.alias,
|
||||||
repoRoot: monorepoRoot,
|
repoRoot: monorepoRoot,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -171,6 +274,7 @@ export async function handleOrchestratorJob(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
await logRoleComplete(db, {
|
await logRoleComplete(db, {
|
||||||
workspaceId: job.workspaceId,
|
workspaceId: job.workspaceId,
|
||||||
convoyId: job.convoyId,
|
convoyId: job.convoyId,
|
||||||
|
|
@ -178,15 +282,27 @@ export async function handleOrchestratorJob(
|
||||||
briefId: job.briefId,
|
briefId: job.briefId,
|
||||||
outcome,
|
outcome,
|
||||||
durationS: Math.round((Date.now() - started) / 1000),
|
durationS: Math.round((Date.now() - started) / 1000),
|
||||||
modelAlias: modelResult.resolvedAlias,
|
modelAlias,
|
||||||
providerType: modelResult.model.providerType,
|
providerType,
|
||||||
modelId: modelResult.model.modelId,
|
modelId,
|
||||||
modelTier: modelResult.modelTier,
|
modelTier,
|
||||||
tokensTotal,
|
tokensTotal,
|
||||||
summary,
|
summary,
|
||||||
|
harnessAlias: harness.alias,
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[worker] role_complete log failed:", error);
|
||||||
|
}
|
||||||
|
|
||||||
if (worktreePath) {
|
// Keep worktree when checkpoints exist and we failed — enables resume without re-clone.
|
||||||
|
const preserveForResume =
|
||||||
|
outcome === "failed" && (wroteCheckpoint || Boolean(resumeToolHistory?.length));
|
||||||
|
if (worktreePath && !preserveForResume) {
|
||||||
await removeWorktree(monorepoRoot, worktreePath);
|
await removeWorktree(monorepoRoot, worktreePath);
|
||||||
|
} else if (worktreePath && preserveForResume) {
|
||||||
|
console.info(
|
||||||
|
`[worker] preserving worktree=${worktreePath} for checkpoint resume`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
94
apps/worker/src/harness.ts
Normal file
94
apps/worker/src/harness.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
import { ALLOWED_COMMANDS } from "@tasks/shared/worker";
|
||||||
|
import type { HarnessType } from "@tasks/shared/worker";
|
||||||
|
|
||||||
|
export type HarnessConfig = {
|
||||||
|
alias: string;
|
||||||
|
harnessType: HarnessType;
|
||||||
|
needsWorktree: boolean;
|
||||||
|
allowedCommands: readonly string[] | null;
|
||||||
|
endpointUrl?: string | null;
|
||||||
|
authEnvVar?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DOC_WRITER: HarnessConfig = {
|
||||||
|
alias: "doc-writer",
|
||||||
|
harnessType: "minimal",
|
||||||
|
needsWorktree: false,
|
||||||
|
allowedCommands: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT: HarnessConfig = {
|
||||||
|
alias: "minimal",
|
||||||
|
harnessType: "minimal",
|
||||||
|
needsWorktree: true,
|
||||||
|
allowedCommands: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const HARNESS_DEFAULTS: Record<string, Partial<HarnessConfig>> = {
|
||||||
|
minimal: { harnessType: "minimal", needsWorktree: true, allowedCommands: null },
|
||||||
|
crewai: { harnessType: "crewai", needsWorktree: true, allowedCommands: null },
|
||||||
|
langgraph: { harnessType: "langgraph", needsWorktree: true, allowedCommands: null },
|
||||||
|
"remote-ag-ui": { harnessType: "remote-ag-ui", needsWorktree: false, allowedCommands: [] },
|
||||||
|
};
|
||||||
|
|
||||||
|
const HARNESS_BY_ALIAS: Record<string, HarnessConfig> = {
|
||||||
|
"doc-writer": DOC_WRITER,
|
||||||
|
minimal: DEFAULT,
|
||||||
|
default: DEFAULT,
|
||||||
|
};
|
||||||
|
|
||||||
|
function configForAlias(alias: string): HarnessConfig {
|
||||||
|
if (HARNESS_BY_ALIAS[alias]) return HARNESS_BY_ALIAS[alias]!;
|
||||||
|
const def = HARNESS_DEFAULTS[alias] as Partial<HarnessConfig> | undefined;
|
||||||
|
if (def?.harnessType) {
|
||||||
|
return {
|
||||||
|
alias,
|
||||||
|
harnessType: def.harnessType,
|
||||||
|
needsWorktree: def.needsWorktree ?? true,
|
||||||
|
allowedCommands: def.allowedCommands ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { alias, harnessType: "minimal", needsWorktree: true, allowedCommands: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve harness config for a job. Brief 3 adds harness_providers + remote-ag-ui.
|
||||||
|
* - If job carries `harnessAlias` use it (from orchestrator or DB route).
|
||||||
|
* - Else if roleId itself is a harness alias use it.
|
||||||
|
* - Else fallback to minimal.
|
||||||
|
*/
|
||||||
|
export function resolveHarnessForJob(job: {
|
||||||
|
roleId: string;
|
||||||
|
harnessAlias?: string | null;
|
||||||
|
harnessType?: string | null;
|
||||||
|
endpointUrl?: string | null;
|
||||||
|
authEnvVar?: string | null;
|
||||||
|
}): HarnessConfig {
|
||||||
|
const alias = job.harnessAlias ?? (HARNESS_BY_ALIAS[job.roleId] ? job.roleId : null);
|
||||||
|
if (alias) {
|
||||||
|
const base = configForAlias(alias);
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
...(job.harnessType ? { harnessType: job.harnessType as HarnessType } : {}),
|
||||||
|
...(job.endpointUrl !== undefined ? { endpointUrl: job.endpointUrl } : {}),
|
||||||
|
...(job.authEnvVar !== undefined ? { authEnvVar: job.authEnvVar } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (job.roleId.includes("doc")) return DOC_WRITER;
|
||||||
|
return DEFAULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function allowedCommandsForHarness(harness: HarnessConfig): readonly string[] | null {
|
||||||
|
return harness.allowedCommands === null ? ALLOWED_COMMANDS : harness.allowedCommands;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executor registry — minimal adapter delegates to MinimalSession; remote adapters
|
||||||
|
* could call AG-UI endpoint. For brief 3 all adapters share the same local
|
||||||
|
* MinimalSession implementation but are differentiated by harnessType for
|
||||||
|
* observability and future extension.
|
||||||
|
*/
|
||||||
|
export function getHarnessExecutor(alias: string): { id: string; harnessType: HarnessType } {
|
||||||
|
const cfg = configForAlias(alias);
|
||||||
|
return { id: cfg.alias, harnessType: cfg.harnessType };
|
||||||
|
}
|
||||||
19
apps/worker/src/index.dry-run.test.ts
Normal file
19
apps/worker/src/index.dry-run.test.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
describe("worker dry-run entry", () => {
|
||||||
|
it("loadEnv treats WORKER_DRY_RUN=1 as dry-run without REDIS_URL", async () => {
|
||||||
|
const previous = { ...process.env };
|
||||||
|
process.env.DATABASE_URL = "postgres://local/test";
|
||||||
|
process.env.WORKER_DRY_RUN = "1";
|
||||||
|
delete process.env.REDIS_URL;
|
||||||
|
|
||||||
|
vi.resetModules();
|
||||||
|
const { loadEnv } = await import("./env.js");
|
||||||
|
const env = loadEnv();
|
||||||
|
|
||||||
|
expect(env.WORKER_DRY_RUN).toBe(true);
|
||||||
|
expect(env.REDIS_URL).toBeUndefined();
|
||||||
|
|
||||||
|
process.env = previous;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -2,10 +2,16 @@ import { readFile } from "node:fs/promises";
|
||||||
|
|
||||||
import { createLanguageModelFromResolved } from "@tasks/ai";
|
import { createLanguageModelFromResolved } from "@tasks/ai";
|
||||||
import type { ResolvedModel } from "@tasks/shared/model-routing";
|
import type { ResolvedModel } from "@tasks/shared/model-routing";
|
||||||
import type { WorkerJobContext, WorkerSession } from "@tasks/shared/worker";
|
import type {
|
||||||
|
WorkerCheckpointFileTouch,
|
||||||
|
WorkerCheckpointToolStep,
|
||||||
|
WorkerJobContext,
|
||||||
|
WorkerSession,
|
||||||
|
} from "@tasks/shared/worker";
|
||||||
import { generateText, tool } from "ai";
|
import { generateText, tool } from "ai";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { digestResult, hashContent } from "../db/checkpoints.js";
|
||||||
import {
|
import {
|
||||||
grepTool,
|
grepTool,
|
||||||
readFileTool,
|
readFileTool,
|
||||||
|
|
@ -14,6 +20,12 @@ import {
|
||||||
writeFileTool,
|
writeFileTool,
|
||||||
} from "../tools/runner.js";
|
} from "../tools/runner.js";
|
||||||
|
|
||||||
|
export type CheckpointSink = (input: {
|
||||||
|
checkpointSeq: number;
|
||||||
|
toolHistory: WorkerCheckpointToolStep[];
|
||||||
|
filesTouched: WorkerCheckpointFileTouch[];
|
||||||
|
}) => Promise<void>;
|
||||||
|
|
||||||
export type MinimalSessionOptions = {
|
export type MinimalSessionOptions = {
|
||||||
resolvedModel: ResolvedModel;
|
resolvedModel: ResolvedModel;
|
||||||
resolvedAlias: string;
|
resolvedAlias: string;
|
||||||
|
|
@ -23,8 +35,56 @@ export type MinimalSessionOptions = {
|
||||||
briefFiles?: string[];
|
briefFiles?: string[];
|
||||||
maxSteps: number;
|
maxSteps: number;
|
||||||
dryRun?: boolean;
|
dryRun?: boolean;
|
||||||
|
/** How often to persist a checkpoint (1 = every tool round). */
|
||||||
|
checkpointEveryNSteps?: number;
|
||||||
|
resumeToolHistory?: WorkerCheckpointToolStep[];
|
||||||
|
resumeFilesTouched?: WorkerCheckpointFileTouch[];
|
||||||
|
onCheckpoint?: CheckpointSink;
|
||||||
|
allowedCommands?: readonly string[] | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function sanitizeToolArgs(
|
||||||
|
toolName: WorkerCheckpointToolStep["tool"],
|
||||||
|
raw: Record<string, unknown>,
|
||||||
|
): WorkerCheckpointToolStep["args"] {
|
||||||
|
if (toolName === "write_file") {
|
||||||
|
const path = typeof raw.path === "string" ? raw.path : undefined;
|
||||||
|
const content = typeof raw.content === "string" ? raw.content : "";
|
||||||
|
return {
|
||||||
|
path,
|
||||||
|
contentHash: hashContent(content),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (toolName === "read_file") {
|
||||||
|
return { path: typeof raw.path === "string" ? raw.path : undefined };
|
||||||
|
}
|
||||||
|
if (toolName === "grep") {
|
||||||
|
return {
|
||||||
|
pattern: typeof raw.pattern === "string" ? raw.pattern : undefined,
|
||||||
|
path: typeof raw.path === "string" ? raw.path : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { command: typeof raw.command === "string" ? raw.command : undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildResumeAppendix(history: WorkerCheckpointToolStep[]): string {
|
||||||
|
if (history.length === 0) return "";
|
||||||
|
const lines = history.map((step) => {
|
||||||
|
const argParts = Object.entries(step.args)
|
||||||
|
.filter(([, v]) => v != null)
|
||||||
|
.map(([k, v]) => `${k}=${v}`)
|
||||||
|
.join(" ");
|
||||||
|
return `- #${step.seq} ${step.tool} ${argParts} → ${step.ok ? "ok" : "err"}${
|
||||||
|
step.resultDigest ? ` (${step.resultDigest})` : ""
|
||||||
|
}`;
|
||||||
|
});
|
||||||
|
return [
|
||||||
|
"\n\n## Resume from checkpoint",
|
||||||
|
"Prior tool rounds already completed (do not redo unless needed):",
|
||||||
|
...lines,
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
export function createMinimalSession(
|
export function createMinimalSession(
|
||||||
context: WorkerJobContext,
|
context: WorkerJobContext,
|
||||||
options: MinimalSessionOptions,
|
options: MinimalSessionOptions,
|
||||||
|
|
@ -51,6 +111,15 @@ export function createMinimalSession(
|
||||||
throw new Error(modelResult.reason);
|
throw new Error(modelResult.reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resumeHistory = options.resumeToolHistory ?? [];
|
||||||
|
const filesTouched: WorkerCheckpointFileTouch[] = [
|
||||||
|
...(options.resumeFilesTouched ?? []),
|
||||||
|
];
|
||||||
|
const toolHistory: WorkerCheckpointToolStep[] = [...resumeHistory];
|
||||||
|
let toolCalls = resumeHistory.length;
|
||||||
|
const checkpointEvery = Math.max(1, options.checkpointEveryNSteps ?? 1);
|
||||||
|
const remainingSteps = Math.max(1, options.maxSteps - resumeHistory.length);
|
||||||
|
|
||||||
const system = [
|
const system = [
|
||||||
rolePrompt.trim(),
|
rolePrompt.trim(),
|
||||||
options.workflowPrompt ? `\n\n## Workflow\n${options.workflowPrompt.trim()}` : "",
|
options.workflowPrompt ? `\n\n## Workflow\n${options.workflowPrompt.trim()}` : "",
|
||||||
|
|
@ -61,14 +130,50 @@ export function createMinimalSession(
|
||||||
? `\n\n## Acceptance\n${options.briefAcceptance.trim()}`
|
? `\n\n## Acceptance\n${options.briefAcceptance.trim()}`
|
||||||
: "",
|
: "",
|
||||||
"\n\nUse tools to inspect and modify the repo. Finish with a concise summary of work done.",
|
"\n\nUse tools to inspect and modify the repo. Finish with a concise summary of work done.",
|
||||||
|
buildResumeAppendix(resumeHistory),
|
||||||
].join("");
|
].join("");
|
||||||
|
|
||||||
let toolCalls = 0;
|
|
||||||
const repoRoot = context.repoRoot;
|
const repoRoot = context.repoRoot;
|
||||||
|
|
||||||
|
const recordStep = async (
|
||||||
|
toolName: WorkerCheckpointToolStep["tool"],
|
||||||
|
rawArgs: Record<string, unknown>,
|
||||||
|
ok: boolean,
|
||||||
|
output: string,
|
||||||
|
) => {
|
||||||
|
toolCalls += 1;
|
||||||
|
const args = sanitizeToolArgs(toolName, rawArgs);
|
||||||
|
const step: WorkerCheckpointToolStep = {
|
||||||
|
seq: toolCalls,
|
||||||
|
tool: toolName,
|
||||||
|
args,
|
||||||
|
ok,
|
||||||
|
resultDigest: digestResult(output, ok),
|
||||||
|
};
|
||||||
|
toolHistory.push(step);
|
||||||
|
|
||||||
|
if (toolName === "write_file" && args.path && args.contentHash) {
|
||||||
|
const existing = filesTouched.findIndex((f) => f.path === args.path);
|
||||||
|
const touch = { path: args.path, contentHash: args.contentHash };
|
||||||
|
if (existing >= 0) {
|
||||||
|
filesTouched[existing] = touch;
|
||||||
|
} else {
|
||||||
|
filesTouched.push(touch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.onCheckpoint && toolCalls % checkpointEvery === 0) {
|
||||||
|
await options.onCheckpoint({
|
||||||
|
checkpointSeq: toolCalls,
|
||||||
|
toolHistory: [...toolHistory],
|
||||||
|
filesTouched: [...filesTouched],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const result = await generateText({
|
const result = await generateText({
|
||||||
model: modelResult.model,
|
model: modelResult.model,
|
||||||
maxSteps: options.maxSteps,
|
maxSteps: remainingSteps,
|
||||||
system,
|
system,
|
||||||
prompt,
|
prompt,
|
||||||
tools: {
|
tools: {
|
||||||
|
|
@ -78,9 +183,10 @@ export function createMinimalSession(
|
||||||
path: z.string().min(1),
|
path: z.string().min(1),
|
||||||
}),
|
}),
|
||||||
execute: async ({ path }) => {
|
execute: async ({ path }) => {
|
||||||
toolCalls += 1;
|
|
||||||
const res = await readFileTool(repoRoot, path);
|
const res = await readFileTool(repoRoot, path);
|
||||||
return res.ok ? res.output : `ERROR: ${res.error}`;
|
const output = res.ok ? res.output : `ERROR: ${res.error}`;
|
||||||
|
await recordStep("read_file", { path }, res.ok, output);
|
||||||
|
return output;
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
write_file: tool({
|
write_file: tool({
|
||||||
|
|
@ -90,9 +196,10 @@ export function createMinimalSession(
|
||||||
content: z.string(),
|
content: z.string(),
|
||||||
}),
|
}),
|
||||||
execute: async ({ path, content }) => {
|
execute: async ({ path, content }) => {
|
||||||
toolCalls += 1;
|
|
||||||
const res = await writeFileTool(repoRoot, path, content);
|
const res = await writeFileTool(repoRoot, path, content);
|
||||||
return res.ok ? res.output : `ERROR: ${res.error}`;
|
const output = res.ok ? res.output : `ERROR: ${res.error}`;
|
||||||
|
await recordStep("write_file", { path, content }, res.ok, output);
|
||||||
|
return output;
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
grep: tool({
|
grep: tool({
|
||||||
|
|
@ -102,9 +209,10 @@ export function createMinimalSession(
|
||||||
path: z.string().optional(),
|
path: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
execute: async ({ pattern, path }) => {
|
execute: async ({ pattern, path }) => {
|
||||||
toolCalls += 1;
|
|
||||||
const res = await grepTool(repoRoot, pattern, path ?? ".");
|
const res = await grepTool(repoRoot, pattern, path ?? ".");
|
||||||
return res.ok ? res.output : `ERROR: ${res.error}`;
|
const output = res.ok ? res.output : `ERROR: ${res.error}`;
|
||||||
|
await recordStep("grep", { pattern, path }, res.ok, output);
|
||||||
|
return output;
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
run_command: tool({
|
run_command: tool({
|
||||||
|
|
@ -113,9 +221,10 @@ export function createMinimalSession(
|
||||||
command: z.string().min(1),
|
command: z.string().min(1),
|
||||||
}),
|
}),
|
||||||
execute: async ({ command }) => {
|
execute: async ({ command }) => {
|
||||||
toolCalls += 1;
|
const res = await runCommandTool(repoRoot, command, options.allowedCommands);
|
||||||
const res = await runCommandTool(repoRoot, command);
|
const output = res.ok ? res.output : `ERROR: ${res.error}`;
|
||||||
return res.ok ? res.output : `ERROR: ${res.error}`;
|
await recordStep("run_command", { command }, res.ok, output);
|
||||||
|
return output;
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
import { ALLOWED_COMMANDS } from "@tasks/shared/worker";
|
import { ALLOWED_COMMANDS } from "@tasks/shared/worker";
|
||||||
|
|
||||||
export function isAllowedCommand(command: string): boolean {
|
export function isAllowedCommand(
|
||||||
|
command: string,
|
||||||
|
allowedCommands?: readonly string[] | null,
|
||||||
|
): boolean {
|
||||||
|
const allowList = allowedCommands === undefined || allowedCommands === null ? ALLOWED_COMMANDS : allowedCommands;
|
||||||
|
if (allowList.length === 0) return false;
|
||||||
const normalized = command.trim().replace(/\s+/g, " ");
|
const normalized = command.trim().replace(/\s+/g, " ");
|
||||||
return ALLOWED_COMMANDS.some((allowed) => normalized === allowed || normalized.startsWith(`${allowed} `));
|
return allowList.some((allowed) => normalized === allowed || normalized.startsWith(`${allowed} `));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -67,8 +67,12 @@ export async function grepTool(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runCommandTool(repoRoot: string, command: string): Promise<WorkerToolResult> {
|
export async function runCommandTool(
|
||||||
if (!isAllowedCommand(command)) {
|
repoRoot: string,
|
||||||
|
command: string,
|
||||||
|
allowedCommands?: readonly string[] | null,
|
||||||
|
): Promise<WorkerToolResult> {
|
||||||
|
if (!isAllowedCommand(command, allowedCommands)) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
output: "",
|
output: "",
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ Server-enforced convoy state machine for the agent pipeline (Epic 4). Convoys ma
|
||||||
|
|
||||||
`draft` → `convoy_review` → `ready` → `in_progress` → `pr_review` → `merged` → `released`
|
`draft` → `convoy_review` → `ready` → `in_progress` → `pr_review` → `merged` → `released`
|
||||||
|
|
||||||
|
Non-code (docs): `in_progress` → `doc_review` → `published` → `released` (mirror of `pr_review` → `merged`)
|
||||||
|
|
||||||
Terminal / exceptional: `blocked`, `cancelled`
|
Terminal / exceptional: `blocked`, `cancelled`
|
||||||
|
|
||||||
## MCP tools
|
## MCP tools
|
||||||
|
|
@ -22,8 +24,8 @@ Terminal / exceptional: `blocked`, `cancelled`
|
||||||
## Gate actors
|
## Gate actors
|
||||||
|
|
||||||
- `human-gate-1` — plan approval (`convoy_review` ↔ `ready` / `draft`)
|
- `human-gate-1` — plan approval (`convoy_review` ↔ `ready` / `draft`)
|
||||||
- `human-gate-2` — PR merge gate (`pr_review` ↔ `merged` / `in_progress`)
|
- `human-gate-2` — PR merge gate (`pr_review` ↔ `merged` / `in_progress`) and doc gate (`doc_review` ↔ `published` / `in_progress`, also `doc-approver` alias)
|
||||||
- `human-gate-3` — prod promote (`merged` → `released`)
|
- `human-gate-3` — prod promote (`merged`/`published` → `released`)
|
||||||
|
|
||||||
Pipeline roles (`role-architect`, `role-implementer`, …) cannot perform gate transitions.
|
Pipeline roles (`role-architect`, `role-implementer`, …) cannot perform gate transitions.
|
||||||
|
|
||||||
|
|
|
||||||
86
docs/decisions/2026-08-25-second-brain-os-pivot.md
Normal file
86
docs/decisions/2026-08-25-second-brain-os-pivot.md
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
# Decision Log 2026-08-25 — Second Brain OS Pivot
|
||||||
|
|
||||||
|
**Date:** 2026-08-25
|
||||||
|
**Participants:** solo operator + deep-dive analysis of Echodo (`tasks` repo) vs Grok Bot / OpenBot / Agents Squads / Agent Teams / OpenClaw
|
||||||
|
**Status:** Accepted
|
||||||
|
**Scope:** Product positioning, roadmap, and harness architecture for remaining epics.
|
||||||
|
|
||||||
|
## TL;DR
|
||||||
|
|
||||||
|
Echodo stays **homelab dogfooded**, publishes as **open MIT self-hosted "Obsidian for agent crews"** — `docker compose up`, BYO LLMs (Ollama `11434` / MLX `8000` / NIM / LiteLLM), any harness via AG-UI/MCP. Do **not** chase Grok Bot SaaS ($300/mo Heavy) or clone LangChain/CrewAI/AutoGen frameworks. Win by being the file-first, gated control plane they lack.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Full deep dive 2026-08-25 covered:
|
||||||
|
- What Echodo is: `tasks` repo `package.json:1`, `@tasks/*`, DB/domain `echodo`, `plans/Plan-*/Epic-*/Task-*.md` git SoT → `markdown_backlog_items` + `cursor_sync_mappings` → tRPC `19 routers` + MCP `claim_task/complete_task` + `model_providers/role_model_routes` + `convoy` state machine + `orchestrator` Redis tick + `worker` worktree+MinimalSession.
|
||||||
|
- Current status: Epics 1-6 `done`, 7 `in_progress` (draft PR + live E2E missing), 8-12 `draft`. 14 migrations, `apps/worker` 4-tool allowlist, uncommitted checkpoint/worktree code.
|
||||||
|
- Market: $6-17B orchestration 2026 → $38-121B 2035, 23-25% CAGR. Human-in-loop hybrid 18% ($1B). Grok Bot has no standalone price (bundled Heavy/Cursor Ultra). OpenClaw 347k stars > React in 60 days. LangChain 97-135k, CrewAI 57k, AutoGen maintenance → Microsoft Agent Framework.
|
||||||
|
|
||||||
|
Examples walked: solo Maya `Clarity` offline notes + local tags (M4 Mac); small team Studio North `RouteMap` delivery planner (CrewAI crew + Nim). Both driven from web `Mission control` `apps/web/app/(app)/[workspaceSlug]/mission-control/page.tsx:12` or via MCP `claim_task` in Cursor/OpenCode.
|
||||||
|
|
||||||
|
## Decisions Made
|
||||||
|
|
||||||
|
### 1. Positioning: Open self-hosted second brain, not managed SaaS
|
||||||
|
|
||||||
|
- **Internal tool first:** Run for self, fleet of personal projects, dogfood every bead via `claim_task → complete_task`.
|
||||||
|
- **Publish as open self-hosted Obsidian** for agent crews: `git clone && cp .env.example .env && docker compose -f docker/docker-compose.yml up` must work without CT102 IPs. BYO LLM headline.
|
||||||
|
- **Monetize later as managed fleet** (SSE, copilot hosting) if `>1k stars or 50 self-hosters or 3 agencies` in 30 days post-publish. Not per-seat SaaS.
|
||||||
|
|
||||||
|
Rejected: competing head-on with Grok Bot appliance; cloning LangChain/CrewAI (223M dl/mo ecosystem tax, `No native policy gate` gap already exists to fill).
|
||||||
|
|
||||||
|
### 2. Harness-agnostic execution (the big change)
|
||||||
|
|
||||||
|
- Add `WorkerAdapter` interface `packages/shared/src/worker/executor.ts` — `handleOrchestratorJob` `apps/worker/src/handle-job.ts:40` dispatches to `minimal | crewai | langgraph | remote-ag-ui (AG-UI)`.
|
||||||
|
- Add `harness_providers` + `role_harness_routes` (mirror `model_providers` `docs/adr-model-routing.md:78`), `harness_type enum`, `auth_env_var` write-only like OpenBot.
|
||||||
|
- Job spec `packages/shared/src/orchestrator/job.ts` adds `harnessAlias`. AG-UI endpoint `apps/web/app/api/ag-ui/[workspace]/route.ts`; MCP stays `apps/mcp-server/src/index.ts:12`.
|
||||||
|
- One agent can author tasks (doc-writer), another harness implements them — `role-architect → claude-planning + langgraph`, `role-implementer → qwen-coder-mlx http://mac:8000/v1 + crewai`.
|
||||||
|
|
||||||
|
### 3. Web-first, almost everything from UI
|
||||||
|
|
||||||
|
Goal: never leave `echodo.stillwell.cloud`.
|
||||||
|
|
||||||
|
- **Fleet:** new `/fleet` rollup across `workspace_id` + `connected_repos` + `convoy_events` SSE (Epic 9).
|
||||||
|
- **Plans:** tree CRUD for `plans/*.md` `packages/database/src/markdown-backlog/sync.ts:38` via `backlog.createPlan/createEpic/createTask` + `Start → Convoy` (Epic 10). Replaces hand markdown + `pnpm import:markdown-backlog` loop.
|
||||||
|
- **Run:** `CreateConvoyDialog` `apps/web/components/mission-control/create-convoy-dialog.tsx` → `convoys.create` `apps/web/server/routers/convoys.ts:173` → orchestrator fans out; gates `gateAction` `convoys.ts:237` + `gateCopilot` `convoys.ts:267` in same panel; history `agent_runs`/`worker_checkpoints`.
|
||||||
|
- MCP remains shortcut for natural language ("OpenCode, add Epic with 3 tasks") via new `create-backlog-item` tool.
|
||||||
|
|
||||||
|
### 4. Non-code support (book/course/research/SOPs)
|
||||||
|
|
||||||
|
- Make worktree optional `apps/worker/src/git/worktree.ts:1` — doc tasks write straight to `docs/`/`plans/` no branch.
|
||||||
|
- Make `allowlist.ts` per-harness — research harness allows search, doc harness allows no `pnpm`.
|
||||||
|
- Add `doc_review→published` transition alongside `pr_review→merged` `packages/shared/src/convoy-lifecycle/state-machine.ts`.
|
||||||
|
- Templates `Settings → Templates` `apps/web/app/(app)/[workspaceSlug]/settings/templates/page.tsx` for research pipeline/book without migration.
|
||||||
|
|
||||||
|
### 5. What stays vs. what goes
|
||||||
|
|
||||||
|
**Keep:** `workspace_id` tenancy `packages/database/src/schema/objects.ts:32`, `plans/` hierarchy `docs/Glossary.md:50`, markdown `contentHash` idempotency, `claim_task/complete_task` primitives `Plan-agent-coordination.md:12`, model routing `resolveModel` `docs/adr-model-routing.md:63`, gates/kanban `apps/web/app/(app)/[workspaceSlug]/mission-control/page.tsx:12`.
|
||||||
|
|
||||||
|
**Add:** harness registry, Plans tree, Fleet rollup, `create-backlog-item`.
|
||||||
|
|
||||||
|
**Not building now:** full Cursor API push/pull (`config/CursorSync.md:5` modes doc-only), LiteLLM required, per-user model overrides `adr-model-routing.md:354`.
|
||||||
|
|
||||||
|
## Examples Consolidated
|
||||||
|
|
||||||
|
- **Maya solo `Clarity`:** starts from web Plans "local tag prompt Ready" → `Start→Convoy` `clarity-tag-v1` → gate `convoy_review` → approve → worker `qwen-coder` + `tag-crew` worktree → checkpoint `toolHistory` → draft PR `feat(tags)` → `pr_review` → merged. Or same via Cursor `claim_task repoPath:plans/...` natural language.
|
||||||
|
- **Studio North `RouteMap`:** Ana triages Fleet → sees `routemap-optimize-v2 pr_review 2d Carlos`, creates `Epic-driver-offline` in Plans; Ben claims via Cursor; Carlos gate `Request changes` via `Gate co-pilot` summary. `Ops copilot` answers "what needs me?".
|
||||||
|
|
||||||
|
Both show markdown as token-cheap second brain `Plan-automation-foundation.md:222` "DB+UI are how agents query without dumping whole trees" — not Obsidian vault scan.
|
||||||
|
|
||||||
|
## Roadmap Impact
|
||||||
|
|
||||||
|
Updates `plans/Plan-automation-foundation/Plan-automation-foundation.md` — see "2026-08-25 pivot" section. Priorities v0.1 for second-brain OS: (1) MCP create item (0.5d), (2) optional worktree + per-harness allowlist (1d), (3) harness registry + WorkerAdapter (2-3d), (4) Plans tree (1w), (5) Fleet rollup. Epics 9/10 remain draft but now unblock non-code. Epic 11/12 unchanged.
|
||||||
|
|
||||||
|
Go/no-go: 30 days post-open: >1k stars / 50 self-hosters / 3 managed inquiries else keep homelab-only.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- Mac Ollama unreachable from Coolify → `testProvider`/`testHarness` surface LAN error; run web locally fallback `docs/adr-model-routing.md:314`.
|
||||||
|
- Scope creep into framework — mitigate `WorkerAdapter` is adapter, not framework.
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- Plan: `plans/Plan-automation-foundation/Plan-automation-foundation.md`
|
||||||
|
- ADR: `docs/adr-model-routing.md`
|
||||||
|
- Convoy lifecycle: `docs/convoy-lifecycle.md`
|
||||||
|
- Worker: `docs/worker.md`
|
||||||
|
- Plans layout: `plans/README.md`, `docs/Glossary.md`, `config/CursorSync.md`
|
||||||
|
|
@ -23,9 +23,10 @@ WORKER_DRY_RUN=0 REDIS_URL=... pnpm --filter @tasks/worker dev
|
||||||
|
|
||||||
- **Queue** — `BLPOP` on `echodo:orchestrator:jobs` (same key as `apps/orchestrator`)
|
- **Queue** — `BLPOP` on `echodo:orchestrator:jobs` (same key as `apps/orchestrator`)
|
||||||
- **Session** — `WorkerSession` interface in `@tasks/shared/worker`; v1 implementation uses Vercel AI SDK `generateText` + tools
|
- **Session** — `WorkerSession` interface in `@tasks/shared/worker`; v1 implementation uses Vercel AI SDK `generateText` + tools
|
||||||
- **Tools** — `read_file`, `write_file`, `grep` (ripgrep), `run_command` (allowlist: `pnpm lint`, `pnpm type-check`, `pnpm test`, `git status`, `git diff`)
|
- **Tools** — `read_file`, `write_file`, `grep` (ripgrep), `run_command` (allowlist per harness: code harness `pnpm lint`, `pnpm type-check`, `pnpm test`, `git status`, `git diff`; doc harness `[]` — no `run_command`, only `read`/`write`/`grep`)
|
||||||
- **Worktree** — one git worktree per brief job under `WORKER_WORKTREE_BASE`
|
- **Worktree** — one git worktree per brief job under `WORKER_WORKTREE_BASE` when `harness.needsWorktree === true` (code tasks). Doc tasks (`harness: doc-writer`) set `needsWorktree: false` → worker skips `ensureWorktree` and runs straight against `monorepoRoot` (`docs/`/`plans/` writes without a branch). See `apps/worker/src/harness.ts` and `apps/worker/src/git/worktree.ts` `needsWorktree` param.
|
||||||
- **Lifecycle** — DB writes mirroring MCP `claim_task` / `complete_task`; `role_complete` convoy event on finish
|
- **Lifecycle** — DB writes mirroring MCP `claim_task` / `complete_task`; `role_complete` convoy event on finish (includes `metadata.harnessAlias`)
|
||||||
|
- **Harnesses** — `resolveHarnessForJob` maps `job.harnessAlias` or `roleId` to `{ alias, needsWorktree, allowedCommands }`; `allowedCommands` is threaded to `runCommandTool` via `MinimalSession`. Brief 3 will promote this to DB-backed `harness_providers` / `role_harness_routes`.
|
||||||
|
|
||||||
## Env
|
## Env
|
||||||
|
|
||||||
|
|
@ -36,7 +37,20 @@ WORKER_DRY_RUN=0 REDIS_URL=... pnpm --filter @tasks/worker dev
|
||||||
| `WORKER_DRY_RUN` | `false` | Skip dequeue loop |
|
| `WORKER_DRY_RUN` | `false` | Skip dequeue loop |
|
||||||
| `WORKER_BLPOP_TIMEOUT_S` | `5` | Blocking pop timeout |
|
| `WORKER_BLPOP_TIMEOUT_S` | `5` | Blocking pop timeout |
|
||||||
| `WORKER_MAX_TOOL_STEPS` | `12` | Max tool rounds per session |
|
| `WORKER_MAX_TOOL_STEPS` | `12` | Max tool rounds per session |
|
||||||
|
| `WORKER_CHECKPOINT_EVERY_N_STEPS` | `1` | Persist a checkpoint every N tool rounds |
|
||||||
| `WORKER_REPO_ROOT` | monorepo root | Repo for role prompts + worktree base |
|
| `WORKER_REPO_ROOT` | monorepo root | Repo for role prompts + worktree base |
|
||||||
| `WORKER_WORKTREE_BASE` | `/tmp/echodo-worker-worktrees` | Worktree parent directory |
|
| `WORKER_WORKTREE_BASE` | `/tmp/echodo-worker-worktrees` | Worktree parent directory |
|
||||||
|
|
||||||
|
## Checkpoints & crash recovery
|
||||||
|
|
||||||
|
After each tool round (or every `WORKER_CHECKPOINT_EVERY_N_STEPS` rounds), the worker writes a row to `worker_checkpoints` (workspace-scoped): bounded `tool_history`, `files_touched` as **paths + content hashes only** (never API keys or full file bodies), plus `branch` / `worktree_path`.
|
||||||
|
|
||||||
|
On re-dispatch with `resumeFromCheckpoint: true` (orchestrator stall detector), the worker:
|
||||||
|
|
||||||
|
1. Loads the latest checkpoint for the open `agent_runs` row (filtered by `workspace_id`)
|
||||||
|
2. Reuses the saved worktree when still present — no re-clone
|
||||||
|
3. Continues the tool loop with prior steps summarized in the system prompt
|
||||||
|
|
||||||
|
Mission control convoy detail shows checkpoint count and last checkpoint time.
|
||||||
|
|
||||||
Provider API keys are read from env vars named in `model_providers.api_key_env_var` (same as web settings).
|
Provider API keys are read from env vars named in `model_providers.api_key_env_var` (same as web settings).
|
||||||
|
|
|
||||||
22
packages/database/migrations/0014_ancient_lenny_balinger.sql
Normal file
22
packages/database/migrations/0014_ancient_lenny_balinger.sql
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
CREATE TABLE "worker_checkpoints" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"workspace_id" uuid NOT NULL,
|
||||||
|
"agent_run_id" uuid NOT NULL,
|
||||||
|
"convoy_id" uuid NOT NULL,
|
||||||
|
"brief_id" uuid,
|
||||||
|
"role_id" varchar(64) NOT NULL,
|
||||||
|
"checkpoint_seq" integer NOT NULL,
|
||||||
|
"tool_history" jsonb NOT NULL,
|
||||||
|
"files_touched" jsonb NOT NULL,
|
||||||
|
"branch" varchar(255),
|
||||||
|
"worktree_path" text,
|
||||||
|
"occurred_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "worker_checkpoints" ADD CONSTRAINT "worker_checkpoints_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "worker_checkpoints" ADD CONSTRAINT "worker_checkpoints_agent_run_id_agent_runs_id_fk" FOREIGN KEY ("agent_run_id") REFERENCES "public"."agent_runs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "worker_checkpoints" ADD CONSTRAINT "worker_checkpoints_convoy_id_objects_id_fk" FOREIGN KEY ("convoy_id") REFERENCES "public"."objects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "worker_checkpoints" ADD CONSTRAINT "worker_checkpoints_brief_id_objects_id_fk" FOREIGN KEY ("brief_id") REFERENCES "public"."objects"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "worker_checkpoints_workspace_id_occurred_at_idx" ON "worker_checkpoints" USING btree ("workspace_id","occurred_at" DESC NULLS LAST);--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "worker_checkpoints_agent_run_id_seq_idx" ON "worker_checkpoints" USING btree ("agent_run_id","checkpoint_seq");--> statement-breakpoint
|
||||||
|
CREATE INDEX "worker_checkpoints_convoy_id_occurred_at_idx" ON "worker_checkpoints" USING btree ("convoy_id","occurred_at" DESC NULLS LAST);
|
||||||
35
packages/database/migrations/0015_curved_iron_fist.sql
Normal file
35
packages/database/migrations/0015_curved_iron_fist.sql
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
CREATE TABLE "harness_providers" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"workspace_id" uuid NOT NULL,
|
||||||
|
"alias" varchar(120) NOT NULL,
|
||||||
|
"display_name" varchar(200) NOT NULL,
|
||||||
|
"harness_type" varchar(40) NOT NULL,
|
||||||
|
"endpoint_url" text,
|
||||||
|
"auth_env_var" varchar(120),
|
||||||
|
"config" jsonb,
|
||||||
|
"enabled" boolean DEFAULT true NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "role_harness_routes" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"workspace_id" uuid NOT NULL,
|
||||||
|
"role_id" varchar(120) NOT NULL,
|
||||||
|
"harness_provider_id" uuid NOT NULL,
|
||||||
|
"enabled" boolean DEFAULT true NOT NULL,
|
||||||
|
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "agent_runs" ADD COLUMN "harness_alias" varchar(120);--> statement-breakpoint
|
||||||
|
ALTER TABLE "convoy_events" ADD COLUMN "harness_alias" varchar(120);--> statement-breakpoint
|
||||||
|
ALTER TABLE "harness_providers" ADD CONSTRAINT "harness_providers_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "role_harness_routes" ADD CONSTRAINT "role_harness_routes_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "role_harness_routes" ADD CONSTRAINT "role_harness_routes_harness_provider_id_harness_providers_id_fk" FOREIGN KEY ("harness_provider_id") REFERENCES "public"."harness_providers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "harness_providers_workspace_id_idx" ON "harness_providers" USING btree ("workspace_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "harness_providers_workspace_id_alias_unique" ON "harness_providers" USING btree ("workspace_id","alias");--> statement-breakpoint
|
||||||
|
CREATE INDEX "role_harness_routes_workspace_id_idx" ON "role_harness_routes" USING btree ("workspace_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "role_harness_routes_workspace_id_role_id_unique" ON "role_harness_routes" USING btree ("workspace_id","role_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "role_harness_routes_harness_provider_id_idx" ON "role_harness_routes" USING btree ("harness_provider_id");
|
||||||
3954
packages/database/migrations/meta/0014_snapshot.json
Normal file
3954
packages/database/migrations/meta/0014_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
4245
packages/database/migrations/meta/0015_snapshot.json
Normal file
4245
packages/database/migrations/meta/0015_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -99,6 +99,20 @@
|
||||||
"when": 1783453846679,
|
"when": 1783453846679,
|
||||||
"tag": "0013_crazy_joseph",
|
"tag": "0013_crazy_joseph",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 14,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1786546664618,
|
||||||
|
"tag": "0014_ancient_lenny_balinger",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 15,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787692015306,
|
||||||
|
"tag": "0015_curved_iron_fist",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -71,6 +71,7 @@ export const agentRuns = pgTable(
|
||||||
providerType: varchar("provider_type", { length: 40 }),
|
providerType: varchar("provider_type", { length: 40 }),
|
||||||
modelId: varchar("model_id", { length: 200 }),
|
modelId: varchar("model_id", { length: 200 }),
|
||||||
modelTier: varchar("model_tier", { length: 20 }),
|
modelTier: varchar("model_tier", { length: 20 }),
|
||||||
|
harnessAlias: varchar("harness_alias", { length: 120 }),
|
||||||
},
|
},
|
||||||
(table) => ({
|
(table) => ({
|
||||||
workspaceStartedIdx: index("agent_runs_workspace_id_started_at_idx").on(
|
workspaceStartedIdx: index("agent_runs_workspace_id_started_at_idx").on(
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ export const convoyEvents = pgTable(
|
||||||
providerType: varchar("provider_type", { length: 40 }),
|
providerType: varchar("provider_type", { length: 40 }),
|
||||||
modelId: varchar("model_id", { length: 200 }),
|
modelId: varchar("model_id", { length: 200 }),
|
||||||
modelTier: varchar("model_tier", { length: 20 }),
|
modelTier: varchar("model_tier", { length: 20 }),
|
||||||
|
harnessAlias: varchar("harness_alias", { length: 120 }),
|
||||||
estimatedCostUsd: numeric("estimated_cost_usd", { precision: 12, scale: 6 }),
|
estimatedCostUsd: numeric("estimated_cost_usd", { precision: 12, scale: 6 }),
|
||||||
metadata: jsonb("metadata").$type<Record<string, unknown> | null>(),
|
metadata: jsonb("metadata").$type<Record<string, unknown> | null>(),
|
||||||
occurredAt: timestamp("occurred_at", { withTimezone: true })
|
occurredAt: timestamp("occurred_at", { withTimezone: true })
|
||||||
|
|
|
||||||
69
packages/database/src/schema/harness_providers.ts
Normal file
69
packages/database/src/schema/harness_providers.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import {
|
||||||
|
boolean,
|
||||||
|
index,
|
||||||
|
integer,
|
||||||
|
jsonb,
|
||||||
|
pgTable,
|
||||||
|
text,
|
||||||
|
timestamp,
|
||||||
|
uniqueIndex,
|
||||||
|
uuid,
|
||||||
|
varchar,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
|
import { workspaces } from "./workspaces";
|
||||||
|
|
||||||
|
/** Workspace-scoped harness provider — mirrors model_providers, secrets stay in env vars. */
|
||||||
|
export const harnessProviders = pgTable(
|
||||||
|
"harness_providers",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
workspaceId: uuid("workspace_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => workspaces.id, { onDelete: "cascade" }),
|
||||||
|
alias: varchar("alias", { length: 120 }).notNull(),
|
||||||
|
displayName: varchar("display_name", { length: 200 }).notNull(),
|
||||||
|
harnessType: varchar("harness_type", { length: 40 }).notNull(),
|
||||||
|
endpointUrl: text("endpoint_url"),
|
||||||
|
authEnvVar: varchar("auth_env_var", { length: 120 }),
|
||||||
|
config: jsonb("config").$type<Record<string, unknown> | null>(),
|
||||||
|
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("harness_providers_workspace_id_idx").on(table.workspaceId),
|
||||||
|
workspaceAliasUnique: uniqueIndex("harness_providers_workspace_id_alias_unique").on(
|
||||||
|
table.workspaceId,
|
||||||
|
table.alias,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Maps pipeline role_id strings to a harness provider row. */
|
||||||
|
export const roleHarnessRoutes = pgTable(
|
||||||
|
"role_harness_routes",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
workspaceId: uuid("workspace_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => workspaces.id, { onDelete: "cascade" }),
|
||||||
|
roleId: varchar("role_id", { length: 120 }).notNull(),
|
||||||
|
harnessProviderId: uuid("harness_provider_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => harnessProviders.id, { onDelete: "cascade" }),
|
||||||
|
enabled: boolean("enabled").notNull().default(true),
|
||||||
|
sortOrder: integer("sort_order").notNull().default(0),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
workspaceIdIdx: index("role_harness_routes_workspace_id_idx").on(table.workspaceId),
|
||||||
|
workspaceRoleUnique: uniqueIndex(
|
||||||
|
"role_harness_routes_workspace_id_role_id_unique",
|
||||||
|
).on(table.workspaceId, table.roleId),
|
||||||
|
harnessProviderIdIdx: index("role_harness_routes_harness_provider_id_idx").on(
|
||||||
|
table.harnessProviderId,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
@ -17,3 +17,5 @@ export * from "./model_providers";
|
||||||
export * from "./role_model_routes";
|
export * from "./role_model_routes";
|
||||||
export * from "./connected_repos";
|
export * from "./connected_repos";
|
||||||
export * from "./convoy_events";
|
export * from "./convoy_events";
|
||||||
|
export * from "./worker_checkpoints";
|
||||||
|
export * from "./harness_providers";
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import { modelProviders } from "./model_providers";
|
||||||
import { roleModelRoutes } from "./role_model_routes";
|
import { roleModelRoutes } from "./role_model_routes";
|
||||||
import { connectedRepos } from "./connected_repos";
|
import { connectedRepos } from "./connected_repos";
|
||||||
import { convoyEvents } from "./convoy_events";
|
import { convoyEvents } from "./convoy_events";
|
||||||
|
import { workerCheckpoints } from "./worker_checkpoints";
|
||||||
|
|
||||||
export const objectRelations = pgTable(
|
export const objectRelations = pgTable(
|
||||||
"object_relations",
|
"object_relations",
|
||||||
|
|
@ -315,3 +316,24 @@ export const roleModelRoutesRelations = relations(roleModelRoutes, ({ one }) =>
|
||||||
references: [modelProviders.id],
|
references: [modelProviders.id],
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const workerCheckpointsRelations = relations(workerCheckpoints, ({ one }) => ({
|
||||||
|
workspace: one(workspaces, {
|
||||||
|
fields: [workerCheckpoints.workspaceId],
|
||||||
|
references: [workspaces.id],
|
||||||
|
}),
|
||||||
|
agentRun: one(agentRuns, {
|
||||||
|
fields: [workerCheckpoints.agentRunId],
|
||||||
|
references: [agentRuns.id],
|
||||||
|
}),
|
||||||
|
convoy: one(objects, {
|
||||||
|
fields: [workerCheckpoints.convoyId],
|
||||||
|
references: [objects.id],
|
||||||
|
relationName: "workerCheckpointConvoy",
|
||||||
|
}),
|
||||||
|
brief: one(objects, {
|
||||||
|
fields: [workerCheckpoints.briefId],
|
||||||
|
references: [objects.id],
|
||||||
|
relationName: "workerCheckpointBrief",
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
|
||||||
64
packages/database/src/schema/worker_checkpoints.ts
Normal file
64
packages/database/src/schema/worker_checkpoints.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import {
|
||||||
|
index,
|
||||||
|
integer,
|
||||||
|
jsonb,
|
||||||
|
pgTable,
|
||||||
|
text,
|
||||||
|
timestamp,
|
||||||
|
uniqueIndex,
|
||||||
|
uuid,
|
||||||
|
varchar,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
WorkerCheckpointFileTouch,
|
||||||
|
WorkerCheckpointToolStep,
|
||||||
|
} from "@tasks/shared/worker";
|
||||||
|
|
||||||
|
import { agentRuns } from "./agent_runs";
|
||||||
|
import { objects } from "./objects";
|
||||||
|
import { workspaces } from "./workspaces";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bounded progress snapshots for worker sessions. Payloads store paths +
|
||||||
|
* content hashes only — never API keys or full file bodies.
|
||||||
|
*/
|
||||||
|
export const workerCheckpoints = pgTable(
|
||||||
|
"worker_checkpoints",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
workspaceId: uuid("workspace_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => workspaces.id, { onDelete: "cascade" }),
|
||||||
|
agentRunId: uuid("agent_run_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => agentRuns.id, { onDelete: "cascade" }),
|
||||||
|
convoyId: uuid("convoy_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => objects.id, { onDelete: "cascade" }),
|
||||||
|
briefId: uuid("brief_id").references(() => objects.id, { onDelete: "set null" }),
|
||||||
|
roleId: varchar("role_id", { length: 64 }).notNull(),
|
||||||
|
checkpointSeq: integer("checkpoint_seq").notNull(),
|
||||||
|
toolHistory: jsonb("tool_history").$type<WorkerCheckpointToolStep[]>().notNull(),
|
||||||
|
filesTouched: jsonb("files_touched").$type<WorkerCheckpointFileTouch[]>().notNull(),
|
||||||
|
branch: varchar("branch", { length: 255 }),
|
||||||
|
worktreePath: text("worktree_path"),
|
||||||
|
occurredAt: timestamp("occurred_at", { withTimezone: true })
|
||||||
|
.defaultNow()
|
||||||
|
.notNull(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
workspaceOccurredIdx: index("worker_checkpoints_workspace_id_occurred_at_idx").on(
|
||||||
|
table.workspaceId,
|
||||||
|
table.occurredAt.desc(),
|
||||||
|
),
|
||||||
|
runSeqIdx: uniqueIndex("worker_checkpoints_agent_run_id_seq_idx").on(
|
||||||
|
table.agentRunId,
|
||||||
|
table.checkpointSeq,
|
||||||
|
),
|
||||||
|
convoyOccurredIdx: index("worker_checkpoints_convoy_id_occurred_at_idx").on(
|
||||||
|
table.convoyId,
|
||||||
|
table.occurredAt.desc(),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
"./convoy-lifecycle": "./src/convoy-lifecycle/index.ts",
|
"./convoy-lifecycle": "./src/convoy-lifecycle/index.ts",
|
||||||
"./orchestrator": "./src/orchestrator/index.ts",
|
"./orchestrator": "./src/orchestrator/index.ts",
|
||||||
"./worker": "./src/worker/index.ts",
|
"./worker": "./src/worker/index.ts",
|
||||||
|
"./worker/executor": "./src/worker/executor.ts",
|
||||||
"./model-routing": "./src/model-routing/index.ts",
|
"./model-routing": "./src/model-routing/index.ts",
|
||||||
"./types": "./src/types/index.ts",
|
"./types": "./src/types/index.ts",
|
||||||
"./utils": "./src/utils/index.ts"
|
"./utils": "./src/utils/index.ts"
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,9 @@ export const MISSION_CONTROL_KANBAN_COLUMNS: KanbanColumn[] = [
|
||||||
{ status: "ready", label: "Ready to build" },
|
{ status: "ready", label: "Ready to build" },
|
||||||
{ status: "in_progress", label: "Building" },
|
{ status: "in_progress", label: "Building" },
|
||||||
{ status: "pr_review", label: "Awaiting merge" },
|
{ status: "pr_review", label: "Awaiting merge" },
|
||||||
|
{ status: "doc_review", label: "Awaiting doc approval" },
|
||||||
{ status: "merged", label: "Awaiting prod" },
|
{ status: "merged", label: "Awaiting prod" },
|
||||||
|
{ status: "published", label: "Published" },
|
||||||
{ status: "released", label: "Done" },
|
{ status: "released", label: "Done" },
|
||||||
{ status: "blocked", label: "Blocked" },
|
{ status: "blocked", label: "Blocked" },
|
||||||
];
|
];
|
||||||
|
|
@ -21,7 +23,9 @@ export const MISSION_CONTROL_KANBAN_COLUMNS: KanbanColumn[] = [
|
||||||
export const GATE_INBOX_STATUSES: ConvoyStatus[] = [
|
export const GATE_INBOX_STATUSES: ConvoyStatus[] = [
|
||||||
"convoy_review",
|
"convoy_review",
|
||||||
"pr_review",
|
"pr_review",
|
||||||
|
"doc_review",
|
||||||
"merged",
|
"merged",
|
||||||
|
"published",
|
||||||
];
|
];
|
||||||
|
|
||||||
export function gateLabelForStatus(status: ConvoyStatus): string | null {
|
export function gateLabelForStatus(status: ConvoyStatus): string | null {
|
||||||
|
|
@ -30,8 +34,12 @@ export function gateLabelForStatus(status: ConvoyStatus): string | null {
|
||||||
return "Gate 1 — Plan approval";
|
return "Gate 1 — Plan approval";
|
||||||
case "pr_review":
|
case "pr_review":
|
||||||
return "Gate 2 — PR merge";
|
return "Gate 2 — PR merge";
|
||||||
|
case "doc_review":
|
||||||
|
return "Gate 2 — Doc approval";
|
||||||
case "merged":
|
case "merged":
|
||||||
return "Gate 3 — Prod promote";
|
return "Gate 3 — Prod promote";
|
||||||
|
case "published":
|
||||||
|
return "Gate 3 — Prod promote";
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -43,8 +51,12 @@ export function humanGateActorForStatus(status: ConvoyStatus): string | null {
|
||||||
return "human-gate-1";
|
return "human-gate-1";
|
||||||
case "pr_review":
|
case "pr_review":
|
||||||
return "human-gate-2";
|
return "human-gate-2";
|
||||||
|
case "doc_review":
|
||||||
|
return "human-gate-2";
|
||||||
case "merged":
|
case "merged":
|
||||||
return "human-gate-3";
|
return "human-gate-3";
|
||||||
|
case "published":
|
||||||
|
return "human-gate-3";
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,9 @@ export const convoyStatusSchema = z.enum([
|
||||||
"ready",
|
"ready",
|
||||||
"in_progress",
|
"in_progress",
|
||||||
"pr_review",
|
"pr_review",
|
||||||
|
"doc_review",
|
||||||
"merged",
|
"merged",
|
||||||
|
"published",
|
||||||
"released",
|
"released",
|
||||||
"blocked",
|
"blocked",
|
||||||
"cancelled",
|
"cancelled",
|
||||||
|
|
@ -54,6 +56,11 @@ export const convoyObjectContentSchema = z.object({
|
||||||
skipFlags: z.array(z.string()).default([]),
|
skipFlags: z.array(z.string()).default([]),
|
||||||
successMetric: z.string().min(1),
|
successMetric: z.string().min(1),
|
||||||
modelPolicy: convoyModelPolicySchema.optional(),
|
modelPolicy: convoyModelPolicySchema.optional(),
|
||||||
|
backlogItemId: z.string().uuid().optional(),
|
||||||
|
repoPath: z.string().min(1).optional(),
|
||||||
|
planSlug: z.string().min(1).optional(),
|
||||||
|
epicSlug: z.string().min(1).optional(),
|
||||||
|
briefFiles: z.array(z.string()).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ConvoyObjectContent = z.infer<typeof convoyObjectContentSchema>;
|
export type ConvoyObjectContent = z.infer<typeof convoyObjectContentSchema>;
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,11 @@ export const VALID_TRANSITIONS: Record<ConvoyStatus, ConvoyStatus[]> = {
|
||||||
draft: ["convoy_review", "blocked", "cancelled"],
|
draft: ["convoy_review", "blocked", "cancelled"],
|
||||||
convoy_review: ["ready", "draft", "blocked", "cancelled"],
|
convoy_review: ["ready", "draft", "blocked", "cancelled"],
|
||||||
ready: ["in_progress", "blocked", "cancelled"],
|
ready: ["in_progress", "blocked", "cancelled"],
|
||||||
in_progress: ["pr_review", "blocked", "cancelled"],
|
in_progress: ["pr_review", "doc_review", "blocked", "cancelled"],
|
||||||
pr_review: ["merged", "in_progress", "blocked", "cancelled"],
|
pr_review: ["merged", "in_progress", "blocked", "cancelled"],
|
||||||
|
doc_review: ["published", "in_progress", "blocked", "cancelled"],
|
||||||
merged: ["released", "blocked"],
|
merged: ["released", "blocked"],
|
||||||
|
published: ["released", "blocked"],
|
||||||
released: [],
|
released: [],
|
||||||
blocked: [
|
blocked: [
|
||||||
"draft",
|
"draft",
|
||||||
|
|
@ -14,7 +16,9 @@ export const VALID_TRANSITIONS: Record<ConvoyStatus, ConvoyStatus[]> = {
|
||||||
"ready",
|
"ready",
|
||||||
"in_progress",
|
"in_progress",
|
||||||
"pr_review",
|
"pr_review",
|
||||||
|
"doc_review",
|
||||||
"merged",
|
"merged",
|
||||||
|
"published",
|
||||||
"cancelled",
|
"cancelled",
|
||||||
],
|
],
|
||||||
cancelled: [],
|
cancelled: [],
|
||||||
|
|
@ -70,6 +74,7 @@ export const ALLOWED_TRANSITIONS: readonly TransitionRule[] = [
|
||||||
requiresReason: true,
|
requiresReason: true,
|
||||||
},
|
},
|
||||||
{ from: "in_progress", to: "pr_review", actors: ["role-implementer", "automation"] },
|
{ from: "in_progress", to: "pr_review", actors: ["role-implementer", "automation"] },
|
||||||
|
{ from: "in_progress", to: "doc_review", actors: ["role-implementer", "automation"] },
|
||||||
{
|
{
|
||||||
from: "in_progress",
|
from: "in_progress",
|
||||||
to: "blocked",
|
to: "blocked",
|
||||||
|
|
@ -96,6 +101,24 @@ export const ALLOWED_TRANSITIONS: readonly TransitionRule[] = [
|
||||||
actors: ["human-gate-1", "human-gate-2"],
|
actors: ["human-gate-1", "human-gate-2"],
|
||||||
requiresReason: true,
|
requiresReason: true,
|
||||||
},
|
},
|
||||||
|
{ from: "doc_review", to: "published", actors: ["human-gate-2", "doc-approver"] },
|
||||||
|
{
|
||||||
|
from: "doc_review",
|
||||||
|
to: "in_progress",
|
||||||
|
actors: ["human-gate-2", "role-implementer", "doc-approver"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: "doc_review",
|
||||||
|
to: "blocked",
|
||||||
|
actors: ["human-gate-2", "automation", "doc-approver"],
|
||||||
|
requiresReason: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: "doc_review",
|
||||||
|
to: "cancelled",
|
||||||
|
actors: ["human-gate-1", "human-gate-2", "doc-approver"],
|
||||||
|
requiresReason: true,
|
||||||
|
},
|
||||||
{ from: "merged", to: "released", actors: ["human-gate-3"] },
|
{ from: "merged", to: "released", actors: ["human-gate-3"] },
|
||||||
{
|
{
|
||||||
from: "merged",
|
from: "merged",
|
||||||
|
|
@ -103,6 +126,13 @@ export const ALLOWED_TRANSITIONS: readonly TransitionRule[] = [
|
||||||
actors: ["human-gate-3", "automation"],
|
actors: ["human-gate-3", "automation"],
|
||||||
requiresReason: true,
|
requiresReason: true,
|
||||||
},
|
},
|
||||||
|
{ from: "published", to: "released", actors: ["human-gate-3"] },
|
||||||
|
{
|
||||||
|
from: "published",
|
||||||
|
to: "blocked",
|
||||||
|
actors: ["human-gate-3", "automation"],
|
||||||
|
requiresReason: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
from: "blocked",
|
from: "blocked",
|
||||||
to: "draft",
|
to: "draft",
|
||||||
|
|
@ -133,12 +163,24 @@ export const ALLOWED_TRANSITIONS: readonly TransitionRule[] = [
|
||||||
actors: ["human-gate-2", "role-conductor", "automation"],
|
actors: ["human-gate-2", "role-conductor", "automation"],
|
||||||
requiresReason: true,
|
requiresReason: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
from: "blocked",
|
||||||
|
to: "doc_review",
|
||||||
|
actors: ["human-gate-2", "role-conductor", "automation", "doc-approver"],
|
||||||
|
requiresReason: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
from: "blocked",
|
from: "blocked",
|
||||||
to: "merged",
|
to: "merged",
|
||||||
actors: ["human-gate-2", "role-conductor", "automation"],
|
actors: ["human-gate-2", "role-conductor", "automation"],
|
||||||
requiresReason: true,
|
requiresReason: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
from: "blocked",
|
||||||
|
to: "published",
|
||||||
|
actors: ["human-gate-2", "role-conductor", "automation", "doc-approver"],
|
||||||
|
requiresReason: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
from: "blocked",
|
from: "blocked",
|
||||||
to: "cancelled",
|
to: "cancelled",
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,9 @@ const GATE_STATUSES: Record<ConvoyStatus, "1" | "2" | "3" | null> = {
|
||||||
ready: null,
|
ready: null,
|
||||||
in_progress: null,
|
in_progress: null,
|
||||||
pr_review: "2",
|
pr_review: "2",
|
||||||
|
doc_review: "2",
|
||||||
merged: "3",
|
merged: "3",
|
||||||
|
published: "3",
|
||||||
released: null,
|
released: null,
|
||||||
blocked: null,
|
blocked: null,
|
||||||
cancelled: null,
|
cancelled: null,
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,9 @@ export const orchestratorJobSchema = z.object({
|
||||||
roleId: z.string().min(1).max(64),
|
roleId: z.string().min(1).max(64),
|
||||||
briefId: z.string().uuid().optional(),
|
briefId: z.string().uuid().optional(),
|
||||||
enqueuedAt: z.string().datetime(),
|
enqueuedAt: z.string().datetime(),
|
||||||
|
/** When true, worker resumes from the latest checkpoint for the open agent run. */
|
||||||
|
resumeFromCheckpoint: z.boolean().optional(),
|
||||||
|
harnessAlias: z.string().min(1).max(120).optional().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type OrchestratorJob = z.infer<typeof orchestratorJobSchema>;
|
export type OrchestratorJob = z.infer<typeof orchestratorJobSchema>;
|
||||||
|
|
|
||||||
42
packages/shared/src/worker/checkpoint.test.ts
Normal file
42
packages/shared/src/worker/checkpoint.test.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
workerCheckpointPayloadSchema,
|
||||||
|
workerCheckpointToolStepSchema,
|
||||||
|
} from "@tasks/shared/worker";
|
||||||
|
|
||||||
|
describe("worker checkpoint schemas", () => {
|
||||||
|
it("accepts path + hash tool steps without file contents", () => {
|
||||||
|
const step = workerCheckpointToolStepSchema.parse({
|
||||||
|
seq: 1,
|
||||||
|
tool: "write_file",
|
||||||
|
args: {
|
||||||
|
path: "apps/worker/src/x.ts",
|
||||||
|
contentHash: "a".repeat(64),
|
||||||
|
},
|
||||||
|
ok: true,
|
||||||
|
resultDigest: "ok:deadbeefdeadbeef",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(step.args).not.toHaveProperty("content");
|
||||||
|
expect(JSON.stringify(step)).not.toMatch(/api[_-]?key/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects oversized tool history beyond the bound", () => {
|
||||||
|
const history = Array.from({ length: 51 }, (_, i) => ({
|
||||||
|
seq: i + 1,
|
||||||
|
tool: "read_file" as const,
|
||||||
|
args: { path: `f-${i}.ts` },
|
||||||
|
ok: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
workerCheckpointPayloadSchema.parse({
|
||||||
|
toolHistory: history,
|
||||||
|
filesTouched: [],
|
||||||
|
branch: "worker/x",
|
||||||
|
worktreePath: "/tmp/wt",
|
||||||
|
}),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
49
packages/shared/src/worker/checkpoint.ts
Normal file
49
packages/shared/src/worker/checkpoint.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { workerToolNameSchema } from "./session";
|
||||||
|
|
||||||
|
/** Max tool steps retained in a single checkpoint payload. */
|
||||||
|
export const WORKER_CHECKPOINT_TOOL_HISTORY_LIMIT = 50;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitized tool-step record: paths + hashes only — never file contents or API keys.
|
||||||
|
*/
|
||||||
|
export const workerCheckpointToolStepSchema = z.object({
|
||||||
|
seq: z.number().int().nonnegative(),
|
||||||
|
tool: workerToolNameSchema,
|
||||||
|
args: z.object({
|
||||||
|
path: z.string().min(1).max(1024).optional(),
|
||||||
|
pattern: z.string().min(1).max(512).optional(),
|
||||||
|
command: z.string().min(1).max(256).optional(),
|
||||||
|
contentHash: z.string().min(1).max(128).optional(),
|
||||||
|
}),
|
||||||
|
ok: z.boolean(),
|
||||||
|
resultDigest: z.string().max(128).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type WorkerCheckpointToolStep = z.infer<typeof workerCheckpointToolStepSchema>;
|
||||||
|
|
||||||
|
export const workerCheckpointFileTouchSchema = z.object({
|
||||||
|
path: z.string().min(1).max(1024),
|
||||||
|
contentHash: z.string().min(1).max(128),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type WorkerCheckpointFileTouch = z.infer<typeof workerCheckpointFileTouchSchema>;
|
||||||
|
|
||||||
|
export const workerCheckpointPayloadSchema = z.object({
|
||||||
|
toolHistory: z
|
||||||
|
.array(workerCheckpointToolStepSchema)
|
||||||
|
.max(WORKER_CHECKPOINT_TOOL_HISTORY_LIMIT),
|
||||||
|
filesTouched: z.array(workerCheckpointFileTouchSchema).max(200),
|
||||||
|
branch: z.string().min(1).max(255).nullable(),
|
||||||
|
worktreePath: z.string().min(1).max(1024).nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type WorkerCheckpointPayload = z.infer<typeof workerCheckpointPayloadSchema>;
|
||||||
|
|
||||||
|
export const workerCheckpointSummarySchema = z.object({
|
||||||
|
count: z.number().int().nonnegative(),
|
||||||
|
lastOccurredAt: z.string().datetime().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type WorkerCheckpointSummary = z.infer<typeof workerCheckpointSummarySchema>;
|
||||||
140
packages/shared/src/worker/executor.ts
Normal file
140
packages/shared/src/worker/executor.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const harnessTypeSchema = z.enum(["minimal", "crewai", "langgraph", "remote-ag-ui"]);
|
||||||
|
|
||||||
|
export type HarnessType = z.infer<typeof harnessTypeSchema>;
|
||||||
|
|
||||||
|
export const harnessProviderSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
workspaceId: z.string().uuid(),
|
||||||
|
alias: z.string().min(1),
|
||||||
|
displayName: z.string().min(1),
|
||||||
|
harnessType: harnessTypeSchema,
|
||||||
|
endpointUrl: z.string().url().nullable().optional(),
|
||||||
|
authEnvVar: z.string().min(1).nullable().optional(),
|
||||||
|
config: z.record(z.unknown()).nullable().optional(),
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type HarnessProvider = z.infer<typeof harnessProviderSchema>;
|
||||||
|
|
||||||
|
export const roleHarnessRouteSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
workspaceId: z.string().uuid(),
|
||||||
|
roleId: z.string().min(1),
|
||||||
|
harnessProviderId: z.string().uuid(),
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
sortOrder: z.number().int().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type RoleHarnessRoute = z.infer<typeof roleHarnessRouteSchema>;
|
||||||
|
|
||||||
|
export type ResolvedHarness = {
|
||||||
|
alias: string;
|
||||||
|
harnessType: HarnessType;
|
||||||
|
endpointUrl?: string | null;
|
||||||
|
authEnvVar?: string | null;
|
||||||
|
config?: Record<string, unknown> | null;
|
||||||
|
enabled: boolean;
|
||||||
|
providerId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResolveHarnessInput = {
|
||||||
|
roleId: string;
|
||||||
|
providers: HarnessProvider[];
|
||||||
|
routes: RoleHarnessRoute[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResolveHarnessResult = {
|
||||||
|
harness: ResolvedHarness;
|
||||||
|
source: "workspace" | "default";
|
||||||
|
routeId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MINIMAL_RESOLVED: ResolvedHarness = {
|
||||||
|
alias: "minimal",
|
||||||
|
harnessType: "minimal",
|
||||||
|
endpointUrl: null,
|
||||||
|
authEnvVar: null,
|
||||||
|
config: null,
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function resolveHarnessForRole(input: ResolveHarnessInput): ResolveHarnessResult {
|
||||||
|
const route = input.routes.find((r) => r.roleId === input.roleId && r.enabled);
|
||||||
|
if (route) {
|
||||||
|
const provider = input.providers.find((p) => p.id === route.harnessProviderId);
|
||||||
|
if (provider && provider.enabled) {
|
||||||
|
return {
|
||||||
|
harness: {
|
||||||
|
alias: provider.alias,
|
||||||
|
harnessType: provider.harnessType,
|
||||||
|
endpointUrl: provider.endpointUrl ?? null,
|
||||||
|
authEnvVar: provider.authEnvVar ?? null,
|
||||||
|
config: provider.config ?? null,
|
||||||
|
enabled: provider.enabled,
|
||||||
|
providerId: provider.id,
|
||||||
|
},
|
||||||
|
source: "workspace",
|
||||||
|
routeId: route.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback to minimal — always available.
|
||||||
|
const minimalProvider = input.providers.find((p) => p.alias === "minimal" && p.enabled);
|
||||||
|
if (minimalProvider) {
|
||||||
|
return {
|
||||||
|
harness: {
|
||||||
|
alias: minimalProvider.alias,
|
||||||
|
harnessType: minimalProvider.harnessType,
|
||||||
|
endpointUrl: minimalProvider.endpointUrl ?? null,
|
||||||
|
authEnvVar: minimalProvider.authEnvVar ?? null,
|
||||||
|
config: minimalProvider.config ?? null,
|
||||||
|
enabled: true,
|
||||||
|
providerId: minimalProvider.id,
|
||||||
|
},
|
||||||
|
source: minimalProvider.id ? "workspace" : "default",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { harness: MINIMAL_RESOLVED, source: "default" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- WorkerExecutor abstraction (mirrors pattern described in brief) ---
|
||||||
|
|
||||||
|
export type WorkerJobContext = {
|
||||||
|
workspaceId: string;
|
||||||
|
convoyId: string;
|
||||||
|
roleId: string;
|
||||||
|
briefId?: string;
|
||||||
|
repoRoot: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResolvedModelLike = {
|
||||||
|
modelId: string;
|
||||||
|
providerType: string;
|
||||||
|
alias: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface WorkerExecutor {
|
||||||
|
id: string;
|
||||||
|
harnessType: HarnessType;
|
||||||
|
run(
|
||||||
|
ctx: WorkerJobContext,
|
||||||
|
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 }>;
|
||||||
|
}
|
||||||
|
|
@ -7,3 +7,27 @@ export {
|
||||||
type WorkerToolResult,
|
type WorkerToolResult,
|
||||||
workerToolNameSchema,
|
workerToolNameSchema,
|
||||||
} from "./session";
|
} from "./session";
|
||||||
|
export {
|
||||||
|
harnessProviderSchema,
|
||||||
|
harnessTypeSchema,
|
||||||
|
roleHarnessRouteSchema,
|
||||||
|
resolveHarnessForRole,
|
||||||
|
type HarnessProvider,
|
||||||
|
type HarnessType,
|
||||||
|
type ResolveHarnessInput,
|
||||||
|
type ResolveHarnessResult,
|
||||||
|
type ResolvedHarness,
|
||||||
|
type WorkerExecutor,
|
||||||
|
} from "./executor";
|
||||||
|
|
||||||
|
export {
|
||||||
|
WORKER_CHECKPOINT_TOOL_HISTORY_LIMIT,
|
||||||
|
workerCheckpointFileTouchSchema,
|
||||||
|
workerCheckpointPayloadSchema,
|
||||||
|
workerCheckpointSummarySchema,
|
||||||
|
workerCheckpointToolStepSchema,
|
||||||
|
type WorkerCheckpointFileTouch,
|
||||||
|
type WorkerCheckpointPayload,
|
||||||
|
type WorkerCheckpointSummary,
|
||||||
|
type WorkerCheckpointToolStep,
|
||||||
|
} from "./checkpoint";
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ status: done
|
||||||
priority: P1
|
priority: P1
|
||||||
tenant_id: global
|
tenant_id: global
|
||||||
cursor_epic_id: null
|
cursor_epic_id: null
|
||||||
updated_at: "2026-07-03"
|
updated_at: "2026-08-03"
|
||||||
---
|
---
|
||||||
|
|
||||||
# Epic objective
|
# Epic objective
|
||||||
|
|
@ -55,6 +55,12 @@ Each job calls `resolveModel(workspace, roleId, convoy, brief)` and passes `Reso
|
||||||
- [x] [Task-shared-orchestrator-driver](./Task-shared-orchestrator-driver.md)
|
- [x] [Task-shared-orchestrator-driver](./Task-shared-orchestrator-driver.md)
|
||||||
- [x] [Task-apps-orchestrator-service](./Task-apps-orchestrator-service.md)
|
- [x] [Task-apps-orchestrator-service](./Task-apps-orchestrator-service.md)
|
||||||
|
|
||||||
|
## Follow-on tasks (Autensa benchmark)
|
||||||
|
|
||||||
|
| Task | Link |
|
||||||
|
|------|------|
|
||||||
|
| Agent health auto-nudge & zombie recovery | [Task-orchestrator-auto-nudge](./Task-orchestrator-auto-nudge.md) |
|
||||||
|
|
||||||
## Note
|
## Note
|
||||||
|
|
||||||
Supersedes [Epic-optional-orchestrator](../../Plan-agent-coordination/Epic-optional-orchestrator/Epic-optional-orchestrator.md) deferred stub with a concrete Echodo-native design.
|
Supersedes [Epic-optional-orchestrator](../../Plan-agent-coordination/Epic-optional-orchestrator/Epic-optional-orchestrator.md) deferred stub with a concrete Echodo-native design.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
---
|
||||||
|
kind: task
|
||||||
|
slug: orchestrator-auto-nudge
|
||||||
|
title: Orchestrator agent health — auto-nudge and zombie recovery
|
||||||
|
plan_slug: automation-foundation
|
||||||
|
epic_slug: apps-orchestrator
|
||||||
|
status: draft
|
||||||
|
priority: P2
|
||||||
|
tenant_id: global
|
||||||
|
owner: unassigned
|
||||||
|
cursor_todo_id: null
|
||||||
|
updated_at: "2026-08-03"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task summary
|
||||||
|
|
||||||
|
Extend stall detection beyond marking convoys `blocked`: detect zombie agent runs, optionally re-dispatch or nudge, and emit health events for mission control / fleet feed.
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
**Benchmark:** Autensa agent health monitoring with auto-nudge and convoy health panel. Echodo already has `ORCHESTRATOR_STALL_MINUTES` and `detectStalledConvoys` — this task adds **recovery actions** and observability.
|
||||||
|
|
||||||
|
### Health states
|
||||||
|
|
||||||
|
| State | Trigger | Default action |
|
||||||
|
|-------|---------|----------------|
|
||||||
|
| **Stalled** | No heartbeat on open `agent_run` for N minutes | Mark convoy `blocked`; surface in gate inbox sidebar |
|
||||||
|
| **Zombie** | Run open but worker process gone | Re-enqueue job with `resume_from_checkpoint` when Epic 7 checkpoint task ships |
|
||||||
|
| **Nudge** | Soft stall (configurable) | Log `convoy_events` health event; optional Redis ping to worker |
|
||||||
|
|
||||||
|
### Config (env)
|
||||||
|
|
||||||
|
- `ORCHESTRATOR_NUDGE_MINUTES` (optional, < stall threshold)
|
||||||
|
- `ORCHESTRATOR_AUTO_REDISPATCH` (default false — opt-in for unattended dogfood)
|
||||||
|
|
||||||
|
### Events
|
||||||
|
|
||||||
|
Append `convoy_events` with `event_type: health` and metadata `{ reason, action, agent_run_id }` for activity feed (Epic 9).
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Epic 6 orchestrator service (shipped)
|
||||||
|
- [Task-worker-checkpoint-resume](../Epic-apps-worker-v1/Task-worker-checkpoint-resume.md) for meaningful re-dispatch
|
||||||
|
|
||||||
|
## Subtasks
|
||||||
|
|
||||||
|
- [ ] Extend `detectStalledConvoys` with nudge vs hard-stall tiers
|
||||||
|
- [ ] Optional re-dispatch path on zombie detection
|
||||||
|
- [ ] `convoy_events` health event writer
|
||||||
|
- [ ] Document in `docs/orchestrator.md`
|
||||||
|
|
||||||
|
## Estimation
|
||||||
|
|
||||||
|
M
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Stalled run moves convoy to `blocked` and writes health event
|
||||||
|
- [ ] With `ORCHESTRATOR_AUTO_REDISPATCH=1`, zombie run triggers new job enqueue
|
||||||
|
- [ ] No unbounded re-dispatch loops (max retries per brief configurable)
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- Epic: `./Epic-apps-orchestrator.md`
|
||||||
|
|
@ -7,7 +7,7 @@ status: in_progress
|
||||||
priority: P1
|
priority: P1
|
||||||
tenant_id: global
|
tenant_id: global
|
||||||
cursor_epic_id: null
|
cursor_epic_id: null
|
||||||
updated_at: "2026-07-03"
|
updated_at: "2026-08-12"
|
||||||
---
|
---
|
||||||
|
|
||||||
# Epic objective
|
# Epic objective
|
||||||
|
|
@ -18,6 +18,12 @@ Role prompt = `.cursor/agents/role-*.md` + brief path. Model endpoint points at
|
||||||
|
|
||||||
**Parallel implementers:** When orchestrator dispatches N implementers to the same Mac inference host, prefer a batching-capable MLX server (**mlx-openai-server**) over `mlx_lm.server` alone. Document chosen server + port in workspace provider alias (`qwen-coder-mlx`).
|
**Parallel implementers:** When orchestrator dispatches N implementers to the same Mac inference host, prefer a batching-capable MLX server (**mlx-openai-server**) over `mlx_lm.server` alone. Document chosen server + port in workspace provider alias (`qwen-coder-mlx`).
|
||||||
|
|
||||||
|
## Status note (2026-08-12)
|
||||||
|
|
||||||
|
- Tool loop + checkpoints are **implemented in code**; task files marked done.
|
||||||
|
- **Gap:** worker does **not** yet open draft PRs via forge adapter (blocks clean Epic 8 dogfood).
|
||||||
|
- **A1 live E2E** (Go → unattended job on real Redis/Coolify) needs explicit operator verify: [Task-worker-live-e2e-dogfood](./Task-worker-live-e2e-dogfood.md).
|
||||||
|
|
||||||
## In scope / out of scope
|
## In scope / out of scope
|
||||||
|
|
||||||
**In scope**
|
**In scope**
|
||||||
|
|
@ -25,11 +31,14 @@ Role prompt = `.cursor/agents/role-*.md` + brief path. Model endpoint points at
|
||||||
- `WorkerSession` adapter interface (for future optional OpenHands plug-in).
|
- `WorkerSession` adapter interface (for future optional OpenHands plug-in).
|
||||||
- `pnpm lint && pnpm test` in allowlisted commands.
|
- `pnpm lint && pnpm test` in allowlisted commands.
|
||||||
- Branch + PR draft output (forge adapter from Epic 3).
|
- Branch + PR draft output (forge adapter from Epic 3).
|
||||||
|
- Checkpoint/resume and operator mid-build notes (Autensa benchmark — see tasks below).
|
||||||
|
- Live deploy dogfood checklist.
|
||||||
|
|
||||||
**Out of scope**
|
**Out of scope**
|
||||||
|
|
||||||
- Browser automation, multi-agent delegation.
|
- Browser automation, multi-agent delegation.
|
||||||
- Feature parity with OpenHands.
|
- Feature parity with OpenHands.
|
||||||
|
- OpenClaw Gateway coupling.
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
|
|
@ -37,13 +46,21 @@ Role prompt = `.cursor/agents/role-*.md` + brief path. Model endpoint points at
|
||||||
|
|
||||||
## Blocks
|
## Blocks
|
||||||
|
|
||||||
- Epic 8 (meaningful PR webhook dogfood).
|
- Epic 8 (meaningful PR webhook dogfood) until draft PR task lands.
|
||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] One brief from `.convoys/` completes unattended on connected repo.
|
- [ ] One brief from `.convoys/` completes unattended on connected repo (see live E2E task).
|
||||||
- [ ] `complete_task` includes model alias + token totals.
|
- [x] Checkpoint resume after kill (code + task done).
|
||||||
|
- [ ] Draft PR opened on implementer success.
|
||||||
|
- [ ] `complete_task` includes model alias + token totals on live run.
|
||||||
|
|
||||||
## Tasks
|
## Tasks
|
||||||
|
|
||||||
_To be decomposed when epic graduates to `ready`._
|
| Task | Link | Status |
|
||||||
|
|------|------|--------|
|
||||||
|
| Minimal tool loop + worktree | [Task-worker-minimal-tool-loop](./Task-worker-minimal-tool-loop.md) | done (code; confirm live via E2E task) |
|
||||||
|
| Checkpoint & crash recovery | [Task-worker-checkpoint-resume](./Task-worker-checkpoint-resume.md) | done |
|
||||||
|
| Draft PR via forge | [Task-worker-draft-pr](./Task-worker-draft-pr.md) | **ready** |
|
||||||
|
| Live E2E dogfood (A1 verify) | [Task-worker-live-e2e-dogfood](./Task-worker-live-e2e-dogfood.md) | **ready** |
|
||||||
|
| Operator notes mid-build | [Task-worker-operator-notes](./Task-worker-operator-notes.md) | draft |
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
---
|
||||||
|
kind: task
|
||||||
|
slug: worker-checkpoint-resume
|
||||||
|
title: Worker checkpoint and crash recovery
|
||||||
|
plan_slug: automation-foundation
|
||||||
|
epic_slug: apps-worker-v1
|
||||||
|
status: done
|
||||||
|
priority: P1
|
||||||
|
tenant_id: global
|
||||||
|
owner: unassigned
|
||||||
|
cursor_todo_id: null
|
||||||
|
updated_at: "2026-08-12"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task summary
|
||||||
|
|
||||||
|
Persist worker session progress at configurable checkpoints so a crashed or stalled job resumes from the last checkpoint instead of restarting the brief from scratch.
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
**Benchmark:** [Autensa mission-control](https://github.com/crshdn/mission-control) checkpoint & crash recovery — study their checkpoint restore API and per-task history UX; implement Echodo-native equivalents on Postgres.
|
||||||
|
|
||||||
|
### Schema
|
||||||
|
|
||||||
|
Add `worker_checkpoints` (or JSONB on `agent_runs`) workspace-scoped:
|
||||||
|
|
||||||
|
- `agent_run_id`, `convoy_id`, `brief_id`
|
||||||
|
- `checkpoint_seq`, `tool_history` (bounded), `files_touched`, `branch`, `worktree_path`
|
||||||
|
- `occurred_at`
|
||||||
|
|
||||||
|
### Worker behavior
|
||||||
|
|
||||||
|
- Save checkpoint after each tool round (configurable via `WORKER_CHECKPOINT_EVERY_N_STEPS`, default 1).
|
||||||
|
- On job start: if open run has latest checkpoint, resume tool loop from saved state.
|
||||||
|
- Orchestrator stall detector can trigger re-dispatch with `resume_from_checkpoint: true`.
|
||||||
|
|
||||||
|
### Operator surface (minimal v1)
|
||||||
|
|
||||||
|
- Mission control detail panel: checkpoint count + last checkpoint time (full history deferred).
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- [Task-worker-minimal-tool-loop](./Task-worker-minimal-tool-loop.md) (baseline loop must exist).
|
||||||
|
|
||||||
|
## Subtasks
|
||||||
|
|
||||||
|
- [x] Zod schemas in `packages/shared/worker`
|
||||||
|
- [x] Drizzle migration + workspace-scoped queries
|
||||||
|
- [x] Checkpoint write in `minimal-session` / `handle-job`
|
||||||
|
- [x] Resume path on re-dispatch
|
||||||
|
- [x] Env docs in `docs/worker.md`
|
||||||
|
|
||||||
|
## Estimation
|
||||||
|
|
||||||
|
M
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] Kill worker mid-brief; re-dispatch resumes from last tool step without re-cloning
|
||||||
|
- [x] Checkpoints filtered by `workspace_id`
|
||||||
|
- [x] Checkpoint payload does not store API keys or full file contents (paths + hashes only)
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- Epic: `./Epic-apps-worker-v1.md`
|
||||||
|
- Blocks: reliable unattended dogfood for Epic 8
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
---
|
||||||
|
kind: task
|
||||||
|
slug: worker-draft-pr
|
||||||
|
title: Worker opens draft PR via forge adapter on implementer success
|
||||||
|
plan_slug: automation-foundation
|
||||||
|
epic_slug: apps-worker-v1
|
||||||
|
status: ready
|
||||||
|
priority: P1
|
||||||
|
tenant_id: global
|
||||||
|
owner: unassigned
|
||||||
|
cursor_todo_id: null
|
||||||
|
updated_at: "2026-08-12"
|
||||||
|
agent_prompt: |
|
||||||
|
Epic 7 gap: apps/worker never calls the forge adapter to open a PR.
|
||||||
|
Wire createPullRequest (or equivalent) after a successful implementer
|
||||||
|
session. Use connected repo from convoy/workspace. Branch naming must
|
||||||
|
match Epic 8 conventions. claim_task / complete_task this task; run
|
||||||
|
pnpm lint && pnpm type-check && pnpm test.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task summary
|
||||||
|
|
||||||
|
When an implementer `role_dispatch` job succeeds, open a **draft PR** on the connected forge (GitHub/Forgejo) from the worktree branch, and store PR URL/metadata on the convoy for Mission Control and Epic 8 webhooks.
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
Gap check (2026-08-12): `Task-worker-minimal-tool-loop` was marked `done` and checkpoints shipped, but `apps/worker` has **no** forge PR call. Epic 7 acceptance (“branch + PR draft output”) and Epic 8 dogfood both require this.
|
||||||
|
|
||||||
|
### Behavior
|
||||||
|
|
||||||
|
- After successful implementer session (and before worktree teardown): push branch if needed, call forge adapter `createPullRequest` (draft).
|
||||||
|
- PR body includes convoy slug, brief id, echodo marker for webhooks (`echodo-convoy: <uuid>`).
|
||||||
|
- Persist PR url / number on convoy content or `convoy_events` metadata.
|
||||||
|
- Non-implementer roles skip PR creation.
|
||||||
|
- Failures: log + mark run notes; do not silently claim success if PR was required.
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
- Epic 3 forge adapters
|
||||||
|
- Worktree + branch from worker tool loop
|
||||||
|
|
||||||
|
## Subtasks
|
||||||
|
|
||||||
|
- [ ] Resolve `repo_links` for convoy workspace
|
||||||
|
- [ ] Push worktree branch with stored credentials pattern
|
||||||
|
- [ ] `createPullRequest` draft via adapter
|
||||||
|
- [ ] Write PR metadata for Mission Control detail panel
|
||||||
|
- [ ] Document branch + marker convention in `docs/worker.md`
|
||||||
|
|
||||||
|
## Estimation
|
||||||
|
|
||||||
|
M
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Successful implementer job produces a draft PR URL on a connected test repo
|
||||||
|
- [ ] PR body contains convoy marker consumable by Epic 8
|
||||||
|
- [ ] Planning-only roles do not open PRs
|
||||||
|
- [ ] Mission control detail can show PR link when present
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- Epic: `./Epic-apps-worker-v1.md`
|
||||||
|
- Blocks: Epic 8 webhook dogfood
|
||||||
|
- Related: [Task-worker-minimal-tool-loop](./Task-worker-minimal-tool-loop.md)
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
---
|
||||||
|
kind: task
|
||||||
|
slug: worker-live-e2e-dogfood
|
||||||
|
title: Live E2E dogfood — Go to worker job without Cursor (A1 verify)
|
||||||
|
plan_slug: automation-foundation
|
||||||
|
epic_slug: apps-worker-v1
|
||||||
|
status: ready
|
||||||
|
priority: P1
|
||||||
|
tenant_id: global
|
||||||
|
owner: unassigned
|
||||||
|
cursor_todo_id: null
|
||||||
|
updated_at: "2026-08-12"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task summary
|
||||||
|
|
||||||
|
Operator verification checklist: prove Mission Control **Go** → orchestrator enqueue → worker (non-dry-run) completes one implementer brief with model/token metadata — **without** a Cursor coding session for that brief.
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
**Phase A1** from the owner vision playbook. Code for the loop and checkpoints exists; this task is the **proof on your deploy** (Coolify / local Redis), not more scaffolding.
|
||||||
|
|
||||||
|
Mark this task `done` only when every acceptance criterion below is true in production or a documented staging environment.
|
||||||
|
|
||||||
|
### Checklist (operator)
|
||||||
|
|
||||||
|
1. `ORCHESTRATOR_DRY_RUN=0`, `WORKER_DRY_RUN=0`, `REDIS_URL` set on orchestrator + worker services
|
||||||
|
2. Workspace has model aliases + role routes for `role-implementer`
|
||||||
|
3. Connected repo (or monorepo) reachable from worker
|
||||||
|
4. Create or reconcile a convoy with an implementer brief
|
||||||
|
5. Advance to `ready` / trigger dispatch (Gate 1 approve if needed)
|
||||||
|
6. Worker logs show job pickup + `complete_task` / `role_complete`
|
||||||
|
7. Settings → Runs shows model alias + tokens for that run
|
||||||
|
8. (Preferred) Draft PR exists after [Task-worker-draft-pr](./Task-worker-draft-pr.md)
|
||||||
|
|
||||||
|
### Not this task
|
||||||
|
|
||||||
|
- Building new worker features (use sibling tasks)
|
||||||
|
- Forge webhooks (Epic 8)
|
||||||
|
|
||||||
|
## Subtasks
|
||||||
|
|
||||||
|
- [ ] Document exact Coolify/env values used (no secrets in git — names only)
|
||||||
|
- [ ] Record convoy slug + run id in task notes or convoy_events
|
||||||
|
- [ ] Confirm dry-run modes still work when Redis missing
|
||||||
|
|
||||||
|
## Estimation
|
||||||
|
|
||||||
|
S (ops)
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] One implementer brief completed unattended (no Cursor session for that brief)
|
||||||
|
- [ ] `agent_runs` row has `model_alias` and token totals
|
||||||
|
- [ ] Convoy progressed or logged `role_complete` visible in Mission Control detail
|
||||||
|
- [ ] Orchestrator/worker were not in dry-run for the successful job
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- Epic: `./Epic-apps-worker-v1.md`
|
||||||
|
- Plan: `../Plan-automation-foundation.md`
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
---
|
||||||
|
kind: task
|
||||||
|
slug: worker-minimal-tool-loop
|
||||||
|
title: apps/worker — minimal tool loop and worktree-per-brief
|
||||||
|
plan_slug: automation-foundation
|
||||||
|
epic_slug: apps-worker-v1
|
||||||
|
status: done
|
||||||
|
priority: P1
|
||||||
|
tenant_id: global
|
||||||
|
owner: unassigned
|
||||||
|
cursor_todo_id: null
|
||||||
|
updated_at: "2026-08-12"
|
||||||
|
agent_prompt: |-
|
||||||
|
Epic: apps/worker v1. Read docs/worker.md and docs/adr-model-routing.md.
|
||||||
|
Consume orchestrator jobs from Redis; resolve model via workspace registry.
|
||||||
|
claim_task / complete_task parity with MCP. Run pnpm lint && pnpm type-check
|
||||||
|
&& pnpm test before done.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task summary
|
||||||
|
|
||||||
|
Ship the baseline `apps/worker` consumer: dequeue `role_dispatch` jobs, run `WorkerSession` tool loop against a git worktree, and close with model + token metadata on `complete_task`.
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
Core v1 scope from [Epic-apps-worker-v1](./Epic-apps-worker-v1.md). Builds on existing package skeleton (`handle-job.ts`, `minimal-session.ts`, allowlisted tools, worktree helpers).
|
||||||
|
|
||||||
|
### Worker loop
|
||||||
|
|
||||||
|
- `BLPOP` on `echodo:orchestrator:jobs` (same key as orchestrator).
|
||||||
|
- Load brief path + role from job payload; resolve model via shared resolver.
|
||||||
|
- `claim_task` on matching backlog item; run `read_file` / `write_file` / `grep` / `run_command` (allowlist: `pnpm lint`, `pnpm type-check`, `pnpm test`, `git status`, `git diff`).
|
||||||
|
- `complete_task` with `modelAlias`, token totals, outcome.
|
||||||
|
|
||||||
|
### Git
|
||||||
|
|
||||||
|
- One worktree per brief under `WORKER_WORKTREE_BASE`.
|
||||||
|
- Branch naming convention documented for Epic 8 PR linking.
|
||||||
|
|
||||||
|
### PR output
|
||||||
|
|
||||||
|
- Open draft PR via forge adapter from connected repo (Epic 3).
|
||||||
|
|
||||||
|
## Subtasks
|
||||||
|
|
||||||
|
- [ ] Wire live dequeue loop (non-dry-run) end-to-end
|
||||||
|
- [ ] Role prompt = `.cursor/agents/role-*.md` + brief markdown path
|
||||||
|
- [ ] Worktree create/teardown on success and failure
|
||||||
|
- [ ] Draft PR via forge adapter when implementer completes
|
||||||
|
- [ ] `docs/worker.md` matches shipped behavior
|
||||||
|
|
||||||
|
## Estimation
|
||||||
|
|
||||||
|
L
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] One implementer brief from `.convoys/` completes unattended on a connected repo
|
||||||
|
- [ ] `complete_task` includes model alias + token totals
|
||||||
|
- [ ] Failed runs tear down worktree and mark run `failed`
|
||||||
|
- [ ] `WORKER_DRY_RUN=1` still exits cleanly without Redis
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- Epic: `./Epic-apps-worker-v1.md`
|
||||||
|
- Plan: `../Plan-automation-foundation.md`
|
||||||
|
- Design reference: Autensa convoy build phase (concepts only — no OpenClaw dependency)
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
---
|
||||||
|
kind: task
|
||||||
|
slug: worker-operator-notes
|
||||||
|
title: Operator notes — mid-build context injection
|
||||||
|
plan_slug: automation-foundation
|
||||||
|
epic_slug: apps-worker-v1
|
||||||
|
status: draft
|
||||||
|
priority: P2
|
||||||
|
tenant_id: global
|
||||||
|
owner: unassigned
|
||||||
|
cursor_todo_id: null
|
||||||
|
updated_at: "2026-08-03"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task summary
|
||||||
|
|
||||||
|
Let operators send **queued notes** and **direct messages** to an active worker session without waiting for a human gate.
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
**Benchmark:** Autensa Operator Chat — two delivery modes:
|
||||||
|
|
||||||
|
| Mode | Delivery | Use case |
|
||||||
|
|------|----------|----------|
|
||||||
|
| **Queued note** | Injected at next checkpoint / tool round | "Use the existing auth middleware" |
|
||||||
|
| **Direct message** | Prepended to very next model call | Urgent course correction |
|
||||||
|
|
||||||
|
Echodo scope is convoy-scoped (not per arbitrary task board). Gate co-pilot (Epic 5) stays gate-focused; this extends intervention to `in_progress` convoys.
|
||||||
|
|
||||||
|
### Data model
|
||||||
|
|
||||||
|
`convoy_operator_messages` table (workspace-scoped):
|
||||||
|
|
||||||
|
- `convoy_id`, `brief_id` (optional), `agent_run_id` (optional)
|
||||||
|
- `delivery`: `queued` | `direct`
|
||||||
|
- `body`, `author_user_id`, `delivered_at`, `created_at`
|
||||||
|
|
||||||
|
### APIs
|
||||||
|
|
||||||
|
- tRPC `convoys.postOperatorNote` (workspace member)
|
||||||
|
- Worker polls or receives note via job metadata on each tool round
|
||||||
|
|
||||||
|
### UI
|
||||||
|
|
||||||
|
- Mission control detail panel: chat thread when convoy status is `in_progress`
|
||||||
|
- Distinct from gate co-pilot (separate tab or section)
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- [Task-worker-minimal-tool-loop](./Task-worker-minimal-tool-loop.md)
|
||||||
|
- Epic 5 mission control detail panel (extend, do not rewrite)
|
||||||
|
|
||||||
|
## Subtasks
|
||||||
|
|
||||||
|
- [ ] Schema + tRPC mutations/list
|
||||||
|
- [ ] Worker consumes queued notes between tool steps
|
||||||
|
- [ ] Worker consumes direct messages before next `generateText`
|
||||||
|
- [ ] Mission control UI thread for `in_progress` convoys
|
||||||
|
|
||||||
|
## Estimation
|
||||||
|
|
||||||
|
M
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Operator posts queued note; worker sees it on next tool round
|
||||||
|
- [ ] Direct message arrives before next model call when run is active
|
||||||
|
- [ ] Messages are workspace-scoped and auditable in convoy detail
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- Epic: `./Epic-apps-worker-v1.md`
|
||||||
|
- Related: [Task-mission-control-ui](../Epic-mission-control-gates-ui/Task-mission-control-ui.md)
|
||||||
|
|
@ -7,7 +7,7 @@ status: done
|
||||||
priority: P1
|
priority: P1
|
||||||
tenant_id: global
|
tenant_id: global
|
||||||
cursor_epic_id: null
|
cursor_epic_id: null
|
||||||
updated_at: "2026-07-03"
|
updated_at: "2026-08-03"
|
||||||
---
|
---
|
||||||
|
|
||||||
# Epic objective
|
# Epic objective
|
||||||
|
|
@ -49,3 +49,9 @@ See automation foundation discussion: Vercel/Coolify-style “show me your repo.
|
||||||
- [x] [Task-db-connected-repos-migration](./Task-db-connected-repos-migration.md)
|
- [x] [Task-db-connected-repos-migration](./Task-db-connected-repos-migration.md)
|
||||||
- [x] [Task-forge-adapters-trpc](./Task-forge-adapters-trpc.md)
|
- [x] [Task-forge-adapters-trpc](./Task-forge-adapters-trpc.md)
|
||||||
- [x] [Task-settings-connected-repos-ui](./Task-settings-connected-repos-ui.md)
|
- [x] [Task-settings-connected-repos-ui](./Task-settings-connected-repos-ui.md)
|
||||||
|
|
||||||
|
## Follow-on tasks (Autensa benchmark)
|
||||||
|
|
||||||
|
| Task | Link |
|
||||||
|
|------|------|
|
||||||
|
| Repo setup preflight tab | [Task-repo-setup-preflight](./Task-repo-setup-preflight.md) |
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
---
|
||||||
|
kind: task
|
||||||
|
slug: repo-setup-preflight
|
||||||
|
title: Connected repo setup preflight — readiness before automation
|
||||||
|
plan_slug: automation-foundation
|
||||||
|
epic_slug: connected-repos
|
||||||
|
status: draft
|
||||||
|
priority: P2
|
||||||
|
tenant_id: global
|
||||||
|
owner: unassigned
|
||||||
|
cursor_todo_id: null
|
||||||
|
updated_at: "2026-08-03"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task summary
|
||||||
|
|
||||||
|
**Repo Setup** tab on connected repos: verify a remote is automation-ready before orchestrator/worker dispatch (git access, default branch, CI permissions, required secrets).
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
**Benchmark:** Autensa v2.5.1 Repo Setup tab — preflight checks with in-UI fixes where the forge API allows. Echodo adapts this to multitenant `repo_links` and forge adapters (Forgejo + GitHub minimum).
|
||||||
|
|
||||||
|
### Preflight checks
|
||||||
|
|
||||||
|
| Check | Pass criteria |
|
||||||
|
|-------|---------------|
|
||||||
|
| Git clone | Shallow clone with stored credentials |
|
||||||
|
| Default branch | API-detected default matches stored value |
|
||||||
|
| Forge API | PR list/create permission |
|
||||||
|
| CI visibility | Latest workflow run status readable |
|
||||||
|
| Actions token (GitHub) | `contents` + `pull-requests` write if applicable |
|
||||||
|
| Required secrets | Names present (values never stored in Echodo DB) |
|
||||||
|
|
||||||
|
### UI
|
||||||
|
|
||||||
|
- Settings → Connected repos → per-repo **Setup** tab
|
||||||
|
- Traffic-light checklist with expandable failure reasons
|
||||||
|
- Block convoy **Go** when critical checks fail (soft warning vs hard block — configurable per workspace)
|
||||||
|
|
||||||
|
### Fixes (where API supports)
|
||||||
|
|
||||||
|
- GitHub: suggest/set workflow token permissions; link to docs for secret creation (do not write secret values through Echodo — operator pastes in forge UI)
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Epic 3 connected repos (shipped)
|
||||||
|
- Epic 6 orchestrator should read preflight status before enqueue
|
||||||
|
|
||||||
|
## Subtasks
|
||||||
|
|
||||||
|
- [ ] `repoPreflightStatus` zod schema in `packages/shared`
|
||||||
|
- [ ] Forge adapter methods: `getDefaultBranch`, `listWorkflowRuns`, `getRepoPermissions`
|
||||||
|
- [ ] tRPC `connectedRepos.runPreflight`
|
||||||
|
- [ ] Settings UI Setup tab
|
||||||
|
- [ ] Orchestrator skips dispatch when repo link `preflight_ok` is false
|
||||||
|
|
||||||
|
## Estimation
|
||||||
|
|
||||||
|
L
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Repo with wrong default branch shows failing check with detected branch name
|
||||||
|
- [ ] Private repo without token shows auth failure (no silent pass)
|
||||||
|
- [ ] Preflight re-runnable without reconnecting repo
|
||||||
|
- [ ] No forge secret values persisted in Postgres
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- Epic: `./Epic-connected-repos.md`
|
||||||
|
|
@ -4,39 +4,52 @@ slug: fleet-dashboard
|
||||||
title: Fleet dashboard — all connected repos and convoys
|
title: Fleet dashboard — all connected repos and convoys
|
||||||
plan_slug: automation-foundation
|
plan_slug: automation-foundation
|
||||||
status: draft
|
status: draft
|
||||||
priority: P2
|
priority: P1
|
||||||
tenant_id: global
|
tenant_id: global
|
||||||
cursor_epic_id: null
|
cursor_epic_id: null
|
||||||
updated_at: "2026-07-03"
|
updated_at: "2026-08-12"
|
||||||
---
|
---
|
||||||
|
|
||||||
# Epic objective
|
# Epic objective
|
||||||
|
|
||||||
Top-level **fleet** view: all `convoys-<repo>` workspaces (or connected repos) in one screen. Roll up convoys by status, gate inbox count, active `agent_runs`, recent spend by model alias.
|
Top-level **fleet** home: all workspaces / connected repos in one screen. Roll up gates needing attention, active convoys and `agent_runs`, recent releases, and (when Epic 11 lands) completion thumbnails.
|
||||||
|
|
||||||
Answers: “What needs me across all projects?”
|
This is Phase B of the owner cockpit vision — “what’s happening across my projects since I was last here?”
|
||||||
|
|
||||||
|
Live **activity feed** (SSE) from `convoy_events`.
|
||||||
|
|
||||||
## In scope / out of scope
|
## In scope / out of scope
|
||||||
|
|
||||||
**In scope**
|
**In scope**
|
||||||
|
|
||||||
- Route `/fleet` or home dashboard for operator account.
|
- Route `/fleet` or authenticated landing dashboard.
|
||||||
- Cross-workspace tRPC (membership-scoped).
|
- Cross-workspace tRPC (membership-scoped).
|
||||||
- Links into per-repo mission control (Epic 5).
|
- Cards per workspace/repo: gate count, running convoys, recent `released` / merged.
|
||||||
|
- Links into per-repo mission control and (later) Plans.
|
||||||
|
- SSE activity stream (workspace + fleet scope).
|
||||||
|
- Placeholder slots for completion summaries (Epic 11) without blocking v1.
|
||||||
|
|
||||||
**Out of scope**
|
**Out of scope**
|
||||||
|
|
||||||
- Multi-tenant fleet across unrelated customers (operator's own repos only for v1).
|
- Multi-tenant fleet across unrelated customers (operator's own repos only for v1).
|
||||||
|
- Autopilot research/ideation feeds.
|
||||||
|
- Full Plans tree (Epic 10).
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
- Epic 4 (queryable convoy status).
|
- Epic 4 (queryable convoy status).
|
||||||
- Epic 5 (gate semantics).
|
- Epic 5 (gate semantics).
|
||||||
|
- Epic 7 live dogfood preferred so “running” is meaningful.
|
||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] Operator with two connected repos sees combined “3 awaiting approval” badge.
|
- [ ] Operator with two connected repos sees combined “N awaiting approval” badge.
|
||||||
|
- [ ] New convoy event appears in activity feed without page refresh.
|
||||||
|
- [ ] Card links into correct workspace mission control.
|
||||||
|
|
||||||
## Tasks
|
## Tasks
|
||||||
|
|
||||||
_To be decomposed when epic graduates to `ready`._
|
| Task | Link | Status |
|
||||||
|
|------|------|--------|
|
||||||
|
| Fleet rollup dashboard | [Task-fleet-rollup-dashboard](./Task-fleet-rollup-dashboard.md) | ready |
|
||||||
|
| Live activity feed (SSE) | [Task-convoy-activity-feed-sse](./Task-convoy-activity-feed-sse.md) | draft |
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
---
|
||||||
|
kind: task
|
||||||
|
slug: convoy-activity-feed-sse
|
||||||
|
title: Live convoy activity feed (SSE)
|
||||||
|
plan_slug: automation-foundation
|
||||||
|
epic_slug: fleet-dashboard
|
||||||
|
status: draft
|
||||||
|
priority: P2
|
||||||
|
tenant_id: global
|
||||||
|
owner: unassigned
|
||||||
|
cursor_todo_id: null
|
||||||
|
updated_at: "2026-08-03"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task summary
|
||||||
|
|
||||||
|
Real-time activity stream from `convoy_events` for mission control and fleet dashboard — research/build/gate/health events without full page refresh.
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
**Benchmark:** Autensa live activity feed (SSE). Echodo already appends `convoy_events` for role completion, transitions, and (future) health events — this task exposes them as a **subscription surface**.
|
||||||
|
|
||||||
|
### Transport
|
||||||
|
|
||||||
|
- Prefer Next.js Route Handler `GET /api/convoys/activity/stream` with SSE (workspace-scoped via session)
|
||||||
|
- Alternative: tRPC subscription if infra already supports it — pick one, document in ADR addendum
|
||||||
|
|
||||||
|
### Event types surfaced
|
||||||
|
|
||||||
|
- `role_complete`, `transition`, `health`, `gate_action`
|
||||||
|
- Payload: `convoy_id`, `title`, `event_type`, `role`, `outcome`, `occurred_at`, `estimated_cost_usd` (when present)
|
||||||
|
|
||||||
|
### UI
|
||||||
|
|
||||||
|
- Mission control: collapsible **Activity** strip (last 20 events for current workspace)
|
||||||
|
- Fleet dashboard: global feed with product/repo filter
|
||||||
|
- Fallback: React Query poll every 30s when SSE unavailable
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Epic 4 `convoy_events` (shipped)
|
||||||
|
- [Task-orchestrator-auto-nudge](../Epic-apps-orchestrator/Task-orchestrator-auto-nudge.md) for health events (soft — feed works without it)
|
||||||
|
|
||||||
|
## Subtasks
|
||||||
|
|
||||||
|
- [ ] SSE route with workspace membership check
|
||||||
|
- [ ] `convoy_events` tail query + fan-out on insert (LISTEN/NOTIFY or short poll server-side)
|
||||||
|
- [ ] `ActivityFeed` client component
|
||||||
|
- [ ] Rate limit / connection cap per user
|
||||||
|
|
||||||
|
## Estimation
|
||||||
|
|
||||||
|
M
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] New `role_complete` event appears in mission control within 5s without refresh
|
||||||
|
- [ ] User cannot subscribe to another workspace's events
|
||||||
|
- [ ] Connection closes cleanly on navigation
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- Epic: `./Epic-fleet-dashboard.md`
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
---
|
||||||
|
kind: task
|
||||||
|
slug: fleet-rollup-dashboard
|
||||||
|
title: Fleet dashboard — cross-repo convoy rollup
|
||||||
|
plan_slug: automation-foundation
|
||||||
|
epic_slug: fleet-dashboard
|
||||||
|
status: ready
|
||||||
|
priority: P1
|
||||||
|
tenant_id: global
|
||||||
|
owner: unassigned
|
||||||
|
cursor_todo_id: null
|
||||||
|
updated_at: "2026-08-03"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task summary
|
||||||
|
|
||||||
|
Operator home at `/fleet` (or authenticated landing): rollup gate inbox counts, convoys by status, and active `agent_runs` across all workspaces the user belongs to.
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
Core Epic 9 scope. Answers: **"What needs me across all projects?"**
|
||||||
|
|
||||||
|
### UI
|
||||||
|
|
||||||
|
- Card per connected repo / convoy workspace
|
||||||
|
- Badge: N awaiting approval (sum of `GATE_INBOX_STATUSES`)
|
||||||
|
- Quick links into `/{workspaceSlug}/mission-control?convoy=...`
|
||||||
|
- Active runs count + blocked count
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
|
- tRPC `fleet.summary` — membership-scoped cross-workspace query (no client-provided workspace_id trust)
|
||||||
|
- Reuse `MISSION_CONTROL_KANBAN_COLUMNS` counts per workspace
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Epic 4, 5 (shipped)
|
||||||
|
|
||||||
|
## Subtasks
|
||||||
|
|
||||||
|
- [ ] `fleet` tRPC router with membership joins
|
||||||
|
- [ ] `/fleet` route (app layout)
|
||||||
|
- [ ] Icon rail / top-header link when user has ≥2 workspaces
|
||||||
|
- [ ] Empty state for single-workspace operators
|
||||||
|
|
||||||
|
## Estimation
|
||||||
|
|
||||||
|
M
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Operator with two connected repos sees combined "3 awaiting approval" badge
|
||||||
|
- [ ] Clicking badge navigates to correct workspace mission control with inbox focused
|
||||||
|
- [ ] Query performs one round-trip (no N+1 per workspace in UI)
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- Epic: `./Epic-fleet-dashboard.md`
|
||||||
|
|
@ -7,13 +7,15 @@ status: draft
|
||||||
priority: P2
|
priority: P2
|
||||||
tenant_id: global
|
tenant_id: global
|
||||||
cursor_epic_id: null
|
cursor_epic_id: null
|
||||||
updated_at: "2026-07-03"
|
updated_at: "2026-08-03"
|
||||||
---
|
---
|
||||||
|
|
||||||
# Epic objective
|
# Epic objective
|
||||||
|
|
||||||
Webhook receivers on Echodo for connected forges: PR opened → `pr_review`; PR merged → trigger doc-writer job + `merged`; CI failed → optional `blocked` or notify. Supports Forgejo and GitHub adapters minimum.
|
Webhook receivers on Echodo for connected forges: PR opened → `pr_review`; PR merged → trigger doc-writer job + `merged`; CI failed → optional `blocked` or notify. Supports Forgejo and GitHub adapters minimum.
|
||||||
|
|
||||||
|
PR checks recovery UI in mission control when CI fails (Autensa benchmark).
|
||||||
|
|
||||||
## In scope / out of scope
|
## In scope / out of scope
|
||||||
|
|
||||||
**In scope**
|
**In scope**
|
||||||
|
|
@ -21,6 +23,7 @@ Webhook receivers on Echodo for connected forges: PR opened → `pr_review`; PR
|
||||||
- `POST /api/webhooks/forge/:repoLinkId` with signature verification.
|
- `POST /api/webhooks/forge/:repoLinkId` with signature verification.
|
||||||
- Map events to `transition_convoy_status(actor: automation)`.
|
- Map events to `transition_convoy_status(actor: automation)`.
|
||||||
- Link PR branch to convoy/brief via branch naming convention or PR body marker.
|
- Link PR branch to convoy/brief via branch naming convention or PR body marker.
|
||||||
|
- PR checks recovery panel (classify failures, rerun where API allows).
|
||||||
|
|
||||||
**Out of scope**
|
**Out of scope**
|
||||||
|
|
||||||
|
|
@ -33,7 +36,11 @@ Webhook receivers on Echodo for connected forges: PR opened → `pr_review`; PR
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] Merge on Forgejo moves convoy from `pr_review` to `merged` when Gate 2 approved.
|
- [ ] Merge on Forgejo moves convoy from `pr_review` to `merged` when Gate 2 approved.
|
||||||
|
- [ ] Mission control shows PR check failures with recovery actions for `pr_review` convoys.
|
||||||
|
|
||||||
## Tasks
|
## Tasks
|
||||||
|
|
||||||
_To be decomposed when epic graduates to `ready`._
|
| Task | Link | Status |
|
||||||
|
|------|------|--------|
|
||||||
|
| Webhook receiver | [Task-forge-webhook-receiver](./Task-forge-webhook-receiver.md) | ready |
|
||||||
|
| PR checks recovery panel | [Task-pr-checks-recovery-panel](./Task-pr-checks-recovery-panel.md) | draft |
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
---
|
||||||
|
kind: task
|
||||||
|
slug: forge-webhook-receiver
|
||||||
|
title: Forge webhook receiver — PR and CI events
|
||||||
|
plan_slug: automation-foundation
|
||||||
|
epic_slug: forge-webhooks
|
||||||
|
status: ready
|
||||||
|
priority: P2
|
||||||
|
tenant_id: global
|
||||||
|
owner: unassigned
|
||||||
|
cursor_todo_id: null
|
||||||
|
updated_at: "2026-08-03"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task summary
|
||||||
|
|
||||||
|
`POST /api/webhooks/forge/:repoLinkId` with HMAC signature verification; map forge events to `transition_convoy_status(actor: automation)`.
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
Core Epic 8 scope. Link PRs to convoys via branch naming convention (`convoy/<slug>/brief-N`) or PR body marker (`echodo-convoy: <uuid>`).
|
||||||
|
|
||||||
|
### Events (minimum)
|
||||||
|
|
||||||
|
| Forge event | Convoy transition |
|
||||||
|
|-------------|-------------------|
|
||||||
|
| PR opened | Ensure convoy in `pr_review` (if Gate 2 not yet human-approved, notify only) |
|
||||||
|
| PR merged | `pr_review` → `merged` when Gate 2 already approved |
|
||||||
|
| CI completed (failure) | Optional `blocked` or `convoy_events` notify |
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Per-repo webhook secret in `repo_links` metadata (reference env var name, not value)
|
||||||
|
- Zod-validated payload per `forge_type`
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Epic 3, 4, 7
|
||||||
|
|
||||||
|
## Subtasks
|
||||||
|
|
||||||
|
- [ ] Route handler + signature verification
|
||||||
|
- [ ] GitHub + Forgejo payload parsers
|
||||||
|
- [ ] Convoy lookup by branch/body marker
|
||||||
|
- [ ] Register webhook URL helper on connect (optional UX)
|
||||||
|
|
||||||
|
## Estimation
|
||||||
|
|
||||||
|
M
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Merge webhook moves convoy from `pr_review` to `merged` when Gate 2 approved
|
||||||
|
- [ ] Invalid signature returns 401
|
||||||
|
- [ ] Unknown branch logs and returns 200 (no retry storm)
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- Epic: `./Epic-forge-webhooks.md`
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue