merge feat/second-brain-os-v01 → main (second brain OS v0.1)

This commit is contained in:
Randall Stillwell 2026-08-25 16:21:55 -05:00
commit 38d91d4749
120 changed files with 16497 additions and 287 deletions

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

View file

@ -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`.

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

View file

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

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

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

View file

@ -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"
]
} }
} }
} }

View file

@ -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

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

View file

@ -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,

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

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

View file

@ -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";

View file

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

View file

@ -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) {

View 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-]+$/;

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

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

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

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

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

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

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

View file

@ -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");
} }

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

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

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

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

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

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

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

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

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

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

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

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

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

View file

@ -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>

View file

@ -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");

View file

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

View file

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

View file

@ -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;

View file

@ -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;

View file

@ -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 };
}),
}); });

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

View file

@ -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%;

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

View file

@ -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)",

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

View file

@ -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,
}, },
}); });
} }

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

View file

@ -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));

View file

@ -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()

View file

@ -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,

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

View file

@ -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`,
);
}
} }
} }

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

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

View file

@ -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;
}, },
}), }),
}, },

View file

@ -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} `));
} }

View file

@ -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: "",

View file

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

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

View file

@ -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).

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

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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -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
} }
] ]
} }

View file

@ -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(

View file

@ -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 })

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

View file

@ -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";

View file

@ -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",
}),
}));

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

View file

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

View file

@ -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;
} }

View file

@ -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>;

View file

@ -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",

View file

@ -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,

View file

@ -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>;

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

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

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

View file

@ -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";

View file

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

View file

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

View file

@ -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 |

View file

@ -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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 |

View file

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

View file

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

View file

@ -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 |

View file

@ -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