feat(second-brain): open self-hosted Obsidian for crews — harness-agnostic v0.1

- MCP create-backlog-item for natural language Plan/Epic/Task authoring
- Worker non-code: optional worktree + per-harness allowlist + doc_review→published
- Harness registry: harness_providers/role_harness_routes, WorkerAdapter (minimal|crewai|langgraph|remote-ag-ui), AG-UI POST /api/ag-ui, Settings → Harnesses with Test
- Plans tree: backlog.listTree + createPlan/Epic/Task + Start→Convoy, web CRUD without CLI
- Fleet: /fleet rollup across workspaces + SSE /api/fleet/events

Decision: docs/decisions/2026-08-25-second-brain-os-pivot.md
Convoy: .convoys/second-brain-os-v01

Co-authored-by: orchestrator
This commit is contained in:
Randall Stillwell 2026-08-25 16:20:53 -05:00
parent d1b2b9695e
commit 6ccb060bea
85 changed files with 14145 additions and 804 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.",
"mcpServers": {
"echodo": {
"command": "pnpm",
"args": ["-s", "--filter", "@tasks/mcp-server", "mcp"]
"command": "/Users/rstillw/.nvm/versions/node/v22.14.0/bin/pnpm",
"args": [
"-s",
"-C",
"/Users/rstillw/Documents/Personal Coding Projects/tasks",
"--filter",
"@tasks/mcp-server",
"mcp"
]
}
}
}

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_BLPOP_TIMEOUT_S=5
# WORKER_MAX_TOOL_STEPS=12
# WORKER_CHECKPOINT_EVERY_N_STEPS=1
# WORKER_REPO_ROOT= # defaults to monorepo root (pnpm-workspace.yaml walk-up)
# WORKER_WORKTREE_BASE=/tmp/echodo-worker-worktrees

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,
markdownBacklogItems,
} from "../schema.js";
import {
BACKLOG_ITEM_NOT_FOUND_MESSAGE,
backlogItemLookupFieldsSchema,
resolveBacklogItemById,
resolveBacklogItemsByLookup,
} from "../lib/resolve-backlog-item.js";
import { resolveWorkspaceHandle } from "../lib/resolve-workspace.js";
import { resolveWorkflowPrompt } from "../../../../packages/database/src/markdown-backlog/resolve-prompt.ts";
import { exportBacklogItemToMarkdown } from "../../../../packages/database/src/markdown-backlog/export.ts";
@ -40,45 +46,62 @@ import { toolCatch, toolErr, toolOk } from "./tool-result.js";
* `agent_runs` separately.
*/
const claimTaskInputSchema = z.object({
workspace: z
.string()
.min(1)
.describe("Workspace UUID or slug (e.g. 'acme' or '550e8400-...')"),
backlogItemId: z
.string()
.uuid()
.describe("UUID of the markdown_backlog_items row being claimed."),
actorUserId: z
.string()
.uuid()
.nullable()
.optional()
.describe(
"Optional UUID of the human user the agent is acting on behalf of. Null = anonymous dev session.",
),
notes: z
.string()
.max(2_000)
.optional()
.describe("Short opener text attached to the run (e.g. session intent)."),
client: z
.string()
.min(1)
.max(50)
.optional()
.describe(
"Caller identifier — e.g. 'cursor-ide', 'cursor-cloud', 'codex', 'echodo-orchestrator'. Persisted in agent_runs.metadata so the runs UI can slice by client.",
),
model: z
.string()
.min(1)
.max(100)
.optional()
.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.",
),
});
const claimTaskInputSchema = z
.object({
workspace: z
.string()
.min(1)
.describe("Workspace UUID or slug (e.g. 'acme' or '550e8400-...')"),
backlogItemId: z
.string()
.uuid()
.optional()
.describe(
"UUID of the markdown_backlog_items row being claimed. Optional when repoPath or slug keys are provided.",
),
actorUserId: z
.string()
.uuid()
.nullable()
.optional()
.describe(
"Optional UUID of the human user the agent is acting on behalf of. Null = anonymous dev session.",
),
notes: z
.string()
.max(2_000)
.optional()
.describe("Short opener text attached to the run (e.g. session intent)."),
client: z
.string()
.min(1)
.max(50)
.optional()
.describe(
"Caller identifier — e.g. 'cursor-ide', 'cursor-cloud', 'codex', 'echodo-orchestrator'. Persisted in agent_runs.metadata so the runs UI can slice by client.",
),
model: z
.string()
.min(1)
.max(100)
.optional()
.describe(
"Model handle driving the session — e.g. 'claude-4.6-sonnet', 'gpt-5-codex'. Free-form because new models ship faster than we can rev a zod enum.",
),
})
.merge(backlogItemLookupFieldsSchema)
.superRefine((input, ctx) => {
const hasId = Boolean(input.backlogItemId);
const hasRepoPath = Boolean(input.repoPath?.trim());
const hasSlugKeys = Boolean(input.slug?.trim() && input.planSlug?.trim());
if (!hasId && !hasRepoPath && !hasSlugKeys) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"Provide backlogItemId, repoPath, or slug + planSlug (add epicSlug for tasks/epics).",
});
}
});
/**
* Build the metadata object stored on agent_runs at claim time. Returns
@ -100,7 +123,7 @@ export function registerClaimTaskTool(mcp: McpServer): void {
"claim_task",
{
description:
"Open an agent_runs row against a backlog item, transition it to in_progress (if eligible), and return the effective workflow prompt for the session.",
"Open an agent_runs row against a backlog item, transition it to in_progress (if eligible), and return the effective workflow prompt for the session. Identify the item by backlogItemId, repoPath, or slug + planSlug.",
inputSchema: claimTaskInputSchema,
},
async (args) => {
@ -108,27 +131,42 @@ export function registerClaimTaskTool(mcp: McpServer): void {
const input = claimTaskInputSchema.parse(args);
const ws = await resolveWorkspaceHandle(input.workspace);
const [item] = await db
.select({
id: markdownBacklogItems.id,
workspaceId: markdownBacklogItems.workspaceId,
title: markdownBacklogItems.title,
bodyMarkdown: markdownBacklogItems.bodyMarkdown,
status: markdownBacklogItems.status,
})
.from(markdownBacklogItems)
.where(eq(markdownBacklogItems.id, input.backlogItemId))
.limit(1);
let item;
let backlogItemId: string;
if (!item) {
return toolErr(`Backlog item ${input.backlogItemId} not found`);
if (input.backlogItemId) {
const found = await resolveBacklogItemById(input.backlogItemId);
if (!found) {
return toolErr(`Backlog item ${input.backlogItemId} not found`);
}
item = found;
backlogItemId = found.id;
} else {
const matches = await resolveBacklogItemsByLookup(ws.id, {
repoPath: input.repoPath,
slug: input.slug,
planSlug: input.planSlug,
epicSlug: input.epicSlug,
kind: input.kind,
});
if (matches.length === 0) {
return toolErr(BACKLOG_ITEM_NOT_FOUND_MESSAGE);
}
if (matches.length > 1) {
return toolErr(
`Ambiguous backlog lookup (${matches.length} matches). Add epicSlug/kind or use repoPath.`,
);
}
item = matches[0]!;
backlogItemId = item.id;
}
if (item.workspaceId !== ws.id) {
// Tenancy fence: the backlog item exists but in a different
// workspace than the resolved handle. Refuse rather than 404 so
// the caller can't fish for cross-tenant existence.
return toolErr(
`Backlog item ${input.backlogItemId} does not belong to workspace ${ws.slug}`,
`Backlog item ${backlogItemId} does not belong to workspace ${ws.slug}`,
);
}
@ -150,7 +188,7 @@ export function registerClaimTaskTool(mcp: McpServer): void {
.from(agentRuns)
.where(
and(
eq(agentRuns.backlogItemId, input.backlogItemId),
eq(agentRuns.backlogItemId, backlogItemId),
isNull(agentRuns.finishedAt),
),
)
@ -190,7 +228,7 @@ export function registerClaimTaskTool(mcp: McpServer): void {
const resolved = await resolveWorkflowPrompt(db, {
workspaceId: ws.id,
backlogItemId: input.backlogItemId,
backlogItemId,
});
return toolOk({
@ -203,6 +241,7 @@ export function registerClaimTaskTool(mcp: McpServer): void {
title: item.title,
bodyMarkdown: item.bodyMarkdown,
status: item.status,
repoPath: item.repoPath,
},
workspace: { id: ws.id, slug: ws.slug, name: ws.name },
});
@ -219,7 +258,7 @@ export function registerClaimTaskTool(mcp: McpServer): void {
.insert(agentRuns)
.values({
workspaceId: ws.id,
backlogItemId: input.backlogItemId,
backlogItemId,
actorUserId,
notes: input.notes ?? null,
metadata: claimMeta,
@ -230,7 +269,7 @@ export function registerClaimTaskTool(mcp: McpServer): void {
await tx
.update(markdownBacklogItems)
.set({ status: "in_progress", updatedAt: new Date() })
.where(eq(markdownBacklogItems.id, input.backlogItemId));
.where(eq(markdownBacklogItems.id, backlogItemId));
}
await tx.insert(auditLog).values({
@ -240,7 +279,7 @@ export function registerClaimTaskTool(mcp: McpServer): void {
targetType: "agent_run",
targetId: created.id,
metadata: {
backlog_item_id: input.backlogItemId,
backlog_item_id: backlogItemId,
prior_status: priorStatus,
transitioned: shouldTransition,
...(claimMeta ?? {}),
@ -253,7 +292,7 @@ export function registerClaimTaskTool(mcp: McpServer): void {
const resolved = await resolveWorkflowPrompt(db, {
workspaceId: ws.id,
backlogItemId: input.backlogItemId,
backlogItemId,
});
// Only export when the row's status actually changed. On a
@ -269,7 +308,7 @@ export function registerClaimTaskTool(mcp: McpServer): void {
try {
const exportResult = await exportBacklogItemToMarkdown(db, {
workspaceId: ws.id,
backlogItemId: input.backlogItemId,
backlogItemId,
repoRootAbs: repoRoot,
});
if (exportResult.ok) {
@ -302,6 +341,7 @@ export function registerClaimTaskTool(mcp: McpServer): void {
title: item.title,
bodyMarkdown: item.bodyMarkdown,
status: shouldTransition ? "in_progress" : priorStatus,
repoPath: item.repoPath,
},
workspace: { id: ws.id, slug: ws.slug, name: ws.name },
export: exportSummary,

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 { registerClaimTaskTool } from "./claim-task.js";
import { registerCompleteTaskTool } from "./complete-task.js";
import { registerCreateBacklogItemTool } from "./create-backlog-item.js";
import { registerFindBacklogItemTool } from "./find-backlog-item.js";
import { registerCreateConvoyTool } from "./create-convoy.js";
import { registerCreateBriefTool } from "./create-brief.js";
import { registerTransitionConvoyStatusTool } from "./transition-convoy-status.js";
@ -21,6 +23,8 @@ export function registerTools(mcp: McpServer): void {
registerManageObjectTool(mcp);
registerClaimTaskTool(mcp);
registerCompleteTaskTool(mcp);
registerCreateBacklogItemTool(mcp);
registerFindBacklogItemTool(mcp);
registerCreateConvoyTool(mcp);
registerCreateBriefTool(mcp);
registerTransitionConvoyStatusTool(mcp);
@ -36,6 +40,8 @@ export { registerListObjectsTool } from "./list-objects.js";
export { registerManageObjectTool } from "./manage-object.js";
export { registerClaimTaskTool } from "./claim-task.js";
export { registerCompleteTaskTool } from "./complete-task.js";
export { registerCreateBacklogItemTool } from "./create-backlog-item.js";
export { registerFindBacklogItemTool } from "./find-backlog-item.js";
export { registerCreateConvoyTool } from "./create-convoy.js";
export { registerCreateBriefTool } from "./create-brief.js";
export { registerTransitionConvoyStatusTool } from "./transition-convoy-status.js";

View file

@ -1,7 +1,7 @@
import { and, desc, eq, inArray, isNull, lt, sql } from "drizzle-orm";
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 {
briefObjectContentSchema,
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 openRuns = await db
.select({
id: agentRuns.id,
workspaceId: agentRuns.workspaceId,
backlogItemId: agentRuns.backlogItemId,
startedAt: agentRuns.startedAt,
})
@ -158,5 +176,41 @@ export async function detectStalledConvoys(db: Db, stallMinutes: number): Promis
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 IoRedis from "ioredis";
import { detectStalledConvoys } from "./db/convoy.js";
import { detectStalledRunsForResume } from "./db/convoy.js";
import { loadEnv } from "./env.js";
import { enqueueJob } from "./queue/redis-queue.js";
import { runOrchestratorTick } from "./tick.js";
async function main(): Promise<void> {
@ -24,11 +25,32 @@ async function main(): Promise<void> {
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) {
console.warn(
`[orchestrator] stall detector: ${stalled.length} open agent_runs older than ${env.ORCHESTRATOR_STALL_MINUTES}m`,
);
for (const run of stalled) {
if (env.ORCHESTRATOR_DRY_RUN || !redis) {
console.info(
`[orchestrator] DRY-RUN resume_from_checkpoint convoy=${run.convoyId} role=${run.roleId} run=${run.agentRunId}`,
);
continue;
}
const job = await enqueueJob(redis, {
type: "role_dispatch",
workspaceId: run.workspaceId,
convoyId: run.convoyId,
roleId: run.roleId,
briefId: run.briefId,
resumeFromCheckpoint: true,
});
console.info(
`[orchestrator] re-dispatched job=${job.id} resume_from_checkpoint run=${run.agentRunId}`,
);
}
}
if (results.length === 0) {

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

@ -1,10 +1,10 @@
import type { Metadata } from "next";
import { ComparisonSection } from "@/components/marketing/comparison-section";
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";
@ -18,9 +18,10 @@ export default function LandingPage() {
<SiteHeader />
<main>
<HeroSection />
<div className="marketing-hard-rule" aria-hidden />
<FeaturesSection />
<HowItWorksSection />
<ComparisonSection />
<ManifestSection />
<CtaSection />
</main>
<SiteFooter />

View file

@ -1,8 +1,7 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Check } from "lucide-react";
import { ArrowRight } from "lucide-react";
import { BorderGlowCard } from "@/components/marketing/border-glow-card";
import { Reveal, Stagger, StaggerItem } from "@/components/marketing/reveal";
import { SiteFooter } from "@/components/marketing/site-footer";
import { SiteHeader } from "@/components/marketing/site-header";
@ -16,38 +15,29 @@ const tiers = [
{
name: "Solo",
price: "Free",
period: "during beta",
description: "Personal workspace for testing agent workflows and markdown sync.",
features: ["1 workspace", "Markdown backlog sync", "MCP claim / complete", "Docs & planner"],
cta: "Start free",
period: "beta",
description: "Personal workspace for agent workflow experiments.",
features: ["1 workspace", "Markdown sync", "MCP tools", "Docs & planner"],
href: "/sign-up",
highlighted: false,
highlight: false,
},
{
name: "Team",
price: "$12",
period: "per seat / mo",
description: "Shared convoys, run history, and model routing for small builder teams.",
features: [
"Unlimited workspaces",
"Agent run history",
"Role-based routing",
"Audit log",
"Priority sync",
],
cta: "Start trial",
period: "seat / mo",
description: "Convoys, run history, and routing for builder teams.",
features: ["Unlimited workspaces", "Agent runs", "Model routing", "Audit log"],
href: "/sign-up",
highlighted: true,
highlight: true,
},
{
name: "Fleet",
price: "Custom",
period: "contact us",
description: "Mission control for multiple repos, tenants, and orchestrated pipelines.",
features: ["Connected repos", "Fleet dashboard", "Webhook gates", "SSO", "Self-hosted option"],
cta: "Contact",
period: "contact",
description: "Mission control for repos, tenants, and orchestration.",
features: ["Connected repos", "Fleet dashboard", "Webhook gates", "SSO"],
href: "mailto:hello@submatter.dev",
highlighted: false,
highlight: false,
},
] as const;
@ -55,69 +45,53 @@ export default function PricingPage() {
return (
<>
<SiteHeader />
<main className="mx-auto max-w-6xl px-4 py-16 sm:px-6 sm:py-24">
<Reveal className="mx-auto max-w-2xl text-center">
<p className="font-mono-brand text-[11px] uppercase tracking-[0.2em] text-muted-foreground">Pricing</p>
<h1 className="mt-4 font-marketing text-4xl font-semibold tracking-[-0.02em] text-foreground sm:text-5xl">
Predictable seats.
<br />
No per-token surprise.
</h1>
<p className="mt-4 font-marketing text-muted-foreground">
Beta pricing for feedback. BYOK for model providers always.
</p>
</Reveal>
<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-4 lg:grid-cols-3">
{tiers.map((tier) => (
<StaggerItem key={tier.name}>
<BorderGlowCard
as="article"
<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 h-full flex-col p-6",
tier.highlighted && "ring-1 ring-matter/30",
"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))]",
)}
>
{tier.highlighted ? (
<span className="mb-4 inline-flex w-fit font-mono-brand text-[10px] uppercase tracking-widest text-matter">
popular
</span>
) : (
<span className="mb-4 h-4" aria-hidden />
)}
<h2 className="font-marketing text-lg font-semibold text-foreground">{tier.name}</h2>
<p className="mt-2 flex items-baseline gap-2">
<span className="font-marketing text-3xl font-semibold text-foreground">{tier.price}</span>
<span className="font-marketing text-sm text-muted-foreground">{tier.period}</span>
<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-3 font-marketing text-sm text-muted-foreground">{tier.description}</p>
<ul className="mt-6 flex-1 space-y-2.5">
{tier.features.map((feature) => (
<li key={feature} className="flex gap-2 font-marketing text-sm text-foreground">
<Check className="mt-0.5 size-4 shrink-0 text-agent" aria-hidden />
{feature}
</li>
<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-8 inline-flex h-10 items-center justify-center font-marketing text-sm font-medium transition-colors",
tier.highlighted
? "bg-foreground text-background hover:bg-foreground/90"
: "border border-border hover:bg-muted",
"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.cta}
{tier.name === "Fleet" ? "Contact" : "Start"}
<ArrowRight className="size-3.5" aria-hidden />
</Link>
</BorderGlowCard>
</StaggerItem>
))}
</Stagger>
<p className="mt-14 text-center font-mono-brand text-[11px] text-muted-foreground">
kind: pricing · status: draft · priority: P2
</p>
</StaggerItem>
))}
</Stagger>
</div>
</main>
<SiteFooter />
</>

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

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

@ -1,22 +0,0 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
type BorderGlowCardProps = {
children: ReactNode;
className?: string;
as?: "div" | "article" | "li";
};
export function BorderGlowCard({ children, className, as: Tag = "div" }: BorderGlowCardProps) {
return (
<Tag
className={cn(
"marketing-glow-card rounded-md border border-border",
className,
)}
>
{children}
</Tag>
);
}

View file

@ -1,78 +0,0 @@
import { Reveal } from "@/components/marketing/reveal";
const rows = [
{ label: "Markdown backlog in git", sbmtr: true, linear: false, notion: "partial", convoy: false },
{ label: "MCP claim / complete", sbmtr: true, linear: false, notion: false, convoy: true },
{ label: "Full workspace surface", sbmtr: true, linear: "partial", notion: true, convoy: false },
{ label: "Role-based convoys", sbmtr: true, linear: false, notion: false, convoy: true },
{ label: "Human approval gates", sbmtr: true, linear: false, notion: false, convoy: "partial" },
] as const;
function Cell({ value }: { value: boolean | string }) {
if (value === true) return <span className="font-mono-brand text-agent">yes</span>;
if (value === false) return <span className="text-muted-foreground/50">—</span>;
return <span className="font-mono-brand text-matter">{value}</span>;
}
export function ComparisonSection() {
return (
<section className="border-b border-border bg-background py-20 sm:py-28">
<div className="mx-auto max-w-6xl px-4 sm:px-6">
<Reveal className="mb-10 max-w-2xl">
<p className="font-mono-brand text-[11px] uppercase tracking-[0.2em] text-muted-foreground">Compare</p>
<h2 className="mt-4 font-marketing text-3xl font-semibold tracking-[-0.02em] text-foreground">
The agent-native lane
</h2>
</Reveal>
<Reveal delay={0.08}>
<div className="overflow-x-auto">
<table className="w-full min-w-[640px] border-collapse text-left text-sm">
<thead>
<tr className="border-b-2 border-foreground">
<th className="py-3 pr-4 font-marketing font-medium text-muted-foreground" scope="col">
Capability
</th>
<th
className="px-4 py-3 font-display text-xs font-extrabold tracking-[0.2em] text-foreground"
scope="col"
>
SBMTR
</th>
<th className="px-4 py-3 font-marketing font-medium text-muted-foreground" scope="col">
Linear
</th>
<th className="px-4 py-3 font-marketing font-medium text-muted-foreground" scope="col">
Notion
</th>
<th className="px-4 py-3 font-marketing font-medium text-muted-foreground" scope="col">
Convoy tools
</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.label} className="border-b border-border">
<td className="py-4 pr-4 font-marketing text-foreground">{row.label}</td>
<td className="px-4 py-4">
<Cell value={row.sbmtr} />
</td>
<td className="px-4 py-4">
<Cell value={row.linear} />
</td>
<td className="px-4 py-4">
<Cell value={row.notion} />
</td>
<td className="px-4 py-4">
<Cell value={row.convoy} />
</td>
</tr>
))}
</tbody>
</table>
</div>
</Reveal>
</div>
</section>
);
}

View file

@ -5,27 +5,32 @@ import { Reveal } from "@/components/marketing/reveal";
export function CtaSection() {
return (
<section className="border-b border-border bg-[hsl(var(--hero))] py-20 text-[hsl(var(--hero-foreground))] sm:py-28">
<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="mx-auto max-w-3xl text-center">
<p className="font-mono-brand text-[11px] uppercase tracking-[0.25em] text-[hsl(var(--hero-muted))]">---</p>
<h2 className="mt-6 font-display text-5xl font-extrabold tracking-[0.12em] sm:text-7xl">SBMTR</h2>
<p className="mx-auto mt-6 max-w-lg font-marketing text-lg text-[hsl(var(--hero-muted))]">
Subagents. Frontmatter. One workspace. Open Monday morning in Submatter — not a demo shell.
<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 justify-center gap-6">
<div className="mt-10 flex flex-wrap items-center gap-8">
<Link
href="/sign-up"
className="group inline-flex items-center gap-2 border-b-2 border-[hsl(var(--matter))] pb-1 font-marketing text-sm font-medium transition-colors hover:text-[hsl(var(--matter))]"
className="group inline-flex items-center gap-3 font-mono-brand text-[11px] uppercase tracking-[0.18em]"
>
Start free
<ArrowRight className="size-4 transition-transform group-hover:translate-x-0.5" aria-hidden />
<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-marketing text-sm text-[hsl(var(--hero-muted))] transition-colors hover:text-[hsl(var(--hero-foreground))]"
className="font-mono-brand text-[11px] uppercase tracking-[0.18em] text-[hsl(var(--hero-muted))] hover:text-[hsl(var(--hero-foreground))]"
>
View pricing
Pricing
</Link>
</div>
</Reveal>

View file

@ -1,21 +0,0 @@
import { cn } from "@/lib/utils";
type DotFieldProps = {
className?: string;
variant?: "hero" | "light";
};
export function DotField({ className, variant = "hero" }: DotFieldProps) {
return (
<div
className={cn(
"pointer-events-none absolute inset-0",
variant === "hero" ? "marketing-dot-field marketing-scanline" : "opacity-30",
variant === "light" &&
"bg-[radial-gradient(circle_at_center,hsl(var(--foreground)/0.07)_1px,transparent_1px)] [background-size:24px_24px]",
className,
)}
aria-hidden
/>
);
}

View file

@ -1,108 +1,69 @@
import { Bot, FileText, GitBranch, Layers, Route, Shield } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { BorderGlowCard } from "@/components/marketing/border-glow-card";
import { DotField } from "@/components/marketing/dot-field";
import { Reveal, Stagger, StaggerItem } from "@/components/marketing/reveal";
type Feature = {
icon: LucideIcon;
title: string;
description: string;
span: string;
tag: string;
};
const features: Feature[] = [
const capabilities = [
{
icon: FileText,
title: "Markdown source of truth",
description: "Plan / Epic / Task files in git. Frontmatter fields drive status, priority, and agent routing.",
span: "lg:col-span-7",
tag: "sync",
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",
},
{
icon: Bot,
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",
description: "claim_task → work → complete_task. Auditable runs, not ad-hoc object patches.",
span: "lg:col-span-5",
tag: "mcp",
body: "claim_task → work → complete_task. Auditable runs instead of ad-hoc object patches.",
mono: "workspace_id scoped",
},
{
icon: GitBranch,
title: "Convoy pipelines",
description: "Architect, implementer, reviewer roles with human gates between stages.",
span: "lg:col-span-4",
tag: "convoy",
index: "04",
title: "Convoy gates",
body: "Role-based subagents with human approval between architect, implementer, and reviewer.",
mono: "ready → in_progress → done",
},
{
icon: Layers,
index: "05",
title: "Full workspace",
description: "Docs, boards, forms, whiteboards — same object model humans and agents share.",
span: "lg:col-span-4",
tag: "workspace",
body: "Docs, boards, forms, whiteboards — one object model, not a task queue bolted on.",
mono: "multitenant · workspace_id",
},
{
icon: Shield,
title: "Multitenant",
description: "workspace_id on every row. Built for teams, not a single homelab tenant.",
span: "lg:col-span-4",
tag: "tenant",
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="relative border-b border-border bg-background py-20 sm:py-28">
<DotField variant="light" className="opacity-40" />
<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-14 max-w-2xl">
<p className="font-mono-brand text-[11px] uppercase tracking-[0.2em] text-muted-foreground">Capabilities</p>
<h2 className="mt-4 font-marketing text-3xl font-semibold tracking-[-0.02em] text-foreground sm:text-4xl">
Not a chat wrapper.
<br />
A coordination plane.
</h2>
<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 gap-4 lg:grid-cols-12">
{features.map((feature) => (
<StaggerItem key={feature.title} className={feature.span}>
<BorderGlowCard className="flex h-full flex-col p-6">
<div className="mb-5 flex items-center justify-between">
<feature.icon className="size-5 text-primary" strokeWidth={1.75} aria-hidden />
<span className="font-mono-brand text-[10px] uppercase tracking-widest text-muted-foreground">
{feature.tag}
</span>
</div>
<h3 className="font-marketing text-lg font-semibold text-foreground">{feature.title}</h3>
<p className="mt-2 flex-1 font-marketing text-sm leading-relaxed text-muted-foreground">
{feature.description}
</p>
</BorderGlowCard>
<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>
))}
<StaggerItem className="lg:col-span-12">
<BorderGlowCard className="flex flex-col gap-4 p-6 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-start gap-4">
<Route className="mt-0.5 size-5 shrink-0 text-matter" strokeWidth={1.75} aria-hidden />
<div>
<h3 className="font-marketing text-lg font-semibold text-foreground">Per-role model routing</h3>
<p className="mt-1 max-w-2xl font-marketing text-sm text-muted-foreground">
Fast implementers, premium architects — declared in convoy frontmatter and enforced by your
agent pipeline.
</p>
</div>
</div>
<p className="font-mono-brand text-xs text-muted-foreground sm:text-right">
model_policy: composer-fast
<br />
tier: fast | premium
</p>
</BorderGlowCard>
</StaggerItem>
</Stagger>
</div>
</section>

View file

@ -1,75 +1,64 @@
import Link from "next/link";
import { ArrowRight } from "lucide-react";
import { DotField } from "@/components/marketing/dot-field";
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="relative overflow-hidden bg-[hsl(var(--hero))] text-[hsl(var(--hero-foreground))]">
<DotField />
<div
className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_60%_50%_at_70%_20%,hsl(var(--matter)/0.08),transparent_60%)]"
aria-hidden
/>
<div className="relative mx-auto grid max-w-6xl gap-12 px-4 pb-20 pt-12 sm:px-6 lg:grid-cols-2 lg:items-center lg:gap-16 lg:pb-28 lg:pt-16">
<div className="space-y-8">
<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.2em] text-[hsl(var(--hero-muted))]">
Agent-native workspace
<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}>
<h1 className="max-w-xl font-marketing text-[2.75rem] font-semibold leading-[1.02] tracking-[-0.03em] sm:text-6xl lg:text-[4.25rem]">
The matter beneath{" "}
<span className="font-display font-extrabold uppercase tracking-[0.08em] text-[hsl(var(--matter))]">
SBMTR
</span>
<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.1}>
<p className="max-w-lg font-marketing text-lg leading-relaxed text-[hsl(var(--hero-muted))]">
Submatter syncs your <span className="font-mono-brand text-[hsl(var(--hero-foreground))]">plans/</span>{" "}
backlog to a real workspace, then lets Cursor subagents claim tasks through MCP — with convoy gates
humans actually approve.
<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.15}>
<div className="flex flex-wrap items-center gap-3">
<Link
href="/sign-up"
className="group inline-flex h-12 items-center gap-2 border-b-2 border-[hsl(var(--matter))] bg-transparent pb-0.5 font-marketing text-sm font-medium text-[hsl(var(--hero-foreground))] transition-colors hover:text-[hsl(var(--matter))]"
>
<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
<ArrowRight className="size-4 transition-transform group-hover:translate-x-0.5" aria-hidden />
</Link>
<Link
href="/landing#pipeline"
className="inline-flex h-12 items-center px-1 font-marketing text-sm text-[hsl(var(--hero-muted))] transition-colors hover:text-[hsl(var(--hero-foreground))]"
>
See the pipeline
</Link>
</div>
</Reveal>
<Reveal delay={0.2}>
<p className="font-mono-brand text-[11px] text-[hsl(var(--hero-muted))]">
subagents · frontmatter · one workspace
</p>
</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.12} y={32} className="lg:translate-y-4">
<ProductDemo />
<Reveal delay={0.22} y={40} className="pb-8">
<ProductDemo className="border-[hsl(var(--hero-border))]" />
</Reveal>
</div>
<div className="h-1 bg-[hsl(var(--matter))]" aria-hidden />
<MarqueeStrip />
</section>
);
}

View file

@ -1,71 +1,57 @@
import { Reveal, Stagger, StaggerItem } from "@/components/marketing/reveal";
import { Reveal } from "@/components/marketing/reveal";
const steps = [
{
n: "01",
title: "Write in plans/",
body: "YAML frontmatter on every Plan, Epic, and Task. Versioned in git like the rest of your codebase.",
code: "plans/Plan-*/Epic-*/Task-*.md",
},
{
n: "02",
title: "Sync to Submatter",
body: "Importer mirrors frontmatter into Postgres. Cursor mappings keep agent ids aligned with slugs.",
code: "watch:markdown-backlog",
},
{
n: "03",
title: "Agents claim work",
body: "Subagents call MCP tools against a scoped workspace_id. Runs appear in fleet history.",
code: "claim_task → complete_task",
},
{
n: "04",
title: "Humans gate convoys",
body: "Reviewer and architect roles pause for approval. Status flows back to markdown on merge.",
code: "ready → in_progress → done",
},
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-border bg-card py-20 sm:py-28">
<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-14 flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-xl">
<p className="font-mono-brand text-[11px] uppercase tracking-[0.2em] text-muted-foreground">
How it works
<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-3xl font-semibold tracking-[-0.02em] text-foreground sm:text-4xl">
<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-sm font-marketing text-sm text-muted-foreground lg:text-right">
Interactive product demo pattern — show the file, show the pipeline, show the gate.
<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>
<Stagger className="relative grid gap-0 md:grid-cols-4">
<div
className="absolute left-0 right-0 top-8 hidden h-px bg-border md:block"
aria-hidden
/>
{steps.map((step) => (
<StaggerItem key={step.n} className="relative border-t border-border pt-8 md:border-t-0 md:pt-0">
<div className="md:px-4 md:first:pl-0 md:last:pr-0">
<div className="mb-6 flex items-center gap-3">
<span className="flex size-8 items-center justify-center rounded-full border border-border bg-background font-mono-brand text-xs text-foreground">
{step.n}
</span>
<span className="hidden h-px flex-1 bg-border md:block" aria-hidden />
</div>
<h3 className="font-marketing text-base font-semibold text-foreground">{step.title}</h3>
<p className="mt-2 font-marketing text-sm leading-relaxed text-muted-foreground">{step.body}</p>
<p className="mt-4 font-mono-brand text-[11px] text-primary">{step.code}</p>
</div>
</StaggerItem>
))}
</Stagger>
<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,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

@ -15,35 +15,25 @@ export function ProductDemo({ className }: { className?: string }) {
const reduceMotion = useReducedMotion();
return (
<div
className={cn(
"relative overflow-hidden rounded-md border border-[hsl(var(--hero-border))] bg-[hsl(240_6%_6%)] shadow-2xl shadow-black/40",
className,
)}
>
<div className="flex items-center gap-2 border-b border-[hsl(var(--hero-border))] px-4 py-3">
<div className="flex gap-1.5" aria-hidden>
<span className="size-2.5 rounded-full bg-[#FF5F57]" />
<span className="size-2.5 rounded-full bg-[#FEBC2E]" />
<span className="size-2.5 rounded-full bg-[#28C840]" />
<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>
<p className="flex-1 text-center font-mono-brand text-[11px] text-[hsl(var(--hero-muted))]">
submatter — convoy · mcp-claim-task-tool
</p>
<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 gap-0 lg:grid-cols-5">
<div className="border-b border-[hsl(var(--hero-border))] p-4 lg:col-span-2 lg:border-b-0 lg:border-r">
<div className="mb-3 flex items-center gap-2 text-[hsl(var(--hero-muted))]">
<Terminal className="size-3.5" aria-hidden />
<span className="font-mono-brand text-[10px] uppercase tracking-widest">plans/</span>
</div>
<pre className="font-mono-brand text-[11px] leading-relaxed text-[hsl(var(--hero-foreground))]">
<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))]">slug</span>
<span className="text-[hsl(var(--hero-muted))]">: </span>mcp-claim-task-tool{"\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>
@ -53,39 +43,30 @@ export function ProductDemo({ className }: { className?: string }) {
<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>
<span className="marketing-cursor-blink text-[hsl(var(--matter))]">█</span>
</pre>
</div>
<div className="p-4 lg:col-span-3">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<GitBranch className="size-3.5 text-[hsl(var(--hero-muted))]" aria-hidden />
<span className="font-mono-brand text-[10px] uppercase tracking-widest text-[hsl(var(--hero-muted))]">
Convoy pipeline
</span>
</div>
<span className="inline-flex items-center gap-1.5 rounded-full border border-[hsl(var(--hero-border))] px-2 py-0.5 font-mono-brand text-[10px] text-[hsl(var(--agent))]">
<span className="marketing-pulse-dot size-1.5 rounded-full bg-[hsl(var(--agent))]" />
MCP live
</span>
<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 rounded border px-3 py-2.5",
"flex items-center justify-between border px-3 py-2.5",
step.status === "active"
? "border-[hsl(var(--matter)/0.45)] bg-[hsl(var(--matter)/0.08)]"
: "border-[hsl(var(--hero-border))] bg-[hsl(240_5%_8%)]",
? "border-[hsl(var(--matter))] bg-[hsl(var(--matter)/0.07)]"
: "border-[hsl(var(--hero-border))]",
)}
initial={reduceMotion ? false : { opacity: 0, x: 12 }}
initial={reduceMotion ? false : { opacity: 0, x: 16 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.15 + index * 0.12, duration: 0.45 }}
transition={{ delay: 0.2 + index * 0.1, duration: 0.4 }}
>
<div className="flex items-center gap-2.5">
<div className="flex items-center gap-2">
{step.status === "done" ? (
<CheckCircle2 className="size-3.5 text-[hsl(var(--agent))]" aria-hidden />
) : (
@ -97,18 +78,12 @@ export function ProductDemo({ className }: { className?: string }) {
aria-hidden
/>
)}
<span className="font-mono-brand text-xs text-[hsl(var(--hero-foreground))]">
role-{step.role}
</span>
<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 className="mt-4 rounded border border-dashed border-[hsl(var(--hero-border))] px-3 py-2 font-mono-brand text-[10px] text-[hsl(var(--hero-muted))]">
claim_task · workspace_id scoped · run ar_8f2a
</div>
</div>
</div>
</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

@ -1,17 +1,19 @@
import Link from "next/link";
import { Logo } from "@/components/marketing/logo";
import { MarqueeStrip } from "@/components/marketing/marquee-strip";
const footerLinks = [
{ href: "/landing#features", label: "Features" },
{ href: "/landing#pipeline", label: "Pipeline" },
{ 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-card">
<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" />
@ -25,7 +27,7 @@ export function SiteFooter() {
<Link
key={item.href}
href={item.href}
className="font-marketing text-sm text-muted-foreground transition-colors hover:text-foreground"
className="font-mono-brand text-[11px] uppercase tracking-[0.16em] text-muted-foreground transition-colors hover:text-foreground"
>
{item.label}
</Link>
@ -33,11 +35,10 @@ export function SiteFooter() {
</nav>
</div>
<div className="border-t-2 border-foreground">
<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">sbmtr.io · built for agent pipelines</p>
</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

@ -4,8 +4,9 @@ import { Logo } from "@/components/marketing/logo";
import { cn } from "@/lib/utils";
const nav = [
{ href: "/landing#features", label: "Features" },
{ href: "/landing#features", label: "Capabilities" },
{ href: "/landing#pipeline", label: "Pipeline" },
{ href: "/landing#manifest", label: "Manifest" },
{ href: "/landing/pricing", label: "Pricing" },
] as const;
@ -13,7 +14,7 @@ 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.85)] text-[hsl(var(--hero-foreground))] backdrop-blur-md",
"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,
)}
>
@ -25,25 +26,25 @@ export function SiteHeader({ className }: { className?: string }) {
<Link
key={item.href}
href={item.href}
className="font-marketing text-sm text-[hsl(var(--hero-muted))] transition-colors hover:text-[hsl(var(--hero-foreground))]"
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-2 sm:gap-3">
<div className="flex items-center gap-4">
<Link
href="/sign-in"
className="hidden font-marketing text-sm text-[hsl(var(--hero-muted))] transition-colors hover:text-[hsl(var(--hero-foreground))] sm:inline"
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-marketing text-sm font-medium text-[hsl(var(--matter))] transition-colors hover:bg-[hsl(var(--matter)/0.1)]"
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 free
Start
</Link>
</div>
</div>

View file

@ -93,6 +93,19 @@ export function ConvoyDetailPanel({
)}
</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 && (
<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>

View file

@ -74,6 +74,7 @@
"tailwindcss-animate": "^1.0.7",
"tldraw": "^4.5.4",
"y-prosemirror": "^1.3.7",
"yaml": "^2.8.3",
"yjs": "^13.6.22",
"zod": "^3.24.0",
"zustand": "^5.0.0"

View file

@ -1,7 +1,7 @@
import { and, desc, eq, sql } from "drizzle-orm";
import type { db as dbInstance } from "@tasks/database";
import { convoyEvents, objects } from "@tasks/database/schema";
import { convoyEvents, objects, workerCheckpoints } from "@tasks/database/schema";
import {
convoyObjectContentSchema,
convoyStatusSchema,
@ -196,3 +196,28 @@ export async function getRecentConvoyEvents(
.orderBy(desc(convoyEvents.occurredAt))
.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 { connectedReposRouter } from "@/server/routers/connected-repos";
import { convoysRouter } from "@/server/routers/convoys";
import { harnessesRouter } from "@/server/routers/harnesses";
export const appRouter = router({
health: healthRouter,
@ -37,6 +38,7 @@ export const appRouter = router({
backlog: backlogRouter,
connectedRepos: connectedReposRouter,
convoys: convoysRouter,
harnesses: harnessesRouter,
});
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 { 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 {
resolveWorkflowPrompt,
DEFAULT_AGENT_PROMPT,
exportBacklogItemToMarkdown,
hashFileContents,
} from "@tasks/database/markdown-backlog";
import { parentRepoPath } from "@tasks/database/markdown-backlog";
import { router, workspaceProcedure } from "@/server/trpc";
import { recordAudit } from "@/server/lib/audit";
import { resolveRepoRoot } from "@/server/lib/repo-root";
import { findConvoyBySlug, insertConvoyEvent } from "@/server/lib/convoy/convoy-service";
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,
@ -144,15 +218,815 @@ export const backlogRouter = router({
);
}
} catch (e) {
console.warn(
"[backlog.updateWorkflowPrompt] markdown export threw:",
e,
);
console.warn("[backlog.updateWorkflowPrompt] markdown export threw:", e);
}
}
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;

View file

@ -1,5 +1,11 @@
import { createLanguageModelFromResolved } from "@tasks/ai";
import { modelProviders, objects, roleModelRoutes } from "@tasks/database/schema";
import {
modelProviders,
objects,
roleModelRoutes,
workspaceMembers,
workspaces,
} from "@tasks/database/schema";
import {
convoyClassificationSchema,
convoyObjectContentSchema,
@ -17,19 +23,20 @@ import {
} from "@tasks/shared/model-routing";
import { TRPCError } from "@trpc/server";
import { generateText } from "ai";
import { asc, eq } from "drizzle-orm";
import { asc, eq, inArray } from "drizzle-orm";
import { z } from "zod";
import {
findConvoyBySlug,
getConvoyBriefs,
getConvoyCheckpointSummary,
getRecentConvoyEvents,
insertConvoyEvent,
listConvoyProjects,
loadConvoyProject,
transitionConvoy,
} 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";
type ProviderRow = typeof modelProviders.$inferSelect;
@ -103,7 +110,11 @@ function resolveGateTransition(
if (action === "approve") return { actor, toStatus: "merged" };
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" };
}
@ -143,6 +154,11 @@ export const convoysRouter = router({
const content = convoyObjectContentSchema.safeParse(convoy.content);
const briefs = await getConvoyBriefs(ctx.db, ctx.workspace.id, convoy.id);
const events = await getRecentConvoyEvents(ctx.db, ctx.workspace.id, convoy.id, 30);
const checkpoints = await getConvoyCheckpointSummary(
ctx.db,
ctx.workspace.id,
convoy.id,
);
return {
convoy: {
@ -155,6 +171,12 @@ export const convoysRouter = router({
},
briefs,
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([]),
successMetric: z.string().min(1).max(2_000),
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 }) => {
@ -189,6 +226,11 @@ export const convoysRouter = router({
classification: input.classification,
skipFlags: input.skipFlags,
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
@ -335,4 +377,64 @@ export const convoysRouter = router({
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

@ -1,40 +1,41 @@
/* Submatter marketing — Signal Editorial (ui-ux-pro-max: Swiss + interactive demo + bold type) */
/* Submatter marketing v2 — Manifest (Exaggerated Minimal + E-Ink + Swiss grid) */
.marketing-theme {
--background: 40 18% 96%;
--foreground: 220 20% 8%;
--background: 45 20% 98%;
--foreground: 0 0% 4%;
--card: 0 0% 100%;
--card-foreground: 220 20% 8%;
--card-foreground: 0 0% 4%;
--primary: 228 45% 22%;
--primary-foreground: 40 20% 98%;
--primary: 0 0% 4%;
--primary-foreground: 45 20% 98%;
--matter: 32 95% 52%;
--matter-foreground: 220 20% 8%;
--matter: 18 100% 50%;
--matter-foreground: 0 0% 4%;
--agent: 162 72% 38%;
--agent: 168 80% 32%;
--agent-foreground: 0 0% 100%;
--secondary: 40 12% 92%;
--secondary-foreground: 220 20% 8%;
--secondary: 45 10% 94%;
--secondary-foreground: 0 0% 4%;
--muted: 40 10% 93%;
--muted-foreground: 220 8% 42%;
--muted: 45 8% 94%;
--muted-foreground: 0 0% 40%;
--accent: 228 30% 94%;
--accent-foreground: 228 45% 22%;
--accent: 45 15% 92%;
--accent-foreground: 0 0% 4%;
--border: 40 8% 86%;
--input: 40 8% 86%;
--ring: 32 95% 52%;
--border: 0 0% 4%;
--border-light: 45 8% 88%;
--input: 45 8% 88%;
--ring: 18 100% 50%;
--hero: 240 6% 4%;
--hero-foreground: 40 15% 96%;
--hero-muted: 240 4% 55%;
--hero-border: 240 4% 14%;
--hero: 0 0% 4%;
--hero-foreground: 45 20% 98%;
--hero-muted: 0 0% 62%;
--hero-border: 0 0% 18%;
--radius: 0.375rem;
--radius: 0px;
color-scheme: light;
}
@ -51,70 +52,80 @@
font-family: var(--font-mono-brand), ui-monospace, monospace;
}
/* Dot field (React Bits–style ambient grid) */
.marketing-dot-field {
background-image: radial-gradient(
circle at center,
hsl(var(--hero-foreground) / 0.12) 1px,
transparent 1px
);
background-size: 28px 28px;
mask-image: radial-gradient(ellipse 80% 70% at 50% 40%, black 20%, transparent 75%);
}
.marketing-scanline {
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
hsl(var(--hero-foreground) / 0.02) 2px,
hsl(var(--hero-foreground) / 0.02) 4px
);
pointer-events: none;
}
/* Border glow card (React Bits Border Glow–inspired) */
.marketing-glow-card {
/* Paper grain (E-Ink texture) */
.marketing-paper-grain {
position: relative;
background: hsl(var(--card));
transition: box-shadow 200ms ease, border-color 200ms ease;
}
.marketing-glow-card::before {
.marketing-paper-grain::after {
content: "";
position: absolute;
inset: -1px;
border-radius: inherit;
padding: 1px;
background: linear-gradient(
135deg,
hsl(var(--matter) / 0),
hsl(var(--matter) / 0.35),
hsl(var(--agent) / 0.2),
hsl(var(--matter) / 0)
);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
opacity: 0;
transition: opacity 200ms ease;
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");
}
.marketing-glow-card:hover::before,
.marketing-glow-card:focus-within::before {
opacity: 1;
/* 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-glow-card:hover {
box-shadow:
0 0 0 1px hsl(var(--border)),
0 12px 40px -12px hsl(var(--foreground) / 0.12);
.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 {
@ -136,11 +147,9 @@
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.55;
transform: scale(0.92);
opacity: 0.4;
}
}
@ -149,12 +158,12 @@
}
@media (prefers-reduced-motion: reduce) {
.marketing-marquee-track {
animation: none;
}
.marketing-cursor-blink,
.marketing-pulse-dot {
animation: none;
}
.marketing-glow-card::before {
transition: none;
}
}

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;
tokensTotal?: number;
summary?: string;
harnessAlias?: string | null;
},
) {
await db.insert(convoyEvents).values({
@ -218,10 +219,12 @@ export async function logRoleComplete(
providerType: input.providerType ?? null,
modelId: input.modelId ?? null,
modelTier: input.modelTier ?? null,
harnessAlias: input.harnessAlias ?? null,
metadata: {
source: "worker",
tokensTotal: input.tokensTotal ?? 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;
providerType?: string;
modelId?: string;
harnessAlias?: string | null;
repoRoot?: string | null;
},
) {
@ -175,9 +176,11 @@ export async function completeTask(
modelTier: input.modelTier ?? null,
providerType: input.providerType ?? null,
modelId: input.modelId ?? null,
harnessAlias: input.harnessAlias ?? null,
metadata: {
...(run.metadata ?? {}),
client: "echodo-worker",
harnessAlias: input.harnessAlias ?? null,
},
})
.where(eq(agentRuns.id, input.runId));

View file

@ -21,6 +21,13 @@ const envSchema = z.object({
const parsed = Number(value ?? "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_WORKTREE_BASE: z
.string()

View file

@ -1,3 +1,4 @@
import { access } from "node:fs/promises";
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
import { execFile } from "node:child_process";
@ -11,16 +12,70 @@ export type WorktreeContext = {
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(
repoRoot: string,
worktreeBase: string,
jobId: string,
worktreeKey: 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 worktreePath = join(worktreeBase, jobId);
const worktreePath = join(worktreeBase, worktreeKey);
await mkdir(worktreeBase, { recursive: true });
const existing = await reuseWorktreeIfPresent(repoRoot, worktreePath, branch);
if (existing) {
return existing;
}
try {
await execFileAsync("git", ["worktree", "add", "-B", branch, worktreePath], {
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 { OrchestratorJob } from "@tasks/shared/orchestrator";
import type {
WorkerCheckpointFileTouch,
WorkerCheckpointToolStep,
} from "@tasks/shared/worker";
import {
getLatestCheckpointForRun,
saveWorkerCheckpoint,
} from "./db/checkpoints.js";
import {
loadJobContext,
logRoleComplete,
@ -15,7 +23,13 @@ import {
pickTaskRepoPath,
} from "./db/task-lifecycle.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 {
buildSessionPrompt,
@ -39,6 +53,7 @@ export async function handleOrchestratorJob(
const started = Date.now();
const dryRun = env.WORKER_DRY_RUN || !env.REDIS_URL;
const monorepoRoot = resolveRepoRoot(env.WORKER_REPO_ROOT);
const resumeRequested = job.resumeFromCheckpoint === true;
const ctx = await loadJobContext(db, {
workspaceId: job.workspaceId,
@ -52,49 +67,41 @@ export async function handleOrchestratorJob(
return;
}
const modelResult = await resolveModelForRole(db, {
workspaceId: job.workspaceId,
roleId: job.roleId,
convoyId: job.convoyId,
});
if (!modelResult.ok) {
console.error(`[worker] job=${job.id} model resolve failed: ${modelResult.reason}`);
await logRoleComplete(db, {
workspaceId: job.workspaceId,
convoyId: job.convoyId,
// Resolve harness via DB registry with fallback to local alias map (mirrors resolveHarnessForRole pure logic)
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,
briefId: job.briefId,
outcome: "failed",
durationS: Math.round((Date.now() - started) / 1000),
summary: modelResult.reason,
harnessAlias: dbHarness.alias,
harnessType: dbHarness.harnessType,
endpointUrl: dbHarness.endpointUrl,
authEnvVar: dbHarness.authEnvVar,
});
return;
} catch (e) {
console.warn("[worker] harness DB resolve failed, using local fallback:", e);
}
const executor = getHarnessExecutor(harness.alias);
const needsWorktree = harness.needsWorktree;
void executor;
let repoRoot = monorepoRoot;
let worktreePath: 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 worktreeBranch: string | null = null;
let runId: string | null = null;
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) {
const backlogItem = await findBacklogItemByRepoPath(db, job.workspaceId, taskRepoPath);
if (backlogItem) {
@ -103,7 +110,6 @@ export async function handleOrchestratorJob(
workspaceId: job.workspaceId,
backlogItemId: backlogItem.id,
client: "echodo-worker",
model: modelResult.resolvedAlias,
});
runId = claim.runId;
workflowPrompt = claim.workflowPrompt;
@ -116,77 +122,187 @@ export async function handleOrchestratorJob(
}
}
const sessionContext = {
workspaceId: job.workspaceId,
convoyId: job.convoyId,
roleId: job.roleId,
briefId: job.briefId,
repoRoot,
};
const session = createMinimalSession(sessionContext, {
resolvedModel: modelResult.model,
resolvedAlias: modelResult.resolvedAlias,
rolePromptPath: resolveRolePromptPath(monorepoRoot, job.roleId),
workflowPrompt,
briefAcceptance: ctx.briefAcceptance,
briefFiles: ctx.briefFiles,
maxSteps: env.WORKER_MAX_TOOL_STEPS,
dryRun,
});
let outcome: "succeeded" | "failed" = "succeeded";
let summary = "";
let tokensTotal: number | undefined;
try {
const result = await session.run(buildSessionPrompt(sessionContext, ctx.convoyTitle));
summary = result.summary;
tokensTotal = result.tokensTotal;
console.info(
`[worker] job=${job.id} role=${job.roleId} tools=${result.toolCalls} dryRun=${dryRun}`,
);
} catch (error) {
outcome = "failed";
summary = error instanceof Error ? error.message : String(error);
console.error(`[worker] job=${job.id} session failed:`, error);
}
if (runId) {
try {
await completeTask(db, {
runId,
outcome,
notes: summary.slice(0, 2000),
error: outcome === "failed" ? summary.slice(0, 2000) : undefined,
tokensTotal,
modelAlias: modelResult.resolvedAlias,
modelTier: modelResult.modelTier,
providerType: modelResult.model.providerType,
modelId: modelResult.model.modelId,
repoRoot: monorepoRoot,
});
} catch (error) {
console.error("[worker] complete_task failed:", error);
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}`);
}
}
await logRoleComplete(db, {
workspaceId: job.workspaceId,
convoyId: job.convoyId,
roleId: job.roleId,
briefId: job.briefId,
outcome,
durationS: Math.round((Date.now() - started) / 1000),
modelAlias: modelResult.resolvedAlias,
providerType: modelResult.model.providerType,
modelId: modelResult.model.modelId,
modelTier: modelResult.modelTier,
tokensTotal,
summary,
});
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);
}
}
}
if (worktreePath) {
await removeWorktree(monorepoRoot, worktreePath);
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 = {
workspaceId: job.workspaceId,
convoyId: job.convoyId,
roleId: job.roleId,
briefId: job.briefId,
repoRoot,
};
// WorkerAdapter dispatch — minimal adapter delegates to createMinimalSession; remote adapters would POST to AG-UI.
const session = createMinimalSession(sessionContext, {
resolvedModel: modelResult.model,
resolvedAlias: modelResult.resolvedAlias,
rolePromptPath: resolveRolePromptPath(monorepoRoot, job.roleId),
workflowPrompt,
briefAcceptance: ctx.briefAcceptance,
briefFiles: ctx.briefFiles,
maxSteps: env.WORKER_MAX_TOOL_STEPS,
dryRun,
checkpointEveryNSteps: env.WORKER_CHECKPOINT_EVERY_N_STEPS,
resumeToolHistory,
resumeFilesTouched,
allowedCommands: harness.allowedCommands,
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,
});
try {
const result = await session.run(buildSessionPrompt(sessionContext, ctx.convoyTitle));
summary = result.summary;
tokensTotal = result.tokensTotal;
console.info(
`[worker] job=${job.id} role=${job.roleId} tools=${result.toolCalls} dryRun=${dryRun}`,
);
} catch (error) {
outcome = "failed";
summary = error instanceof Error ? error.message : String(error);
console.error(`[worker] job=${job.id} session failed:`, error);
}
} finally {
if (runId) {
try {
await completeTask(db, {
runId,
outcome,
notes: summary.slice(0, 2000),
error: outcome === "failed" ? summary.slice(0, 2000) : undefined,
tokensTotal,
modelAlias,
modelTier,
providerType,
modelId,
harnessAlias: harness.alias,
repoRoot: monorepoRoot,
});
} catch (error) {
console.error("[worker] complete_task failed:", error);
}
}
try {
await logRoleComplete(db, {
workspaceId: job.workspaceId,
convoyId: job.convoyId,
roleId: job.roleId,
briefId: job.briefId,
outcome,
durationS: Math.round((Date.now() - started) / 1000),
modelAlias,
providerType,
modelId,
modelTier,
tokensTotal,
summary,
harnessAlias: harness.alias,
});
} catch (error) {
console.error("[worker] role_complete log failed:", error);
}
// 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);
} 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 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 { z } from "zod";
import { digestResult, hashContent } from "../db/checkpoints.js";
import {
grepTool,
readFileTool,
@ -14,6 +20,12 @@ import {
writeFileTool,
} from "../tools/runner.js";
export type CheckpointSink = (input: {
checkpointSeq: number;
toolHistory: WorkerCheckpointToolStep[];
filesTouched: WorkerCheckpointFileTouch[];
}) => Promise<void>;
export type MinimalSessionOptions = {
resolvedModel: ResolvedModel;
resolvedAlias: string;
@ -23,8 +35,56 @@ export type MinimalSessionOptions = {
briefFiles?: string[];
maxSteps: number;
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(
context: WorkerJobContext,
options: MinimalSessionOptions,
@ -51,6 +111,15 @@ export function createMinimalSession(
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 = [
rolePrompt.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\nUse tools to inspect and modify the repo. Finish with a concise summary of work done.",
buildResumeAppendix(resumeHistory),
].join("");
let toolCalls = 0;
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({
model: modelResult.model,
maxSteps: options.maxSteps,
maxSteps: remainingSteps,
system,
prompt,
tools: {
@ -78,9 +183,10 @@ export function createMinimalSession(
path: z.string().min(1),
}),
execute: async ({ path }) => {
toolCalls += 1;
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({
@ -90,9 +196,10 @@ export function createMinimalSession(
content: z.string(),
}),
execute: async ({ path, content }) => {
toolCalls += 1;
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({
@ -102,9 +209,10 @@ export function createMinimalSession(
path: z.string().optional(),
}),
execute: async ({ pattern, path }) => {
toolCalls += 1;
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({
@ -113,9 +221,10 @@ export function createMinimalSession(
command: z.string().min(1),
}),
execute: async ({ command }) => {
toolCalls += 1;
const res = await runCommandTool(repoRoot, command);
return res.ok ? res.output : `ERROR: ${res.error}`;
const res = await runCommandTool(repoRoot, command, options.allowedCommands);
const output = 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";
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, " ");
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> {
if (!isAllowedCommand(command)) {
export async function runCommandTool(
repoRoot: string,
command: string,
allowedCommands?: readonly string[] | null,
): Promise<WorkerToolResult> {
if (!isAllowedCommand(command, allowedCommands)) {
return {
ok: false,
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`
Non-code (docs): `in_progress` → `doc_review` → `published` → `released` (mirror of `pr_review` → `merged`)
Terminal / exceptional: `blocked`, `cancelled`
## MCP tools
@ -22,8 +24,8 @@ Terminal / exceptional: `blocked`, `cancelled`
## Gate actors
- `human-gate-1` — plan approval (`convoy_review` ↔ `ready` / `draft`)
- `human-gate-2` — PR merge gate (`pr_review` ↔ `merged` / `in_progress`)
- `human-gate-3` — prod promote (`merged` → `released`)
- `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`/`published` → `released`)
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`)
- **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`)
- **Worktree** — one git worktree per brief job under `WORKER_WORKTREE_BASE`
- **Lifecycle** — DB writes mirroring MCP `claim_task` / `complete_task`; `role_complete` convoy event on finish
- **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` 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 (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
@ -36,7 +37,20 @@ WORKER_DRY_RUN=0 REDIS_URL=... pnpm --filter @tasks/worker dev
| `WORKER_DRY_RUN` | `false` | Skip dequeue loop |
| `WORKER_BLPOP_TIMEOUT_S` | `5` | Blocking pop timeout |
| `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_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).

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,
"tag": "0013_crazy_joseph",
"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 }),
modelId: varchar("model_id", { length: 200 }),
modelTier: varchar("model_tier", { length: 20 }),
harnessAlias: varchar("harness_alias", { length: 120 }),
},
(table) => ({
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 }),
modelId: varchar("model_id", { length: 200 }),
modelTier: varchar("model_tier", { length: 20 }),
harnessAlias: varchar("harness_alias", { length: 120 }),
estimatedCostUsd: numeric("estimated_cost_usd", { precision: 12, scale: 6 }),
metadata: jsonb("metadata").$type<Record<string, unknown> | null>(),
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 "./connected_repos";
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 { connectedRepos } from "./connected_repos";
import { convoyEvents } from "./convoy_events";
import { workerCheckpoints } from "./worker_checkpoints";
export const objectRelations = pgTable(
"object_relations",
@ -315,3 +316,24 @@ export const roleModelRoutesRelations = relations(roleModelRoutes, ({ one }) =>
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",
"./orchestrator": "./src/orchestrator/index.ts",
"./worker": "./src/worker/index.ts",
"./worker/executor": "./src/worker/executor.ts",
"./model-routing": "./src/model-routing/index.ts",
"./types": "./src/types/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: "in_progress", label: "Building" },
{ status: "pr_review", label: "Awaiting merge" },
{ status: "doc_review", label: "Awaiting doc approval" },
{ status: "merged", label: "Awaiting prod" },
{ status: "published", label: "Published" },
{ status: "released", label: "Done" },
{ status: "blocked", label: "Blocked" },
];
@ -21,7 +23,9 @@ export const MISSION_CONTROL_KANBAN_COLUMNS: KanbanColumn[] = [
export const GATE_INBOX_STATUSES: ConvoyStatus[] = [
"convoy_review",
"pr_review",
"doc_review",
"merged",
"published",
];
export function gateLabelForStatus(status: ConvoyStatus): string | null {
@ -30,8 +34,12 @@ export function gateLabelForStatus(status: ConvoyStatus): string | null {
return "Gate 1 — Plan approval";
case "pr_review":
return "Gate 2 — PR merge";
case "doc_review":
return "Gate 2 — Doc approval";
case "merged":
return "Gate 3 — Prod promote";
case "published":
return "Gate 3 — Prod promote";
default:
return null;
}
@ -43,8 +51,12 @@ export function humanGateActorForStatus(status: ConvoyStatus): string | null {
return "human-gate-1";
case "pr_review":
return "human-gate-2";
case "doc_review":
return "human-gate-2";
case "merged":
return "human-gate-3";
case "published":
return "human-gate-3";
default:
return null;
}

View file

@ -8,7 +8,9 @@ export const convoyStatusSchema = z.enum([
"ready",
"in_progress",
"pr_review",
"doc_review",
"merged",
"published",
"released",
"blocked",
"cancelled",
@ -54,6 +56,11 @@ export const convoyObjectContentSchema = z.object({
skipFlags: z.array(z.string()).default([]),
successMetric: z.string().min(1),
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>;

View file

@ -4,9 +4,11 @@ export const VALID_TRANSITIONS: Record<ConvoyStatus, ConvoyStatus[]> = {
draft: ["convoy_review", "blocked", "cancelled"],
convoy_review: ["ready", "draft", "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"],
doc_review: ["published", "in_progress", "blocked", "cancelled"],
merged: ["released", "blocked"],
published: ["released", "blocked"],
released: [],
blocked: [
"draft",
@ -14,7 +16,9 @@ export const VALID_TRANSITIONS: Record<ConvoyStatus, ConvoyStatus[]> = {
"ready",
"in_progress",
"pr_review",
"doc_review",
"merged",
"published",
"cancelled",
],
cancelled: [],
@ -70,6 +74,7 @@ export const ALLOWED_TRANSITIONS: readonly TransitionRule[] = [
requiresReason: true,
},
{ from: "in_progress", to: "pr_review", actors: ["role-implementer", "automation"] },
{ from: "in_progress", to: "doc_review", actors: ["role-implementer", "automation"] },
{
from: "in_progress",
to: "blocked",
@ -96,6 +101,24 @@ export const ALLOWED_TRANSITIONS: readonly TransitionRule[] = [
actors: ["human-gate-1", "human-gate-2"],
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",
@ -103,6 +126,13 @@ export const ALLOWED_TRANSITIONS: readonly TransitionRule[] = [
actors: ["human-gate-3", "automation"],
requiresReason: true,
},
{ from: "published", to: "released", actors: ["human-gate-3"] },
{
from: "published",
to: "blocked",
actors: ["human-gate-3", "automation"],
requiresReason: true,
},
{
from: "blocked",
to: "draft",
@ -133,12 +163,24 @@ export const ALLOWED_TRANSITIONS: readonly TransitionRule[] = [
actors: ["human-gate-2", "role-conductor", "automation"],
requiresReason: true,
},
{
from: "blocked",
to: "doc_review",
actors: ["human-gate-2", "role-conductor", "automation", "doc-approver"],
requiresReason: true,
},
{
from: "blocked",
to: "merged",
actors: ["human-gate-2", "role-conductor", "automation"],
requiresReason: true,
},
{
from: "blocked",
to: "published",
actors: ["human-gate-2", "role-conductor", "automation", "doc-approver"],
requiresReason: true,
},
{
from: "blocked",
to: "cancelled",

View file

@ -49,7 +49,9 @@ const GATE_STATUSES: Record<ConvoyStatus, "1" | "2" | "3" | null> = {
ready: null,
in_progress: null,
pr_review: "2",
doc_review: "2",
merged: "3",
published: "3",
released: null,
blocked: null,
cancelled: null,

View file

@ -12,6 +12,9 @@ export const orchestratorJobSchema = z.object({
roleId: z.string().min(1).max(64),
briefId: z.string().uuid().optional(),
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>;

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,
workerToolNameSchema,
} 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

@ -4,12 +4,12 @@ slug: worker-checkpoint-resume
title: Worker checkpoint and crash recovery
plan_slug: automation-foundation
epic_slug: apps-worker-v1
status: draft
status: done
priority: P1
tenant_id: global
owner: unassigned
cursor_todo_id: null
updated_at: "2026-08-03"
updated_at: "2026-08-12"
---
# Task summary
@ -44,11 +44,11 @@ Add `worker_checkpoints` (or JSONB on `agent_runs`) workspace-scoped:
## Subtasks
- [ ] Zod schemas in `packages/shared/worker`
- [ ] Drizzle migration + workspace-scoped queries
- [ ] Checkpoint write in `minimal-session` / `handle-job`
- [ ] Resume path on re-dispatch
- [ ] Env docs in `docs/worker.md`
- [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
@ -56,9 +56,9 @@ M
## Acceptance criteria
- [ ] Kill worker mid-brief; re-dispatch resumes from last tool step without re-cloning
- [ ] Checkpoints filtered by `workspace_id`
- [ ] Checkpoint payload does not store API keys or full file contents (paths + hashes only)
- [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

View file

@ -4,13 +4,13 @@ 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: ready
status: done
priority: P1
tenant_id: global
owner: unassigned
cursor_todo_id: null
updated_at: "2026-08-03"
agent_prompt: |
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

View file

@ -6,7 +6,7 @@ status: ready
priority: P1
tenant_id: global
cursor_plan_id: null
updated_at: "2026-08-12"
updated_at: "2026-08-25"
agent_prompt: |
You are implementing Echodo's automation foundation. Read docs/adr-model-routing.md
before touching model routing code. This repo uses pnpm, Drizzle, tRPC, zod in
@ -30,7 +30,25 @@ Echodo / **Submatter** is evolving from a multitenant task app with MCP coordina
Epics **1–6 shipped**. Epic **7 in progress** (tool loop + checkpoints in code; draft PR + live E2E verify still open). Epics **8–9** (webhooks, fleet) and **10–12** (plans UI, completion artifacts, ops copilot) carry the full owner vision. Autensa/Builderz-style ideas are benchmarks only — no OpenClaw dependency.
## Owner vision phases
## 2026-08-25 pivot — open self-hosted "Second Brain OS" (decision: `docs/decisions/2026-08-25-second-brain-os-pivot.md`)
After deep dive vs Grok Bot ($300/mo Heavy, no standalone) / OpenBot (AG-UI) / OpenClaw 347k stars / CrewAI 57k / LangChain 97k+ and two real-life walkthroughs (solo Maya `Clarity` offline notes, Studio North `RouteMap`), the plan pivots:
* **Run for self** (homelab dogfood) + **publish as open MIT self-hosted Obsidian for crews** — `docker compose -f docker/docker-compose.yml up`, BYO LLMs (`qwen-coder` `11434` / `qwen-coder-mlx` `8000` / NIM `https://integrate.api.nvidia.com/v1` / LiteLLM), any harness via `AG-UI`/`MCP`. **Do not** clone LangChain/CrewAI; be the gated control plane they lack (`No native policy gate`).
* **Harness-agnostic execution:** `WorkerAdapter` `packages/shared/src/worker/executor.ts` dispatches `minimal | crewai | langgraph | remote-ag-ui` per `role_harness_routes` (mirror `model_providers` `docs/adr-model-routing.md:78`). `handleOrchestratorJob` `apps/worker/src/handle-job.ts:40` stops hard-calling `MinimalSession` `apps/worker/src/session/minimal-session.ts:87`; job spec `packages/shared/src/orchestrator/job.ts` adds `harnessAlias`.
* **Web-first:** almost everything from `echodo.stillwell.cloud` — `Fleet` rollup + `Plans` tree CRUD for `plans/` (`backlog.createPlan/createEpic/createTask` → writes `.md` → `sync.ts`), `CreateConvoyDialog` `apps/web/components/mission-control/create-convoy-dialog.tsx` → `convoys.create` `apps/web/server/routers/convoys.ts:173`, gates `gateAction` `convoys.ts:237` + `gateCopilot` `convoys.ts:267`. MCP `create-backlog-item` is shortcut for natural "OpenCode, add Epic with 3 tasks".
* **Non-code ready:** worktree optional (`docs/SOPs` write straight to `docs/` no branch `apps/worker/src/git/worktree.ts:1`), `allowlist.ts` per-harness, `doc_review→published` alongside `pr_review→merged` `packages/shared/src/convoy-lifecycle/state-machine.ts`, templates `Settings → Templates` for book/course/research.
* **Monetize only if signal:** `>1k stars or 50 self-hosters or 3 managed fleet inquiries` in 30 days post-publish; else keep homelab.
### v0.1 for second brain (next 2 weeks) — second-brain-first order
1. MCP `create-backlog-item` (0.5d) — agents can author `Task-*.md` naturally
2. Optional worktree + per-harness allowlist (1d) — unlocks book/SOPs/research
3. Harness registry + `WorkerAdapter` (2-3d) — `harness_providers` table + `Settings → Harnesses` + `AG-UI /api/ag-ui/[workspace]` + `testHarness`
4. Plans tree (1w) — `apps/web/app/(app)/[workspaceSlug]/plans/page.tsx` `Epic 10` (`Task-plans-roadmap-page` + `Task-trpc-backlog-tree` + `Task-start-convoy-from-task`)
5. Fleet rollup (3-4d) — `/fleet` `Epic 9` (`Task-fleet-rollup-dashboard` + SSE) reusing `ConvoyKanban`
### Owner vision phases (pre-pivot, kept for traceability)
| Phase | Goal | Epics |
|-------|------|-------|
@ -123,14 +141,20 @@ Start with **`Task-shared-schemas-resolve-model`** — no DB migration, pure `pa
- LiteLLM (optional gateway later)
- Symphony
## Objectives and success criteria
## Objectives and success criteria — updated 2026-08-25 for second brain OS
- Workspace admins can CRUD model provider aliases (Anthropic, OpenAI, Google, OpenAI-compatible for Ollama/NIM/LiteLLM).
- Workspace admins can map any `role_id` (nine L2 defaults + custom) to a provider alias.
- `resolveModel()` implements ADR resolution order (brief → convoy → workspace → template).
- Settings UI: Providers + Role routing pages with connection test.
- `agent_runs` records `model_alias`, `provider_type`, `model_id`, `model_tier` on completion.
- Documented in ADR; `.env.example` updated with referenced env var names.
**Original (Epic 1) still holds + new for pivot:**
- Workspace admins can CRUD model provider aliases (Anthropic, OpenAI, Google, OpenAI-compatible for Ollama/NIM/LiteLLM) — `docs/adr-model-routing.md`, Epic 1 `done`.
- Workspace admins can map any `role_id` (nine L2 defaults + custom) to a provider alias — `role_model_routes`.
- **New:** Workspace admins can CRUD **harness providers** (`minimal | crewai | langgraph | remote-ag-ui`) and map `role_id → harness_alias` (mirror of model routing, `harness_providers` + `role_harness_routes`, write-only `auth_env_var`).
- `resolveModel()` + `resolveHarnessForRole()` implement ADR resolution order (brief → convoy → workspace → template).
- Settings UI: Providers + Role routing + **Harnesses** pages with connection test (`testProvider` + `testHarness`).
- `agent_runs` + `convoy_events` + `worker_checkpoints` record `harness_alias`, `model_alias`, `provider_type`, `model_id`, `model_tier` on completion.
- **Web-first:** `Fleet` shows gates/PRs across workspaces, `Plans` tree shows `plans/Plan-*/Epic-*/Task-*.md` without dumping vault into context (`Plan-automation-foundation.md:222`), `Start → Convoy` without CLI.
- **Non-code:** `docs/` second-brain + book/course/research pipelines runnable as `doc_review→published` without git worktree; `allowlist` per-harness; personal tasks `status:ready` pillable by any agent via `claim_task`.
- Dogfooded end-to-end via `claim_task → complete_task` with harness+model fields.
- Documented in ADRs (`adr-model-routing.md` + new `adr-harness-routing.md` / this decision log); `.env.example` updated.
## Scope and boundaries
@ -155,9 +179,11 @@ Start with **`Task-shared-schemas-resolve-model`** — no DB migration, pure `pa
|--------------|------|
| Solo operator | Product, implement, dogfood pipeline |
## Epic roadmap (complete)
## Epic roadmap (updated 2026-08-25 — second brain OS)
**Current focus: close Epic 7** (draft PR + live E2E), then **Epic 8 → 9 → 10 → 11 → 12**. Follow-ons on done epics can run in parallel when they do not block the critical path.
**Current focus (v0.1, per pivot):** `MCP create-backlog-item (0.5d)` → **make worker non-code** `optional worktree + per-harness allowlist (1d)` → **harness-agnostic** `harness registry + WorkerAdapter (2-3d)` → **Plans tree** `Epic 10 (1w)` → **Fleet rollup** `Epic 9 (3-4d)` → then close `Epic 7 draft PR + live E2E` and `Epic 8 webhooks`. Follow-ons on done epics can run in parallel when they do not block the critical path.
**Legacy order (pre-pivot, kept for history):** close Epic 7 (draft PR + live E2E), then Epic 8 → 9 → 10 → 11 → 12.
```mermaid
flowchart LR
@ -200,10 +226,11 @@ flowchart LR
| 4 | [Convoy lifecycle MCP](./Epic-convoy-lifecycle-mcp/Epic-convoy-lifecycle-mcp.md) | done | create/transition/events | 2, 3 |
| 5 | [Mission control gates UI](./Epic-mission-control-gates-ui/Epic-mission-control-gates-ui.md) | done (+2 follow-ons) | Kanban, gates, co-pilot; DAG, tiers | 4 |
| 6 | [Orchestrator](./Epic-apps-orchestrator/Epic-apps-orchestrator.md) | done (+1 follow-on) | Redis dispatch; auto-nudge | 1, 3, 4 |
| 7 | [Worker v1](./Epic-apps-worker-v1/Epic-apps-worker-v1.md) | **in progress** | Loop, checkpoints, **draft PR**, **live E2E**, notes | 1, 3, 6 |
| 8 | [Forge webhooks](./Epic-forge-webhooks/Epic-forge-webhooks.md) | draft | Webhooks + PR checks recovery | 3, 4, 7 |
| 9 | [Fleet dashboard](./Epic-fleet-dashboard/Epic-fleet-dashboard.md) | draft | Cross-repo home + SSE feed | 4, 5 |
| 10 | [Plans roadmap UI](./Epic-plans-roadmap-ui/Epic-plans-roadmap-ui.md) | draft | Tree, status, Start → convoy | 4, 5 |
| 7 | [Worker v1](./Epic-apps-worker-v1/Epic-apps-worker-v1.md) | **in progress → v0.1: add optional worktree + per-harness allowlist + draft PR** | Loop, checkpoints, **draft PR**, **live E2E**, notes; `doc_review→published` | 1, 3, 6 |
| 7b | **Harness-agnostic** (new) | **planned (v0.1)** | `harness_providers` + `role_harness_routes`, `WorkerAdapter(minimal|crewai|langgraph|remote-ag-ui)`, `AG-UI /api/ag-ui`, MCP `create-backlog-item` | 1, 6, 7 |
| 8 | [Forge webhooks](./Epic-forge-webhooks/Epic-forge-webhooks.md) | draft (post-v0.1) | Webhooks + PR checks recovery | 3, 4, 7 |
| 9 | [Fleet dashboard](./Epic-fleet-dashboard/Epic-fleet-dashboard.md) | **draft → next after harnesses (v0.1)** | Cross-repo home + SSE feed (`/fleet`) | 4, 5 |
| 10 | [Plans roadmap UI](./Epic-plans-roadmap-ui/Epic-plans-roadmap-ui.md) | **draft → next after harnesses (v0.1)** | Tree, status, Start → convoy (web `plans/` CRUD) | 4, 5 |
| 11 | [Owner completion artifacts](./Epic-owner-completion-artifacts/Epic-owner-completion-artifacts.md) | draft | Summaries, gallery, screenshots | 7, 8 |
| 12 | [Ops copilot](./Epic-ops-copilot/Epic-ops-copilot.md) | draft | Project-aware status chat | 9, 10 |
@ -256,51 +283,47 @@ Studied [crshdn/mission-control](https://github.com/crshdn/mission-control) (Aut
| 11 Owner completion artifacts | `./Epic-owner-completion-artifacts/Epic-owner-completion-artifacts.md` |
| 12 Ops copilot | `./Epic-ops-copilot/Epic-ops-copilot.md` |
## Metrics and milestones
## Metrics and milestones — updated 2026-08-25
| Milestone | Epic | Metric |
|-----------|------|--------|
| M0 ADR | — | `docs/adr-model-routing.md` merged |
| M1 Model registry | 1 | Settings: configure + test Anthropic + Ollama aliases |
| M2 Dogfood loop | 1–2 | One task via claim_task → complete_task with model fields |
| M1b Harness registry | 7b | `Settings → Harnesses` CRUD + `testHarness` + `resolveHarnessForRole` |
| M2 Dogfood loop | 1–2 | One task via claim_task → complete_task with model + harness fields |
| M2b Author via MCP | 7b | `create-backlog-item` creates `Task-*.md` from OpenCode natural language |
| M3 Repos connected | 3 | Paste Forge/GitHub URL; clone succeeds from Echodo |
| M4 Convoy state | 4 | `transition_convoy_status` enforces gate actors |
| M5 Human surface | 5 | Gate inbox shows pending `convoy_review` |
| M6 Automation | 6–7 | Unattended implementer brief (live E2E task) |
| M6b Draft PR | 7 | Worker opens draft PR on success |
| M6 Automation | 6–7 | Unattended implementer brief (live E2E task) — **now optional-worktree for docs** |
| M6b Draft PR | 7 | Worker opens draft PR on success (code) / `published` on docs |
| M7 PR loop | 8 | Merge webhook moves convoy to `merged` |
| M8 Fleet | 9 | Single view across ≥2 connected repos |
| M8 Fleet | 9 | **`/fleet` single view across ≥2 connected repos** (v0.1) |
| M9 Reliability | 7 | Checkpoint resume after kill |
| M10 Observability | 9 | Activity feed shows role_complete without refresh |
| M11 Spend control | 1 follow-on | Month-to-date spend + dispatch pause on cap |
| M12 Roadmap UI | 10 | Plans tree + Start → convoy |
| M12 Roadmap UI | 10 | **Plans tree + Start → convoy in-web, no CLI** (v0.1) |
| M13 Owner package | 11 | Completion summary (+ screenshot when UI) |
| M14 Ops chat | 12 | “What needs me?” answered with links |
| M15 Open publish | — | `docker compose -f docker/docker-compose.yml up` star-ready, BYO LLM headline |
## Recommended execution order (owner vision)
## Recommended execution order — updated 2026-08-25 (v0.1 second brain OS first)
### Phase A — Engine (do now)
### v0.1 — Publishable second brain (next 2 weeks, per `docs/decisions/2026-08-25-second-brain-os-pivot.md`)
1. [Task-worker-live-e2e-dogfood](./Epic-apps-worker-v1/Task-worker-live-e2e-dogfood.md) — confirm A1 on Coolify/local (**ops checklist**)
2. [Task-worker-draft-pr](./Epic-apps-worker-v1/Task-worker-draft-pr.md) — **next coding chat** if PR not already working
3. [Task-forge-webhook-receiver](./Epic-forge-webhooks/Task-forge-webhook-receiver.md) → [Task-pr-checks-recovery-panel](./Epic-forge-webhooks/Task-pr-checks-recovery-panel.md)
4. *Parallel optional:* operator notes, auto-nudge, repo preflight
1. MCP `create-backlog-item` (0.5d) — `apps/mcp-server/src/tools/create-backlog-item.ts` — agents author `Plan/Epic/Task` naturally
2. Worker non-code (1d) — `apps/worker/src/git/worktree.ts:1` optional worktree + `apps/worker/src/tools/allowlist.ts:1` per-harness + `doc_review→published` `packages/shared/src/convoy-lifecycle/state-machine.ts`
3. Harness registry + `WorkerAdapter` (2-3d) — `packages/database/src/schema/harness_providers.ts` + `role_harness_routes` + `packages/shared/src/worker/executor.ts` + `Settings → Harnesses` + `AG-UI /api/ag-ui/[workspace]` + `testHarness`
4. Plans tree (1w) — Epic 10 `Task-plans-roadmap-page` + `Task-trpc-backlog-tree` + `Task-start-convoy-from-task` → `apps/web/app/(app)/[workspaceSlug]/plans/page.tsx` — web CRUD for `plans/` without CLI
5. Fleet rollup (3-4d) — Epic 9 `Task-fleet-rollup-dashboard` + SSE → `/fleet`
### Phase B — Fleet
Then close original A1: `Task-worker-draft-pr` + `Task-worker-live-e2e-dogfood` (now harness-aware).
5. [Task-fleet-rollup-dashboard](./Epic-fleet-dashboard/Task-fleet-rollup-dashboard.md) → SSE feed
### Post-v0.1 — legacy engine order (kept for traceability)
### Phase C — Roadmap
6. Epic 10: backlog tree tRPC → Plans page → Start → convoy
### Phase D — Owner review
7. Epic 11: completion summary → artifact gallery → screenshot capture
### Phase E — Ops chat
8. Epic 12: context tools → UI → draft work from chat
6. `Task-forge-webhook-receiver` → `Task-pr-checks-recovery-panel` (Epic 8)
7. Epic 11: completion summary → artifact gallery → screenshot capture
8. Epic 12: context tools → UI → draft work from chat
### Polish (anytime after blockers clear)
@ -319,5 +342,7 @@ Studied [crshdn/mission-control](https://github.com/crshdn/mission-control) (Aut
## Links
- [ADR: Model routing](../../docs/adr-model-routing.md)
- [Decision 2026-08-25 second brain pivot](../../docs/decisions/2026-08-25-second-brain-os-pivot.md)
- [Plan: Agent coordination](../Plan-agent-coordination/Plan-agent-coordination.md)
- Agent-pipeline: `docs/model-routing-policy.md`, `docs/role-reference.md`
- Examples: Maya `Clarity` + Studio North `RouteMap` walkthroughs in decision log

View file

@ -323,6 +323,9 @@ importers:
y-prosemirror:
specifier: ^1.3.7
version: 1.3.7(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.7)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30)
yaml:
specifier: ^2.8.3
version: 2.8.3
yjs:
specifier: ^13.6.22
version: 13.6.30