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:
parent
d1b2b9695e
commit
6ccb060bea
85 changed files with 14145 additions and 804 deletions
69
.convoys/second-brain-os-v01.md
Normal file
69
.convoys/second-brain-os-v01.md
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
---
|
||||
name: second-brain-os-v01
|
||||
classification: server-only
|
||||
success_metric: Web-first second brain OS publishable via docker compose up — any agent (OpenCode/Cursor) can create Plan/Epic/Task via MCP, Plans tree CRUD works without CLI, Fleet shows all workspaces, harness-agnostic worker runs doc + code tasks with BYO LLMs
|
||||
skip:
|
||||
- ia
|
||||
- ux
|
||||
- design
|
||||
- a11y
|
||||
- visual
|
||||
- smoke
|
||||
status: in-progress
|
||||
created: 2026-08-25
|
||||
model_policy:
|
||||
roles:
|
||||
role-conductor: claude-planning
|
||||
role-architect: claude-planning
|
||||
role-implementer: qwen-coder
|
||||
role-reviewer: qwen-coder
|
||||
escalate_to: claude-planning
|
||||
---
|
||||
|
||||
# Second Brain OS v0.1 — open self-hosted Obsidian for crews
|
||||
|
||||
## Why
|
||||
|
||||
Echodo pivots per `docs/decisions/2026-08-25-second-brain-os-pivot.md` from closed code-cockpit to open "Obsidian for agent crews": `docker compose up`, BYO LLMs (Ollama 11434 / MLX 8000 / NIM), any harness via AG-UI/MCP. Current `plans/` is hand-edited markdown + CLI import; Fleet is per-workspace kanban only; worker is code-only with hard worktree + 4-tool allowlist. This convoy makes the homelab dogfoodable + publishable for stars.
|
||||
|
||||
See `plans/Plan-automation-foundation/Plan-automation-foundation.md` v0.1 order (2026-08-25 pivot).
|
||||
|
||||
## Scope
|
||||
|
||||
1. MCP `create-backlog-item` — natural language `Plan/Epic/Task` creation (0.5d)
|
||||
2. Worker non-code: optional worktree + per-harness allowlist + `doc_review→published` (1d)
|
||||
3. Harness registry + WorkerAdapter: `harness_providers` + `role_harness_routes`, `AG-UI /api/ag-ui`, `Settings → Harnesses` (2-3d)
|
||||
4. Plans tree: `apps/web/app/(app)/[workspaceSlug]/plans/page.tsx` + `backlog.createPlan/createEpic/createTask` + `Start→Convoy` (1w) — Epic 10
|
||||
5. Fleet rollup: `/fleet` + SSE `convoy_events` feed (3-4d) — Epic 9
|
||||
|
||||
Out of scope: Epic 8 webhooks, Epic 11 artifacts, Epic 12 copilot (post-v0.1); LiteLLM required, per-user overrides.
|
||||
|
||||
## Roles invoked
|
||||
|
||||
Conductor → Architect (briefs = 5 above, 1:1 to roadmap) → Implementer per brief (sequential, each needs prior) → Reviewer (PR).
|
||||
|
||||
## Todos
|
||||
|
||||
- [ ] Brief 1 MCP create
|
||||
- [ ] Brief 2 worker non-code
|
||||
- [ ] Brief 3 harness registry — depends on 2
|
||||
- [ ] Brief 4 Plans tree — depends on 1,3
|
||||
- [ ] Brief 5 Fleet — depends on 3
|
||||
|
||||
## IA
|
||||
|
||||
Skipped (`ia` in skip). No new IA — `plans/` hierarchy `docs/Glossary.md:50` + `objects` `packages/database/src/schema/objects.ts:18` unchanged.
|
||||
|
||||
## UX
|
||||
|
||||
Skipped (`ux` in skip). Reuses existing `Mission control` kanban `apps/web/app/(app)/[workspaceSlug]/mission-control/page.tsx:12` + `Settings` patterns.
|
||||
|
||||
## Architecture
|
||||
|
||||
See decision log + `docs/adr-model-routing.md`. Key new:
|
||||
- `packages/database/src/schema/harness_providers.ts` (mirror `model_providers` `docs/adr-model-routing.md:78`)
|
||||
- `packages/shared/src/worker/executor.ts` `WorkerAdapter`
|
||||
- `apps/worker/src/handle-job.ts:40` dispatch via adapter
|
||||
- `apps/web/app/api/ag-ui/[workspace]/route.ts` AG-UI
|
||||
- `apps/mcp-server/src/tools/create-backlog-item.ts`
|
||||
- `apps/web/server/routers/backlog.ts:29` add CRUD for `plans/*.md`
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
---
|
||||
brief: 1
|
||||
convoy: second-brain-os-v01
|
||||
role: role-implementer
|
||||
title: MCP create-backlog-item tool for natural language Plan/Epic/Task authoring
|
||||
status: ready
|
||||
priority: P0
|
||||
slice_dependencies: []
|
||||
recommended_model: qwen-coder
|
||||
model_tier: local
|
||||
workspace_id: global
|
||||
---
|
||||
|
||||
# Brief 1 — MCP `create-backlog-item` (0.5d)
|
||||
|
||||
## Goal
|
||||
|
||||
Any agent (OpenCode/Cursor/Codex) via MCP can say "create an Epic for offline mode with 3 tasks" and it writes `plans/Plan-*/Epic-*/Task-*.md` + re-imports to `markdown_backlog_items` `packages/database/src/markdown-backlog/sync.ts:38`.
|
||||
|
||||
## Files
|
||||
|
||||
- `apps/mcp-server/src/tools/create-backlog-item.ts` NEW — zod schema `kind: plan|epic|task`, `slug`, `title`, `planSlug`, `epicSlug`, `status`, `priority`, `bodyMarkdown`, `agent_prompt`; validates frontmatter per `docs/templates/`, writes file via `exportBacklogItemToMarkdown` inverse, calls `syncMarkdownBacklogScan` or direct DB insert + `contentHash` `packages/database/src/markdown-backlog/parse.ts:1`.
|
||||
- `apps/mcp-server/src/tools/index.ts` register
|
||||
- `packages/database/src/markdown-backlog/parse.test.ts` add case
|
||||
|
||||
## Acceptance
|
||||
|
||||
- `mcp call create-backlog-item {workspace:"maya-workspace", kind:"task", slug:"research-prompt", planSlug:"clarity", epicSlug:"ai-tags", title:"Research prompt", bodyMarkdown:"..."}` creates `plans/Plan-clarity/Epic-ai-tags/Task-research-prompt.md` with YAML frontmatter + body, row appears in `markdown_backlog_items` without manual `pnpm import`.
|
||||
- Duplicate slug under same epic → error `CONFLICT`.
|
||||
- `pnpm lint && pnpm type-check && pnpm --filter @tasks/mcp-server test` (if exists) pass; `apps/mcp-server` still builds Docker.
|
||||
|
||||
## Prompt for implementer
|
||||
|
||||
You are `role-implementer` for `second-brain-os-v01` brief 1. Read `docs/decisions/2026-08-25-second-brain-os-pivot.md`, `packages/database/src/markdown-backlog/parse.ts`, `apps/mcp-server/src/tools/claim-task.ts:1` as reference. Implement exactly this brief, keep changes small, run `pnpm lint && pnpm type-check`.
|
||||
|
||||
## Dogfood
|
||||
|
||||
`claim_task` on the matching `plans/Plan-automation-foundation/.../Task-*.md` if exists (or ad-hoc) → `complete_task outcome:succeeded tokensTotal:... modelAlias:qwen-coder`.
|
||||
40
.convoys/second-brain-os-v01/brief-2-worker-non-code.md
Normal file
40
.convoys/second-brain-os-v01/brief-2-worker-non-code.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
---
|
||||
brief: 2
|
||||
convoy: second-brain-os-v01
|
||||
role: role-implementer
|
||||
title: Worker non-code — optional worktree + per-harness allowlist + doc_review→published
|
||||
status: ready
|
||||
priority: P0
|
||||
slice_dependencies: []
|
||||
recommended_model: qwen-coder
|
||||
model_tier: local
|
||||
---
|
||||
|
||||
# Brief 2 — Worker non-code (1d)
|
||||
|
||||
## Goal
|
||||
|
||||
`book/course/research/SOPs` docs don't need git worktree or `pnpm`. Worker must handle doc tasks writing straight to `docs/`/`plans/` no branch, with allowlist per harness.
|
||||
|
||||
## Files
|
||||
|
||||
- `apps/worker/src/git/worktree.ts:1` — add `needsWorktree: boolean` param; when false skip `ensureWorktree`, use `monorepoRoot` directly. Keep existing path for code briefs `job.briefId` present.
|
||||
- `apps/worker/src/tools/allowlist.ts:1` — make `allowlist` configurable per harness `harness.config.allowedCommands` (or `minimal: [read,write,grep]` only). Pass `allowedCommands` into `runCommandTool`.
|
||||
- `packages/shared/src/convoy-lifecycle/state-machine.ts:1` — add `doc_review → published` transition (mirror `pr_review → merged`), allow `human-gate-2` analogue `doc-approver`.
|
||||
- `apps/worker/src/handle-job.ts:40` — thread `harness.needsWorktree` + `harness.allowlist` through; log `harnessAlias` to `convoy_events`.
|
||||
- `docs/worker.md:1` document optional worktree.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- `Task-write-manifesto.md` (doc task) with `harness: doc-writer` creates `docs/manifesto.md` via `write_file` tool without worktree directory under `/tmp/echodo-worker-worktrees`.
|
||||
- Code task `Task-fix-lint` still creates worktree per prior behavior.
|
||||
- `transition_convoy_status` `doc_review → published` succeeds; existing `pr_review → merged` still works.
|
||||
- `pnpm lint && pnpm type-check && pnpm --filter @tasks/worker test` pass.
|
||||
|
||||
## Prompt
|
||||
|
||||
You are `role-implementer` brief 2. Read `docs/convoy-lifecycle.md`, `packages/shared/src/convoy-lifecycle/state-machine.ts`, `apps/worker/src/handle-job.ts`, `docs/decisions/2026-08-25-second-brain-os-pivot.md`. Implement small, gated.
|
||||
|
||||
## Depends
|
||||
|
||||
None — can run in parallel with brief 1.
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
---
|
||||
brief: 3
|
||||
convoy: second-brain-os-v01
|
||||
role: role-implementer
|
||||
title: Harness registry + WorkerAdapter + AG-UI + Settings → Harnesses
|
||||
status: ready
|
||||
priority: P0
|
||||
slice_dependencies: [1, 2]
|
||||
recommended_model: qwen-coder
|
||||
model_tier: local
|
||||
---
|
||||
|
||||
# Brief 3 — Harness registry + WorkerAdapter (2-3d) — THE big one
|
||||
|
||||
## Goal
|
||||
|
||||
Workspace admins CRUD harness providers (`minimal|crewai|langgraph|remote-ag-ui`) + map `role_id → harness_alias`, worker dispatches via adapter, any harness via `AG-UI /api/ag-ui/[workspace]`, test button proves reachability.
|
||||
|
||||
## Files
|
||||
|
||||
- `packages/database/src/schema/harness_providers.ts` NEW — mirror `model_providers` `docs/adr-model-routing.md:78`: `id, workspace_id fk, alias unique per workspace, display_name, harness_type enum, endpoint_url nullable, auth_env_var varchar nullable, config jsonb, enabled bool, created_at/updated_at`. + `role_harness_routes` `workspace_id, role_id varchar, harness_provider_id fk, enabled, sort_order`, unique `(workspace_id, role_id)`.
|
||||
- `packages/database/migrations/0015_*` via `pnpm db:generate`.
|
||||
- `packages/shared/src/worker/executor.ts` NEW — `WorkerExecutor {id, run(ctx, model):Promise<...>}`, `resolveHarnessForRole()` pure like `resolveModel` `packages/shared/src/model-routing/index.ts`.
|
||||
- `apps/worker/src/handle-job.ts:40` — replace hard `createMinimalSession` with `registry.get(harnessAlias).run(...)`; keep `MinimalSession` as `minimal` adapter.
|
||||
- `apps/web/server/routers/harnesses.ts` NEW — `list/create/update/delete/testHarness` (admin only, never return `auth` value, `test` does fetch `health` on endpoint with `Authorization: Bearer ${process.env[AUTH_ENV_VAR]}`).
|
||||
- `apps/web/app/(app)/[workspaceSlug]/settings/harnesses/page.tsx` NEW — table + add/edit dialog + test.
|
||||
- `apps/web/app/api/ag-ui/[workspace]/route.ts` NEW — POST ag-ui envelope, validate `workspace_id`, write `convoy_events`/`agent_runs`, enforce tenant fence.
|
||||
- `packages/database/src/schema/index.ts` exports.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Migration applies `pnpm db:generate && pnpm db:migrate` locally (no drift).
|
||||
- `Settings → Harnesses` CRUD works; `Test` on `remote-ag-ui https://crew.example.com/ag-ui` with `HARNESS_AUTH_TOKEN` env shows latency or error detail, never leaks token to browser.
|
||||
- `resolveHarnessForRole(workspaceId, "role-implementer")` returns `crewai` when mapped, `minimal` fallback.
|
||||
- Worker dispatched `role-implementer` via `crewai` adapter still `claim_task`/`complete_task` and writes `harness_alias` to `convoy_events.harness_alias` + `agent_runs`.
|
||||
- `pnpm lint && pnpm type-check && pnpm test` pass.
|
||||
|
||||
## Prompt
|
||||
|
||||
You are `role-implementer` brief 3. Read `docs/adr-model-routing.md`, `docs/decisions/2026-08-25-second-brain-os-pivot.md`, `packages/database/src/schema/model_providers.ts`, `packages/shared/src/model-routing/index.ts`. Keep diff minimal, follow existing Drizzle/tRPC patterns in `server/routers/ai-models.ts`.
|
||||
|
||||
## Depends
|
||||
|
||||
Briefs 1,2 — needs optional worktree done.
|
||||
44
.convoys/second-brain-os-v01/brief-4-plans-tree.md
Normal file
44
.convoys/second-brain-os-v01/brief-4-plans-tree.md
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
---
|
||||
brief: 4
|
||||
convoy: second-brain-os-v01
|
||||
role: role-implementer
|
||||
title: Plans tree — web CRUD for plans/ without CLI, Start→Convoy
|
||||
status: ready
|
||||
priority: P0
|
||||
slice_dependencies: [1, 3]
|
||||
recommended_model: qwen-coder
|
||||
model_tier: local
|
||||
---
|
||||
|
||||
# Brief 4 — Plans tree (1w) — Epic 10
|
||||
|
||||
## Goal
|
||||
|
||||
Web `Plans` is Obsidian-like tree — read/write `plans/Plan-*/Epic-*/Task-*.md` without `pnpm import:markdown-backlog` CLI; click `Start → Convoy`.
|
||||
|
||||
## Files
|
||||
|
||||
- `apps/web/server/routers/backlog.ts:29` — add `listTree` (returns hierarchical `Plan → Epic → Task` with `status/priority/repoPath`), `createPlan/createEpic/createTask` (`slug/title/bodyMarkdown/agent_prompt` → writes `.md` via `join(repoRoot, repoPath)` atomically `.tmp→rename`, then direct DB upsert or `syncMarkdownBacklogScan`), `updateTaskBody`, `getBacklogItem` already exists. Enforce slug `^[a-z0-9-]+$`, `workspace_id` fence, `updated_at` bump.
|
||||
- `apps/web/app/(app)/[workspaceSlug]/plans/page.tsx` NEW — tree component (collapsible `Radix Collapsible` + `ScrollArea`), status chips, search, `New Plan/Epic/Task` dialogs, `Start` button → calls `convoys.create` `apps/web/server/routers/convoys.ts:173` with `planSlug/epicSlug`.
|
||||
- Reuse `resolveWorkflowPrompt` preview in task detail drawer.
|
||||
- `docs/Glossary.md:50` hierarchy respected; `config/CursorSync.md:33` export allow-list preserved (web writes go through same export path).
|
||||
|
||||
## Acceptance
|
||||
|
||||
- `Plans` page shows `Plan-clarity → Epic-ai-tags → Task-local-tag-prompt [ready]` from DB without CLI after DB seed.
|
||||
- `New Task` dialog `title: Research prompt` `slug: research-prompt` creates `plans/Plan-clarity/Epic-ai-tags/Task-research-prompt.md` on disk + row in `markdown_backlog_items` visible after refresh without manual import.
|
||||
- `Start` on a `ready` task creates convoy `clarity-research-prompt` at `draft` and links `briefFiles: [repoPath]`.
|
||||
- `MCP create-backlog-item` brief 1 and web create produce same file format (round-trip parse test).
|
||||
- `pnpm lint && pnpm type-check` pass.
|
||||
|
||||
## Prompt
|
||||
|
||||
You are `role-implementer` brief 4. Read `plans/README.md`, `packages/database/src/markdown-backlog/parse.ts`, `apps/web/server/routers/backlog.ts`, `docs/decisions/2026-08-25-second-brain-os-pivot.md`. Follow existing tRPC patterns `ai-models.ts`. Keep UI to `components/ui/*` + Tailwind.
|
||||
|
||||
## Depends
|
||||
|
||||
Briefs 1,3 — needs MCP tool shape + harness registry for Start.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Full Notion drag-n-drop, breadcrumb perf — keep tree simple.
|
||||
38
.convoys/second-brain-os-v01/brief-5-fleet-rollup.md
Normal file
38
.convoys/second-brain-os-v01/brief-5-fleet-rollup.md
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
---
|
||||
brief: 5
|
||||
convoy: second-brain-os-v01
|
||||
role: role-implementer
|
||||
title: Fleet rollup /fleet + SSE convoy_events feed
|
||||
status: ready
|
||||
priority: P1
|
||||
slice_dependencies: [3]
|
||||
recommended_model: qwen-coder
|
||||
model_tier: local
|
||||
---
|
||||
|
||||
# Brief 5 — Fleet rollup `/fleet` + SSE (3-4d) — Epic 9
|
||||
|
||||
## Goal
|
||||
|
||||
`echodo.stillwell.cloud/fleet` shows *all* workspaces + `connected_repos` projects needing you — the "land and see everything" you asked for. Live `convoy_events` tail without refresh.
|
||||
|
||||
## Files
|
||||
|
||||
- `apps/web/server/routers/convoys.ts:119` — add `fleet` procedure (no `workspace` arg, uses `ctx.session.user.id` to gather all `workspaceMembers` workspaces → `listConvoyProjects` across them, return `byWorkspace, byStatus, gateInboxAcross`). Reuse `GATE_INBOX_STATUSES`.
|
||||
- `apps/web/app/(app)/fleet/page.tsx` NEW — top `Needs you` list, grid `byWorkspace` cards, reuse `ConvoyKanban` `apps/web/app/(app)/[workspaceSlug]/mission-control/page.tsx:12` + `ConvoyDetailPanel` but workspace-agnostic.
|
||||
- `apps/web/app/api/fleet/events/route.ts` NEW — SSE `text/event-stream` tailing `convoy_events` `packages/database/src/schema/convoy_events.ts:1` filtered to user's workspaces, `occurredAt desc` 30 rows + live publish via `pg_notify` or polling 5s fallback (reuse orchestrator poll).
|
||||
- `apps/web/components/fleet/fleet-activity-feed.tsx` NEW — auto-append without refresh.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- `/fleet` lists convoys from `maya-workspace` + `studio-internal` + `bakery-coop` together; `Needs you` shows `× pr_review 2d` count.
|
||||
- Clicking fleet card opens detail with correct `workspaceSlug` context for `gateAction`.
|
||||
- SSE feed shows `role_complete` event within 5s of worker `logConvoyEvent` `apps/mcp-server/src/tools/log-convoy-event.ts`.
|
||||
|
||||
## Prompt
|
||||
|
||||
You are `role-implementer` brief 5. Read `apps/web/server/routers/convoys.ts:173`, `apps/web/server/lib/convoy/convoy-service.ts`, `plans/Plan-automation-foundation/Epic-fleet-dashboard/Epic-fleet-dashboard.md`, `docs/decisions/2026-08-25-second-brain-os-pivot.md`. Keep polling fallback simple, no Redis pubsub v1.
|
||||
|
||||
## Depends
|
||||
|
||||
Brief 3 — needs harness field in events for display.
|
||||
|
|
@ -2,8 +2,15 @@
|
|||
"_comment": "Echodo's own MCP server. Spawned per Cursor session over stdio. The 'mcp' script in apps/mcp-server/package.json invokes tsx with --env-file=../../.env so DATABASE_URL is read from the repo's .env without leaking it into this committed file. The -s/--silent flag on pnpm suppresses its progress output that would otherwise corrupt the MCP stdio protocol. If you fork the repo, copy .env.example → .env and fill in DATABASE_URL.",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
123
apps/mcp-server/src/lib/resolve-backlog-item.ts
Normal file
123
apps/mcp-server/src/lib/resolve-backlog-item.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { z } from "zod";
|
||||
|
||||
import { and, eq, isNull } from "../drizzle.js";
|
||||
import { db } from "../db.js";
|
||||
import { markdownBacklogItems } from "../schema.js";
|
||||
|
||||
export const backlogKindSchema = z.enum(["plan", "epic", "task"]);
|
||||
|
||||
export const backlogItemLookupFieldsSchema = z.object({
|
||||
repoPath: z
|
||||
.string()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe(
|
||||
"Repo-relative path under plans/, e.g. plans/Plan-automation-foundation/Epic-apps-worker-v1/Task-worker-minimal-tool-loop.md",
|
||||
),
|
||||
slug: z.string().min(1).optional(),
|
||||
planSlug: z.string().min(1).optional(),
|
||||
epicSlug: z.string().min(1).optional(),
|
||||
kind: backlogKindSchema.optional(),
|
||||
});
|
||||
|
||||
export const backlogItemLookupSchema = backlogItemLookupFieldsSchema.superRefine(
|
||||
(input, ctx) => {
|
||||
const hasRepoPath = Boolean(input.repoPath?.trim());
|
||||
const hasSlugKeys = Boolean(input.slug?.trim() && input.planSlug?.trim());
|
||||
if (!hasRepoPath && !hasSlugKeys) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message:
|
||||
"Provide repoPath, or slug + planSlug (add epicSlug for tasks/epics).",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export type BacklogItemLookup = z.infer<typeof backlogItemLookupFieldsSchema>;
|
||||
|
||||
export type ResolvedBacklogItem = {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
kind: string;
|
||||
slug: string;
|
||||
planSlug: string;
|
||||
epicSlug: string | null;
|
||||
repoPath: string;
|
||||
title: string;
|
||||
bodyMarkdown: string;
|
||||
status: string | null;
|
||||
priority: string | null;
|
||||
};
|
||||
|
||||
export const BACKLOG_ITEM_NOT_FOUND_MESSAGE =
|
||||
"Backlog item not found. Run `pnpm --filter @tasks/database import:markdown-backlog` (with MARKDOWN_BACKLOG_WORKSPACE_ID set) if the plans/ file exists but has not been imported yet.";
|
||||
|
||||
export function normalizeRepoPath(repoPath: string): string {
|
||||
return repoPath.trim().replace(/^\.\/+/, "");
|
||||
}
|
||||
|
||||
function buildLookupConditions(
|
||||
workspaceId: string,
|
||||
lookup: BacklogItemLookup,
|
||||
) {
|
||||
const conditions = [
|
||||
eq(markdownBacklogItems.workspaceId, workspaceId),
|
||||
isNull(markdownBacklogItems.archivedAt),
|
||||
];
|
||||
|
||||
if (lookup.repoPath?.trim()) {
|
||||
conditions.push(
|
||||
eq(markdownBacklogItems.repoPath, normalizeRepoPath(lookup.repoPath)),
|
||||
);
|
||||
return conditions;
|
||||
}
|
||||
|
||||
conditions.push(eq(markdownBacklogItems.slug, lookup.slug!.trim()));
|
||||
conditions.push(eq(markdownBacklogItems.planSlug, lookup.planSlug!.trim()));
|
||||
if (lookup.epicSlug?.trim()) {
|
||||
conditions.push(eq(markdownBacklogItems.epicSlug, lookup.epicSlug.trim()));
|
||||
}
|
||||
if (lookup.kind) {
|
||||
conditions.push(eq(markdownBacklogItems.kind, lookup.kind));
|
||||
}
|
||||
|
||||
return conditions;
|
||||
}
|
||||
|
||||
const backlogItemSelect = {
|
||||
id: markdownBacklogItems.id,
|
||||
workspaceId: markdownBacklogItems.workspaceId,
|
||||
kind: markdownBacklogItems.kind,
|
||||
slug: markdownBacklogItems.slug,
|
||||
planSlug: markdownBacklogItems.planSlug,
|
||||
epicSlug: markdownBacklogItems.epicSlug,
|
||||
repoPath: markdownBacklogItems.repoPath,
|
||||
title: markdownBacklogItems.title,
|
||||
bodyMarkdown: markdownBacklogItems.bodyMarkdown,
|
||||
status: markdownBacklogItems.status,
|
||||
priority: markdownBacklogItems.priority,
|
||||
};
|
||||
|
||||
export async function resolveBacklogItemById(
|
||||
backlogItemId: string,
|
||||
): Promise<ResolvedBacklogItem | null> {
|
||||
const [row] = await db
|
||||
.select(backlogItemSelect)
|
||||
.from(markdownBacklogItems)
|
||||
.where(eq(markdownBacklogItems.id, backlogItemId))
|
||||
.limit(1);
|
||||
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
export async function resolveBacklogItemsByLookup(
|
||||
workspaceId: string,
|
||||
lookup: BacklogItemLookup,
|
||||
): Promise<ResolvedBacklogItem[]> {
|
||||
return db
|
||||
.select(backlogItemSelect)
|
||||
.from(markdownBacklogItems)
|
||||
.where(and(...buildLookupConditions(workspaceId, lookup)))
|
||||
.limit(5);
|
||||
}
|
||||
|
|
@ -8,6 +8,12 @@ import {
|
|||
auditLog,
|
||||
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,
|
||||
|
|
|
|||
362
apps/mcp-server/src/tools/create-backlog-item.ts
Normal file
362
apps/mcp-server/src/tools/create-backlog-item.ts
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
import { mkdir, writeFile, rename, access } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
import { parseDocument, Scalar } from "yaml";
|
||||
|
||||
import { eq, and } from "../drizzle.js";
|
||||
import { db } from "../db.js";
|
||||
import { markdownBacklogItems } from "../schema.js";
|
||||
import { resolveWorkspaceHandle } from "../lib/resolve-workspace.js";
|
||||
import { resolveRepoRoot } from "../lib/repo-root.js";
|
||||
import { parentRepoPath } from "../../../../packages/database/src/markdown-backlog/paths.ts";
|
||||
import { hashFileContents } from "../../../../packages/database/src/markdown-backlog/parse.ts";
|
||||
import { toolCatch, toolErr, toolOk } from "./tool-result.js";
|
||||
|
||||
const slugRe = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
|
||||
const kindSchema = z.enum(["plan", "epic", "task"]);
|
||||
const statusSchema = z.enum(["draft", "ready", "in_progress", "blocked", "done", "cancelled"]);
|
||||
const prioritySchema = z.enum(["P0", "P1", "P2", "P3"]);
|
||||
|
||||
const createBacklogItemInputSchema = z
|
||||
.object({
|
||||
workspace: z.string().min(1).describe("Workspace UUID or slug (e.g. 'acme' or '550e8400-...')"),
|
||||
kind: kindSchema,
|
||||
slug: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(200)
|
||||
.regex(slugRe, "slug must be lowercase-hyphenated (a-z, 0-9, hyphen)"),
|
||||
title: z.string().min(1).max(500),
|
||||
planSlug: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(200)
|
||||
.regex(slugRe, "planSlug must be lowercase-hyphenated")
|
||||
.optional(),
|
||||
epicSlug: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(200)
|
||||
.regex(slugRe, "epicSlug must be lowercase-hyphenated")
|
||||
.optional(),
|
||||
status: statusSchema.optional(),
|
||||
priority: prioritySchema.optional(),
|
||||
bodyMarkdown: z.string().optional(),
|
||||
agent_prompt: z.string().nullable().optional().describe("Optional per-item agent prompt (multiline supported)"),
|
||||
})
|
||||
.superRefine((input, ctx) => {
|
||||
if (input.kind === "plan") {
|
||||
if (input.epicSlug) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "epicSlug must not be provided for kind=plan",
|
||||
path: ["epicSlug"],
|
||||
});
|
||||
}
|
||||
if (input.planSlug && input.planSlug !== input.slug) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "planSlug for kind=plan must equal slug or be omitted",
|
||||
path: ["planSlug"],
|
||||
});
|
||||
}
|
||||
}
|
||||
if (input.kind === "epic") {
|
||||
if (!input.planSlug?.trim()) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "planSlug is required for kind=epic",
|
||||
path: ["planSlug"],
|
||||
});
|
||||
}
|
||||
}
|
||||
if (input.kind === "task") {
|
||||
if (!input.planSlug?.trim()) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "planSlug is required for kind=task",
|
||||
path: ["planSlug"],
|
||||
});
|
||||
}
|
||||
if (!input.epicSlug?.trim()) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "epicSlug is required for kind=task",
|
||||
path: ["epicSlug"],
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function todayIso(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function defaultStatusForKind(kind: z.infer<typeof kindSchema>): string {
|
||||
if (kind === "task") return "ready";
|
||||
return "draft";
|
||||
}
|
||||
|
||||
function defaultBodyForKind(kind: string, title: string): string {
|
||||
if (kind === "plan") {
|
||||
return `# ${title}\n\nShort summary of the initiative and why it exists.\n`;
|
||||
}
|
||||
if (kind === "epic") {
|
||||
return `# ${title}\n\nOne paragraph: what milestone this epic delivers.\n`;
|
||||
}
|
||||
return `# ${title}\n\nOne or two sentences.\n`;
|
||||
}
|
||||
|
||||
function repoPathFor(input: {
|
||||
kind: z.infer<typeof kindSchema>;
|
||||
slug: string;
|
||||
planSlug?: string;
|
||||
epicSlug?: string;
|
||||
}): string {
|
||||
if (input.kind === "plan") {
|
||||
return `plans/Plan-${input.slug}/Plan-${input.slug}.md`;
|
||||
}
|
||||
if (input.kind === "epic") {
|
||||
return `plans/Plan-${input.planSlug}/Epic-${input.slug}/Epic-${input.slug}.md`;
|
||||
}
|
||||
return `plans/Plan-${input.planSlug}/Epic-${input.epicSlug}/Task-${input.slug}.md`;
|
||||
}
|
||||
|
||||
function buildFrontmatterYaml(input: {
|
||||
kind: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
planSlug: string | null;
|
||||
epicSlug: string | null;
|
||||
status: string;
|
||||
priority: string;
|
||||
agentPrompt: string | null;
|
||||
}): string {
|
||||
const doc = parseDocument("");
|
||||
// Ensure we start from empty map
|
||||
if (!doc.contents) {
|
||||
// parseDocument("") yields empty; ensure contents is mapping
|
||||
}
|
||||
doc.set("kind", input.kind);
|
||||
doc.set("slug", input.slug);
|
||||
doc.set("title", input.title);
|
||||
if (input.planSlug) doc.set("plan_slug", input.planSlug);
|
||||
if (input.epicSlug) doc.set("epic_slug", input.epicSlug);
|
||||
doc.set("status", input.status);
|
||||
doc.set("priority", input.priority);
|
||||
doc.set("updated_at", todayIso());
|
||||
if (input.agentPrompt !== null && input.agentPrompt.trim() !== "") {
|
||||
const isMultiline = input.agentPrompt.includes("\n");
|
||||
const node = doc.createNode(input.agentPrompt);
|
||||
if (node instanceof Scalar) {
|
||||
node.type = isMultiline ? Scalar.BLOCK_LITERAL : Scalar.PLAIN;
|
||||
}
|
||||
doc.set("agent_prompt", node);
|
||||
}
|
||||
const yamlText = String(doc).replace(/\n$/, "");
|
||||
return yamlText;
|
||||
}
|
||||
|
||||
async function fileExists(absPath: string): Promise<boolean> {
|
||||
try {
|
||||
await access(absPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function atomicWrite(absPath: string, contents: string): Promise<void> {
|
||||
const dir = dirname(absPath);
|
||||
await mkdir(dir, { recursive: true });
|
||||
const tmp = join(dir, `.${randomUUID()}.tmp`);
|
||||
await writeFile(tmp, contents, "utf8");
|
||||
await rename(tmp, absPath);
|
||||
}
|
||||
|
||||
export function registerCreateBacklogItemTool(mcp: McpServer): void {
|
||||
mcp.registerTool(
|
||||
"create-backlog-item",
|
||||
{
|
||||
description:
|
||||
"Create a new Plan/Epic/Task markdown file under plans/ and upsert markdown_backlog_items. Validates frontmatter per docs/templates/. Duplicate slug under same epic → CONFLICT.",
|
||||
inputSchema: createBacklogItemInputSchema,
|
||||
},
|
||||
async (args) => {
|
||||
try {
|
||||
const input = createBacklogItemInputSchema.parse(args);
|
||||
const ws = await resolveWorkspaceHandle(input.workspace);
|
||||
|
||||
const effectivePlanSlug =
|
||||
input.kind === "plan" ? input.slug : (input.planSlug?.trim() ?? null);
|
||||
const effectiveEpicSlug =
|
||||
input.kind === "task"
|
||||
? (input.epicSlug?.trim() ?? null)
|
||||
: input.kind === "epic"
|
||||
? null
|
||||
: null;
|
||||
|
||||
const status = input.status ?? defaultStatusForKind(input.kind);
|
||||
const priority = input.priority ?? "P2";
|
||||
const agentPrompt =
|
||||
input.agent_prompt !== undefined && input.agent_prompt !== null
|
||||
? input.agent_prompt
|
||||
: null;
|
||||
const bodyMarkdown = input.bodyMarkdown ?? defaultBodyForKind(input.kind, input.title);
|
||||
|
||||
const repoPath = repoPathFor({
|
||||
kind: input.kind,
|
||||
slug: input.slug,
|
||||
planSlug: effectivePlanSlug ?? undefined,
|
||||
epicSlug: effectiveEpicSlug ?? undefined,
|
||||
});
|
||||
|
||||
const repoRoot = resolveRepoRoot();
|
||||
if (!repoRoot) {
|
||||
return toolErr("MARKDOWN_BACKLOG_REPO_ROOT is disabled; cannot write plans/ file");
|
||||
}
|
||||
const absPath = join(repoRoot, ...repoPath.split("/"));
|
||||
|
||||
// Duplicate check: file exists
|
||||
if (await fileExists(absPath)) {
|
||||
return toolErr(`CONFLICT: backlog item already exists at ${repoPath}`);
|
||||
}
|
||||
|
||||
// Duplicate check: DB row exists for same workspace+repoPath
|
||||
const [existingRow] = await db
|
||||
.select({ id: markdownBacklogItems.id })
|
||||
.from(markdownBacklogItems)
|
||||
.where(and(eq(markdownBacklogItems.workspaceId, ws.id), eq(markdownBacklogItems.repoPath, repoPath)))
|
||||
.limit(1);
|
||||
if (existingRow) {
|
||||
return toolErr(`CONFLICT: backlog item already exists at ${repoPath}`);
|
||||
}
|
||||
|
||||
// Build frontmatter + raw markdown (exportBacklogItemToMarkdown inverse)
|
||||
// Plan files per template do not carry plan_slug/epic_slug
|
||||
const yamlPlanSlug = input.kind === "plan" ? null : effectivePlanSlug;
|
||||
const yamlEpicSlug = input.kind === "task" ? effectiveEpicSlug : null;
|
||||
const yamlText = buildFrontmatterYaml({
|
||||
kind: input.kind,
|
||||
slug: input.slug,
|
||||
title: input.title,
|
||||
planSlug: yamlPlanSlug,
|
||||
epicSlug: yamlEpicSlug,
|
||||
status,
|
||||
priority,
|
||||
agentPrompt,
|
||||
});
|
||||
|
||||
const body = bodyMarkdown.startsWith("\n") ? bodyMarkdown : `\n${bodyMarkdown}`;
|
||||
// Ensure body ends with newline
|
||||
const normalizedBody = body.endsWith("\n") ? body : `${body}\n`;
|
||||
const raw = `---\n${yamlText}\n---${normalizedBody}`;
|
||||
|
||||
const contentHash = hashFileContents(raw);
|
||||
|
||||
// Frontmatter stored in DB (mirrors sync's parsed frontmatter)
|
||||
const frontmatter: Record<string, unknown> = {
|
||||
kind: input.kind,
|
||||
slug: input.slug,
|
||||
title: input.title,
|
||||
status,
|
||||
priority,
|
||||
updated_at: todayIso(),
|
||||
};
|
||||
if (yamlPlanSlug) frontmatter.plan_slug = yamlPlanSlug;
|
||||
if (yamlEpicSlug) frontmatter.epic_slug = yamlEpicSlug;
|
||||
if (agentPrompt !== null && agentPrompt.trim() !== "") {
|
||||
frontmatter.agent_prompt = agentPrompt;
|
||||
}
|
||||
|
||||
// Atomic write .tmp → rename
|
||||
await atomicWrite(absPath, raw);
|
||||
|
||||
// Upsert markdown_backlog_items with contentHash
|
||||
// Compute parentId by looking up parent repo path if any
|
||||
const parentPath = parentRepoPath(repoPath);
|
||||
let parentId: string | null = null;
|
||||
if (parentPath) {
|
||||
const [parentRow] = await db
|
||||
.select({ id: markdownBacklogItems.id })
|
||||
.from(markdownBacklogItems)
|
||||
.where(and(eq(markdownBacklogItems.workspaceId, ws.id), eq(markdownBacklogItems.repoPath, parentPath)))
|
||||
.limit(1);
|
||||
parentId = parentRow?.id ?? null;
|
||||
}
|
||||
|
||||
let insertedId: string | null = null;
|
||||
try {
|
||||
const [inserted] = await db
|
||||
.insert(markdownBacklogItems)
|
||||
.values({
|
||||
workspaceId: ws.id,
|
||||
kind: input.kind,
|
||||
slug: input.slug,
|
||||
planSlug: effectivePlanSlug ?? input.slug,
|
||||
epicSlug: effectiveEpicSlug,
|
||||
parentId,
|
||||
repoPath,
|
||||
title: input.title,
|
||||
status,
|
||||
priority,
|
||||
owner: null,
|
||||
frontmatter,
|
||||
bodyMarkdown: normalizedBody.replace(/^\n/, ""),
|
||||
contentHash,
|
||||
workflowPrompt: agentPrompt?.trim() ? agentPrompt.trim() : null,
|
||||
})
|
||||
.returning({ id: markdownBacklogItems.id });
|
||||
insertedId = inserted?.id ?? null;
|
||||
} catch (e) {
|
||||
// Unique violation → CONFLICT (race with concurrent writer)
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (msg.includes("duplicate") || msg.includes("unique") || msg.includes("CONFLICT")) {
|
||||
return toolErr(`CONFLICT: backlog item already exists at ${repoPath}`);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
// If we had a parent but it was inserted concurrently after our lookup,
|
||||
// refresh parentId (best-effort)
|
||||
if (parentPath && !parentId) {
|
||||
const [parentRow] = await db
|
||||
.select({ id: markdownBacklogItems.id })
|
||||
.from(markdownBacklogItems)
|
||||
.where(and(eq(markdownBacklogItems.workspaceId, ws.id), eq(markdownBacklogItems.repoPath, parentPath)))
|
||||
.limit(1);
|
||||
if (parentRow) {
|
||||
await db
|
||||
.update(markdownBacklogItems)
|
||||
.set({ parentId: parentRow.id })
|
||||
.where(eq(markdownBacklogItems.id, insertedId!));
|
||||
parentId = parentRow.id;
|
||||
}
|
||||
}
|
||||
|
||||
return toolOk({
|
||||
workspace: { id: ws.id, slug: ws.slug, name: ws.name },
|
||||
repoPath,
|
||||
absPath,
|
||||
contentHash,
|
||||
backlogItem: {
|
||||
id: insertedId,
|
||||
kind: input.kind,
|
||||
slug: input.slug,
|
||||
planSlug: effectivePlanSlug,
|
||||
epicSlug: effectiveEpicSlug,
|
||||
title: input.title,
|
||||
status,
|
||||
priority,
|
||||
parentId,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
return toolCatch(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
70
apps/mcp-server/src/tools/find-backlog-item.ts
Normal file
70
apps/mcp-server/src/tools/find-backlog-item.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
BACKLOG_ITEM_NOT_FOUND_MESSAGE,
|
||||
backlogItemLookupFieldsSchema,
|
||||
resolveBacklogItemsByLookup,
|
||||
} from "../lib/resolve-backlog-item.js";
|
||||
import { resolveWorkspaceHandle } from "../lib/resolve-workspace.js";
|
||||
import { toolCatch, toolErr, toolOk } from "./tool-result.js";
|
||||
|
||||
const findBacklogItemInputSchema = z
|
||||
.object({
|
||||
workspace: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe("Workspace UUID or slug (e.g. 'convoys-tasks')."),
|
||||
})
|
||||
.merge(backlogItemLookupFieldsSchema)
|
||||
.superRefine((input, ctx) => {
|
||||
const hasRepoPath = Boolean(input.repoPath?.trim());
|
||||
const hasSlugKeys = Boolean(input.slug?.trim() && input.planSlug?.trim());
|
||||
if (!hasRepoPath && !hasSlugKeys) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message:
|
||||
"Provide repoPath, or slug + planSlug (add epicSlug for tasks/epics).",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export function registerFindBacklogItemTool(mcp: McpServer): void {
|
||||
mcp.registerTool(
|
||||
"find_backlog_item",
|
||||
{
|
||||
description:
|
||||
"Resolve a markdown backlog item to its backlogItemId by repo path or slug keys. Optional when claim_task is called with repoPath directly.",
|
||||
inputSchema: findBacklogItemInputSchema,
|
||||
},
|
||||
async (args) => {
|
||||
try {
|
||||
const input = findBacklogItemInputSchema.parse(args);
|
||||
const ws = await resolveWorkspaceHandle(input.workspace);
|
||||
const rows = await resolveBacklogItemsByLookup(ws.id, input);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return toolErr(BACKLOG_ITEM_NOT_FOUND_MESSAGE);
|
||||
}
|
||||
|
||||
if (rows.length > 1) {
|
||||
return toolOk({
|
||||
workspace: { id: ws.id, slug: ws.slug, name: ws.name },
|
||||
ambiguous: true,
|
||||
matches: rows,
|
||||
count: rows.length,
|
||||
});
|
||||
}
|
||||
|
||||
const item = rows[0]!;
|
||||
return toolOk({
|
||||
workspace: { id: ws.id, slug: ws.slug, name: ws.name },
|
||||
backlogItem: item,
|
||||
backlogItemId: item.id,
|
||||
});
|
||||
} catch (e) {
|
||||
return toolCatch(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -6,6 +6,8 @@ import { registerSearchObjectsTool } from "./search-objects.js";
|
|||
import { registerUpdateObjectTool } from "./update-object.js";
|
||||
import { 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";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
655
apps/web/app/(app)/[workspaceSlug]/plans/page.tsx
Normal file
655
apps/web/app/(app)/[workspaceSlug]/plans/page.tsx
Normal file
|
|
@ -0,0 +1,655 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
|
||||
import { ChevronDown, ChevronRight, FileText, Folder, FolderKanban, Loader2, Plus, Search, Rocket, X } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from "@/components/ui/sheet";
|
||||
import { api } from "@/lib/trpc";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const STATUS_VARIANT: Record<string, string> = {
|
||||
draft: "bg-muted text-muted-foreground",
|
||||
ready: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
|
||||
in_progress: "bg-amber-500/15 text-amber-600 dark:text-amber-400 border-amber-500/20",
|
||||
blocked: "bg-destructive/10 text-destructive border-destructive/20",
|
||||
done: "bg-sky-500/15 text-sky-600 dark:text-sky-400 border-sky-500/20",
|
||||
cancelled: "bg-muted text-muted-foreground",
|
||||
};
|
||||
|
||||
function StatusChip({ status }: { status: string | null }) {
|
||||
const s = status ?? "unknown";
|
||||
const cls = STATUS_VARIANT[s] ?? STATUS_VARIANT.draft;
|
||||
return (
|
||||
<span className={cn("inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium", cls)}>{s}</span>
|
||||
);
|
||||
}
|
||||
|
||||
function slugify(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.replace(/--+/g, "-");
|
||||
}
|
||||
|
||||
type DialogState =
|
||||
| { kind: "plan" }
|
||||
| { kind: "epic"; planSlug: string }
|
||||
| { kind: "task"; planSlug: string; epicSlug: string }
|
||||
| null;
|
||||
|
||||
export default function PlansPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const workspaceSlug = params?.workspaceSlug as string | undefined;
|
||||
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [expandedPlans, setExpandedPlans] = React.useState<Set<string>>(new Set());
|
||||
const [expandedEpics, setExpandedEpics] = React.useState<Set<string>>(new Set());
|
||||
const [selectedId, setSelectedId] = React.useState<string | null>(null);
|
||||
const [dialog, setDialog] = React.useState<DialogState>(null);
|
||||
const [formSlug, setFormSlug] = React.useState("");
|
||||
const [formTitle, setFormTitle] = React.useState("");
|
||||
const [formBody, setFormBody] = React.useState("");
|
||||
const [formAgentPrompt, setFormAgentPrompt] = React.useState("");
|
||||
const [formError, setFormError] = React.useState<string | null>(null);
|
||||
|
||||
const treeQuery = api.backlog.listTree.useQuery({ workspace: workspaceSlug! }, { enabled: Boolean(workspaceSlug) });
|
||||
const detailQuery = api.backlog.getBacklogItem.useQuery(
|
||||
{ workspace: workspaceSlug!, backlogItemId: selectedId! },
|
||||
{ enabled: Boolean(workspaceSlug && selectedId) },
|
||||
);
|
||||
const promptQuery = api.backlog.getWorkflowPrompt.useQuery(
|
||||
{ workspace: workspaceSlug!, backlogItemId: selectedId! },
|
||||
{ enabled: Boolean(workspaceSlug && selectedId) },
|
||||
);
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const createPlan = api.backlog.createPlan.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.backlog.listTree.invalidate();
|
||||
closeDialog();
|
||||
},
|
||||
onError: (e) => setFormError(e.message),
|
||||
});
|
||||
const createEpic = api.backlog.createEpic.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.backlog.listTree.invalidate();
|
||||
closeDialog();
|
||||
},
|
||||
onError: (e) => setFormError(e.message),
|
||||
});
|
||||
const createTask = api.backlog.createTask.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.backlog.listTree.invalidate();
|
||||
closeDialog();
|
||||
},
|
||||
onError: (e) => setFormError(e.message),
|
||||
});
|
||||
const startConvoy = api.backlog.startConvoy.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.convoys.kanban.invalidate();
|
||||
if (workspaceSlug) router.push(`/${workspaceSlug}/mission-control`);
|
||||
},
|
||||
onError: (e) => setFormError(e.message),
|
||||
});
|
||||
// Direct convoy.create path required by brief 4 — Start → Convoy via convoys.create with planSlug/epicSlug
|
||||
const createConvoyDirect = api.convoys.create.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.convoys.kanban.invalidate();
|
||||
if (workspaceSlug) router.push(`/${workspaceSlug}/mission-control`);
|
||||
},
|
||||
onError: (e) => setFormError(e.message),
|
||||
});
|
||||
|
||||
function closeDialog() {
|
||||
setDialog(null);
|
||||
setFormSlug("");
|
||||
setFormTitle("");
|
||||
setFormBody("");
|
||||
setFormAgentPrompt("");
|
||||
setFormError(null);
|
||||
}
|
||||
|
||||
function openPlanDialog() {
|
||||
setFormError(null);
|
||||
setFormSlug("");
|
||||
setFormTitle("");
|
||||
setFormBody("");
|
||||
setFormAgentPrompt("");
|
||||
setDialog({ kind: "plan" });
|
||||
}
|
||||
function openEpicDialog(planSlug: string) {
|
||||
setFormError(null);
|
||||
setFormSlug("");
|
||||
setFormTitle("");
|
||||
setFormBody("");
|
||||
setFormAgentPrompt("");
|
||||
setDialog({ kind: "epic", planSlug });
|
||||
}
|
||||
function openTaskDialog(planSlug: string, epicSlug: string) {
|
||||
setFormError(null);
|
||||
setFormSlug("");
|
||||
setFormTitle("");
|
||||
setFormBody("");
|
||||
setFormAgentPrompt("");
|
||||
setDialog({ kind: "task", planSlug, epicSlug });
|
||||
}
|
||||
|
||||
const tree = React.useMemo(() => treeQuery.data?.plans ?? [], [treeQuery.data?.plans]);
|
||||
|
||||
const filtered = React.useMemo(() => {
|
||||
if (!search.trim()) return tree;
|
||||
const q = search.trim().toLowerCase();
|
||||
return tree
|
||||
.map((plan) => {
|
||||
const planMatches = plan.title.toLowerCase().includes(q) || plan.slug.toLowerCase().includes(q);
|
||||
const epics = plan.epics
|
||||
.map((epic) => {
|
||||
const epicMatches = epic.title.toLowerCase().includes(q) || epic.slug.toLowerCase().includes(q);
|
||||
const tasks = epic.tasks.filter(
|
||||
(t) =>
|
||||
t.title.toLowerCase().includes(q) ||
|
||||
t.slug.toLowerCase().includes(q) ||
|
||||
(t.status ?? "").toLowerCase().includes(q),
|
||||
);
|
||||
if (epicMatches || tasks.length > 0 || planMatches) {
|
||||
return { ...epic, tasks: epicMatches && tasks.length === 0 ? epic.tasks : tasks };
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean) as typeof plan.epics;
|
||||
if (planMatches) return { ...plan, epics: epics.length ? epics : plan.epics };
|
||||
if (epics.length) return { ...plan, epics };
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean) as typeof tree;
|
||||
}, [tree, search]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (tree.length && expandedPlans.size === 0) {
|
||||
setExpandedPlans(new Set(tree.slice(0, 2).map((p) => p.slug)));
|
||||
}
|
||||
}, [tree, expandedPlans.size]);
|
||||
|
||||
if (!workspaceSlug) return null;
|
||||
|
||||
const isMutating = createPlan.isPending || createEpic.isPending || createTask.isPending;
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<header className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-border px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
|
||||
<FolderKanban className="size-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">Plans</h1>
|
||||
<p className="text-xs text-muted-foreground">Plan → Epic → Task tree from plans/ markdown</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input placeholder="Search plans, epics, tasks…" value={search} onChange={(e) => setSearch(e.target.value)} className="w-64 pl-8" />
|
||||
</div>
|
||||
<Button type="button" size="sm" onClick={openPlanDialog}>
|
||||
<Plus className="size-4" />
|
||||
New Plan
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<div className="flex min-h-0 flex-1 flex-col border-r border-border">
|
||||
{treeQuery.isLoading ? (
|
||||
<div className="flex items-center gap-2 p-6 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading plans…
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-8 text-center">
|
||||
<FolderKanban className="size-8 text-muted-foreground" />
|
||||
<p className="text-sm font-medium">No plans yet</p>
|
||||
<p className="max-w-sm text-xs text-muted-foreground">
|
||||
{search.trim()
|
||||
? "No results for your search."
|
||||
: "Create your first plan or run pnpm --filter @tasks/database watch:markdown-backlog to import plans/ after seeding."}
|
||||
</p>
|
||||
{!search.trim() && (
|
||||
<Button size="sm" onClick={openPlanDialog}>
|
||||
<Plus className="mr-1 size-4" />
|
||||
New Plan
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-1 p-3">
|
||||
{filtered.map((plan) => {
|
||||
const planOpen = expandedPlans.has(plan.slug);
|
||||
return (
|
||||
<CollapsiblePrimitive.Root
|
||||
key={plan.id}
|
||||
open={planOpen}
|
||||
onOpenChange={(open) =>
|
||||
setExpandedPlans((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (open) next.add(plan.slug);
|
||||
else next.delete(plan.slug);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
>
|
||||
<div className="group flex items-center gap-1 rounded-md px-1 py-1 hover:bg-muted/50">
|
||||
<CollapsiblePrimitive.Trigger asChild>
|
||||
<button type="button" className="flex items-center gap-1 rounded p-1 hover:bg-muted" aria-label={planOpen ? "Collapse" : "Expand"}>
|
||||
{planOpen ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}
|
||||
</button>
|
||||
</CollapsiblePrimitive.Trigger>
|
||||
<Folder className="size-4 shrink-0 text-primary" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedId(plan.id.startsWith("placeholder-") ? null : plan.id)}
|
||||
className={cn(
|
||||
"min-w-0 flex-1 truncate text-left text-sm font-medium hover:underline",
|
||||
selectedId === plan.id && "text-primary",
|
||||
)}
|
||||
>
|
||||
{plan.title || plan.slug}
|
||||
</button>
|
||||
<StatusChip status={plan.status} />
|
||||
<Badge variant="outline" className="text-[10px]">{plan.slug}</Badge>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => openEpicDialog(plan.slug)}>
|
||||
<Plus className="size-3" />
|
||||
Epic
|
||||
</Button>
|
||||
</div>
|
||||
<CollapsiblePrimitive.Content className="ml-5 border-l border-border pl-3">
|
||||
<div className="space-y-1 py-1">
|
||||
{plan.epics.length === 0 ? (
|
||||
<p className="px-2 py-1 text-xs text-muted-foreground">No epics — add one.</p>
|
||||
) : (
|
||||
plan.epics.map((epic) => {
|
||||
const epicKey = `${plan.slug}/${epic.slug}`;
|
||||
const epicOpen = expandedEpics.has(epicKey);
|
||||
return (
|
||||
<CollapsiblePrimitive.Root
|
||||
key={epic.id}
|
||||
open={epicOpen}
|
||||
onOpenChange={(open) =>
|
||||
setExpandedEpics((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (open) next.add(epicKey);
|
||||
else next.delete(epicKey);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-1 rounded-md px-1 py-1 hover:bg-muted/50">
|
||||
<CollapsiblePrimitive.Trigger asChild>
|
||||
<button type="button" className="flex items-center rounded p-1 hover:bg-muted">
|
||||
{epicOpen ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}
|
||||
</button>
|
||||
</CollapsiblePrimitive.Trigger>
|
||||
<FolderKanban className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedId(epic.id.startsWith("placeholder-") ? null : epic.id)}
|
||||
className={cn(
|
||||
"min-w-0 flex-1 truncate text-left text-sm hover:underline",
|
||||
selectedId === epic.id && "text-primary",
|
||||
)}
|
||||
>
|
||||
{epic.title || epic.slug}
|
||||
</button>
|
||||
<StatusChip status={epic.status} />
|
||||
<Badge variant="outline" className="hidden text-[10px] sm:inline-flex">{epic.slug}</Badge>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => openTaskDialog(plan.slug, epic.slug)}>
|
||||
<Plus className="size-3" />
|
||||
Task
|
||||
</Button>
|
||||
</div>
|
||||
<CollapsiblePrimitive.Content className="ml-5 border-l border-border pl-3">
|
||||
<ul className="space-y-0.5 py-1">
|
||||
{epic.tasks.length === 0 ? (
|
||||
<li className="px-2 py-1 text-xs text-muted-foreground">No tasks.</li>
|
||||
) : (
|
||||
epic.tasks.map((task) => (
|
||||
<li
|
||||
key={task.id}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-md px-2 py-1.5 hover:bg-muted",
|
||||
selectedId === task.id && "bg-muted ring-1 ring-primary/20",
|
||||
)}
|
||||
>
|
||||
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedId(task.id)}
|
||||
className="min-w-0 flex-1 truncate text-left text-sm"
|
||||
>
|
||||
<span className="font-medium">{task.title || task.slug}</span>
|
||||
<span className="ml-1 text-xs text-muted-foreground">{task.slug}</span>
|
||||
</button>
|
||||
<StatusChip status={task.status} />
|
||||
{task.priority && (
|
||||
<Badge variant="outline" className="hidden text-[10px] sm:inline-flex">
|
||||
{task.priority}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="hidden max-w-[140px] truncate text-[10px] text-muted-foreground sm:inline">{task.repoPath}</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={task.status === "ready" ? "default" : "outline"}
|
||||
className="h-7 shrink-0 px-2 text-xs"
|
||||
disabled={startConvoy.isPending || createConvoyDirect.isPending}
|
||||
onClick={() => {
|
||||
setSelectedId(task.id);
|
||||
// Primary: backlog.startConvoy (idempotent, creates briefFiles: [repoPath]); fallback/additional: convoys.create with planSlug/epicSlug per brief
|
||||
const convoySlug = `${task.planSlug}-${task.slug}`.toLowerCase();
|
||||
if (task.status === "ready") {
|
||||
startConvoy.mutate({ workspace: workspaceSlug, backlogItemId: task.id });
|
||||
} else {
|
||||
createConvoyDirect.mutate({
|
||||
workspace: workspaceSlug,
|
||||
slug: convoySlug,
|
||||
classification: "feature",
|
||||
successMetric: task.title || task.slug,
|
||||
ideaMarkdown: task.bodyMarkdown || `# ${task.title}`,
|
||||
planSlug: task.planSlug,
|
||||
epicSlug: task.epicSlug ?? undefined,
|
||||
backlogItemId: task.id,
|
||||
repoPath: task.repoPath,
|
||||
briefFiles: [task.repoPath],
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Rocket className="mr-1 size-3" />
|
||||
Start
|
||||
</Button>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
</CollapsiblePrimitive.Content>
|
||||
</CollapsiblePrimitive.Root>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</CollapsiblePrimitive.Content>
|
||||
</CollapsiblePrimitive.Root>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Sheet open={Boolean(selectedId)} onOpenChange={(open) => !open && setSelectedId(null)}>
|
||||
<SheetContent className="flex w-[480px] max-w-[90vw] flex-col sm:max-w-[480px]">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="pr-6">{detailQuery.data?.title ?? "Details"}</SheetTitle>
|
||||
<SheetDescription className="break-all text-xs">
|
||||
{detailQuery.data?.repoPath ?? ""}
|
||||
{detailQuery.data ? ` · ${detailQuery.data.kind}` : ""}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="space-y-4 p-1 pr-4">
|
||||
{detailQuery.isLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading…
|
||||
</div>
|
||||
) : detailQuery.data ? (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<StatusChip status={detailQuery.data.status} />
|
||||
{detailQuery.data.priority && <Badge variant="outline">{detailQuery.data.priority}</Badge>}
|
||||
<Badge variant="secondary">{detailQuery.data.kind}</Badge>
|
||||
</div>
|
||||
<div className="rounded-md border border-border bg-muted/20 p-3">
|
||||
<p className="text-xs font-medium text-muted-foreground">Repo path</p>
|
||||
<p className="mt-1 break-all font-mono text-xs">{detailQuery.data.repoPath}</p>
|
||||
<p className="mt-2 text-xs font-medium text-muted-foreground">Slugs</p>
|
||||
<p className="mt-1 text-xs">
|
||||
Plan: <code className="rounded bg-muted px-1 py-0.5">{detailQuery.data.planSlug}</code>
|
||||
{detailQuery.data.epicSlug ? (
|
||||
<>
|
||||
{" "}
|
||||
Epic: <code className="rounded bg-muted px-1 py-0.5">{detailQuery.data.epicSlug}</code>
|
||||
</>
|
||||
) : null}{" "}
|
||||
Slug: <code className="rounded bg-muted px-1 py-0.5">{detailQuery.data.slug}</code>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Body</h3>
|
||||
<pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-md border border-border bg-muted/20 p-3 text-xs leading-relaxed">
|
||||
{detailQuery.data.bodyMarkdown || "(empty)"}
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Effective prompt</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Source: {promptQuery.data?.source ?? "—"}</p>
|
||||
<pre className="mt-2 max-h-48 overflow-auto whitespace-pre-wrap break-words rounded-md border border-border bg-muted/20 p-3 text-xs leading-relaxed">
|
||||
{promptQuery.data?.effectivePrompt ?? promptQuery.data?.ownOverride ?? "(no prompt)"}
|
||||
</pre>
|
||||
{promptQuery.data?.ownOverride && <p className="mt-1 text-[10px] text-muted-foreground">Own override present</p>}
|
||||
</div>
|
||||
{detailQuery.data.kind === "task" && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={startConvoy.isPending || createConvoyDirect.isPending}
|
||||
onClick={() => {
|
||||
const d = detailQuery.data!;
|
||||
const convoySlug = `${d.planSlug}-${d.slug}`.toLowerCase();
|
||||
// Use convoys.create with planSlug/epicSlug as required by brief, via createConvoyDirect
|
||||
createConvoyDirect.mutate({
|
||||
workspace: workspaceSlug,
|
||||
slug: convoySlug,
|
||||
classification: "feature",
|
||||
successMetric: d.title || d.slug,
|
||||
ideaMarkdown: d.bodyMarkdown || `# ${d.title}`,
|
||||
planSlug: d.planSlug,
|
||||
epicSlug: d.epicSlug ?? undefined,
|
||||
backlogItemId: d.id,
|
||||
repoPath: d.repoPath,
|
||||
briefFiles: [d.repoPath],
|
||||
});
|
||||
}}
|
||||
>
|
||||
{(startConvoy.isPending || createConvoyDirect.isPending) ? <Loader2 className="size-4 animate-spin" /> : <Rocket className="size-4" />}
|
||||
Start → Convoy
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setSelectedId(null)}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{(startConvoy.error || createConvoyDirect.error) && (
|
||||
<p className="text-xs text-destructive">{(startConvoy.error ?? createConvoyDirect.error)?.message}</p>
|
||||
)}
|
||||
{(startConvoy.isSuccess || createConvoyDirect.isSuccess) && (
|
||||
<p className="text-xs text-emerald-600">
|
||||
Convoy {(startConvoy.data?.convoySlug ?? createConvoyDirect.data?.convoyId) ?? ""} created — check Mission Control.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Select an item to see details.</p>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
|
||||
<DialogPrimitive.Root open={Boolean(dialog)} onOpenChange={(open) => !open && closeDialog()}>
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/50" />
|
||||
<DialogPrimitive.Content className="fixed left-1/2 top-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-background p-6 shadow-lg">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<DialogPrimitive.Title className="text-lg font-semibold">
|
||||
{dialog?.kind === "plan" ? "New Plan" : dialog?.kind === "epic" ? "New Epic" : dialog?.kind === "task" ? "New Task" : "New"}
|
||||
</DialogPrimitive.Title>
|
||||
<DialogPrimitive.Close asChild>
|
||||
<button type="button" className="rounded p-1 hover:bg-muted" aria-label="Close">
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</DialogPrimitive.Close>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{dialog?.kind === "epic" && (
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">Plan</label>
|
||||
<div className="rounded-md border border-input bg-muted px-3 py-2 text-sm">{dialog.planSlug}</div>
|
||||
</div>
|
||||
)}
|
||||
{dialog?.kind === "task" && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">Plan</label>
|
||||
<div className="rounded-md border border-input bg-muted px-3 py-2 text-sm">{dialog.planSlug}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">Epic</label>
|
||||
<div className="rounded-md border border-input bg-muted px-3 py-2 text-sm">{dialog.epicSlug}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium" htmlFor="plans-slug">
|
||||
Slug <span className="font-normal text-muted-foreground">(a-z 0-9 -)</span>
|
||||
</label>
|
||||
<Input
|
||||
id="plans-slug"
|
||||
placeholder="my-slug"
|
||||
value={formSlug}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value.toLowerCase();
|
||||
setFormSlug(v);
|
||||
if (!formTitle.trim() || slugify(formTitle) === formSlug) {
|
||||
// keep title in sync helper? no auto
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">Must match ^[a-z0-9-]+$</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium" htmlFor="plans-title">
|
||||
Title
|
||||
</label>
|
||||
<Input
|
||||
id="plans-title"
|
||||
placeholder="Human readable title"
|
||||
value={formTitle}
|
||||
onChange={(e) => setFormTitle(e.target.value)}
|
||||
onBlur={() => {
|
||||
if (!formSlug.trim() && formTitle.trim()) setFormSlug(slugify(formTitle));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium" htmlFor="plans-body">
|
||||
Body markdown (optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="plans-body"
|
||||
className="min-h-[90px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
placeholder="# Summary…"
|
||||
value={formBody}
|
||||
onChange={(e) => setFormBody(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium" htmlFor="plans-prompt">
|
||||
Agent prompt (optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="plans-prompt"
|
||||
className="min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
placeholder="Custom prompt for agents…"
|
||||
value={formAgentPrompt}
|
||||
onChange={(e) => setFormAgentPrompt(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{formError && <p className="text-xs text-destructive">{formError}</p>}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button type="button" variant="outline" onClick={closeDialog}>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogPrimitive.Close>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={isMutating || !formSlug.trim() || !formTitle.trim()}
|
||||
onClick={() => {
|
||||
setFormError(null);
|
||||
const slug = formSlug.trim().toLowerCase();
|
||||
if (!SLUG_RE.test(slug)) {
|
||||
setFormError("slug must match ^[a-z0-9-]+$");
|
||||
return;
|
||||
}
|
||||
if (dialog?.kind === "plan") {
|
||||
createPlan.mutate({
|
||||
workspace: workspaceSlug,
|
||||
slug,
|
||||
title: formTitle.trim(),
|
||||
bodyMarkdown: formBody.trim() || undefined,
|
||||
agentPrompt: formAgentPrompt.trim() || null,
|
||||
});
|
||||
} else if (dialog?.kind === "epic") {
|
||||
createEpic.mutate({
|
||||
workspace: workspaceSlug,
|
||||
planSlug: dialog.planSlug,
|
||||
slug,
|
||||
title: formTitle.trim(),
|
||||
bodyMarkdown: formBody.trim() || undefined,
|
||||
agentPrompt: formAgentPrompt.trim() || null,
|
||||
});
|
||||
} else if (dialog?.kind === "task") {
|
||||
createTask.mutate({
|
||||
workspace: workspaceSlug,
|
||||
planSlug: dialog.planSlug,
|
||||
epicSlug: dialog.epicSlug,
|
||||
slug,
|
||||
title: formTitle.trim(),
|
||||
bodyMarkdown: formBody.trim() || undefined,
|
||||
agentPrompt: formAgentPrompt.trim() || null,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isMutating ? <Loader2 className="size-4 animate-spin" /> : <Plus className="size-4" />}
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
</DialogPrimitive.Root>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SLUG_RE = /^[a-z0-9-]+$/;
|
||||
165
apps/web/app/(app)/[workspaceSlug]/settings/harnesses/page.tsx
Normal file
165
apps/web/app/(app)/[workspaceSlug]/settings/harnesses/page.tsx
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { Loader2, Plus, Wrench, X } from "lucide-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/lib/trpc";
|
||||
import type { HarnessType } from "@tasks/shared/worker";
|
||||
|
||||
const HARNESS_TYPES: HarnessType[] = ["minimal", "crewai", "langgraph", "remote-ag-ui"];
|
||||
|
||||
type HarnessForm = {
|
||||
alias: string;
|
||||
displayName: string;
|
||||
harnessType: HarnessType;
|
||||
endpointUrl: string;
|
||||
authEnvVar: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
const EMPTY_FORM: HarnessForm = {
|
||||
alias: "",
|
||||
displayName: "",
|
||||
harnessType: "minimal",
|
||||
endpointUrl: "",
|
||||
authEnvVar: "",
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
export default function HarnessesSettingsPage() {
|
||||
const params = useParams();
|
||||
const workspaceSlug = params?.workspaceSlug as string | undefined;
|
||||
const utils = api.useUtils();
|
||||
const listQuery = api.harnesses.list.useQuery({ workspace: workspaceSlug ?? "" }, { enabled: Boolean(workspaceSlug) });
|
||||
const routesQuery = api.harnesses.listRoutes.useQuery({ workspace: workspaceSlug ?? "" }, { enabled: Boolean(workspaceSlug) });
|
||||
|
||||
const [dialogOpen, setDialogOpen] = React.useState(false);
|
||||
const [editingId, setEditingId] = React.useState<string | null>(null);
|
||||
const [form, setForm] = React.useState<HarnessForm>(EMPTY_FORM);
|
||||
const [testResult, setTestResult] = React.useState<Record<string, string>>({});
|
||||
const [newRoleId, setNewRoleId] = React.useState("");
|
||||
const [newHarnessId, setNewHarnessId] = React.useState("");
|
||||
|
||||
const createMut = api.harnesses.create.useMutation({ onSuccess: () => { void utils.harnesses.list.invalidate(); setDialogOpen(false); } });
|
||||
const updateMut = api.harnesses.update.useMutation({ onSuccess: () => { void utils.harnesses.list.invalidate(); setDialogOpen(false); } });
|
||||
const deleteMut = api.harnesses.delete.useMutation({ onSuccess: () => void utils.harnesses.list.invalidate() });
|
||||
const testMut = api.harnesses.testHarness.useMutation({
|
||||
onSuccess: (result, vars) => {
|
||||
setTestResult((prev) => ({ ...prev, [vars.harnessId]: result.ok ? `OK in ${result.latencyMs}ms` : `Failed: ${result.error ?? "unknown"}` }));
|
||||
},
|
||||
});
|
||||
const upsertRouteMut = api.harnesses.upsertRoute.useMutation({ onSuccess: () => { void utils.harnesses.listRoutes.invalidate(); setNewRoleId(""); setNewHarnessId(""); } });
|
||||
const deleteRouteMut = api.harnesses.deleteRoute.useMutation({ onSuccess: () => void utils.harnesses.listRoutes.invalidate() });
|
||||
|
||||
const canManage = listQuery.data?.canManage ?? false;
|
||||
const harnesses = listQuery.data?.harnesses ?? [];
|
||||
const routes = routesQuery.data?.routes ?? [];
|
||||
|
||||
const openCreate = () => { setEditingId(null); setForm(EMPTY_FORM); setDialogOpen(true); };
|
||||
const openEdit = (h: (typeof harnesses)[number]) => {
|
||||
setEditingId(h.id);
|
||||
setForm({ alias: h.alias, displayName: h.displayName, harnessType: h.harnessType as HarnessType, endpointUrl: h.endpointUrl ?? "", authEnvVar: h.authEnvVar ?? "", enabled: h.enabled });
|
||||
setDialogOpen(true);
|
||||
};
|
||||
const submit = () => {
|
||||
if (!workspaceSlug) return;
|
||||
const payload = { workspace: workspaceSlug, alias: form.alias, displayName: form.displayName, harnessType: form.harnessType, endpointUrl: form.endpointUrl.trim() || null, authEnvVar: form.authEnvVar.trim() || null, enabled: form.enabled };
|
||||
if (editingId) updateMut.mutate({ ...payload, id: editingId });
|
||||
else createMut.mutate(payload);
|
||||
};
|
||||
|
||||
if (!workspaceSlug) return <p className="p-8 text-sm text-muted-foreground">No workspace selected.</p>;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl px-8 py-10">
|
||||
<div className="mb-4 flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10"><Wrench className="size-5 text-primary" /></div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Harnesses</h1>
|
||||
<p className="text-xs text-muted-foreground">Providers for <code className="text-[10px]">minimal|crewai|langgraph|remote-ag-ui</code> + role routing. Auth tokens stay in server env.</p>
|
||||
</div>
|
||||
</div>
|
||||
{canManage ? <Button size="sm" className="gap-1.5" onClick={openCreate}><Plus className="size-4" />Add harness</Button> : null}
|
||||
</div>
|
||||
|
||||
{listQuery.isLoading ? <div className="flex justify-center py-16"><Loader2 className="size-6 animate-spin text-muted-foreground" /></div>
|
||||
: listQuery.error ? <p className="text-sm text-destructive">{listQuery.error.message}</p>
|
||||
: harnesses.length === 0 ? <div className="rounded-lg border border-dashed px-6 py-12 text-center text-sm text-muted-foreground">No harnesses yet. Add minimal or a remote AG-UI endpoint.</div>
|
||||
: (
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/40 text-xs uppercase tracking-wide text-muted-foreground">
|
||||
<tr><th className="px-4 py-2 text-left font-medium">Alias</th><th className="px-4 py-2 text-left font-medium">Type</th><th className="px-4 py-2 text-left font-medium">Endpoint</th><th className="px-4 py-2 text-left font-medium">On</th>{canManage ? <th className="px-4 py-2 text-right font-medium">Actions</th> : null}</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{harnesses.map((h) => (
|
||||
<tr key={h.id} className="hover:bg-muted/20">
|
||||
<td className="px-4 py-2"><div className="font-medium">{h.alias}</div><div className="text-xs text-muted-foreground">{h.displayName}</div></td>
|
||||
<td className="px-4 py-2 font-mono text-xs">{h.harnessType}</td>
|
||||
<td className="max-w-[24ch] truncate px-4 py-2 font-mono text-xs text-muted-foreground">{h.endpointUrl ?? "—"}</td>
|
||||
<td className="px-4 py-2 text-xs">{h.enabled ? "yes" : "no"}</td>
|
||||
{canManage ? (
|
||||
<td className="space-x-2 px-4 py-2 text-right">
|
||||
<Button variant="outline" size="sm" disabled={testMut.isPending} onClick={() => testMut.mutate({ workspace: workspaceSlug, harnessId: h.id })}>Test</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => openEdit(h)}>Edit</Button>
|
||||
<Button variant="ghost" size="sm" className="text-destructive" onClick={() => { if (window.confirm(`Delete harness "${h.alias}"?`)) deleteMut.mutate({ workspace: workspaceSlug, id: h.id }); }}>Delete</Button>
|
||||
{testResult[h.id] ? <div className="mt-1 text-[10px] text-muted-foreground">{testResult[h.id]}</div> : null}
|
||||
</td>
|
||||
) : null}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-10 rounded-lg border">
|
||||
<div className="border-b px-4 py-3 text-sm font-medium">Role → harness routing</div>
|
||||
{routesQuery.isLoading ? <div className="flex justify-center py-8"><Loader2 className="size-5 animate-spin text-muted-foreground" /></div>
|
||||
: (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/40 text-xs uppercase tracking-wide text-muted-foreground"><tr><th className="px-4 py-2 text-left font-medium">Role</th><th className="px-4 py-2 text-left font-medium">Harness</th><th className="px-4 py-2 text-left font-medium">Type</th>{canManage ? <th className="px-4 py-2 text-right font-medium">Actions</th> : null}</tr></thead>
|
||||
<tbody className="divide-y">
|
||||
{routes.length === 0 ? <tr><td colSpan={canManage ? 4 : 3} className="px-4 py-8 text-center text-muted-foreground">No harness routes. Map role-implementer to crewai etc.</td></tr>
|
||||
: routes.map((r) => (
|
||||
<tr key={r.id} className="hover:bg-muted/20">
|
||||
<td className="px-4 py-2 font-mono text-xs">{r.roleId}</td>
|
||||
<td className="px-4 py-2 font-mono text-xs">{r.harnessAlias}</td>
|
||||
<td className="px-4 py-2 font-mono text-xs">{r.harnessType}</td>
|
||||
{canManage ? <td className="px-4 py-2 text-right"><select className="mr-2 rounded border border-input bg-background px-2 py-1 text-xs" value={r.harnessProviderId} onChange={(e) => upsertRouteMut.mutate({ workspace: workspaceSlug!, roleId: r.roleId, harnessProviderId: e.target.value })}>{harnesses.filter((h) => h.enabled).map((h) => <option key={h.id} value={h.id}>{h.alias}</option>)}</select><Button variant="ghost" size="sm" className="text-destructive" onClick={() => { if (window.confirm(`Remove harness route for ${r.roleId}?`)) deleteRouteMut.mutate({ workspace: workspaceSlug!, id: r.id }); }}>Remove</Button></td> : null}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{canManage && harnesses.length > 0 ? (
|
||||
<div className="flex flex-wrap items-end gap-3 border-t bg-muted/20 p-4">
|
||||
<div><label className="mb-1 block text-xs text-muted-foreground">role_id</label><Input className="w-48 font-mono text-xs" placeholder="role-implementer" value={newRoleId} onChange={(e) => setNewRoleId(e.target.value)} /></div>
|
||||
<div><label className="mb-1 block text-xs text-muted-foreground">Harness</label><select className="h-9 rounded-md border border-input bg-background px-3 text-sm" value={newHarnessId} onChange={(e) => setNewHarnessId(e.target.value)}><option value="">Select…</option>{harnesses.filter((h) => h.enabled).map((h) => <option key={h.id} value={h.id}>{h.alias}</option>)}</select></div>
|
||||
<Button size="sm" className="gap-1" disabled={!newRoleId.trim() || !newHarnessId || upsertRouteMut.isPending} onClick={() => upsertRouteMut.mutate({ workspace: workspaceSlug!, roleId: newRoleId.trim(), harnessProviderId: newHarnessId })}><Plus className="size-3.5" />Map</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogPrimitive.Root open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogPrimitive.Portal><DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/50" /><DialogPrimitive.Content className="fixed left-1/2 top-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-6 shadow-lg">
|
||||
<div className="mb-4 flex items-center justify-between"><DialogPrimitive.Title className="text-lg font-semibold">{editingId ? "Edit harness" : "Add harness"}</DialogPrimitive.Title><DialogPrimitive.Close asChild><button type="button" className="rounded-md p-1 hover:bg-muted"><X className="size-4" /></button></DialogPrimitive.Close></div>
|
||||
<div className="space-y-3">
|
||||
<Field label="Alias"><Input value={form.alias} onChange={(e) => setForm((f) => ({ ...f, alias: e.target.value }))} /></Field>
|
||||
<Field label="Display name"><Input value={form.displayName} onChange={(e) => setForm((f) => ({ ...f, displayName: e.target.value }))} /></Field>
|
||||
<Field label="Harness type"><select className="flex h-9 w-full rounded-md border border-input bg-background px-3 text-sm" value={form.harnessType} onChange={(e) => setForm((f) => ({ ...f, harnessType: e.target.value as HarnessType }))}>{HARNESS_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}</select></Field>
|
||||
<Field label="Endpoint URL (nullable)"><Input placeholder="https://crew.example.com/ag-ui" value={form.endpointUrl} onChange={(e) => setForm((f) => ({ ...f, endpointUrl: e.target.value }))} /></Field>
|
||||
<Field label="Auth env var (write-only)"><Input placeholder="HARNESS_AUTH_TOKEN" value={form.authEnvVar} onChange={(e) => setForm((f) => ({ ...f, authEnvVar: e.target.value }))} /><p className="mt-1 text-[10px] text-muted-foreground">Token lives in server env, never returned to browser.</p></Field>
|
||||
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={form.enabled} onChange={(e) => setForm((f) => ({ ...f, enabled: e.target.checked }))} /> Enabled</label>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-2"><Button variant="outline" onClick={() => setDialogOpen(false)}>Cancel</Button><Button onClick={submit} disabled={createMut.isPending || updateMut.isPending}>{createMut.isPending || updateMut.isPending ? <Loader2 className="size-4 animate-spin" /> : "Save"}</Button></div>
|
||||
</DialogPrimitive.Content></DialogPrimitive.Portal>
|
||||
</DialogPrimitive.Root>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) { return <div><label className="mb-1 block text-xs font-medium text-muted-foreground">{label}</label>{children}</div>; }
|
||||
190
apps/web/app/(app)/fleet/page.tsx
Normal file
190
apps/web/app/(app)/fleet/page.tsx
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { gateLabelForStatus } from "@tasks/shared/convoy-lifecycle";
|
||||
import type { ConvoyStatus } from "@tasks/shared/convoy-lifecycle";
|
||||
import { Loader2, Radar } from "lucide-react";
|
||||
|
||||
import { ConvoyDetailPanel } from "@/components/mission-control/convoy-detail-panel";
|
||||
import { FleetActivityFeed } from "@/components/fleet/fleet-activity-feed";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/lib/trpc";
|
||||
|
||||
function formatAge(date: Date | string): string {
|
||||
const d = new Date(date);
|
||||
const diff = Date.now() - d.getTime();
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
if (days > 0) return `${days}d`;
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
if (hours > 0) return `${hours}h`;
|
||||
const mins = Math.floor(diff / (1000 * 60));
|
||||
return `${mins}m`;
|
||||
}
|
||||
|
||||
export default function FleetPage() {
|
||||
const fleetQuery = api.convoys.fleet.useQuery();
|
||||
const [selected, setSelected] = React.useState<{ convoyId: string; workspaceSlug: string } | null>(null);
|
||||
|
||||
if (fleetQuery.isLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-6 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading fleet…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (fleetQuery.error) {
|
||||
return <p className="p-6 text-sm text-destructive">{fleetQuery.error.message}</p>;
|
||||
}
|
||||
|
||||
const data = fleetQuery.data;
|
||||
const byWorkspace = data?.byWorkspace ?? [];
|
||||
const gateInboxAcross = data?.gateInboxAcross ?? [];
|
||||
|
||||
// Group gate inbox by status for "× pr_review 2d" count display
|
||||
const gateByStatus = new Map<string, typeof gateInboxAcross>();
|
||||
for (const c of gateInboxAcross) {
|
||||
const arr = gateByStatus.get(c.status) ?? [];
|
||||
arr.push(c);
|
||||
gateByStatus.set(c.status, arr);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<header className="flex shrink-0 items-center gap-3 border-b border-border px-6 py-4">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Radar className="size-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">Fleet</h1>
|
||||
<p className="text-xs text-muted-foreground">All workspaces · gates needing you · live convoy events</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="grid min-h-0 flex-1 grid-cols-1 gap-4 overflow-y-auto p-4 lg:grid-cols-[260px_1fr_340px]">
|
||||
{/* Needs you */}
|
||||
<div className="flex min-h-0 flex-col gap-4">
|
||||
<section className="rounded-lg border border-border bg-card">
|
||||
<div className="border-b border-border p-4">
|
||||
<h2 className="text-sm font-semibold">Needs you</h2>
|
||||
<p className="text-xs text-muted-foreground">Gate inbox across all workspaces</p>
|
||||
{gateInboxAcross.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-xs">
|
||||
{[...gateByStatus.entries()].map(([status, list]) => (
|
||||
<span key={status} className="rounded bg-amber-500/10 px-2 py-0.5 text-amber-700 dark:text-amber-300">
|
||||
× {status} {list.length} · {formatAge(list[0]?.updatedAt ?? new Date())}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3">
|
||||
{gateInboxAcross.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No pending gates.</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{gateInboxAcross.map((convoy) => (
|
||||
<li key={`${convoy.workspaceId}-${convoy.id}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelected({ convoyId: convoy.id, workspaceSlug: convoy.workspaceSlug })}
|
||||
className={cn(
|
||||
"w-full rounded-md border border-amber-500/30 bg-amber-500/5 p-3 text-left text-sm hover:bg-amber-500/10",
|
||||
selected?.convoyId === convoy.id && "ring-2 ring-amber-500",
|
||||
)}
|
||||
>
|
||||
<p className="font-medium">{convoy.content?.convoySlug ?? convoy.title}</p>
|
||||
<p className="text-xs text-amber-700 dark:text-amber-300">{gateLabelForStatus(convoy.status as ConvoyStatus) ?? convoy.status}</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{convoy.workspaceName} · {convoy.workspaceSlug} · {formatAge(convoy.updatedAt)} ago
|
||||
</p>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<FleetActivityFeed />
|
||||
</div>
|
||||
|
||||
{/* byWorkspace cards grid */}
|
||||
<div className="min-h-0 space-y-4">
|
||||
{byWorkspace.length === 0 ? (
|
||||
<p className="rounded-lg border border-dashed p-6 text-sm text-muted-foreground">No workspaces or no convoys yet.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
{byWorkspace.map(({ workspace, convoys }) => {
|
||||
const workspaceGateCount = gateInboxAcross.filter((c) => c.workspaceId === workspace.id).length;
|
||||
return (
|
||||
<div key={workspace.id} className="rounded-lg border border-border bg-card">
|
||||
<div className="border-b border-border px-4 py-3">
|
||||
<h3 className="text-sm font-semibold">{workspace.name}</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{workspace.slug} · {convoys.length} convoys
|
||||
{workspaceGateCount > 0 ? ` · ${workspaceGateCount} needing you` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<ul className="space-y-2 p-3">
|
||||
{convoys.length === 0 ? (
|
||||
<li className="text-xs text-muted-foreground">No convoys.</li>
|
||||
) : (
|
||||
convoys.slice(0, 8).map((convoy) => (
|
||||
<li key={convoy.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelected({ convoyId: convoy.id, workspaceSlug: convoy.workspaceSlug })}
|
||||
className={cn(
|
||||
"w-full rounded-md border border-border bg-background p-2 text-left text-sm transition-colors hover:bg-muted",
|
||||
selected?.convoyId === convoy.id && "ring-2 ring-primary",
|
||||
)}
|
||||
>
|
||||
<p className="font-medium">{convoy.content?.convoySlug ?? convoy.title}</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{convoy.status} · {convoy.content?.classification ?? ""} · {formatAge(convoy.updatedAt)} ago
|
||||
</p>
|
||||
</button>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
{convoys.length > 8 && (
|
||||
<li className="text-center text-xs text-muted-foreground">+{convoys.length - 8} more</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* byStatus summary */}
|
||||
{data?.byStatus && Object.keys(data.byStatus).length > 0 && (
|
||||
<div className="rounded-lg border border-border bg-muted/10 p-3">
|
||||
<p className="mb-2 text-xs font-semibold uppercase text-muted-foreground">By status</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(data.byStatus).map(([status, list]) => (
|
||||
<span key={status} className="rounded border border-border bg-background px-2 py-1 text-xs">
|
||||
{status}: {(list as unknown[]).length}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Detail panel workspace-agnostic */}
|
||||
<aside className="min-h-0 border border-border bg-background lg:rounded-lg">
|
||||
{selected ? (
|
||||
<ConvoyDetailPanel workspaceSlug={selected.workspaceSlug} convoyId={selected.convoyId} />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center p-6 text-sm text-muted-foreground">
|
||||
Select a convoy to review gates and co-pilot suggestions.
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
</>
|
||||
|
|
|
|||
105
apps/web/app/api/ag-ui/[workspace]/route.ts
Normal file
105
apps/web/app/api/ag-ui/[workspace]/route.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { and, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { db } from "@tasks/database";
|
||||
import { agentRuns, convoyEvents, objects } from "@tasks/database/schema";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { resolveWorkspace } from "@/server/lib/resolve-workspace";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const agUiEnvelopeSchema = z.object({
|
||||
convoyId: z.string().uuid().optional(),
|
||||
briefId: z.string().uuid().nullable().optional(),
|
||||
roleId: z.string().min(1).max(120).optional(),
|
||||
eventType: z.string().min(1).max(64).optional(),
|
||||
outcome: z.string().min(1).max(20).optional(),
|
||||
harnessAlias: z.string().min(1).max(120).optional(),
|
||||
summary: z.string().max(5000).optional(),
|
||||
tokensTotal: z.number().int().nonnegative().optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
// Backlog lifecycle via ag-ui envelope (for remote harness that ran a task externally)
|
||||
backlogItemId: z.string().uuid().optional(),
|
||||
});
|
||||
|
||||
function json(status: number, body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
export async function POST(req: Request, { params }: { params: Promise<{ workspace: string }> }) {
|
||||
const { workspace: handle } = await params;
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return json(401, { error: "Unauthorized" });
|
||||
|
||||
let workspace;
|
||||
try {
|
||||
workspace = await resolveWorkspace({ handle, userId: session.user.id });
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : "Workspace not found";
|
||||
const code = (e as { code?: string })?.code;
|
||||
const status = code === "FORBIDDEN" ? 403 : code === "NOT_FOUND" ? 404 : 400;
|
||||
return json(status, { error: msg });
|
||||
}
|
||||
|
||||
let body: z.infer<typeof agUiEnvelopeSchema>;
|
||||
try {
|
||||
body = agUiEnvelopeSchema.parse(await req.json());
|
||||
} catch (e) {
|
||||
if (e instanceof z.ZodError) return json(400, { error: e.issues[0]?.message ?? "Invalid body" });
|
||||
return json(400, { error: "Invalid JSON" });
|
||||
}
|
||||
|
||||
// Tenant fence: if convoyId provided, ensure it belongs to this workspace
|
||||
if (body.convoyId) {
|
||||
const [convoy] = await db.select({ id: objects.id, workspaceId: objects.workspaceId }).from(objects).where(and(eq(objects.id, body.convoyId), eq(objects.workspaceId, workspace.id))).limit(1);
|
||||
if (!convoy) return json(403, { error: "convoyId not in workspace" });
|
||||
}
|
||||
if (body.briefId) {
|
||||
const [brief] = await db.select({ id: objects.id, workspaceId: objects.workspaceId }).from(objects).where(and(eq(objects.id, body.briefId), eq(objects.workspaceId, workspace.id))).limit(1);
|
||||
if (!brief) return json(403, { error: "briefId not in workspace" });
|
||||
}
|
||||
if (body.backlogItemId) {
|
||||
// validate backlog item belongs to workspace via agent_runs path? We check objects? backlog stored in markdown_backlog_items — skip heavy check, just allow if workspace matches via conduct?
|
||||
// For now allow but ensure if we can find agent run path.
|
||||
}
|
||||
|
||||
// Write convoy event if we have convoy context
|
||||
if (body.convoyId && body.roleId) {
|
||||
await db.insert(convoyEvents).values({
|
||||
workspaceId: workspace.id,
|
||||
convoyId: body.convoyId,
|
||||
briefId: body.briefId ?? null,
|
||||
role: body.roleId,
|
||||
actor: body.roleId,
|
||||
eventType: body.eventType ?? "ag_ui",
|
||||
outcome: body.outcome ?? null,
|
||||
harnessAlias: body.harnessAlias ?? null,
|
||||
metadata: {
|
||||
source: "ag-ui",
|
||||
summary: body.summary ?? null,
|
||||
tokensTotal: body.tokensTotal ?? null,
|
||||
...(body.metadata ?? {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Optionally touch agent run if backlogItemId provided — create or update latest open run
|
||||
if (body.backlogItemId && body.convoyId) {
|
||||
// best-effort: insert completed run for observability; if harness already claimed, this will be additive
|
||||
try {
|
||||
await db.insert(agentRuns).values({
|
||||
workspaceId: workspace.id,
|
||||
backlogItemId: body.backlogItemId,
|
||||
harnessAlias: body.harnessAlias ?? null,
|
||||
outcome: body.outcome ?? null,
|
||||
tokensTotal: body.tokensTotal ?? null,
|
||||
notes: body.summary?.slice(0, 2000) ?? null,
|
||||
metadata: { source: "ag-ui", ...(body.metadata ?? {}) },
|
||||
});
|
||||
} catch {
|
||||
// ignore duplicate
|
||||
}
|
||||
}
|
||||
|
||||
return json(200, { ok: true });
|
||||
}
|
||||
165
apps/web/app/api/fleet/events/route.ts
Normal file
165
apps/web/app/api/fleet/events/route.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import { desc, eq, inArray } from "drizzle-orm";
|
||||
|
||||
import { db } from "@tasks/database";
|
||||
import { convoyEvents, workspaceMembers, workspaces } from "@tasks/database/schema";
|
||||
import { auth } from "@/lib/auth";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
async function getUserWorkspaceIds(userId: string): Promise<string[]> {
|
||||
const memberRows = await db
|
||||
.select({ workspaceId: workspaceMembers.workspaceId })
|
||||
.from(workspaceMembers)
|
||||
.where(eq(workspaceMembers.userId, userId));
|
||||
const ownedRows = await db
|
||||
.select({ id: workspaces.id })
|
||||
.from(workspaces)
|
||||
.where(eq(workspaces.ownerUserId, userId));
|
||||
const ids = new Set<string>([...memberRows.map((r) => r.workspaceId), ...ownedRows.map((r) => r.id)]);
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
const userId = session.user.id;
|
||||
const workspaceIds = await getUserWorkspaceIds(userId);
|
||||
|
||||
if (workspaceIds.length === 0) {
|
||||
// No workspaces: return empty SSE that stays open with keepalive
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
const encoder = new TextEncoder();
|
||||
controller.enqueue(encoder.encode(`: no workspaces\n\n`));
|
||||
const iv = setInterval(() => {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`: keepalive\n\n`));
|
||||
} catch {
|
||||
clearInterval(iv);
|
||||
}
|
||||
}, 15000);
|
||||
},
|
||||
});
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
let closed = false;
|
||||
let pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const fetchRecent = async () =>
|
||||
db
|
||||
.select()
|
||||
.from(convoyEvents)
|
||||
.where(inArray(convoyEvents.workspaceId, workspaceIds))
|
||||
.orderBy(desc(convoyEvents.occurredAt))
|
||||
.limit(30);
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const send = (data: unknown) => {
|
||||
if (closed) return;
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
|
||||
} catch {
|
||||
closed = true;
|
||||
}
|
||||
};
|
||||
|
||||
const sendComment = (comment: string) => {
|
||||
if (closed) return;
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`: ${comment}\n\n`));
|
||||
} catch {
|
||||
closed = true;
|
||||
}
|
||||
};
|
||||
|
||||
let initial: Awaited<ReturnType<typeof fetchRecent>> = [];
|
||||
try {
|
||||
initial = await fetchRecent();
|
||||
} catch {
|
||||
initial = [];
|
||||
}
|
||||
|
||||
for (const ev of [...initial].reverse()) {
|
||||
send({
|
||||
id: ev.id,
|
||||
workspaceId: ev.workspaceId,
|
||||
convoyId: ev.convoyId,
|
||||
briefId: ev.briefId,
|
||||
role: ev.role,
|
||||
actor: ev.actor,
|
||||
eventType: ev.eventType,
|
||||
harnessAlias: ev.harnessAlias,
|
||||
occurredAt: ev.occurredAt.toISOString(),
|
||||
metadata: ev.metadata,
|
||||
classification: ev.classification,
|
||||
durationS: ev.durationS,
|
||||
outcome: ev.outcome,
|
||||
});
|
||||
}
|
||||
|
||||
const seen = new Set(initial.map((e) => e.id));
|
||||
|
||||
const poll = async () => {
|
||||
if (closed) return;
|
||||
try {
|
||||
const rows = await fetchRecent();
|
||||
const fresh = rows.filter((r) => !seen.has(r.id));
|
||||
for (const ev of [...fresh].reverse()) {
|
||||
seen.add(ev.id);
|
||||
send({
|
||||
id: ev.id,
|
||||
workspaceId: ev.workspaceId,
|
||||
convoyId: ev.convoyId,
|
||||
briefId: ev.briefId,
|
||||
role: ev.role,
|
||||
actor: ev.actor,
|
||||
eventType: ev.eventType,
|
||||
harnessAlias: ev.harnessAlias,
|
||||
occurredAt: ev.occurredAt.toISOString(),
|
||||
metadata: ev.metadata,
|
||||
classification: ev.classification,
|
||||
durationS: ev.durationS,
|
||||
outcome: ev.outcome,
|
||||
});
|
||||
}
|
||||
if (seen.size > 400) {
|
||||
const toKeep = new Set(rows.map((r) => r.id));
|
||||
for (const id of seen) {
|
||||
if (!toKeep.has(id) && seen.size > 200) seen.delete(id);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore poll errors
|
||||
}
|
||||
if (!closed) sendComment("keepalive");
|
||||
};
|
||||
|
||||
pollInterval = setInterval(poll, 5000);
|
||||
},
|
||||
cancel() {
|
||||
closed = true;
|
||||
if (pollInterval) clearInterval(pollInterval);
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
});
|
||||
}
|
||||
101
apps/web/components/fleet/fleet-activity-feed.tsx
Normal file
101
apps/web/components/fleet/fleet-activity-feed.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
type FleetEvent = {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
convoyId: string;
|
||||
briefId: string | null;
|
||||
role: string;
|
||||
actor: string | null;
|
||||
eventType: string;
|
||||
harnessAlias: string | null;
|
||||
occurredAt: string;
|
||||
metadata: Record<string, unknown> | null;
|
||||
classification?: string | null;
|
||||
durationS?: number | null;
|
||||
outcome?: string | null;
|
||||
};
|
||||
|
||||
function formatRelative(iso: string): string {
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const s = Math.floor(diff / 1000);
|
||||
if (s < 60) return `${s}s ago`;
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
return `${Math.floor(h / 24)}d ago`;
|
||||
}
|
||||
|
||||
export function FleetActivityFeed() {
|
||||
const [events, setEvents] = React.useState<FleetEvent[]>([]);
|
||||
const [connected, setConnected] = React.useState(false);
|
||||
const seenRef = React.useRef<Set<string>>(new Set());
|
||||
|
||||
React.useEffect(() => {
|
||||
const es = new EventSource("/api/fleet/events");
|
||||
|
||||
es.onopen = () => setConnected(true);
|
||||
es.onerror = () => setConnected(false);
|
||||
|
||||
es.onmessage = (e) => {
|
||||
try {
|
||||
const parsed = JSON.parse(e.data) as FleetEvent | FleetEvent[];
|
||||
const incoming = Array.isArray(parsed) ? parsed : [parsed];
|
||||
// Filter duplicates by id
|
||||
const fresh = incoming.filter((ev) => {
|
||||
if (!ev?.id || seenRef.current.has(ev.id)) return false;
|
||||
seenRef.current.add(ev.id);
|
||||
return true;
|
||||
});
|
||||
if (fresh.length === 0) return;
|
||||
setEvents((prev) => {
|
||||
const merged = [...fresh, ...prev];
|
||||
// keep sort by occurredAt desc, dedup already handled, limit 50
|
||||
merged.sort((a, b) => new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime());
|
||||
return merged.slice(0, 100);
|
||||
});
|
||||
} catch {
|
||||
// ignore malformed
|
||||
}
|
||||
};
|
||||
|
||||
return () => {
|
||||
es.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card">
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<h3 className="text-sm font-semibold">Activity feed</h3>
|
||||
<span className="text-[10px] text-muted-foreground">{connected ? "live" : "reconnecting…"}</span>
|
||||
</div>
|
||||
<div className="max-h-[360px] overflow-y-auto">
|
||||
{events.length === 0 ? (
|
||||
<p className="p-4 text-sm text-muted-foreground">No recent convoy events.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border">
|
||||
{events.map((ev) => (
|
||||
<li key={ev.id} className="px-4 py-2.5 text-sm">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">{ev.eventType}</span>
|
||||
<span className="text-xs text-muted-foreground">{formatRelative(ev.occurredAt)}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{ev.role}
|
||||
{ev.actor && ev.actor !== ev.role ? ` · ${ev.actor}` : ""}
|
||||
{ev.harnessAlias ? ` · ${ev.harnessAlias}` : ""}
|
||||
{ev.outcome ? ` · ${ev.outcome}` : ""}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground">convoy {ev.convoyId.slice(0, 8)}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
57
apps/web/components/marketing/manifest-section.tsx
Normal file
57
apps/web/components/marketing/manifest-section.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { Reveal } from "@/components/marketing/reveal";
|
||||
|
||||
const principles = [
|
||||
{
|
||||
title: "Frontmatter is law",
|
||||
body: "If it is not in YAML at the top of the file, it is not part of the contract. Agents read the same fields humans edit.",
|
||||
},
|
||||
{
|
||||
title: "Repo-native backlog",
|
||||
body: "plans/ lives in git. Submatter syncs — it does not replace your source of truth.",
|
||||
},
|
||||
{
|
||||
title: "Humans at the gates",
|
||||
body: "Convoys pause for approval. Nothing merges because an agent got confident.",
|
||||
},
|
||||
{
|
||||
title: "Tenant-scoped everything",
|
||||
body: "workspace_id on every row. Multitenancy is not a migration you schedule for later.",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function ManifestSection() {
|
||||
return (
|
||||
<section id="manifest" className="marketing-paper-grain border-b border-foreground bg-background py-24 sm:py-32">
|
||||
<div className="mx-auto max-w-6xl px-4 sm:px-6">
|
||||
<Reveal className="mb-16">
|
||||
<p className="font-mono-brand text-[11px] uppercase tracking-[0.24em] text-muted-foreground">Manifest</p>
|
||||
<h2 className="mt-4 max-w-3xl font-marketing text-[clamp(2rem,5vw,3.25rem)] font-semibold leading-[1.08] tracking-[-0.03em]">
|
||||
We are not building another chat box with a kanban view glued on.
|
||||
</h2>
|
||||
</Reveal>
|
||||
|
||||
<div className="grid gap-0 border border-foreground lg:grid-cols-2">
|
||||
{principles.map((item, i) => (
|
||||
<Reveal key={item.title} delay={i * 0.06}>
|
||||
<article
|
||||
className={`border-foreground p-8 ${i % 2 === 0 ? "lg:border-r" : ""} ${i < 2 ? "border-b" : ""}`}
|
||||
>
|
||||
<h3 className="font-marketing text-lg font-semibold tracking-[-0.02em]">{item.title}</h3>
|
||||
<p className="mt-3 font-marketing text-sm leading-relaxed text-muted-foreground">{item.body}</p>
|
||||
</article>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Reveal delay={0.2} className="mt-16 border-l-4 border-matter pl-6">
|
||||
<p className="font-marketing text-lg leading-relaxed text-foreground">
|
||||
Subagents. Frontmatter. One workspace.
|
||||
</p>
|
||||
<p className="mt-2 font-mono-brand text-[11px] text-muted-foreground">
|
||||
kind: manifest · version: 2 · status: locked
|
||||
</p>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
49
apps/web/components/marketing/marquee-strip.tsx
Normal file
49
apps/web/components/marketing/marquee-strip.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
|
||||
const items = [
|
||||
"frontmatter",
|
||||
"subagents",
|
||||
"claim_task",
|
||||
"complete_task",
|
||||
"convoy",
|
||||
"workspace_id",
|
||||
"plans/",
|
||||
"mcp",
|
||||
"markdown-backlog",
|
||||
"human-gates",
|
||||
"model_policy",
|
||||
"role-implementer",
|
||||
"role-reviewer",
|
||||
"role-architect",
|
||||
] as const;
|
||||
|
||||
export function MarqueeStrip({ className, variant = "dark" }: { className?: string; variant?: "dark" | "light" }) {
|
||||
const track = [...items, ...items];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden border-y py-3",
|
||||
variant === "dark"
|
||||
? "border-[hsl(var(--hero-border))] bg-[hsl(var(--hero))]"
|
||||
: "border-foreground bg-background",
|
||||
className,
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
<div className="marketing-marquee-track flex w-max gap-8 whitespace-nowrap px-4">
|
||||
{track.map((item, i) => (
|
||||
<span
|
||||
key={`${item}-${i}`}
|
||||
className={cn(
|
||||
"font-mono-brand text-[11px] uppercase tracking-[0.18em]",
|
||||
variant === "dark" ? "text-[hsl(var(--hero-muted))]" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
64
apps/web/components/marketing/scramble-text.tsx
Normal file
64
apps/web/components/marketing/scramble-text.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789/_-";
|
||||
|
||||
type ScrambleTextProps = {
|
||||
text: string;
|
||||
className?: string;
|
||||
durationMs?: number;
|
||||
};
|
||||
|
||||
export function ScrambleText({ text, className, durationMs = 1200 }: ScrambleTextProps) {
|
||||
const reduceMotion = useReducedMotion();
|
||||
const [display, setDisplay] = useState(text);
|
||||
|
||||
useEffect(() => {
|
||||
if (reduceMotion) {
|
||||
setDisplay(text);
|
||||
return;
|
||||
}
|
||||
|
||||
let frame = 0;
|
||||
const totalFrames = Math.ceil(durationMs / 32);
|
||||
const locked = new Set<number>();
|
||||
|
||||
const id = window.setInterval(() => {
|
||||
frame += 1;
|
||||
const progress = frame / totalFrames;
|
||||
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
if (text[i] === " ") continue;
|
||||
if (Math.random() < progress * 0.15 + 0.05) locked.add(i);
|
||||
}
|
||||
|
||||
const next = text
|
||||
.split("")
|
||||
.map((char, i) => {
|
||||
if (char === " ") return " ";
|
||||
if (locked.has(i) || progress > 0.92) return char;
|
||||
return CHARSET[Math.floor(Math.random() * CHARSET.length)] ?? char;
|
||||
})
|
||||
.join("");
|
||||
|
||||
setDisplay(next);
|
||||
|
||||
if (frame >= totalFrames) {
|
||||
window.clearInterval(id);
|
||||
setDisplay(text);
|
||||
}
|
||||
}, 32);
|
||||
|
||||
return () => window.clearInterval(id);
|
||||
}, [text, durationMs, reduceMotion]);
|
||||
|
||||
return (
|
||||
<motion.span className={cn("inline-block", className)} initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
|
||||
{display}
|
||||
</motion.span>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
256
apps/web/server/routers/harnesses.ts
Normal file
256
apps/web/server/routers/harnesses.ts
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
import { harnessProviders, roleHarnessRoutes } from "@tasks/database/schema";
|
||||
import { harnessTypeSchema } from "@tasks/shared/worker";
|
||||
import { rateLimit } from "@/server/lib/rate-limit";
|
||||
import { router, workspaceProcedure } from "@/server/trpc";
|
||||
import type { WorkspaceProcedureContext } from "@/server/trpc";
|
||||
|
||||
type HarnessRow = typeof harnessProviders.$inferSelect;
|
||||
type RouteRow = typeof roleHarnessRoutes.$inferSelect;
|
||||
|
||||
function assertWorkspaceAdmin(ctx: WorkspaceProcedureContext): void {
|
||||
if (ctx.workspace.role !== "owner" && ctx.workspace.role !== "admin") {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Only workspace owners and admins can manage harnesses." });
|
||||
}
|
||||
}
|
||||
|
||||
function canManageWorkspace(ctx: WorkspaceProcedureContext): boolean {
|
||||
return ctx.workspace.role === "owner" || ctx.workspace.role === "admin";
|
||||
}
|
||||
|
||||
function mapHarnessRow(row: HarnessRow) {
|
||||
return {
|
||||
id: row.id,
|
||||
workspaceId: row.workspaceId,
|
||||
alias: row.alias,
|
||||
displayName: row.displayName,
|
||||
harnessType: harnessTypeSchema.parse(row.harnessType),
|
||||
endpointUrl: row.endpointUrl,
|
||||
authEnvVar: row.authEnvVar,
|
||||
config: row.config,
|
||||
enabled: row.enabled,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function mapRouteRow(row: RouteRow) {
|
||||
return {
|
||||
id: row.id,
|
||||
workspaceId: row.workspaceId,
|
||||
roleId: row.roleId,
|
||||
harnessProviderId: row.harnessProviderId,
|
||||
enabled: row.enabled,
|
||||
sortOrder: row.sortOrder,
|
||||
};
|
||||
}
|
||||
|
||||
const harnessFieldsSchema = z.object({
|
||||
alias: z.string().min(1).max(120),
|
||||
displayName: z.string().min(1).max(200),
|
||||
harnessType: harnessTypeSchema,
|
||||
endpointUrl: z.string().url().nullable().optional(),
|
||||
authEnvVar: z.string().min(1).max(120).nullable().optional(),
|
||||
config: z.record(z.unknown()).nullable().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const harnessesRouter = router({
|
||||
list: workspaceProcedure.query(async ({ ctx }) => {
|
||||
const rows = await ctx.db
|
||||
.select()
|
||||
.from(harnessProviders)
|
||||
.where(eq(harnessProviders.workspaceId, ctx.workspace.id))
|
||||
.orderBy(asc(harnessProviders.alias));
|
||||
return { harnesses: rows.map(mapHarnessRow), canManage: canManageWorkspace(ctx) };
|
||||
}),
|
||||
|
||||
create: workspaceProcedure.input(harnessFieldsSchema).mutation(async ({ ctx, input }) => {
|
||||
assertWorkspaceAdmin(ctx);
|
||||
try {
|
||||
const [created] = await ctx.db
|
||||
.insert(harnessProviders)
|
||||
.values({
|
||||
workspaceId: ctx.workspace.id,
|
||||
alias: input.alias.trim(),
|
||||
displayName: input.displayName.trim(),
|
||||
harnessType: input.harnessType,
|
||||
endpointUrl: input.endpointUrl?.trim() || null,
|
||||
authEnvVar: input.authEnvVar?.trim() || null,
|
||||
config: input.config ?? null,
|
||||
enabled: input.enabled ?? true,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
if (!created) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to create harness provider." });
|
||||
return mapHarnessRow(created);
|
||||
} catch (e) {
|
||||
if (isUniqueViolation(e)) throw new TRPCError({ code: "CONFLICT", message: `Alias "${input.alias}" already exists in this workspace.` });
|
||||
throw e;
|
||||
}
|
||||
}),
|
||||
|
||||
update: workspaceProcedure.input(harnessFieldsSchema.partial().extend({ id: z.string().uuid() })).mutation(async ({ ctx, input }) => {
|
||||
assertWorkspaceAdmin(ctx);
|
||||
const [existing] = await ctx.db
|
||||
.select()
|
||||
.from(harnessProviders)
|
||||
.where(and(eq(harnessProviders.id, input.id), eq(harnessProviders.workspaceId, ctx.workspace.id)))
|
||||
.limit(1);
|
||||
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Harness provider not found." });
|
||||
try {
|
||||
const [updated] = await ctx.db
|
||||
.update(harnessProviders)
|
||||
.set({
|
||||
...(input.alias !== undefined ? { alias: input.alias.trim() } : {}),
|
||||
...(input.displayName !== undefined ? { displayName: input.displayName.trim() } : {}),
|
||||
...(input.harnessType !== undefined ? { harnessType: input.harnessType } : {}),
|
||||
...(input.endpointUrl !== undefined ? { endpointUrl: input.endpointUrl?.trim() || null } : {}),
|
||||
...(input.authEnvVar !== undefined ? { authEnvVar: input.authEnvVar?.trim() || null } : {}),
|
||||
...(input.config !== undefined ? { config: input.config } : {}),
|
||||
...(input.enabled !== undefined ? { enabled: input.enabled } : {}),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(harnessProviders.id, input.id), eq(harnessProviders.workspaceId, ctx.workspace.id)))
|
||||
.returning();
|
||||
if (!updated) throw new TRPCError({ code: "NOT_FOUND", message: "Harness provider not found." });
|
||||
return mapHarnessRow(updated);
|
||||
} catch (e) {
|
||||
if (isUniqueViolation(e)) throw new TRPCError({ code: "CONFLICT", message: "Another harness already uses that alias." });
|
||||
throw e;
|
||||
}
|
||||
}),
|
||||
|
||||
delete: workspaceProcedure.input(z.object({ id: z.string().uuid() })).mutation(async ({ ctx, input }) => {
|
||||
assertWorkspaceAdmin(ctx);
|
||||
const [deleted] = await ctx.db
|
||||
.delete(harnessProviders)
|
||||
.where(and(eq(harnessProviders.id, input.id), eq(harnessProviders.workspaceId, ctx.workspace.id)))
|
||||
.returning({ id: harnessProviders.id });
|
||||
if (!deleted) throw new TRPCError({ code: "NOT_FOUND", message: "Harness provider not found." });
|
||||
return { id: deleted.id };
|
||||
}),
|
||||
|
||||
testHarness: workspaceProcedure.input(z.object({ harnessId: z.string().uuid() })).mutation(async ({ ctx, input }) => {
|
||||
assertWorkspaceAdmin(ctx);
|
||||
const limit = rateLimit({ key: `harness:test:${ctx.workspace.id}`, limit: 10, windowMs: 60_000 });
|
||||
if (!limit.allowed) throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: "Test rate limit exceeded. Try again shortly." });
|
||||
const [row] = await ctx.db
|
||||
.select()
|
||||
.from(harnessProviders)
|
||||
.where(and(eq(harnessProviders.id, input.harnessId), eq(harnessProviders.workspaceId, ctx.workspace.id)))
|
||||
.limit(1);
|
||||
if (!row) throw new TRPCError({ code: "NOT_FOUND", message: "Harness provider not found." });
|
||||
if (!row.endpointUrl) {
|
||||
return { ok: false as const, latencyMs: 0, error: "No endpoint_url configured for this harness." };
|
||||
}
|
||||
const started = Date.now();
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
if (row.authEnvVar) {
|
||||
const token = process.env[row.authEnvVar]?.trim();
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
const base = row.endpointUrl.replace(/\/+$/, "");
|
||||
// Try /health then base itself as fallback
|
||||
let res: Response | null = null;
|
||||
let lastError = "";
|
||||
for (const suffix of ["/health", ""]) {
|
||||
try {
|
||||
res = await fetch(`${base}${suffix}`, { headers, signal: AbortSignal.timeout(8000) });
|
||||
if (res.ok) break;
|
||||
lastError = `HTTP ${res.status} ${res.statusText}`;
|
||||
// don't break on non-ok if we have another suffix
|
||||
if (suffix === "") lastError = `HTTP ${res.status} ${res.statusText}`;
|
||||
} catch (e) {
|
||||
lastError = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
}
|
||||
const latencyMs = Date.now() - started;
|
||||
if (res && res.ok) return { ok: true as const, latencyMs };
|
||||
return { ok: false as const, latencyMs, error: lastError || "Health check failed" };
|
||||
} catch (e) {
|
||||
return { ok: false as const, latencyMs: Date.now() - started, error: e instanceof Error ? e.message : String(e) };
|
||||
}
|
||||
}),
|
||||
|
||||
listRoutes: workspaceProcedure.query(async ({ ctx }) => {
|
||||
const routes = await ctx.db
|
||||
.select({
|
||||
route: roleHarnessRoutes,
|
||||
harnessAlias: harnessProviders.alias,
|
||||
harnessType: harnessProviders.harnessType,
|
||||
harnessEnabled: harnessProviders.enabled,
|
||||
})
|
||||
.from(roleHarnessRoutes)
|
||||
.innerJoin(harnessProviders, eq(roleHarnessRoutes.harnessProviderId, harnessProviders.id))
|
||||
.where(eq(roleHarnessRoutes.workspaceId, ctx.workspace.id))
|
||||
.orderBy(asc(roleHarnessRoutes.sortOrder), asc(roleHarnessRoutes.roleId));
|
||||
return {
|
||||
routes: routes.map((row) => ({
|
||||
...mapRouteRow(row.route),
|
||||
harnessAlias: row.harnessAlias,
|
||||
harnessType: row.harnessType,
|
||||
harnessEnabled: row.harnessEnabled,
|
||||
})),
|
||||
canManage: canManageWorkspace(ctx),
|
||||
};
|
||||
}),
|
||||
|
||||
upsertRoute: workspaceProcedure
|
||||
.input(z.object({ roleId: z.string().min(1).max(120), harnessProviderId: z.string().uuid(), enabled: z.boolean().optional(), sortOrder: z.number().int().optional() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
assertWorkspaceAdmin(ctx);
|
||||
const [provider] = await ctx.db
|
||||
.select({ id: harnessProviders.id })
|
||||
.from(harnessProviders)
|
||||
.where(and(eq(harnessProviders.id, input.harnessProviderId), eq(harnessProviders.workspaceId, ctx.workspace.id)))
|
||||
.limit(1);
|
||||
if (!provider) throw new TRPCError({ code: "NOT_FOUND", message: "Harness provider not found." });
|
||||
const [existing] = await ctx.db
|
||||
.select({ id: roleHarnessRoutes.id })
|
||||
.from(roleHarnessRoutes)
|
||||
.where(and(eq(roleHarnessRoutes.workspaceId, ctx.workspace.id), eq(roleHarnessRoutes.roleId, input.roleId)))
|
||||
.limit(1);
|
||||
if (existing) {
|
||||
const [updated] = await ctx.db
|
||||
.update(roleHarnessRoutes)
|
||||
.set({
|
||||
harnessProviderId: input.harnessProviderId,
|
||||
enabled: input.enabled ?? true,
|
||||
...(input.sortOrder !== undefined ? { sortOrder: input.sortOrder } : {}),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(roleHarnessRoutes.id, existing.id), eq(roleHarnessRoutes.workspaceId, ctx.workspace.id)))
|
||||
.returning();
|
||||
if (!updated) throw new TRPCError({ code: "NOT_FOUND", message: "Route not found." });
|
||||
return mapRouteRow(updated);
|
||||
}
|
||||
const [created] = await ctx.db
|
||||
.insert(roleHarnessRoutes)
|
||||
.values({
|
||||
workspaceId: ctx.workspace.id,
|
||||
roleId: input.roleId,
|
||||
harnessProviderId: input.harnessProviderId,
|
||||
enabled: input.enabled ?? true,
|
||||
sortOrder: input.sortOrder ?? 0,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
if (!created) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to create harness route." });
|
||||
return mapRouteRow(created);
|
||||
}),
|
||||
|
||||
deleteRoute: workspaceProcedure.input(z.object({ id: z.string().uuid() })).mutation(async ({ ctx, input }) => {
|
||||
assertWorkspaceAdmin(ctx);
|
||||
const [deleted] = await ctx.db.delete(roleHarnessRoutes).where(and(eq(roleHarnessRoutes.id, input.id), eq(roleHarnessRoutes.workspaceId, ctx.workspace.id))).returning({ id: roleHarnessRoutes.id });
|
||||
if (!deleted) throw new TRPCError({ code: "NOT_FOUND", message: "Route not found." });
|
||||
return { id: deleted.id };
|
||||
}),
|
||||
});
|
||||
|
||||
function isUniqueViolation(error: unknown): boolean {
|
||||
return typeof error === "object" && error !== null && "code" in error && (error as { code?: string }).code === "23505";
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
143
apps/worker/src/db/checkpoints.ts
Normal file
143
apps/worker/src/db/checkpoints.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import { createHash } from "node:crypto";
|
||||
|
||||
import type { db as dbInstance } from "@tasks/database";
|
||||
import { workerCheckpoints } from "@tasks/database/schema";
|
||||
import {
|
||||
WORKER_CHECKPOINT_TOOL_HISTORY_LIMIT,
|
||||
workerCheckpointPayloadSchema,
|
||||
type WorkerCheckpointFileTouch,
|
||||
type WorkerCheckpointToolStep,
|
||||
} from "@tasks/shared/worker";
|
||||
import { and, desc, eq, sql } from "drizzle-orm";
|
||||
|
||||
type Db = typeof dbInstance;
|
||||
|
||||
export type SaveWorkerCheckpointInput = {
|
||||
workspaceId: string;
|
||||
agentRunId: string;
|
||||
convoyId: string;
|
||||
briefId?: string;
|
||||
roleId: string;
|
||||
checkpointSeq: number;
|
||||
toolHistory: WorkerCheckpointToolStep[];
|
||||
filesTouched: WorkerCheckpointFileTouch[];
|
||||
branch: string | null;
|
||||
worktreePath: string | null;
|
||||
};
|
||||
|
||||
export type WorkerCheckpointRow = {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
agentRunId: string;
|
||||
convoyId: string;
|
||||
briefId: string | null;
|
||||
roleId: string;
|
||||
checkpointSeq: number;
|
||||
toolHistory: WorkerCheckpointToolStep[];
|
||||
filesTouched: WorkerCheckpointFileTouch[];
|
||||
branch: string | null;
|
||||
worktreePath: string | null;
|
||||
occurredAt: Date;
|
||||
};
|
||||
|
||||
export function hashContent(content: string): string {
|
||||
return createHash("sha256").update(content).digest("hex");
|
||||
}
|
||||
|
||||
export function digestResult(output: string, ok: boolean): string {
|
||||
const prefix = ok ? "ok" : "err";
|
||||
return `${prefix}:${hashContent(output).slice(0, 16)}`;
|
||||
}
|
||||
|
||||
export async function saveWorkerCheckpoint(
|
||||
db: Db,
|
||||
input: SaveWorkerCheckpointInput,
|
||||
): Promise<WorkerCheckpointRow> {
|
||||
const payload = workerCheckpointPayloadSchema.parse({
|
||||
toolHistory: input.toolHistory.slice(-WORKER_CHECKPOINT_TOOL_HISTORY_LIMIT),
|
||||
filesTouched: input.filesTouched.slice(-200),
|
||||
branch: input.branch,
|
||||
worktreePath: input.worktreePath,
|
||||
});
|
||||
|
||||
const [row] = await db
|
||||
.insert(workerCheckpoints)
|
||||
.values({
|
||||
workspaceId: input.workspaceId,
|
||||
agentRunId: input.agentRunId,
|
||||
convoyId: input.convoyId,
|
||||
briefId: input.briefId ?? null,
|
||||
roleId: input.roleId,
|
||||
checkpointSeq: input.checkpointSeq,
|
||||
toolHistory: payload.toolHistory,
|
||||
filesTouched: payload.filesTouched,
|
||||
branch: payload.branch,
|
||||
worktreePath: payload.worktreePath,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [workerCheckpoints.agentRunId, workerCheckpoints.checkpointSeq],
|
||||
set: {
|
||||
toolHistory: payload.toolHistory,
|
||||
filesTouched: payload.filesTouched,
|
||||
branch: payload.branch,
|
||||
worktreePath: payload.worktreePath,
|
||||
occurredAt: sql`now()`,
|
||||
},
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!row) {
|
||||
throw new Error("Failed to persist worker checkpoint");
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
workspaceId: row.workspaceId,
|
||||
agentRunId: row.agentRunId,
|
||||
convoyId: row.convoyId,
|
||||
briefId: row.briefId,
|
||||
roleId: row.roleId,
|
||||
checkpointSeq: row.checkpointSeq,
|
||||
toolHistory: row.toolHistory,
|
||||
filesTouched: row.filesTouched,
|
||||
branch: row.branch,
|
||||
worktreePath: row.worktreePath,
|
||||
occurredAt: row.occurredAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** Latest checkpoint for an agent run — always scoped by workspace_id. */
|
||||
export async function getLatestCheckpointForRun(
|
||||
db: Db,
|
||||
workspaceId: string,
|
||||
agentRunId: string,
|
||||
): Promise<WorkerCheckpointRow | null> {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(workerCheckpoints)
|
||||
.where(
|
||||
and(
|
||||
eq(workerCheckpoints.workspaceId, workspaceId),
|
||||
eq(workerCheckpoints.agentRunId, agentRunId),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(workerCheckpoints.checkpointSeq))
|
||||
.limit(1);
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
workspaceId: row.workspaceId,
|
||||
agentRunId: row.agentRunId,
|
||||
convoyId: row.convoyId,
|
||||
briefId: row.briefId,
|
||||
roleId: row.roleId,
|
||||
checkpointSeq: row.checkpointSeq,
|
||||
toolHistory: row.toolHistory,
|
||||
filesTouched: row.filesTouched,
|
||||
branch: row.branch,
|
||||
worktreePath: row.worktreePath,
|
||||
occurredAt: row.occurredAt,
|
||||
};
|
||||
}
|
||||
|
|
@ -203,6 +203,7 @@ export async function logRoleComplete(
|
|||
modelTier?: string;
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
53
apps/worker/src/db/harness.ts
Normal file
53
apps/worker/src/db/harness.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { and, eq } from "drizzle-orm";
|
||||
import type { db as dbInstance } from "@tasks/database";
|
||||
import { harnessProviders, roleHarnessRoutes } from "@tasks/database/schema";
|
||||
import { resolveHarnessForRole } from "@tasks/shared/worker/executor";
|
||||
|
||||
type Db = typeof dbInstance;
|
||||
|
||||
export async function resolveHarnessForRoleDb(
|
||||
db: Db,
|
||||
workspaceId: string,
|
||||
roleId: string,
|
||||
overrideAlias?: string | null,
|
||||
): Promise<{ alias: string; harnessType: string; endpointUrl: string | null; authEnvVar: string | null }> {
|
||||
if (overrideAlias) {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(harnessProviders)
|
||||
.where(and(eq(harnessProviders.workspaceId, workspaceId), eq(harnessProviders.alias, overrideAlias)))
|
||||
.limit(1);
|
||||
if (row && row.enabled) {
|
||||
return { alias: row.alias, harnessType: row.harnessType, endpointUrl: row.endpointUrl, authEnvVar: row.authEnvVar };
|
||||
}
|
||||
}
|
||||
const providers = await db.select().from(harnessProviders).where(eq(harnessProviders.workspaceId, workspaceId));
|
||||
const routes = await db.select().from(roleHarnessRoutes).where(eq(roleHarnessRoutes.workspaceId, workspaceId));
|
||||
// map to shared types
|
||||
const mappedProviders = providers.map((p) => ({
|
||||
id: p.id,
|
||||
workspaceId: p.workspaceId,
|
||||
alias: p.alias,
|
||||
displayName: p.displayName,
|
||||
harnessType: p.harnessType as "minimal" | "crewai" | "langgraph" | "remote-ag-ui",
|
||||
endpointUrl: p.endpointUrl,
|
||||
authEnvVar: p.authEnvVar,
|
||||
config: p.config,
|
||||
enabled: p.enabled,
|
||||
}));
|
||||
const mappedRoutes = routes.map((r) => ({
|
||||
id: r.id,
|
||||
workspaceId: r.workspaceId,
|
||||
roleId: r.roleId,
|
||||
harnessProviderId: r.harnessProviderId,
|
||||
enabled: r.enabled,
|
||||
sortOrder: r.sortOrder,
|
||||
}));
|
||||
const result = resolveHarnessForRole({ roleId, providers: mappedProviders, routes: mappedRoutes });
|
||||
return {
|
||||
alias: result.harness.alias,
|
||||
harnessType: result.harness.harnessType,
|
||||
endpointUrl: result.harness.endpointUrl ?? null,
|
||||
authEnvVar: result.harness.authEnvVar ?? null,
|
||||
};
|
||||
}
|
||||
|
|
@ -143,6 +143,7 @@ export async function completeTask(
|
|||
modelTier?: string;
|
||||
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));
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
286
apps/worker/src/handle-job.test.ts
Normal file
286
apps/worker/src/handle-job.test.ts
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { OrchestratorJob } from "@tasks/shared/orchestrator";
|
||||
|
||||
const {
|
||||
loadJobContext,
|
||||
resolveModelForRole,
|
||||
logRoleComplete,
|
||||
claimTask,
|
||||
completeTask,
|
||||
findBacklogItemByRepoPath,
|
||||
pickTaskRepoPath,
|
||||
ensureWorktree,
|
||||
removeWorktree,
|
||||
reuseWorktreeIfPresent,
|
||||
createMinimalSession,
|
||||
buildSessionPrompt,
|
||||
resolveRolePromptPath,
|
||||
getLatestCheckpointForRun,
|
||||
saveWorkerCheckpoint,
|
||||
} = vi.hoisted(() => ({
|
||||
loadJobContext: vi.fn(),
|
||||
resolveModelForRole: vi.fn(),
|
||||
logRoleComplete: vi.fn(),
|
||||
claimTask: vi.fn(),
|
||||
completeTask: vi.fn(),
|
||||
findBacklogItemByRepoPath: vi.fn(),
|
||||
pickTaskRepoPath: vi.fn(),
|
||||
ensureWorktree: vi.fn(),
|
||||
removeWorktree: vi.fn(),
|
||||
reuseWorktreeIfPresent: vi.fn(),
|
||||
createMinimalSession: vi.fn(),
|
||||
buildSessionPrompt: vi.fn(),
|
||||
resolveRolePromptPath: vi.fn(),
|
||||
getLatestCheckpointForRun: vi.fn(),
|
||||
saveWorkerCheckpoint: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./db/convoy.js", () => ({
|
||||
loadJobContext,
|
||||
resolveModelForRole,
|
||||
logRoleComplete,
|
||||
}));
|
||||
|
||||
vi.mock("./db/task-lifecycle.js", () => ({
|
||||
claimTask,
|
||||
completeTask,
|
||||
findBacklogItemByRepoPath,
|
||||
pickTaskRepoPath,
|
||||
}));
|
||||
|
||||
vi.mock("./db/checkpoints.js", () => ({
|
||||
getLatestCheckpointForRun,
|
||||
saveWorkerCheckpoint,
|
||||
}));
|
||||
|
||||
vi.mock("./git/worktree.js", () => ({
|
||||
ensureWorktree,
|
||||
removeWorktree,
|
||||
reuseWorktreeIfPresent,
|
||||
}));
|
||||
|
||||
vi.mock("./session/minimal-session.js", () => ({
|
||||
createMinimalSession,
|
||||
buildSessionPrompt,
|
||||
resolveRolePromptPath,
|
||||
}));
|
||||
|
||||
import { handleOrchestratorJob } from "./handle-job.js";
|
||||
|
||||
const db = {} as never;
|
||||
|
||||
const job: OrchestratorJob = {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
type: "role_dispatch",
|
||||
workspaceId: "22222222-2222-4222-8222-222222222222",
|
||||
convoyId: "33333333-3333-4333-8333-333333333333",
|
||||
roleId: "role-implementer",
|
||||
briefId: "44444444-4444-4444-8444-444444444444",
|
||||
enqueuedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const env = {
|
||||
DATABASE_URL: "postgres://local/test",
|
||||
WORKER_DRY_RUN: false,
|
||||
WORKER_BLPOP_TIMEOUT_S: 5,
|
||||
WORKER_MAX_TOOL_STEPS: 12,
|
||||
WORKER_CHECKPOINT_EVERY_N_STEPS: 1,
|
||||
WORKER_WORKTREE_BASE: "/tmp/echodo-worker-worktrees",
|
||||
REDIS_URL: "redis://localhost:6379",
|
||||
};
|
||||
|
||||
const ctx = {
|
||||
convoyId: job.convoyId,
|
||||
convoyTitle: "Test convoy",
|
||||
convoySlug: "test-convoy",
|
||||
workspaceId: job.workspaceId,
|
||||
roleId: job.roleId,
|
||||
briefId: job.briefId,
|
||||
briefFiles: [".convoys/test-convoy/brief-1-implementer.md"],
|
||||
briefAcceptance: "Ship the feature",
|
||||
};
|
||||
|
||||
const resolvedModel = {
|
||||
alias: "qwen-coder",
|
||||
providerType: "openai_compatible" as const,
|
||||
modelId: "qwen2.5-coder",
|
||||
baseUrl: "http://localhost:11434/v1",
|
||||
apiKey: "test-key",
|
||||
tier: "local" as const,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
loadJobContext.mockResolvedValue(ctx);
|
||||
pickTaskRepoPath.mockReturnValue(ctx.briefFiles[0]);
|
||||
findBacklogItemByRepoPath.mockResolvedValue({
|
||||
id: "55555555-5555-4555-8555-555555555555",
|
||||
title: "Brief task",
|
||||
status: "ready",
|
||||
repoPath: ctx.briefFiles[0],
|
||||
});
|
||||
claimTask.mockResolvedValue({
|
||||
runId: "66666666-6666-4666-8666-666666666666",
|
||||
workflowPrompt: "Implement the brief.",
|
||||
});
|
||||
resolveModelForRole.mockResolvedValue({
|
||||
ok: true,
|
||||
model: resolvedModel,
|
||||
resolvedAlias: "qwen-coder",
|
||||
modelTier: "local",
|
||||
});
|
||||
ensureWorktree.mockResolvedValue({
|
||||
repoRoot: "/repo",
|
||||
worktreePath: "/tmp/echodo-worker-worktrees/job-1",
|
||||
branch: "worker/test-convoy-brief-44444444",
|
||||
});
|
||||
reuseWorktreeIfPresent.mockResolvedValue(null);
|
||||
getLatestCheckpointForRun.mockResolvedValue(null);
|
||||
saveWorkerCheckpoint.mockResolvedValue(undefined);
|
||||
removeWorktree.mockResolvedValue(undefined);
|
||||
buildSessionPrompt.mockReturnValue("Run the brief.");
|
||||
resolveRolePromptPath.mockReturnValue("/repo/.cursor/agents/role-implementer.md");
|
||||
logRoleComplete.mockResolvedValue(undefined);
|
||||
completeTask.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("handleOrchestratorJob", () => {
|
||||
it("completes a brief with model alias and token totals", async () => {
|
||||
const run = vi.fn().mockResolvedValue({
|
||||
summary: "Implemented brief changes.",
|
||||
toolCalls: 3,
|
||||
tokensTotal: 1280,
|
||||
});
|
||||
createMinimalSession.mockReturnValue({ context: {}, run });
|
||||
|
||||
await handleOrchestratorJob(db, null, env, job);
|
||||
|
||||
expect(ensureWorktree).toHaveBeenCalledOnce();
|
||||
expect(run).toHaveBeenCalledOnce();
|
||||
expect(completeTask).toHaveBeenCalledWith(
|
||||
db,
|
||||
expect.objectContaining({
|
||||
runId: "66666666-6666-4666-8666-666666666666",
|
||||
outcome: "succeeded",
|
||||
tokensTotal: 1280,
|
||||
modelAlias: "qwen-coder",
|
||||
modelTier: "local",
|
||||
providerType: "openai_compatible",
|
||||
modelId: "qwen2.5-coder",
|
||||
}),
|
||||
);
|
||||
expect(removeWorktree).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("marks the run failed and tears down the worktree when the session throws", async () => {
|
||||
createMinimalSession.mockReturnValue({
|
||||
context: {},
|
||||
run: vi.fn().mockRejectedValue(new Error("model unavailable")),
|
||||
});
|
||||
|
||||
await handleOrchestratorJob(db, null, env, job);
|
||||
|
||||
expect(completeTask).toHaveBeenCalledWith(
|
||||
db,
|
||||
expect.objectContaining({
|
||||
outcome: "failed",
|
||||
error: "model unavailable",
|
||||
}),
|
||||
);
|
||||
expect(removeWorktree).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("marks the run failed when model resolution fails after claim", async () => {
|
||||
resolveModelForRole.mockResolvedValue({
|
||||
ok: false,
|
||||
reason: "No provider route for role-implementer",
|
||||
});
|
||||
|
||||
await handleOrchestratorJob(db, null, env, job);
|
||||
|
||||
expect(createMinimalSession).not.toHaveBeenCalled();
|
||||
expect(completeTask).toHaveBeenCalledWith(
|
||||
db,
|
||||
expect.objectContaining({
|
||||
outcome: "failed",
|
||||
error: "No provider route for role-implementer",
|
||||
}),
|
||||
);
|
||||
expect(removeWorktree).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("skips worktree setup in dry-run mode", async () => {
|
||||
createMinimalSession.mockReturnValue({
|
||||
context: {},
|
||||
run: vi.fn().mockResolvedValue({
|
||||
summary: "[DRY-RUN] Would run role-implementer",
|
||||
toolCalls: 0,
|
||||
}),
|
||||
});
|
||||
|
||||
await handleOrchestratorJob(db, null, { ...env, WORKER_DRY_RUN: true }, job);
|
||||
|
||||
expect(ensureWorktree).not.toHaveBeenCalled();
|
||||
expect(removeWorktree).not.toHaveBeenCalled();
|
||||
expect(createMinimalSession).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ dryRun: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it("resumes from checkpoint without re-cloning when worktree is present", async () => {
|
||||
const resumeJob = { ...job, resumeFromCheckpoint: true };
|
||||
getLatestCheckpointForRun.mockResolvedValue({
|
||||
id: "cp-1",
|
||||
workspaceId: job.workspaceId,
|
||||
agentRunId: "66666666-6666-4666-8666-666666666666",
|
||||
convoyId: job.convoyId,
|
||||
briefId: job.briefId,
|
||||
roleId: job.roleId,
|
||||
checkpointSeq: 2,
|
||||
toolHistory: [
|
||||
{
|
||||
seq: 1,
|
||||
tool: "read_file",
|
||||
args: { path: "README.md" },
|
||||
ok: true,
|
||||
resultDigest: "ok:abc",
|
||||
},
|
||||
],
|
||||
filesTouched: [],
|
||||
branch: "worker/test-convoy-brief-44444444",
|
||||
worktreePath: "/tmp/echodo-worker-worktrees/existing",
|
||||
occurredAt: new Date(),
|
||||
});
|
||||
reuseWorktreeIfPresent.mockResolvedValue({
|
||||
repoRoot: "/repo",
|
||||
worktreePath: "/tmp/echodo-worker-worktrees/existing",
|
||||
branch: "worker/test-convoy-brief-44444444",
|
||||
});
|
||||
const run = vi.fn().mockResolvedValue({
|
||||
summary: "Resumed and finished.",
|
||||
toolCalls: 3,
|
||||
tokensTotal: 400,
|
||||
});
|
||||
createMinimalSession.mockReturnValue({ context: {}, run });
|
||||
|
||||
await handleOrchestratorJob(db, null, env, resumeJob);
|
||||
|
||||
expect(reuseWorktreeIfPresent).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
"/tmp/echodo-worker-worktrees/existing",
|
||||
"worker/test-convoy-brief-44444444",
|
||||
);
|
||||
expect(ensureWorktree).not.toHaveBeenCalled();
|
||||
expect(createMinimalSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ repoRoot: "/tmp/echodo-worker-worktrees/existing" }),
|
||||
expect.objectContaining({
|
||||
resumeToolHistory: expect.arrayContaining([
|
||||
expect.objectContaining({ tool: "read_file", seq: 1 }),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
expect(removeWorktree).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
|
@ -2,7 +2,15 @@ import type IoRedis from "ioredis";
|
|||
|
||||
import type { db as dbInstance } from "@tasks/database";
|
||||
import type { 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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
94
apps/worker/src/harness.ts
Normal file
94
apps/worker/src/harness.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { ALLOWED_COMMANDS } from "@tasks/shared/worker";
|
||||
import type { HarnessType } from "@tasks/shared/worker";
|
||||
|
||||
export type HarnessConfig = {
|
||||
alias: string;
|
||||
harnessType: HarnessType;
|
||||
needsWorktree: boolean;
|
||||
allowedCommands: readonly string[] | null;
|
||||
endpointUrl?: string | null;
|
||||
authEnvVar?: string | null;
|
||||
};
|
||||
|
||||
const DOC_WRITER: HarnessConfig = {
|
||||
alias: "doc-writer",
|
||||
harnessType: "minimal",
|
||||
needsWorktree: false,
|
||||
allowedCommands: [],
|
||||
};
|
||||
|
||||
const DEFAULT: HarnessConfig = {
|
||||
alias: "minimal",
|
||||
harnessType: "minimal",
|
||||
needsWorktree: true,
|
||||
allowedCommands: null,
|
||||
};
|
||||
|
||||
const HARNESS_DEFAULTS: Record<string, Partial<HarnessConfig>> = {
|
||||
minimal: { harnessType: "minimal", needsWorktree: true, allowedCommands: null },
|
||||
crewai: { harnessType: "crewai", needsWorktree: true, allowedCommands: null },
|
||||
langgraph: { harnessType: "langgraph", needsWorktree: true, allowedCommands: null },
|
||||
"remote-ag-ui": { harnessType: "remote-ag-ui", needsWorktree: false, allowedCommands: [] },
|
||||
};
|
||||
|
||||
const HARNESS_BY_ALIAS: Record<string, HarnessConfig> = {
|
||||
"doc-writer": DOC_WRITER,
|
||||
minimal: DEFAULT,
|
||||
default: DEFAULT,
|
||||
};
|
||||
|
||||
function configForAlias(alias: string): HarnessConfig {
|
||||
if (HARNESS_BY_ALIAS[alias]) return HARNESS_BY_ALIAS[alias]!;
|
||||
const def = HARNESS_DEFAULTS[alias] as Partial<HarnessConfig> | undefined;
|
||||
if (def?.harnessType) {
|
||||
return {
|
||||
alias,
|
||||
harnessType: def.harnessType,
|
||||
needsWorktree: def.needsWorktree ?? true,
|
||||
allowedCommands: def.allowedCommands ?? null,
|
||||
};
|
||||
}
|
||||
return { alias, harnessType: "minimal", needsWorktree: true, allowedCommands: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve harness config for a job. Brief 3 adds harness_providers + remote-ag-ui.
|
||||
* - If job carries `harnessAlias` use it (from orchestrator or DB route).
|
||||
* - Else if roleId itself is a harness alias use it.
|
||||
* - Else fallback to minimal.
|
||||
*/
|
||||
export function resolveHarnessForJob(job: {
|
||||
roleId: string;
|
||||
harnessAlias?: string | null;
|
||||
harnessType?: string | null;
|
||||
endpointUrl?: string | null;
|
||||
authEnvVar?: string | null;
|
||||
}): HarnessConfig {
|
||||
const alias = job.harnessAlias ?? (HARNESS_BY_ALIAS[job.roleId] ? job.roleId : null);
|
||||
if (alias) {
|
||||
const base = configForAlias(alias);
|
||||
return {
|
||||
...base,
|
||||
...(job.harnessType ? { harnessType: job.harnessType as HarnessType } : {}),
|
||||
...(job.endpointUrl !== undefined ? { endpointUrl: job.endpointUrl } : {}),
|
||||
...(job.authEnvVar !== undefined ? { authEnvVar: job.authEnvVar } : {}),
|
||||
};
|
||||
}
|
||||
if (job.roleId.includes("doc")) return DOC_WRITER;
|
||||
return DEFAULT;
|
||||
}
|
||||
|
||||
export function allowedCommandsForHarness(harness: HarnessConfig): readonly string[] | null {
|
||||
return harness.allowedCommands === null ? ALLOWED_COMMANDS : harness.allowedCommands;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executor registry — minimal adapter delegates to MinimalSession; remote adapters
|
||||
* could call AG-UI endpoint. For brief 3 all adapters share the same local
|
||||
* MinimalSession implementation but are differentiated by harnessType for
|
||||
* observability and future extension.
|
||||
*/
|
||||
export function getHarnessExecutor(alias: string): { id: string; harnessType: HarnessType } {
|
||||
const cfg = configForAlias(alias);
|
||||
return { id: cfg.alias, harnessType: cfg.harnessType };
|
||||
}
|
||||
19
apps/worker/src/index.dry-run.test.ts
Normal file
19
apps/worker/src/index.dry-run.test.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("worker dry-run entry", () => {
|
||||
it("loadEnv treats WORKER_DRY_RUN=1 as dry-run without REDIS_URL", async () => {
|
||||
const previous = { ...process.env };
|
||||
process.env.DATABASE_URL = "postgres://local/test";
|
||||
process.env.WORKER_DRY_RUN = "1";
|
||||
delete process.env.REDIS_URL;
|
||||
|
||||
vi.resetModules();
|
||||
const { loadEnv } = await import("./env.js");
|
||||
const env = loadEnv();
|
||||
|
||||
expect(env.WORKER_DRY_RUN).toBe(true);
|
||||
expect(env.REDIS_URL).toBeUndefined();
|
||||
|
||||
process.env = previous;
|
||||
});
|
||||
});
|
||||
|
|
@ -2,10 +2,16 @@ import { readFile } from "node:fs/promises";
|
|||
|
||||
import { createLanguageModelFromResolved } from "@tasks/ai";
|
||||
import 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;
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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} `));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: "",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
86
docs/decisions/2026-08-25-second-brain-os-pivot.md
Normal file
86
docs/decisions/2026-08-25-second-brain-os-pivot.md
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
# Decision Log 2026-08-25 — Second Brain OS Pivot
|
||||
|
||||
**Date:** 2026-08-25
|
||||
**Participants:** solo operator + deep-dive analysis of Echodo (`tasks` repo) vs Grok Bot / OpenBot / Agents Squads / Agent Teams / OpenClaw
|
||||
**Status:** Accepted
|
||||
**Scope:** Product positioning, roadmap, and harness architecture for remaining epics.
|
||||
|
||||
## TL;DR
|
||||
|
||||
Echodo stays **homelab dogfooded**, publishes as **open MIT self-hosted "Obsidian for agent crews"** — `docker compose up`, BYO LLMs (Ollama `11434` / MLX `8000` / NIM / LiteLLM), any harness via AG-UI/MCP. Do **not** chase Grok Bot SaaS ($300/mo Heavy) or clone LangChain/CrewAI/AutoGen frameworks. Win by being the file-first, gated control plane they lack.
|
||||
|
||||
## Context
|
||||
|
||||
Full deep dive 2026-08-25 covered:
|
||||
- What Echodo is: `tasks` repo `package.json:1`, `@tasks/*`, DB/domain `echodo`, `plans/Plan-*/Epic-*/Task-*.md` git SoT → `markdown_backlog_items` + `cursor_sync_mappings` → tRPC `19 routers` + MCP `claim_task/complete_task` + `model_providers/role_model_routes` + `convoy` state machine + `orchestrator` Redis tick + `worker` worktree+MinimalSession.
|
||||
- Current status: Epics 1-6 `done`, 7 `in_progress` (draft PR + live E2E missing), 8-12 `draft`. 14 migrations, `apps/worker` 4-tool allowlist, uncommitted checkpoint/worktree code.
|
||||
- Market: $6-17B orchestration 2026 → $38-121B 2035, 23-25% CAGR. Human-in-loop hybrid 18% ($1B). Grok Bot has no standalone price (bundled Heavy/Cursor Ultra). OpenClaw 347k stars > React in 60 days. LangChain 97-135k, CrewAI 57k, AutoGen maintenance → Microsoft Agent Framework.
|
||||
|
||||
Examples walked: solo Maya `Clarity` offline notes + local tags (M4 Mac); small team Studio North `RouteMap` delivery planner (CrewAI crew + Nim). Both driven from web `Mission control` `apps/web/app/(app)/[workspaceSlug]/mission-control/page.tsx:12` or via MCP `claim_task` in Cursor/OpenCode.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
### 1. Positioning: Open self-hosted second brain, not managed SaaS
|
||||
|
||||
- **Internal tool first:** Run for self, fleet of personal projects, dogfood every bead via `claim_task → complete_task`.
|
||||
- **Publish as open self-hosted Obsidian** for agent crews: `git clone && cp .env.example .env && docker compose -f docker/docker-compose.yml up` must work without CT102 IPs. BYO LLM headline.
|
||||
- **Monetize later as managed fleet** (SSE, copilot hosting) if `>1k stars or 50 self-hosters or 3 agencies` in 30 days post-publish. Not per-seat SaaS.
|
||||
|
||||
Rejected: competing head-on with Grok Bot appliance; cloning LangChain/CrewAI (223M dl/mo ecosystem tax, `No native policy gate` gap already exists to fill).
|
||||
|
||||
### 2. Harness-agnostic execution (the big change)
|
||||
|
||||
- Add `WorkerAdapter` interface `packages/shared/src/worker/executor.ts` — `handleOrchestratorJob` `apps/worker/src/handle-job.ts:40` dispatches to `minimal | crewai | langgraph | remote-ag-ui (AG-UI)`.
|
||||
- Add `harness_providers` + `role_harness_routes` (mirror `model_providers` `docs/adr-model-routing.md:78`), `harness_type enum`, `auth_env_var` write-only like OpenBot.
|
||||
- Job spec `packages/shared/src/orchestrator/job.ts` adds `harnessAlias`. AG-UI endpoint `apps/web/app/api/ag-ui/[workspace]/route.ts`; MCP stays `apps/mcp-server/src/index.ts:12`.
|
||||
- One agent can author tasks (doc-writer), another harness implements them — `role-architect → claude-planning + langgraph`, `role-implementer → qwen-coder-mlx http://mac:8000/v1 + crewai`.
|
||||
|
||||
### 3. Web-first, almost everything from UI
|
||||
|
||||
Goal: never leave `echodo.stillwell.cloud`.
|
||||
|
||||
- **Fleet:** new `/fleet` rollup across `workspace_id` + `connected_repos` + `convoy_events` SSE (Epic 9).
|
||||
- **Plans:** tree CRUD for `plans/*.md` `packages/database/src/markdown-backlog/sync.ts:38` via `backlog.createPlan/createEpic/createTask` + `Start → Convoy` (Epic 10). Replaces hand markdown + `pnpm import:markdown-backlog` loop.
|
||||
- **Run:** `CreateConvoyDialog` `apps/web/components/mission-control/create-convoy-dialog.tsx` → `convoys.create` `apps/web/server/routers/convoys.ts:173` → orchestrator fans out; gates `gateAction` `convoys.ts:237` + `gateCopilot` `convoys.ts:267` in same panel; history `agent_runs`/`worker_checkpoints`.
|
||||
- MCP remains shortcut for natural language ("OpenCode, add Epic with 3 tasks") via new `create-backlog-item` tool.
|
||||
|
||||
### 4. Non-code support (book/course/research/SOPs)
|
||||
|
||||
- Make worktree optional `apps/worker/src/git/worktree.ts:1` — doc tasks write straight to `docs/`/`plans/` no branch.
|
||||
- Make `allowlist.ts` per-harness — research harness allows search, doc harness allows no `pnpm`.
|
||||
- Add `doc_review→published` transition alongside `pr_review→merged` `packages/shared/src/convoy-lifecycle/state-machine.ts`.
|
||||
- Templates `Settings → Templates` `apps/web/app/(app)/[workspaceSlug]/settings/templates/page.tsx` for research pipeline/book without migration.
|
||||
|
||||
### 5. What stays vs. what goes
|
||||
|
||||
**Keep:** `workspace_id` tenancy `packages/database/src/schema/objects.ts:32`, `plans/` hierarchy `docs/Glossary.md:50`, markdown `contentHash` idempotency, `claim_task/complete_task` primitives `Plan-agent-coordination.md:12`, model routing `resolveModel` `docs/adr-model-routing.md:63`, gates/kanban `apps/web/app/(app)/[workspaceSlug]/mission-control/page.tsx:12`.
|
||||
|
||||
**Add:** harness registry, Plans tree, Fleet rollup, `create-backlog-item`.
|
||||
|
||||
**Not building now:** full Cursor API push/pull (`config/CursorSync.md:5` modes doc-only), LiteLLM required, per-user model overrides `adr-model-routing.md:354`.
|
||||
|
||||
## Examples Consolidated
|
||||
|
||||
- **Maya solo `Clarity`:** starts from web Plans "local tag prompt Ready" → `Start→Convoy` `clarity-tag-v1` → gate `convoy_review` → approve → worker `qwen-coder` + `tag-crew` worktree → checkpoint `toolHistory` → draft PR `feat(tags)` → `pr_review` → merged. Or same via Cursor `claim_task repoPath:plans/...` natural language.
|
||||
- **Studio North `RouteMap`:** Ana triages Fleet → sees `routemap-optimize-v2 pr_review 2d Carlos`, creates `Epic-driver-offline` in Plans; Ben claims via Cursor; Carlos gate `Request changes` via `Gate co-pilot` summary. `Ops copilot` answers "what needs me?".
|
||||
|
||||
Both show markdown as token-cheap second brain `Plan-automation-foundation.md:222` "DB+UI are how agents query without dumping whole trees" — not Obsidian vault scan.
|
||||
|
||||
## Roadmap Impact
|
||||
|
||||
Updates `plans/Plan-automation-foundation/Plan-automation-foundation.md` — see "2026-08-25 pivot" section. Priorities v0.1 for second-brain OS: (1) MCP create item (0.5d), (2) optional worktree + per-harness allowlist (1d), (3) harness registry + WorkerAdapter (2-3d), (4) Plans tree (1w), (5) Fleet rollup. Epics 9/10 remain draft but now unblock non-code. Epic 11/12 unchanged.
|
||||
|
||||
Go/no-go: 30 days post-open: >1k stars / 50 self-hosters / 3 managed inquiries else keep homelab-only.
|
||||
|
||||
## Risks
|
||||
|
||||
- Mac Ollama unreachable from Coolify → `testProvider`/`testHarness` surface LAN error; run web locally fallback `docs/adr-model-routing.md:314`.
|
||||
- Scope creep into framework — mitigate `WorkerAdapter` is adapter, not framework.
|
||||
|
||||
## Links
|
||||
|
||||
- Plan: `plans/Plan-automation-foundation/Plan-automation-foundation.md`
|
||||
- ADR: `docs/adr-model-routing.md`
|
||||
- Convoy lifecycle: `docs/convoy-lifecycle.md`
|
||||
- Worker: `docs/worker.md`
|
||||
- Plans layout: `plans/README.md`, `docs/Glossary.md`, `config/CursorSync.md`
|
||||
|
|
@ -23,9 +23,10 @@ WORKER_DRY_RUN=0 REDIS_URL=... pnpm --filter @tasks/worker dev
|
|||
|
||||
- **Queue** — `BLPOP` on `echodo:orchestrator:jobs` (same key as `apps/orchestrator`)
|
||||
- **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).
|
||||
|
|
|
|||
22
packages/database/migrations/0014_ancient_lenny_balinger.sql
Normal file
22
packages/database/migrations/0014_ancient_lenny_balinger.sql
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
CREATE TABLE "worker_checkpoints" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"workspace_id" uuid NOT NULL,
|
||||
"agent_run_id" uuid NOT NULL,
|
||||
"convoy_id" uuid NOT NULL,
|
||||
"brief_id" uuid,
|
||||
"role_id" varchar(64) NOT NULL,
|
||||
"checkpoint_seq" integer NOT NULL,
|
||||
"tool_history" jsonb NOT NULL,
|
||||
"files_touched" jsonb NOT NULL,
|
||||
"branch" varchar(255),
|
||||
"worktree_path" text,
|
||||
"occurred_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "worker_checkpoints" ADD CONSTRAINT "worker_checkpoints_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "worker_checkpoints" ADD CONSTRAINT "worker_checkpoints_agent_run_id_agent_runs_id_fk" FOREIGN KEY ("agent_run_id") REFERENCES "public"."agent_runs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "worker_checkpoints" ADD CONSTRAINT "worker_checkpoints_convoy_id_objects_id_fk" FOREIGN KEY ("convoy_id") REFERENCES "public"."objects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "worker_checkpoints" ADD CONSTRAINT "worker_checkpoints_brief_id_objects_id_fk" FOREIGN KEY ("brief_id") REFERENCES "public"."objects"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "worker_checkpoints_workspace_id_occurred_at_idx" ON "worker_checkpoints" USING btree ("workspace_id","occurred_at" DESC NULLS LAST);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "worker_checkpoints_agent_run_id_seq_idx" ON "worker_checkpoints" USING btree ("agent_run_id","checkpoint_seq");--> statement-breakpoint
|
||||
CREATE INDEX "worker_checkpoints_convoy_id_occurred_at_idx" ON "worker_checkpoints" USING btree ("convoy_id","occurred_at" DESC NULLS LAST);
|
||||
35
packages/database/migrations/0015_curved_iron_fist.sql
Normal file
35
packages/database/migrations/0015_curved_iron_fist.sql
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
CREATE TABLE "harness_providers" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"workspace_id" uuid NOT NULL,
|
||||
"alias" varchar(120) NOT NULL,
|
||||
"display_name" varchar(200) NOT NULL,
|
||||
"harness_type" varchar(40) NOT NULL,
|
||||
"endpoint_url" text,
|
||||
"auth_env_var" varchar(120),
|
||||
"config" jsonb,
|
||||
"enabled" boolean DEFAULT true NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "role_harness_routes" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"workspace_id" uuid NOT NULL,
|
||||
"role_id" varchar(120) NOT NULL,
|
||||
"harness_provider_id" uuid NOT NULL,
|
||||
"enabled" boolean DEFAULT true NOT NULL,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "agent_runs" ADD COLUMN "harness_alias" varchar(120);--> statement-breakpoint
|
||||
ALTER TABLE "convoy_events" ADD COLUMN "harness_alias" varchar(120);--> statement-breakpoint
|
||||
ALTER TABLE "harness_providers" ADD CONSTRAINT "harness_providers_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "role_harness_routes" ADD CONSTRAINT "role_harness_routes_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "role_harness_routes" ADD CONSTRAINT "role_harness_routes_harness_provider_id_harness_providers_id_fk" FOREIGN KEY ("harness_provider_id") REFERENCES "public"."harness_providers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "harness_providers_workspace_id_idx" ON "harness_providers" USING btree ("workspace_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "harness_providers_workspace_id_alias_unique" ON "harness_providers" USING btree ("workspace_id","alias");--> statement-breakpoint
|
||||
CREATE INDEX "role_harness_routes_workspace_id_idx" ON "role_harness_routes" USING btree ("workspace_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "role_harness_routes_workspace_id_role_id_unique" ON "role_harness_routes" USING btree ("workspace_id","role_id");--> statement-breakpoint
|
||||
CREATE INDEX "role_harness_routes_harness_provider_id_idx" ON "role_harness_routes" USING btree ("harness_provider_id");
|
||||
3954
packages/database/migrations/meta/0014_snapshot.json
Normal file
3954
packages/database/migrations/meta/0014_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
4245
packages/database/migrations/meta/0015_snapshot.json
Normal file
4245
packages/database/migrations/meta/0015_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -99,6 +99,20 @@
|
|||
"when": 1783453846679,
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
69
packages/database/src/schema/harness_providers.ts
Normal file
69
packages/database/src/schema/harness_providers.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import {
|
||||
boolean,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
varchar,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
import { workspaces } from "./workspaces";
|
||||
|
||||
/** Workspace-scoped harness provider — mirrors model_providers, secrets stay in env vars. */
|
||||
export const harnessProviders = pgTable(
|
||||
"harness_providers",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
workspaceId: uuid("workspace_id")
|
||||
.notNull()
|
||||
.references(() => workspaces.id, { onDelete: "cascade" }),
|
||||
alias: varchar("alias", { length: 120 }).notNull(),
|
||||
displayName: varchar("display_name", { length: 200 }).notNull(),
|
||||
harnessType: varchar("harness_type", { length: 40 }).notNull(),
|
||||
endpointUrl: text("endpoint_url"),
|
||||
authEnvVar: varchar("auth_env_var", { length: 120 }),
|
||||
config: jsonb("config").$type<Record<string, unknown> | null>(),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
workspaceIdIdx: index("harness_providers_workspace_id_idx").on(table.workspaceId),
|
||||
workspaceAliasUnique: uniqueIndex("harness_providers_workspace_id_alias_unique").on(
|
||||
table.workspaceId,
|
||||
table.alias,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
/** Maps pipeline role_id strings to a harness provider row. */
|
||||
export const roleHarnessRoutes = pgTable(
|
||||
"role_harness_routes",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
workspaceId: uuid("workspace_id")
|
||||
.notNull()
|
||||
.references(() => workspaces.id, { onDelete: "cascade" }),
|
||||
roleId: varchar("role_id", { length: 120 }).notNull(),
|
||||
harnessProviderId: uuid("harness_provider_id")
|
||||
.notNull()
|
||||
.references(() => harnessProviders.id, { onDelete: "cascade" }),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
workspaceIdIdx: index("role_harness_routes_workspace_id_idx").on(table.workspaceId),
|
||||
workspaceRoleUnique: uniqueIndex(
|
||||
"role_harness_routes_workspace_id_role_id_unique",
|
||||
).on(table.workspaceId, table.roleId),
|
||||
harnessProviderIdIdx: index("role_harness_routes_harness_provider_id_idx").on(
|
||||
table.harnessProviderId,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
|
@ -17,3 +17,5 @@ export * from "./model_providers";
|
|||
export * from "./role_model_routes";
|
||||
export * from "./connected_repos";
|
||||
export * from "./convoy_events";
|
||||
export * from "./worker_checkpoints";
|
||||
export * from "./harness_providers";
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { modelProviders } from "./model_providers";
|
|||
import { roleModelRoutes } from "./role_model_routes";
|
||||
import { 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",
|
||||
}),
|
||||
}));
|
||||
|
|
|
|||
64
packages/database/src/schema/worker_checkpoints.ts
Normal file
64
packages/database/src/schema/worker_checkpoints.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import {
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
varchar,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
import type {
|
||||
WorkerCheckpointFileTouch,
|
||||
WorkerCheckpointToolStep,
|
||||
} from "@tasks/shared/worker";
|
||||
|
||||
import { agentRuns } from "./agent_runs";
|
||||
import { objects } from "./objects";
|
||||
import { workspaces } from "./workspaces";
|
||||
|
||||
/**
|
||||
* Bounded progress snapshots for worker sessions. Payloads store paths +
|
||||
* content hashes only — never API keys or full file bodies.
|
||||
*/
|
||||
export const workerCheckpoints = pgTable(
|
||||
"worker_checkpoints",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
workspaceId: uuid("workspace_id")
|
||||
.notNull()
|
||||
.references(() => workspaces.id, { onDelete: "cascade" }),
|
||||
agentRunId: uuid("agent_run_id")
|
||||
.notNull()
|
||||
.references(() => agentRuns.id, { onDelete: "cascade" }),
|
||||
convoyId: uuid("convoy_id")
|
||||
.notNull()
|
||||
.references(() => objects.id, { onDelete: "cascade" }),
|
||||
briefId: uuid("brief_id").references(() => objects.id, { onDelete: "set null" }),
|
||||
roleId: varchar("role_id", { length: 64 }).notNull(),
|
||||
checkpointSeq: integer("checkpoint_seq").notNull(),
|
||||
toolHistory: jsonb("tool_history").$type<WorkerCheckpointToolStep[]>().notNull(),
|
||||
filesTouched: jsonb("files_touched").$type<WorkerCheckpointFileTouch[]>().notNull(),
|
||||
branch: varchar("branch", { length: 255 }),
|
||||
worktreePath: text("worktree_path"),
|
||||
occurredAt: timestamp("occurred_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
workspaceOccurredIdx: index("worker_checkpoints_workspace_id_occurred_at_idx").on(
|
||||
table.workspaceId,
|
||||
table.occurredAt.desc(),
|
||||
),
|
||||
runSeqIdx: uniqueIndex("worker_checkpoints_agent_run_id_seq_idx").on(
|
||||
table.agentRunId,
|
||||
table.checkpointSeq,
|
||||
),
|
||||
convoyOccurredIdx: index("worker_checkpoints_convoy_id_occurred_at_idx").on(
|
||||
table.convoyId,
|
||||
table.occurredAt.desc(),
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
|
@ -11,6 +11,7 @@
|
|||
"./convoy-lifecycle": "./src/convoy-lifecycle/index.ts",
|
||||
"./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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
42
packages/shared/src/worker/checkpoint.test.ts
Normal file
42
packages/shared/src/worker/checkpoint.test.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
workerCheckpointPayloadSchema,
|
||||
workerCheckpointToolStepSchema,
|
||||
} from "@tasks/shared/worker";
|
||||
|
||||
describe("worker checkpoint schemas", () => {
|
||||
it("accepts path + hash tool steps without file contents", () => {
|
||||
const step = workerCheckpointToolStepSchema.parse({
|
||||
seq: 1,
|
||||
tool: "write_file",
|
||||
args: {
|
||||
path: "apps/worker/src/x.ts",
|
||||
contentHash: "a".repeat(64),
|
||||
},
|
||||
ok: true,
|
||||
resultDigest: "ok:deadbeefdeadbeef",
|
||||
});
|
||||
|
||||
expect(step.args).not.toHaveProperty("content");
|
||||
expect(JSON.stringify(step)).not.toMatch(/api[_-]?key/i);
|
||||
});
|
||||
|
||||
it("rejects oversized tool history beyond the bound", () => {
|
||||
const history = Array.from({ length: 51 }, (_, i) => ({
|
||||
seq: i + 1,
|
||||
tool: "read_file" as const,
|
||||
args: { path: `f-${i}.ts` },
|
||||
ok: true,
|
||||
}));
|
||||
|
||||
expect(() =>
|
||||
workerCheckpointPayloadSchema.parse({
|
||||
toolHistory: history,
|
||||
filesTouched: [],
|
||||
branch: "worker/x",
|
||||
worktreePath: "/tmp/wt",
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
49
packages/shared/src/worker/checkpoint.ts
Normal file
49
packages/shared/src/worker/checkpoint.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { z } from "zod";
|
||||
|
||||
import { workerToolNameSchema } from "./session";
|
||||
|
||||
/** Max tool steps retained in a single checkpoint payload. */
|
||||
export const WORKER_CHECKPOINT_TOOL_HISTORY_LIMIT = 50;
|
||||
|
||||
/**
|
||||
* Sanitized tool-step record: paths + hashes only — never file contents or API keys.
|
||||
*/
|
||||
export const workerCheckpointToolStepSchema = z.object({
|
||||
seq: z.number().int().nonnegative(),
|
||||
tool: workerToolNameSchema,
|
||||
args: z.object({
|
||||
path: z.string().min(1).max(1024).optional(),
|
||||
pattern: z.string().min(1).max(512).optional(),
|
||||
command: z.string().min(1).max(256).optional(),
|
||||
contentHash: z.string().min(1).max(128).optional(),
|
||||
}),
|
||||
ok: z.boolean(),
|
||||
resultDigest: z.string().max(128).optional(),
|
||||
});
|
||||
|
||||
export type WorkerCheckpointToolStep = z.infer<typeof workerCheckpointToolStepSchema>;
|
||||
|
||||
export const workerCheckpointFileTouchSchema = z.object({
|
||||
path: z.string().min(1).max(1024),
|
||||
contentHash: z.string().min(1).max(128),
|
||||
});
|
||||
|
||||
export type WorkerCheckpointFileTouch = z.infer<typeof workerCheckpointFileTouchSchema>;
|
||||
|
||||
export const workerCheckpointPayloadSchema = z.object({
|
||||
toolHistory: z
|
||||
.array(workerCheckpointToolStepSchema)
|
||||
.max(WORKER_CHECKPOINT_TOOL_HISTORY_LIMIT),
|
||||
filesTouched: z.array(workerCheckpointFileTouchSchema).max(200),
|
||||
branch: z.string().min(1).max(255).nullable(),
|
||||
worktreePath: z.string().min(1).max(1024).nullable(),
|
||||
});
|
||||
|
||||
export type WorkerCheckpointPayload = z.infer<typeof workerCheckpointPayloadSchema>;
|
||||
|
||||
export const workerCheckpointSummarySchema = z.object({
|
||||
count: z.number().int().nonnegative(),
|
||||
lastOccurredAt: z.string().datetime().nullable(),
|
||||
});
|
||||
|
||||
export type WorkerCheckpointSummary = z.infer<typeof workerCheckpointSummarySchema>;
|
||||
140
packages/shared/src/worker/executor.ts
Normal file
140
packages/shared/src/worker/executor.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const harnessTypeSchema = z.enum(["minimal", "crewai", "langgraph", "remote-ag-ui"]);
|
||||
|
||||
export type HarnessType = z.infer<typeof harnessTypeSchema>;
|
||||
|
||||
export const harnessProviderSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
workspaceId: z.string().uuid(),
|
||||
alias: z.string().min(1),
|
||||
displayName: z.string().min(1),
|
||||
harnessType: harnessTypeSchema,
|
||||
endpointUrl: z.string().url().nullable().optional(),
|
||||
authEnvVar: z.string().min(1).nullable().optional(),
|
||||
config: z.record(z.unknown()).nullable().optional(),
|
||||
enabled: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export type HarnessProvider = z.infer<typeof harnessProviderSchema>;
|
||||
|
||||
export const roleHarnessRouteSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
workspaceId: z.string().uuid(),
|
||||
roleId: z.string().min(1),
|
||||
harnessProviderId: z.string().uuid(),
|
||||
enabled: z.boolean().default(true),
|
||||
sortOrder: z.number().int().optional(),
|
||||
});
|
||||
|
||||
export type RoleHarnessRoute = z.infer<typeof roleHarnessRouteSchema>;
|
||||
|
||||
export type ResolvedHarness = {
|
||||
alias: string;
|
||||
harnessType: HarnessType;
|
||||
endpointUrl?: string | null;
|
||||
authEnvVar?: string | null;
|
||||
config?: Record<string, unknown> | null;
|
||||
enabled: boolean;
|
||||
providerId?: string;
|
||||
};
|
||||
|
||||
export type ResolveHarnessInput = {
|
||||
roleId: string;
|
||||
providers: HarnessProvider[];
|
||||
routes: RoleHarnessRoute[];
|
||||
};
|
||||
|
||||
export type ResolveHarnessResult = {
|
||||
harness: ResolvedHarness;
|
||||
source: "workspace" | "default";
|
||||
routeId?: string;
|
||||
};
|
||||
|
||||
const MINIMAL_RESOLVED: ResolvedHarness = {
|
||||
alias: "minimal",
|
||||
harnessType: "minimal",
|
||||
endpointUrl: null,
|
||||
authEnvVar: null,
|
||||
config: null,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
export function resolveHarnessForRole(input: ResolveHarnessInput): ResolveHarnessResult {
|
||||
const route = input.routes.find((r) => r.roleId === input.roleId && r.enabled);
|
||||
if (route) {
|
||||
const provider = input.providers.find((p) => p.id === route.harnessProviderId);
|
||||
if (provider && provider.enabled) {
|
||||
return {
|
||||
harness: {
|
||||
alias: provider.alias,
|
||||
harnessType: provider.harnessType,
|
||||
endpointUrl: provider.endpointUrl ?? null,
|
||||
authEnvVar: provider.authEnvVar ?? null,
|
||||
config: provider.config ?? null,
|
||||
enabled: provider.enabled,
|
||||
providerId: provider.id,
|
||||
},
|
||||
source: "workspace",
|
||||
routeId: route.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
// Fallback to minimal — always available.
|
||||
const minimalProvider = input.providers.find((p) => p.alias === "minimal" && p.enabled);
|
||||
if (minimalProvider) {
|
||||
return {
|
||||
harness: {
|
||||
alias: minimalProvider.alias,
|
||||
harnessType: minimalProvider.harnessType,
|
||||
endpointUrl: minimalProvider.endpointUrl ?? null,
|
||||
authEnvVar: minimalProvider.authEnvVar ?? null,
|
||||
config: minimalProvider.config ?? null,
|
||||
enabled: true,
|
||||
providerId: minimalProvider.id,
|
||||
},
|
||||
source: minimalProvider.id ? "workspace" : "default",
|
||||
};
|
||||
}
|
||||
return { harness: MINIMAL_RESOLVED, source: "default" };
|
||||
}
|
||||
|
||||
// --- WorkerExecutor abstraction (mirrors pattern described in brief) ---
|
||||
|
||||
export type WorkerJobContext = {
|
||||
workspaceId: string;
|
||||
convoyId: string;
|
||||
roleId: string;
|
||||
briefId?: string;
|
||||
repoRoot: string;
|
||||
};
|
||||
|
||||
export type ResolvedModelLike = {
|
||||
modelId: string;
|
||||
providerType: string;
|
||||
alias: string;
|
||||
};
|
||||
|
||||
export interface WorkerExecutor {
|
||||
id: string;
|
||||
harnessType: HarnessType;
|
||||
run(
|
||||
ctx: WorkerJobContext,
|
||||
opts: {
|
||||
resolvedModel: ResolvedModelLike;
|
||||
resolvedAlias: string;
|
||||
rolePromptPath: string;
|
||||
workflowPrompt?: string;
|
||||
briefAcceptance?: string;
|
||||
briefFiles?: string[];
|
||||
maxSteps: number;
|
||||
dryRun?: boolean;
|
||||
checkpointEveryNSteps?: number;
|
||||
resumeToolHistory?: unknown[];
|
||||
resumeFilesTouched?: unknown[];
|
||||
allowedCommands?: readonly string[] | null;
|
||||
onCheckpoint?: (input: unknown) => Promise<void>;
|
||||
buildPrompt: (ctx: WorkerJobContext, title: string) => string;
|
||||
},
|
||||
): Promise<{ summary: string; toolCalls: number; tokensTotal?: number }>;
|
||||
}
|
||||
|
|
@ -7,3 +7,27 @@ export {
|
|||
type WorkerToolResult,
|
||||
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";
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue