feat(owner-cockpit): forge webhooks + artifacts + copilot (Epic 8/11/12)
- Forge webhook POST /api/webhooks/forge HMAC sha256 verify, marker echodo-convoy → pr_review→merged as automation - Completion artifacts table 0017, deterministic summary template, gallery /artifacts - Ops copilot tRPC copilot + context tools (fleet, convoy, backlog) + panel + Draft Task → backlog.createTask - Doc-writer SOPs: webhook rotation, copilot prompt, published flow examples - Allow pr_review→merged as automation for webhook Convoy: .convoys/owner-cockpit-v02 Co-authored-by: orchestrator
This commit is contained in:
parent
6e17936b7f
commit
6f139d6af2
24 changed files with 6247 additions and 2 deletions
68
.convoys/owner-cockpit-v02.md
Normal file
68
.convoys/owner-cockpit-v02.md
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
---
|
||||||
|
name: owner-cockpit-v02
|
||||||
|
classification: server-only
|
||||||
|
success_metric: Fleet webhooks close PR loop (merged), completion gallery shows summaries+screenshots, ops copilot answers "what needs me?" with links — all from web without dumping plans/
|
||||||
|
skip:
|
||||||
|
- ia
|
||||||
|
- design
|
||||||
|
- a11y
|
||||||
|
- visual
|
||||||
|
- smoke
|
||||||
|
status: open
|
||||||
|
created: 2026-08-25
|
||||||
|
model_policy:
|
||||||
|
roles:
|
||||||
|
role-conductor: claude-planning
|
||||||
|
role-architect: claude-planning
|
||||||
|
role-implementer: qwen-coder
|
||||||
|
role-doc-writer: qwen-coder
|
||||||
|
role-reviewer: qwen-coder
|
||||||
|
escalate_to: claude-planning
|
||||||
|
---
|
||||||
|
|
||||||
|
# Owner cockpit v02 — forge webhooks + artifacts + copilot (post-v0.1)
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
v0.1 made second brain publishable (MCP create, harness-agnostic, Plans tree, Fleet). Worker closeout closed Epic 7 with doc_review→published + draft PR + operator notes + doc-writer. Remaining owner vision per `plans/Plan-automation-foundation/Plan-automation-foundation.md:222` — "fleet → Plans → Start → Go → PR → gates → completion + copilot" — still misses PR loop close (Epic 8), owner review pack (Epic 11), and ops chat (Epic 12). This convoy finishes the cockpit so a solo can land in Fleet, triage, and ask copilot without grepping.
|
||||||
|
|
||||||
|
Decision: `docs/decisions/2026-08-25-second-brain-os-pivot.md`.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
1. **Forge webhooks** `Epic-forge-webhooks` — `POST /api/webhooks/forge` (GitHub/Forgejo) verifies `X-Hub-Signature`/`X-Forgejo-Signature` via `FORGE_WEBHOOK_SECRET`, finds `echodo-convoy: <uuid>` marker in PR body, transitions `pr_review→merged` (`human-gate-2` automation path) and writes `convoy_events`.
|
||||||
|
2. **PR checks recovery panel** `Task-pr-checks-recovery-panel.md` — Mission control detail shows PR checks + retry.
|
||||||
|
3. **Completion artifacts** `Epic-owner-completion-artifacts` — `completion_summary` on `published/merged→released` (markdown summary + optional screenshot placeholder), gallery list, tRPC `listArtifacts`.
|
||||||
|
4. **Ops copilot** `Epic-ops-copilot` — `ops-copilot-context-tools` (fleet + convoy + backlog queries scoped to workspace) + `ops-copilot-ui` chat panel + `draft-work` from chat (create Task via MCP).
|
||||||
|
5. Doc-writer: SOP for webhook secret rotation + copilot prompt.
|
||||||
|
|
||||||
|
Out of scope: LiteLLM gateway, SQLite single-tenant, preference learning.
|
||||||
|
|
||||||
|
## Roles invoked
|
||||||
|
|
||||||
|
Conductor → Architect (4 briefs) → Implementer per brief (disjoint: webhooks, artifacts, copilot) → Doc-writer (SOP) → Reviewer.
|
||||||
|
|
||||||
|
## Todos
|
||||||
|
|
||||||
|
- [ ] Brief 1 forge webhook receiver
|
||||||
|
- [ ] Brief 2 completion gallery + summaries
|
||||||
|
- [ ] Brief 3 ops copilot (context tools + UI)
|
||||||
|
- [ ] Brief 4 doc-writer SOPs
|
||||||
|
|
||||||
|
## IA
|
||||||
|
|
||||||
|
Skipped (`ia` skip set — server-only, no new top-level entities beyond `completion_artifacts` optional table; reuse `convoy_events metadata.prUrl` + `objects content`).
|
||||||
|
|
||||||
|
## UX
|
||||||
|
|
||||||
|
Partial — `ux` not skipped for copilot: chat panel reuses `components/ui/*`.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- Webhooks: `apps/web/app/api/webhooks/forge/route.ts` HMAC verify, `reconcile` via `transitionConvoy` `packages/shared/src/convoy-lifecycle/state-machine.ts:90` `pr_review→merged`.
|
||||||
|
- Artifacts: optional `completion_artifacts` table or reuse `objects content.completionSummary` + `convoy_events metadata.completionScreenshot`.
|
||||||
|
- Copilot: `apps/web/server/routers/copilot.ts` with `resolveModel(gate-copilot)` + context tools `fleet, convoys.list, backlog.listTree` scoped to `workspace_id`; UI `components/copilot/ops-copilot-panel.tsx`.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- v0.1 + worker-closeout (harness, doc_review, prUrl)
|
||||||
36
.convoys/owner-cockpit-v02/brief-1-forge-webhook-receiver.md
Normal file
36
.convoys/owner-cockpit-v02/brief-1-forge-webhook-receiver.md
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
---
|
||||||
|
brief: 1
|
||||||
|
convoy: owner-cockpit-v02
|
||||||
|
role: role-implementer
|
||||||
|
title: Forge webhook receiver — GitHub/Forgejo → pr_review→merged
|
||||||
|
status: ready
|
||||||
|
priority: P0
|
||||||
|
slice_dependencies: []
|
||||||
|
recommended_model: qwen-coder
|
||||||
|
model_tier: local
|
||||||
|
---
|
||||||
|
|
||||||
|
# Brief 1 — `Task-forge-webhook-receiver.md` (Epic 8)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
`POST /api/webhooks/forge` verifies signature via `FORGE_WEBHOOK_SECRET`, finds `echodo-convoy: <uuid>` marker in PR body (from `apps/worker/src/git/pr.ts`), transitions `pr_review→merged` via `transitionConvoy` `packages/shared/src/convoy-lifecycle/state-machine.ts:90` as `automation`, writes `convoy_events`.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `apps/web/app/api/webhooks/forge/route.ts` NEW — parse `X-Hub-Signature-256` (GitHub) or `X-Forgejo-Signature` (Forgejo) HMAC sha256 `FORGE_WEBHOOK_SECRET`, handle `pull_request: closed & merged=true` + `pr.body` marker regex `/echodo-convoy:\s*([0-9a-f-]{36})/`, lookup convoy by `id`, validate `status===pr_review`, call `transitionConvoy(db, {workspaceId, convoyId, toStatus:merged, actor:automation, reason: pr#N merged})`. Return 200 + `ok` even if no convoy (don't leak). Rate-limit per IP.
|
||||||
|
- `apps/web/server/lib/forge/adapters.ts` — reuse `ForgeAdapter` if needed for verify.
|
||||||
|
- `docs/operations/live-e2e-checklist.md` — add webhook step.
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
- Forgejo/GitHub payload with `body: "… echodo-convoy: <id> …"` + valid HMAC transitions `pr_review→merged`; invalid signature 401; no marker 200 no-op.
|
||||||
|
- Existing `pnpm lint && pnpm type-check` pass.
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
You are `role-implementer` brief 1. Read `packages/shared/src/convoy-lifecycle/state-machine.ts`, `apps/web/server/lib/forge/adapters.ts`, `apps/worker/src/git/pr.ts`, `docs/convoy-lifecycle.md`. Keep verify constant-time, workspace-scoped.
|
||||||
|
|
||||||
|
## Depends
|
||||||
|
|
||||||
|
None.
|
||||||
37
.convoys/owner-cockpit-v02/brief-2-completion-artifacts.md
Normal file
37
.convoys/owner-cockpit-v02/brief-2-completion-artifacts.md
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
---
|
||||||
|
brief: 2
|
||||||
|
convoy: owner-cockpit-v02
|
||||||
|
role: role-implementer
|
||||||
|
title: Owner completion artifacts — summary + gallery
|
||||||
|
status: ready
|
||||||
|
priority: P1
|
||||||
|
slice_dependencies: []
|
||||||
|
recommended_model: qwen-coder
|
||||||
|
model_tier: local
|
||||||
|
---
|
||||||
|
|
||||||
|
# Brief 2 — `Epic-owner-completion-artifacts` (Epic 11)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
When `merged/published → released` (`human-gate-3`), generate completion summary markdown + optional screenshot placeholder, list in gallery for owner review. No browser automation v1 — screenshot is upload placeholder.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `packages/database/src/schema/completion_artifacts.ts` NEW — `id uuid PK, workspace_id fk, convoy_id fk objects.id, summary_md text, screenshot_url text nullable, created_by fk users, created_at`. Or reuse `objects content.completionSummary` — pick minimal table for gallery query.
|
||||||
|
- `apps/web/server/routers/convoys.ts` — add `complete` mutation (`doc_review→published→released` or `pr_review→merged→released`) that writes artifact + transitions `released` as `human-gate-3`; `listArtifacts` query for gallery.
|
||||||
|
- `apps/web/app/(app)/[workspaceSlug]/artifacts/page.tsx` NEW — gallery grid, click → detail with summary + PR link + convoy timeline (`convoy_events`).
|
||||||
|
- `packages/shared/src/worker` — generate summary template: `## Summary\nConvoy ${slug} ${classification} — ${successMetric}\nBriefs: ...\nModel: ... Harness: ...`.
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
- Completing `doc_review→published→released` creates artifact row visible in `/artifacts`; screenshot upload placeholder shows.
|
||||||
|
- `pnpm lint && pnpm type-check` pass.
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
You are `role-implementer` brief 2. Read `plans/Plan-automation-foundation/Epic-owner-completion-artifacts/Epic-owner-completion-artifacts.md`, `packages/shared/src/convoy-lifecycle/state-machine.ts:122`, `apps/web/server/routers/convoys.ts:237`. Keep artifact generation deterministic, no LLM call v1 (template only).
|
||||||
|
|
||||||
|
## Depends
|
||||||
|
|
||||||
|
None — disjoint from webhooks.
|
||||||
39
.convoys/owner-cockpit-v02/brief-3-ops-copilot.md
Normal file
39
.convoys/owner-cockpit-v02/brief-3-ops-copilot.md
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
---
|
||||||
|
brief: 3
|
||||||
|
convoy: owner-cockpit-v02
|
||||||
|
role: role-implementer
|
||||||
|
title: Ops copilot — context tools + UI + draft work from chat
|
||||||
|
status: ready
|
||||||
|
priority: P1
|
||||||
|
slice_dependencies: []
|
||||||
|
recommended_model: claude-planning
|
||||||
|
model_tier: premium
|
||||||
|
---
|
||||||
|
|
||||||
|
# Brief 3 — `Epic-ops-copilot` (Epic 12)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
`ops copilot` answers "what needs me?" with links, triages, and can draft a `Task` from chat. Uses `gate-copilot` routing but broader context.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `apps/web/server/routers/copilot.ts` NEW — `opsCopilot.generateText` tRPC: `resolveModel(gate-copilot)` `packages/shared/src/model-routing/index.ts`, context tools: `fleetStatus()` (reuse `convoys.fleet` logic), `convoyGet(workspaceSlug, convoyId)`, `backlogListTree(workspaceSlug)` (reuse `backlog.listTree`), `backlogSearch` (grep `markdown_backlog_items`). Tools are read-only, workspace-scoped, bounded. System prompt: "You are ops copilot for second brain OS… respond with links [workspace/slug]."
|
||||||
|
- `apps/web/server/routers/harnesses.ts` — no change, just reuse `resolveModel`.
|
||||||
|
- `apps/web/components/copilot/ops-copilot-panel.tsx` NEW — chat panel (reuses `components/ui/*`, `ai` SDK `useChat` if available or plain `generateText` loop), shows `fleet → needs you` citations, button `Draft Task` → calls `backlog.createTask` via `create-backlog-item` shape.
|
||||||
|
- `apps/web/app/(app)/fleet/page.tsx` — add copilot FAB or sidebar embed.
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
- "What needs me?" returns gate inbox items with `[fleet]` links to convoys.
|
||||||
|
- `Draft Task` from copilot creates `Task-*.md` via same path as Plans tree (tool `create-backlog-item` logic).
|
||||||
|
- Workspace-scoped — no cross-tenant leakage.
|
||||||
|
- `pnpm lint && pnpm type-check` pass.
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
You are `role-implementer` brief 3. Read `plans/Plan-automation-foundation/Epic-ops-copilot/Epic-ops-copilot.md`, `docs/adr-model-routing.md`, `apps/web/server/routers/convoys.ts:267` `gateCopilot` as reference, `docs/decisions/2026-08-25-second-brain-os-pivot.md`. Keep tools small (<3 tools), no secrets in prompt.
|
||||||
|
|
||||||
|
## Depends
|
||||||
|
|
||||||
|
None — can run parallel with 1,2.
|
||||||
36
.convoys/owner-cockpit-v02/brief-4-doc-writer-sop.md
Normal file
36
.convoys/owner-cockpit-v02/brief-4-doc-writer-sop.md
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
---
|
||||||
|
brief: 4
|
||||||
|
convoy: owner-cockpit-v02
|
||||||
|
role: role-doc-writer
|
||||||
|
title: Doc-writer SOPs — webhook secret rotation + copilot prompt
|
||||||
|
status: ready
|
||||||
|
priority: P2
|
||||||
|
slice_dependencies: [1]
|
||||||
|
recommended_model: qwen-coder
|
||||||
|
model_tier: local
|
||||||
|
---
|
||||||
|
|
||||||
|
# Brief 4 — `role-doc-writer` SOPs for owner cockpit
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Document rotation and prompts so future agents read them as second-brain context, not just code.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `docs/operations/webhook-secret-rotation.md` NEW — one page: where `FORGE_WEBHOOK_SECRET` lives (Coolify env), how to rotate (generate `openssl rand -hex 32`, update Forgejo webhook + Coolify `FORGE_WEBHOOK_SECRET`, test via `curl -H 'X-Hub-Signature-256: sha256=...' http://localhost:3000/api/webhooks/forge`).
|
||||||
|
- `docs/copilot-prompt.md` NEW — system prompt for `gate-copilot` and `ops copilot` (reuse `convoys.ts:gut` prompt but versioned in `docs/` for harness portability).
|
||||||
|
- `docs/examples/sop-published-flow.md` NEW — one SOP showing `Task-write-sop → doc_review → published → artifact gallery` e2e with screenshots placeholder.
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
- Three docs exist, linked from `docs/README` or `docs/operations/live-e2e-checklist.md`.
|
||||||
|
- No code, only markdown; `pnpm lint` still passes (markdown ignored).
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
You are `role-doc-writer` brief 4. Read `docs/convoy-lifecycle.md`, `apps/web/app/api/webhooks/forge/route.ts` (once brief 1 lands) to document accurately. Keep docs small, imperative, no narration.
|
||||||
|
|
||||||
|
## Depends
|
||||||
|
|
||||||
|
Brief 1 — needs webhook route to document accurately.
|
||||||
204
apps/web/app/(app)/[workspaceSlug]/artifacts/page.tsx
Normal file
204
apps/web/app/(app)/[workspaceSlug]/artifacts/page.tsx
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { ExternalLink, ImageOff, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
|
export default function ArtifactsPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const workspaceSlug =
|
||||||
|
typeof params?.workspaceSlug === "string" ? params.workspaceSlug : undefined;
|
||||||
|
const [selectedId, setSelectedId] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
const artifactsQuery = api.convoys.listArtifacts.useQuery(
|
||||||
|
{ workspace: workspaceSlug! },
|
||||||
|
{ enabled: Boolean(workspaceSlug) },
|
||||||
|
);
|
||||||
|
|
||||||
|
const artifacts = artifactsQuery.data ?? [];
|
||||||
|
const selected = artifacts.find((a) => a.id === selectedId) ?? null;
|
||||||
|
|
||||||
|
if (!workspaceSlug) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0 flex-col">
|
||||||
|
<header className="shrink-0 border-b border-border px-6 py-4">
|
||||||
|
<h1 className="text-lg font-semibold">Completion artifacts</h1>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Gallery of released convoys — summary + PR link + timeline
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[1fr_380px]">
|
||||||
|
<section className="min-h-0 overflow-y-auto p-6">
|
||||||
|
{artifactsQuery.isLoading ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
Loading artifacts…
|
||||||
|
</div>
|
||||||
|
) : artifacts.length === 0 ? (
|
||||||
|
<div className="rounded-md border border-dashed border-border p-8 text-center">
|
||||||
|
<p className="text-sm font-medium">No completion artifacts yet</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
Complete a convoy from merged or published via human-gate-3 to generate one.
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 inline-flex items-center gap-2 rounded-md border border-border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
||||||
|
<ImageOff className="size-4" />
|
||||||
|
Screenshot upload placeholder — shows when no image is attached
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{artifacts.map((artifact) => {
|
||||||
|
const isSelected = artifact.id === selectedId;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={artifact.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedId(artifact.id)}
|
||||||
|
className={`text-left ${isSelected ? "ring-2 ring-primary" : ""} rounded-lg`}
|
||||||
|
>
|
||||||
|
<Card className={`h-full ${isSelected ? "border-primary" : ""}`}>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<CardTitle className="truncate text-sm">{artifact.convoySlug}</CardTitle>
|
||||||
|
<Badge variant="secondary" className="shrink-0 text-[10px]">
|
||||||
|
{artifact.convoyStatus}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{new Date(artifact.createdAt).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{artifact.screenshotUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={artifact.screenshotUrl}
|
||||||
|
alt="screenshot"
|
||||||
|
className="h-32 w-full rounded-md object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-32 w-full items-center justify-center rounded-md border border-dashed border-border bg-muted/20 p-3 text-xs text-muted-foreground">
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<ImageOff className="size-4" />
|
||||||
|
Screenshot upload placeholder
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<pre className="line-clamp-3 whitespace-pre-wrap break-words rounded bg-muted/30 p-2 text-xs">
|
||||||
|
{artifact.summaryMd.slice(0, 180)}
|
||||||
|
{artifact.summaryMd.length > 180 ? "…" : ""}
|
||||||
|
</pre>
|
||||||
|
{artifact.pr.prUrl && (
|
||||||
|
<a
|
||||||
|
href={artifact.pr.prUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-primary underline"
|
||||||
|
>
|
||||||
|
PR <ExternalLink className="size-3" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside className="min-h-0 overflow-y-auto border-l border-border bg-background p-4">
|
||||||
|
{!selected ? (
|
||||||
|
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
Select an artifact to see detail summary + PR link + convoy timeline.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold">{selected.convoySlug}</h2>
|
||||||
|
<p className="text-xs text-muted-foreground">{selected.convoyTitle}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{selected.convoyStatus}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border border-border bg-muted/30 p-3">
|
||||||
|
<p className="mb-1 text-xs font-medium uppercase text-muted-foreground">Summary</p>
|
||||||
|
<pre className="whitespace-pre-wrap break-words text-sm">
|
||||||
|
{selected.summaryMd}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border border-border p-3">
|
||||||
|
<p className="mb-1 text-xs font-medium uppercase text-muted-foreground">PR link</p>
|
||||||
|
{selected.pr.prUrl ? (
|
||||||
|
<a
|
||||||
|
href={selected.pr.prUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="break-all text-sm text-primary underline"
|
||||||
|
>
|
||||||
|
{selected.pr.prUrl}
|
||||||
|
{selected.pr.prNumber ? ` (#${selected.pr.prNumber})` : ""}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground">No PR linked</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selected.screenshotUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={selected.screenshotUrl}
|
||||||
|
alt="screenshot"
|
||||||
|
className="w-full rounded-md border border-border object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-32 w-full items-center justify-center rounded-md border border-dashed border-border bg-muted/20 p-3 text-xs text-muted-foreground">
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<ImageOff className="size-4" />
|
||||||
|
Screenshot upload placeholder — no screenshot yet
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-xs font-medium uppercase text-muted-foreground">
|
||||||
|
Convoy timeline ({selected.timeline.length})
|
||||||
|
</p>
|
||||||
|
{selected.timeline.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted-foreground">No events</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{selected.timeline.map((ev) => (
|
||||||
|
<li
|
||||||
|
key={ev.id}
|
||||||
|
className="rounded border border-border bg-muted/10 px-2 py-1.5 text-xs"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="font-medium">{ev.eventType}</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{new Date(ev.occurredAt).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{ev.role}
|
||||||
|
{ev.actor ? ` · ${ev.actor}` : ""}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import type { ConvoyStatus } from "@tasks/shared/convoy-lifecycle";
|
||||||
import { Loader2, Radar } from "lucide-react";
|
import { Loader2, Radar } from "lucide-react";
|
||||||
|
|
||||||
import { ConvoyDetailPanel } from "@/components/mission-control/convoy-detail-panel";
|
import { ConvoyDetailPanel } from "@/components/mission-control/convoy-detail-panel";
|
||||||
|
import { OpsCopilotFab } from "@/components/copilot/ops-copilot-panel";
|
||||||
import { FleetActivityFeed } from "@/components/fleet/fleet-activity-feed";
|
import { FleetActivityFeed } from "@/components/fleet/fleet-activity-feed";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { api } from "@/lib/trpc";
|
import { api } from "@/lib/trpc";
|
||||||
|
|
@ -41,6 +42,8 @@ export default function FleetPage() {
|
||||||
const data = fleetQuery.data;
|
const data = fleetQuery.data;
|
||||||
const byWorkspace = data?.byWorkspace ?? [];
|
const byWorkspace = data?.byWorkspace ?? [];
|
||||||
const gateInboxAcross = data?.gateInboxAcross ?? [];
|
const gateInboxAcross = data?.gateInboxAcross ?? [];
|
||||||
|
const defaultWorkspaceSlug =
|
||||||
|
selected?.workspaceSlug ?? byWorkspace[0]?.workspace.slug ?? gateInboxAcross[0]?.workspaceSlug ?? "";
|
||||||
|
|
||||||
// Group gate inbox by status for "× pr_review 2d" count display
|
// Group gate inbox by status for "× pr_review 2d" count display
|
||||||
const gateByStatus = new Map<string, typeof gateInboxAcross>();
|
const gateByStatus = new Map<string, typeof gateInboxAcross>();
|
||||||
|
|
@ -185,6 +188,7 @@ export default function FleetPage() {
|
||||||
)}
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
<OpsCopilotFab workspaceSlug={defaultWorkspaceSlug} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
143
apps/web/app/api/webhooks/forge/route.ts
Normal file
143
apps/web/app/api/webhooks/forge/route.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
import { db } from "@tasks/database";
|
||||||
|
import { objects } from "@tasks/database/schema";
|
||||||
|
import { transitionConvoy } from "@/server/lib/convoy/convoy-service";
|
||||||
|
import { rateLimit } from "@/server/lib/rate-limit";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const CONVOY_MARKER_RE = /echodo-convoy:\s*([0-9a-f-]{36})/i;
|
||||||
|
const RATE_LIMIT = { limit: 60, windowMs: 60_000 } as const;
|
||||||
|
|
||||||
|
function getClientIp(req: Request): string {
|
||||||
|
const fwd = req.headers.get("x-forwarded-for");
|
||||||
|
if (fwd) {
|
||||||
|
const first = fwd.split(",")[0]?.trim();
|
||||||
|
if (first) return first;
|
||||||
|
}
|
||||||
|
const realIp = req.headers.get("x-real-ip");
|
||||||
|
if (realIp?.trim()) return realIp.trim();
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyHmacSignature(rawBody: string, signatureHeader: string | null, secret: string): boolean {
|
||||||
|
if (!signatureHeader) return false;
|
||||||
|
let sig = signatureHeader.trim();
|
||||||
|
if (sig.startsWith("sha256=")) sig = sig.slice(7);
|
||||||
|
const computed = createHmac("sha256", secret).update(Buffer.from(rawBody, "utf8")).digest("hex");
|
||||||
|
const a = Buffer.from(computed, "utf8");
|
||||||
|
const b = Buffer.from(sig, "utf8");
|
||||||
|
if (a.length !== b.length) {
|
||||||
|
try {
|
||||||
|
timingSafeEqual(a, a);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return timingSafeEqual(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function json(status: number, body: unknown): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request): Promise<Response> {
|
||||||
|
const ip = getClientIp(req);
|
||||||
|
const rl = rateLimit({
|
||||||
|
key: `forge-webhook:${ip}`,
|
||||||
|
limit: RATE_LIMIT.limit,
|
||||||
|
windowMs: RATE_LIMIT.windowMs,
|
||||||
|
});
|
||||||
|
if (!rl.allowed) {
|
||||||
|
return json(429, { ok: false, error: "rate_limited", retryAfterMs: rl.retryAfterMs });
|
||||||
|
}
|
||||||
|
|
||||||
|
const secret = process.env.FORGE_WEBHOOK_SECRET?.trim();
|
||||||
|
if (!secret) {
|
||||||
|
return json(500, { ok: false, error: "webhook_not_configured" });
|
||||||
|
}
|
||||||
|
|
||||||
|
let rawBody: string;
|
||||||
|
try {
|
||||||
|
rawBody = await req.text();
|
||||||
|
} catch {
|
||||||
|
return json(400, { ok: false, error: "invalid_body" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hubSig = req.headers.get("x-hub-signature-256") ?? req.headers.get("x-hub-signature");
|
||||||
|
const forgejoSig = req.headers.get("x-forgejo-signature");
|
||||||
|
const providedSig = hubSig ?? forgejoSig;
|
||||||
|
|
||||||
|
if (!providedSig) {
|
||||||
|
return json(401, { ok: false, error: "missing_signature" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!verifyHmacSignature(rawBody, providedSig, secret)) {
|
||||||
|
return json(401, { ok: false, error: "invalid_signature" });
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
payload = rawBody ? (JSON.parse(rawBody) as Record<string, unknown>) : {};
|
||||||
|
} catch {
|
||||||
|
return json(200, { ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const action = typeof payload.action === "string" ? payload.action : null;
|
||||||
|
const prRaw = payload.pull_request as Record<string, unknown> | null | undefined;
|
||||||
|
|
||||||
|
if (action !== "closed" || !prRaw || typeof prRaw !== "object") {
|
||||||
|
return json(200, { ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const merged = prRaw.merged === true;
|
||||||
|
if (!merged) {
|
||||||
|
return json(200, { ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const bodyText = typeof prRaw.body === "string" ? prRaw.body : "";
|
||||||
|
const match = CONVOY_MARKER_RE.exec(bodyText);
|
||||||
|
if (!match?.[1]) {
|
||||||
|
return json(200, { ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const convoyId = match[1].toLowerCase();
|
||||||
|
const prNumber = typeof prRaw.number === "number" ? prRaw.number : null;
|
||||||
|
|
||||||
|
const [convoyRow] = await db
|
||||||
|
.select({ id: objects.id, workspaceId: objects.workspaceId, status: objects.status, type: objects.type })
|
||||||
|
.from(objects)
|
||||||
|
.where(eq(objects.id, convoyId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!convoyRow || convoyRow.type !== "project") {
|
||||||
|
return json(200, { ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (convoyRow.status !== "pr_review") {
|
||||||
|
return json(200, { ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const reason = prNumber !== null ? `pr#${prNumber} merged` : "pr merged";
|
||||||
|
|
||||||
|
try {
|
||||||
|
await transitionConvoy(db, {
|
||||||
|
workspaceId: convoyRow.workspaceId,
|
||||||
|
convoyId: convoyRow.id,
|
||||||
|
toStatus: "merged",
|
||||||
|
actor: "automation",
|
||||||
|
reason,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return json(200, { ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return json(200, { ok: true });
|
||||||
|
}
|
||||||
375
apps/web/components/copilot/ops-copilot-panel.tsx
Normal file
375
apps/web/components/copilot/ops-copilot-panel.tsx
Normal file
|
|
@ -0,0 +1,375 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { Loader2, Send, Sparkles, X } from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
SheetDescription,
|
||||||
|
} from "@/components/ui/sheet";
|
||||||
|
|
||||||
|
type ChatRole = "user" | "assistant";
|
||||||
|
|
||||||
|
type ChatMessage = {
|
||||||
|
id: string;
|
||||||
|
role: ChatRole;
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function TypingDots() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 px-1 py-2" aria-hidden>
|
||||||
|
{[0, 1, 2].map((i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="inline-block h-2 w-2 animate-bounce rounded-full bg-muted-foreground/70"
|
||||||
|
style={{ animationDelay: `${i * 0.15}s` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OpsCopilotPanel({
|
||||||
|
workspaceSlug,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: {
|
||||||
|
workspaceSlug: string;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const [messages, setMessages] = React.useState<ChatMessage[]>([]);
|
||||||
|
const [input, setInput] = React.useState("");
|
||||||
|
const [draftOpen, setDraftOpen] = React.useState(false);
|
||||||
|
const [draftPlanSlug, setDraftPlanSlug] = React.useState("");
|
||||||
|
const [draftEpicSlug, setDraftEpicSlug] = React.useState("");
|
||||||
|
const [draftSlug, setDraftSlug] = React.useState("");
|
||||||
|
const [draftTitle, setDraftTitle] = React.useState("");
|
||||||
|
const [draftBody, setDraftBody] = React.useState("");
|
||||||
|
const bottomRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const copilotMut = api.opsCopilot.generateText.useMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setMessages((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ id: crypto.randomUUID(), role: "assistant", content: data.text },
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createTaskMut = api.backlog.createTask.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
setDraftOpen(false);
|
||||||
|
setDraftPlanSlug("");
|
||||||
|
setDraftEpicSlug("");
|
||||||
|
setDraftSlug("");
|
||||||
|
setDraftTitle("");
|
||||||
|
setDraftBody("");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}, [messages, copilotMut.isPending]);
|
||||||
|
|
||||||
|
const send = React.useCallback(() => {
|
||||||
|
const trimmed = input.trim();
|
||||||
|
if (!trimmed || copilotMut.isPending) return;
|
||||||
|
if (!workspaceSlug) return;
|
||||||
|
const userMsg: ChatMessage = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
role: "user",
|
||||||
|
content: trimmed,
|
||||||
|
};
|
||||||
|
const next = [...messages, userMsg];
|
||||||
|
setMessages(next);
|
||||||
|
setInput("");
|
||||||
|
const history = next.slice(0, -1).map((m) => ({
|
||||||
|
role: m.role,
|
||||||
|
content: m.content,
|
||||||
|
}));
|
||||||
|
copilotMut.mutate({
|
||||||
|
workspace: workspaceSlug,
|
||||||
|
prompt: trimmed,
|
||||||
|
messages: history.length > 0 ? history : undefined,
|
||||||
|
});
|
||||||
|
}, [input, copilotMut, messages, workspaceSlug]);
|
||||||
|
|
||||||
|
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
send();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDraftTask = () => {
|
||||||
|
if (!workspaceSlug) return;
|
||||||
|
if (!draftPlanSlug.trim() || !draftEpicSlug.trim() || !draftSlug.trim() || !draftTitle.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createTaskMut.mutate({
|
||||||
|
workspace: workspaceSlug,
|
||||||
|
planSlug: draftPlanSlug.trim().toLowerCase(),
|
||||||
|
epicSlug: draftEpicSlug.trim().toLowerCase(),
|
||||||
|
slug: draftSlug.trim().toLowerCase(),
|
||||||
|
title: draftTitle.trim(),
|
||||||
|
bodyMarkdown: draftBody.trim() || undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const isBusy = copilotMut.isPending;
|
||||||
|
const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
|
<SheetContent side="right" className="flex w-full max-w-md flex-col p-0 sm:max-w-md">
|
||||||
|
<SheetHeader className="shrink-0 border-b px-4 py-3 text-left">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Sparkles className="size-5 text-primary" />
|
||||||
|
<SheetTitle className="text-base">Ops copilot</SheetTitle>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<SheetDescription className="text-xs">
|
||||||
|
Ask "what needs me?" — grounded in fleet, convoys and backlog. Links use [workspace/slug].
|
||||||
|
{workspaceSlug ? ` · ${workspaceSlug}` : " · no workspace"}
|
||||||
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto px-3 py-3">
|
||||||
|
{messages.length === 0 && !isBusy ? (
|
||||||
|
<div className="space-y-3 px-1 py-6 text-center">
|
||||||
|
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
|
||||||
|
<Sparkles className="size-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium">What needs you?</p>
|
||||||
|
<p className="mx-auto max-w-[22rem] text-xs text-muted-foreground">
|
||||||
|
Try "what needs my approval?" — fleet → needs you will be cited with [fleet] links.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap justify-center gap-2">
|
||||||
|
{["What needs me?", "What's ready to start?", "Show my gate inbox"].map((chip) => (
|
||||||
|
<button
|
||||||
|
key={chip}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setInput(chip)}
|
||||||
|
className="rounded-full border bg-card px-3 py-1.5 text-xs hover:bg-muted"
|
||||||
|
>
|
||||||
|
{chip}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{!workspaceSlug && (
|
||||||
|
<p className="text-xs text-destructive">No workspace selected — create or select one first.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{messages.map((m) => (
|
||||||
|
<div
|
||||||
|
key={m.id}
|
||||||
|
className={cn("flex gap-2", m.role === "user" ? "justify-end" : "justify-start")}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"max-w-[85%] rounded-lg px-3 py-2 text-sm whitespace-pre-wrap",
|
||||||
|
m.role === "user"
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "border bg-muted",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{m.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{isBusy && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="rounded-lg border bg-card px-3 py-2">
|
||||||
|
<TypingDots />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{copilotMut.error && (
|
||||||
|
<p className="rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||||
|
{copilotMut.error.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{copilotMut.data && (
|
||||||
|
<p className="px-1 text-[10px] text-muted-foreground">
|
||||||
|
Model: {copilotMut.data.resolvedAlias} ({copilotMut.data.modelId})
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="shrink-0 border-t bg-muted/20 px-3 py-3">
|
||||||
|
<div className="mb-2 flex items-center justify-between gap-2">
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
{lastAssistant ? "Need to create work from this answer?" : "Draft a Task from chat"}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
onClick={() => setDraftOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
Draft Task
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{draftOpen && (
|
||||||
|
<div className="mb-3 rounded-md border bg-background p-3">
|
||||||
|
<p className="mb-2 text-xs font-medium">Create backlog task via create-backlog-item shape</p>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-[10px] font-medium uppercase text-muted-foreground">Plan slug</label>
|
||||||
|
<Input
|
||||||
|
value={draftPlanSlug}
|
||||||
|
onChange={(e) => setDraftPlanSlug(e.target.value)}
|
||||||
|
placeholder="automation-foundation"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-[10px] font-medium uppercase text-muted-foreground">Epic slug</label>
|
||||||
|
<Input
|
||||||
|
value={draftEpicSlug}
|
||||||
|
onChange={(e) => setDraftEpicSlug(e.target.value)}
|
||||||
|
placeholder="ops-copilot"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-[10px] font-medium uppercase text-muted-foreground">Task slug</label>
|
||||||
|
<Input
|
||||||
|
value={draftSlug}
|
||||||
|
onChange={(e) => setDraftSlug(e.target.value)}
|
||||||
|
placeholder="triage-bug"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-[10px] font-medium uppercase text-muted-foreground">Title</label>
|
||||||
|
<Input
|
||||||
|
value={draftTitle}
|
||||||
|
onChange={(e) => setDraftTitle(e.target.value)}
|
||||||
|
placeholder="Triage bug from copilot"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
<label className="text-[10px] font-medium uppercase text-muted-foreground">Body markdown</label>
|
||||||
|
<textarea
|
||||||
|
value={draftBody}
|
||||||
|
onChange={(e) => setDraftBody(e.target.value)}
|
||||||
|
placeholder="One or two sentences. Will become Task-*.md"
|
||||||
|
rows={3}
|
||||||
|
className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setDraftOpen(false)}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
disabled={
|
||||||
|
createTaskMut.isPending ||
|
||||||
|
!draftPlanSlug.trim() ||
|
||||||
|
!draftEpicSlug.trim() ||
|
||||||
|
!draftSlug.trim() ||
|
||||||
|
!draftTitle.trim() ||
|
||||||
|
!workspaceSlug
|
||||||
|
}
|
||||||
|
onClick={handleDraftTask}
|
||||||
|
>
|
||||||
|
{createTaskMut.isPending ? <Loader2 className="size-3 animate-spin" /> : "Create Task"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{createTaskMut.error && (
|
||||||
|
<p className="mt-2 text-xs text-destructive">{createTaskMut.error.message}</p>
|
||||||
|
)}
|
||||||
|
{createTaskMut.isSuccess && (
|
||||||
|
<p className="mt-2 text-xs text-green-600">Task created — check Plans tree.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<textarea
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
placeholder={workspaceSlug ? "What needs me?" : "Select workspace first"}
|
||||||
|
disabled={isBusy || !workspaceSlug}
|
||||||
|
rows={2}
|
||||||
|
className={cn(
|
||||||
|
"min-h-[44px] flex-1 resize-none rounded-md border border-input bg-background px-3 py-2 text-sm",
|
||||||
|
"placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
className="h-[44px] w-[44px] shrink-0"
|
||||||
|
onClick={send}
|
||||||
|
disabled={isBusy || !input.trim() || !workspaceSlug}
|
||||||
|
aria-label="Send"
|
||||||
|
>
|
||||||
|
{isBusy ? <Loader2 className="size-4 animate-spin" /> : <Send className="size-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OpsCopilotFab({ workspaceSlug }: { workspaceSlug: string }) {
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="fixed bottom-6 right-6 z-40 h-12 w-12 rounded-full shadow-lg"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Open ops copilot"
|
||||||
|
>
|
||||||
|
<Sparkles className="size-5" />
|
||||||
|
</Button>
|
||||||
|
<OpsCopilotPanel workspaceSlug={workspaceSlug} open={open} onOpenChange={setOpen} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,7 @@ import { runsRouter } from "@/server/routers/runs";
|
||||||
import { backlogRouter } from "@/server/routers/backlog";
|
import { backlogRouter } from "@/server/routers/backlog";
|
||||||
import { connectedReposRouter } from "@/server/routers/connected-repos";
|
import { connectedReposRouter } from "@/server/routers/connected-repos";
|
||||||
import { convoysRouter } from "@/server/routers/convoys";
|
import { convoysRouter } from "@/server/routers/convoys";
|
||||||
|
import { copilotRouter, opsCopilotRouter } from "@/server/routers/copilot";
|
||||||
import { harnessesRouter } from "@/server/routers/harnesses";
|
import { harnessesRouter } from "@/server/routers/harnesses";
|
||||||
|
|
||||||
export const appRouter = router({
|
export const appRouter = router({
|
||||||
|
|
@ -39,6 +40,8 @@ export const appRouter = router({
|
||||||
connectedRepos: connectedReposRouter,
|
connectedRepos: connectedReposRouter,
|
||||||
convoys: convoysRouter,
|
convoys: convoysRouter,
|
||||||
harnesses: harnessesRouter,
|
harnesses: harnessesRouter,
|
||||||
|
copilot: copilotRouter,
|
||||||
|
opsCopilot: opsCopilotRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AppRouter = typeof appRouter;
|
export type AppRouter = typeof appRouter;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { createLanguageModelFromResolved } from "@tasks/ai";
|
import { createLanguageModelFromResolved } from "@tasks/ai";
|
||||||
import {
|
import {
|
||||||
|
completionArtifacts,
|
||||||
convoyOperatorMessages,
|
convoyOperatorMessages,
|
||||||
modelProviders,
|
modelProviders,
|
||||||
objects,
|
objects,
|
||||||
|
|
@ -22,9 +23,10 @@ import {
|
||||||
type ModelProvider,
|
type ModelProvider,
|
||||||
type RoleModelRoute,
|
type RoleModelRoute,
|
||||||
} from "@tasks/shared/model-routing";
|
} from "@tasks/shared/model-routing";
|
||||||
|
import { generateCompletionSummary } from "@tasks/shared/worker";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { generateText } from "ai";
|
import { generateText } from "ai";
|
||||||
import { and, asc, eq, inArray } from "drizzle-orm";
|
import { and, asc, desc, eq, inArray } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -462,6 +464,162 @@ export const convoysRouter = router({
|
||||||
}));
|
}));
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
complete: workspaceProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
workspace: z.string().min(1),
|
||||||
|
convoyId: z.string().uuid(),
|
||||||
|
screenshotUrl: z.string().url().nullable().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const convoy = await loadConvoyProject(ctx.db, ctx.workspace.id, input.convoyId);
|
||||||
|
if (!convoy) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: "Convoy not found." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = (convoy.status ?? "draft") as ConvoyStatus;
|
||||||
|
if (status !== "merged" && status !== "published") {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: `Convoy must be in merged or published to complete, currently ${status}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentParsed = convoyObjectContentSchema.safeParse(convoy.content);
|
||||||
|
const briefs = await getConvoyBriefs(ctx.db, ctx.workspace.id, convoy.id);
|
||||||
|
const pr = await getConvoyPrUrl(ctx.db, ctx.workspace.id, convoy.id);
|
||||||
|
const events = await getRecentConvoyEvents(ctx.db, ctx.workspace.id, convoy.id, 30);
|
||||||
|
|
||||||
|
let modelAlias: string | null = null;
|
||||||
|
let harnessAlias: string | null = null;
|
||||||
|
for (const ev of events) {
|
||||||
|
const maybeModel = (ev as Record<string, unknown>).modelAlias as string | null | undefined;
|
||||||
|
const maybeHarness = (ev as Record<string, unknown>).harnessAlias as string | null | undefined;
|
||||||
|
if (!modelAlias && typeof maybeModel === "string" && maybeModel) modelAlias = maybeModel;
|
||||||
|
if (!harnessAlias && typeof maybeHarness === "string" && maybeHarness) harnessAlias = maybeHarness;
|
||||||
|
if (modelAlias && harnessAlias) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const slug = contentParsed.success ? contentParsed.data.convoySlug : convoy.title;
|
||||||
|
const classification = contentParsed.success
|
||||||
|
? contentParsed.data.classification
|
||||||
|
: ("feature" as const);
|
||||||
|
const successMetric = contentParsed.success
|
||||||
|
? contentParsed.data.successMetric
|
||||||
|
: "No success metric captured";
|
||||||
|
|
||||||
|
const summaryMd = generateCompletionSummary({
|
||||||
|
slug,
|
||||||
|
classification,
|
||||||
|
successMetric,
|
||||||
|
briefs: briefs.map((b) => ({ title: b.title, status: b.status })),
|
||||||
|
modelAlias,
|
||||||
|
harnessAlias,
|
||||||
|
prUrl: pr.prUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
let result: Awaited<ReturnType<typeof transitionConvoy>>;
|
||||||
|
try {
|
||||||
|
result = await transitionConvoy(ctx.db, {
|
||||||
|
workspaceId: ctx.workspace.id,
|
||||||
|
convoyId: input.convoyId,
|
||||||
|
toStatus: "released",
|
||||||
|
actor: "human-gate-3",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ConvoyTransitionError) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: error.message,
|
||||||
|
cause: { code: error.code },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [artifact] = await ctx.db
|
||||||
|
.insert(completionArtifacts)
|
||||||
|
.values({
|
||||||
|
workspaceId: ctx.workspace.id,
|
||||||
|
convoyId: convoy.id,
|
||||||
|
summaryMd,
|
||||||
|
screenshotUrl: input.screenshotUrl ?? null,
|
||||||
|
createdBy: ctx.session.user.id,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!artifact) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message: "Failed to create completion artifact.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
convoy: result.convoy,
|
||||||
|
artifact: {
|
||||||
|
id: artifact.id,
|
||||||
|
workspaceId: artifact.workspaceId,
|
||||||
|
convoyId: artifact.convoyId,
|
||||||
|
summaryMd: artifact.summaryMd,
|
||||||
|
screenshotUrl: artifact.screenshotUrl,
|
||||||
|
createdBy: artifact.createdBy,
|
||||||
|
createdAt: artifact.createdAt.toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
listArtifacts: workspaceProcedure.query(async ({ ctx }) => {
|
||||||
|
const rows = await ctx.db
|
||||||
|
.select({
|
||||||
|
artifact: completionArtifacts,
|
||||||
|
convoyTitle: objects.title,
|
||||||
|
convoyStatus: objects.status,
|
||||||
|
convoyContent: objects.content,
|
||||||
|
})
|
||||||
|
.from(completionArtifacts)
|
||||||
|
.leftJoin(objects, eq(objects.id, completionArtifacts.convoyId))
|
||||||
|
.where(eq(completionArtifacts.workspaceId, ctx.workspace.id))
|
||||||
|
.orderBy(desc(completionArtifacts.createdAt));
|
||||||
|
|
||||||
|
const enriched = await Promise.all(
|
||||||
|
rows.map(async (row) => {
|
||||||
|
const pr = await getConvoyPrUrl(ctx.db, ctx.workspace.id, row.artifact.convoyId);
|
||||||
|
const events = await getRecentConvoyEvents(
|
||||||
|
ctx.db,
|
||||||
|
ctx.workspace.id,
|
||||||
|
row.artifact.convoyId,
|
||||||
|
20,
|
||||||
|
);
|
||||||
|
const content = convoyObjectContentSchema.safeParse(row.convoyContent);
|
||||||
|
return {
|
||||||
|
id: row.artifact.id,
|
||||||
|
workspaceId: row.artifact.workspaceId,
|
||||||
|
convoyId: row.artifact.convoyId,
|
||||||
|
convoyTitle: row.convoyTitle ?? "Untitled",
|
||||||
|
convoySlug: content.success ? content.data.convoySlug : (row.convoyTitle ?? ""),
|
||||||
|
convoyStatus: row.convoyStatus ?? "released",
|
||||||
|
summaryMd: row.artifact.summaryMd,
|
||||||
|
screenshotUrl: row.artifact.screenshotUrl,
|
||||||
|
createdBy: row.artifact.createdBy,
|
||||||
|
createdAt: row.artifact.createdAt.toISOString(),
|
||||||
|
pr,
|
||||||
|
timeline: events.map((ev) => ({
|
||||||
|
id: ev.id,
|
||||||
|
eventType: ev.eventType,
|
||||||
|
role: ev.role,
|
||||||
|
actor: ev.actor,
|
||||||
|
occurredAt: ev.occurredAt.toISOString(),
|
||||||
|
metadata: ev.metadata,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return enriched;
|
||||||
|
}),
|
||||||
|
|
||||||
fleet: protectedProcedure.query(async ({ ctx }) => {
|
fleet: protectedProcedure.query(async ({ ctx }) => {
|
||||||
const userId = ctx.session.user.id;
|
const userId = ctx.session.user.id;
|
||||||
|
|
||||||
|
|
|
||||||
271
apps/web/server/routers/copilot.ts
Normal file
271
apps/web/server/routers/copilot.ts
Normal file
|
|
@ -0,0 +1,271 @@
|
||||||
|
import { createLanguageModelFromResolved } from "@tasks/ai";
|
||||||
|
import {
|
||||||
|
markdownBacklogItems,
|
||||||
|
modelProviders,
|
||||||
|
roleModelRoutes,
|
||||||
|
} from "@tasks/database/schema";
|
||||||
|
import {
|
||||||
|
convoyObjectContentSchema,
|
||||||
|
GATE_INBOX_STATUSES,
|
||||||
|
} from "@tasks/shared/convoy-lifecycle";
|
||||||
|
import {
|
||||||
|
providerTypeSchema,
|
||||||
|
resolveModel,
|
||||||
|
type ModelProvider,
|
||||||
|
type RoleModelRoute,
|
||||||
|
} from "@tasks/shared/model-routing";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { generateText, tool } from "ai";
|
||||||
|
import { asc, eq } from "drizzle-orm";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import {
|
||||||
|
listConvoyProjects,
|
||||||
|
loadConvoyProject,
|
||||||
|
} from "@/server/lib/convoy/convoy-service";
|
||||||
|
import { router, workspaceProcedure } from "@/server/trpc";
|
||||||
|
import type { WorkspaceProcedureContext } from "@/server/trpc";
|
||||||
|
|
||||||
|
type ProviderRow = typeof modelProviders.$inferSelect;
|
||||||
|
type RouteRow = typeof roleModelRoutes.$inferSelect;
|
||||||
|
|
||||||
|
function mapProviderRow(row: ProviderRow): ModelProvider {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
workspaceId: row.workspaceId,
|
||||||
|
alias: row.alias,
|
||||||
|
displayName: row.displayName,
|
||||||
|
providerType: providerTypeSchema.parse(row.providerType),
|
||||||
|
modelId: row.modelId,
|
||||||
|
baseUrl: row.baseUrl,
|
||||||
|
apiKeyEnvVar: row.apiKeyEnvVar,
|
||||||
|
extraHeaders: row.extraHeaders,
|
||||||
|
defaultParams: row.defaultParams,
|
||||||
|
metadata: row.metadata,
|
||||||
|
enabled: row.enabled,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapRouteRow(row: RouteRow): RoleModelRoute {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
workspaceId: row.workspaceId,
|
||||||
|
roleId: row.roleId,
|
||||||
|
providerAliasId: row.providerAliasId,
|
||||||
|
enabled: row.enabled,
|
||||||
|
sortOrder: row.sortOrder,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readApiKey(envVarName: string): string | undefined {
|
||||||
|
return process.env[envVarName]?.trim() || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertWorkspaceAdmin(_ctx: WorkspaceProcedureContext): void {
|
||||||
|
// ops copilot is read-only; no admin check needed, but keep helper for future
|
||||||
|
void _ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OPS_SYSTEM_PROMPT =
|
||||||
|
"You are ops copilot for second brain OS. You help owners triage \"what needs me?\" across fleet, convoys and backlog. " +
|
||||||
|
"Use the provided tools (fleetStatus, convoyGet, backlogListTree) to ground answers. " +
|
||||||
|
"Always respond with links in form [workspaceSlug/convoySlug] for convoys and [workspaceSlug/planSlug/epicSlug/taskSlug] for backlog items. " +
|
||||||
|
"Show fleet → needs you citations when answering gate questions. Be concise, actionable, no secrets.";
|
||||||
|
|
||||||
|
const messageSchema = z.object({
|
||||||
|
role: z.enum(["user", "assistant"]),
|
||||||
|
content: z.string().min(1).max(4000),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const opsCopilotRouter = router({
|
||||||
|
generateText: workspaceProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
workspace: z.string().min(1),
|
||||||
|
prompt: z.string().min(1).max(4000),
|
||||||
|
messages: z.array(messageSchema).optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
void assertWorkspaceAdmin(ctx);
|
||||||
|
|
||||||
|
const providerRows = await ctx.db
|
||||||
|
.select()
|
||||||
|
.from(modelProviders)
|
||||||
|
.where(eq(modelProviders.workspaceId, ctx.workspace.id))
|
||||||
|
.orderBy(asc(modelProviders.alias));
|
||||||
|
|
||||||
|
const routeRows = await ctx.db
|
||||||
|
.select()
|
||||||
|
.from(roleModelRoutes)
|
||||||
|
.where(eq(roleModelRoutes.workspaceId, ctx.workspace.id));
|
||||||
|
|
||||||
|
const resolved = resolveModel({
|
||||||
|
roleId: "gate-copilot",
|
||||||
|
providers: providerRows.map(mapProviderRow),
|
||||||
|
routes: routeRows.map(mapRouteRow),
|
||||||
|
getApiKey: readApiKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resolved.ok) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: resolved.error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelResult = createLanguageModelFromResolved(resolved.model);
|
||||||
|
if (!modelResult.ok) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: modelResult.reason,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bounded, workspace-scoped, read-only tools (<3 tools)
|
||||||
|
const fleetStatusTool = tool({
|
||||||
|
description:
|
||||||
|
"Get fleet gate inbox needing you for this workspace. Returns up to 10 gate items with links [workspaceSlug/convoySlug].",
|
||||||
|
parameters: z.object({}),
|
||||||
|
execute: async () => {
|
||||||
|
const convoys = await listConvoyProjects(ctx.db, ctx.workspace.id);
|
||||||
|
const gateInbox = convoys.filter((c) =>
|
||||||
|
(GATE_INBOX_STATUSES as readonly string[]).includes(c.status),
|
||||||
|
);
|
||||||
|
const bounded = gateInbox.slice(0, 10);
|
||||||
|
const payload = {
|
||||||
|
workspace: ctx.workspace.slug,
|
||||||
|
totalNeedingYou: gateInbox.length,
|
||||||
|
items: bounded.map((c) => ({
|
||||||
|
convoyId: c.id,
|
||||||
|
convoySlug: c.content?.convoySlug ?? c.title,
|
||||||
|
status: c.status,
|
||||||
|
classification: c.content?.classification ?? null,
|
||||||
|
updatedAt: c.updatedAt.toISOString(),
|
||||||
|
link: `${ctx.workspace.slug}/${c.content?.convoySlug ?? c.id}`,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
return JSON.stringify(payload);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const convoyGetTool = tool({
|
||||||
|
description: "Get one convoy by id (workspace-scoped, read-only).",
|
||||||
|
parameters: z.object({
|
||||||
|
convoyId: z.string().uuid(),
|
||||||
|
}),
|
||||||
|
execute: async ({ convoyId }: { convoyId: string }) => {
|
||||||
|
const convoy = await loadConvoyProject(ctx.db, ctx.workspace.id, convoyId);
|
||||||
|
if (!convoy) {
|
||||||
|
return JSON.stringify({ error: "Convoy not found in this workspace" });
|
||||||
|
}
|
||||||
|
const content = convoyObjectContentSchema.safeParse(convoy.content);
|
||||||
|
const payload = {
|
||||||
|
id: convoy.id,
|
||||||
|
title: convoy.title,
|
||||||
|
status: convoy.status ?? "draft",
|
||||||
|
description: convoy.description ? convoy.description.slice(0, 1000) : null,
|
||||||
|
content: content.success ? content.data : null,
|
||||||
|
updatedAt: convoy.updatedAt.toISOString(),
|
||||||
|
link: `${ctx.workspace.slug}/${content.success ? content.data.convoySlug : convoy.title}`,
|
||||||
|
};
|
||||||
|
return JSON.stringify(payload);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const backlogListTreeTool = tool({
|
||||||
|
description:
|
||||||
|
"List backlog tree (plans/epics/tasks) for this workspace. Bounded to 20 items, read-only.",
|
||||||
|
parameters: z.object({}),
|
||||||
|
execute: async () => {
|
||||||
|
const rows = await ctx.db
|
||||||
|
.select({
|
||||||
|
id: markdownBacklogItems.id,
|
||||||
|
kind: markdownBacklogItems.kind,
|
||||||
|
slug: markdownBacklogItems.slug,
|
||||||
|
planSlug: markdownBacklogItems.planSlug,
|
||||||
|
epicSlug: markdownBacklogItems.epicSlug,
|
||||||
|
title: markdownBacklogItems.title,
|
||||||
|
status: markdownBacklogItems.status,
|
||||||
|
priority: markdownBacklogItems.priority,
|
||||||
|
repoPath: markdownBacklogItems.repoPath,
|
||||||
|
updatedAt: markdownBacklogItems.updatedAt,
|
||||||
|
})
|
||||||
|
.from(markdownBacklogItems)
|
||||||
|
.where(eq(markdownBacklogItems.workspaceId, ctx.workspace.id))
|
||||||
|
.orderBy(asc(markdownBacklogItems.planSlug), asc(markdownBacklogItems.kind), asc(markdownBacklogItems.slug));
|
||||||
|
|
||||||
|
const bounded = rows.slice(0, 20);
|
||||||
|
const payload = {
|
||||||
|
workspace: ctx.workspace.slug,
|
||||||
|
total: rows.length,
|
||||||
|
items: bounded.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
kind: r.kind,
|
||||||
|
slug: r.slug,
|
||||||
|
planSlug: r.planSlug,
|
||||||
|
epicSlug: r.epicSlug,
|
||||||
|
title: r.title,
|
||||||
|
status: r.status,
|
||||||
|
priority: r.priority,
|
||||||
|
repoPath: r.repoPath,
|
||||||
|
link: `${ctx.workspace.slug}/${r.planSlug}${r.epicSlug ? `/${r.epicSlug}` : ""}/${r.slug}`,
|
||||||
|
updatedAt: r.updatedAt.toISOString(),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
return JSON.stringify(payload);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const historyMessages = (input.messages ?? []).map((m) => ({
|
||||||
|
role: m.role as "user" | "assistant",
|
||||||
|
content: m.content,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Merge prompt as final user message; system is separate
|
||||||
|
const promptMessages = [
|
||||||
|
...historyMessages,
|
||||||
|
{ role: "user" as const, content: input.prompt },
|
||||||
|
];
|
||||||
|
|
||||||
|
const system = [
|
||||||
|
OPS_SYSTEM_PROMPT,
|
||||||
|
`Current workspace: ${ctx.workspace.name} (${ctx.workspace.slug})`,
|
||||||
|
`Workspace id: ${ctx.workspace.id}`,
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const { text, toolCalls, steps } = await generateText({
|
||||||
|
model: modelResult.model,
|
||||||
|
system,
|
||||||
|
messages: promptMessages,
|
||||||
|
tools: {
|
||||||
|
fleetStatus: fleetStatusTool,
|
||||||
|
convoyGet: convoyGetTool,
|
||||||
|
backlogListTree: backlogListTreeTool,
|
||||||
|
},
|
||||||
|
maxSteps: 3,
|
||||||
|
temperature: 0.4,
|
||||||
|
maxTokens: 1200,
|
||||||
|
});
|
||||||
|
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message: "Empty copilot response",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: trimmed,
|
||||||
|
resolvedAlias: resolved.resolvedAlias,
|
||||||
|
modelId: resolved.model.modelId,
|
||||||
|
providerType: resolved.model.providerType,
|
||||||
|
toolCalls: toolCalls ?? [],
|
||||||
|
steps: steps?.length ?? 0,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Alias for file naming: brief says copilot.ts with opsCopilot.generateText
|
||||||
|
export const copilotRouter = opsCopilotRouter;
|
||||||
65
docs/copilot-prompt.md
Normal file
65
docs/copilot-prompt.md
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
# Copilot prompts — versioned system prompts for `gate-copilot` and ops copilot
|
||||||
|
|
||||||
|
Version: `v1` — 2026-08-25. Source of truth for harness portability (DB, worker, web).
|
||||||
|
If you change a prompt, bump version and update both implementations in one commit.
|
||||||
|
|
||||||
|
## `gate-copilot` — gate co-pilot (`apps/web/server/routers/convoys.ts:gut`)
|
||||||
|
|
||||||
|
Used by `convoys.gateCopilot` tRPC. Resolved via `resolveModel({ roleId: "gate-copilot" })` with `convoyPolicy` overlay.
|
||||||
|
|
||||||
|
```
|
||||||
|
You are the gate co-pilot for an agent-pipeline convoy.
|
||||||
|
Summarize the plan, highlight risks, and recommend approve / revise / request changes.
|
||||||
|
Be concise and actionable.
|
||||||
|
|
||||||
|
Convoy: <convoySlug or title>
|
||||||
|
Status: <ConvoyStatus>
|
||||||
|
Classification: <classification>
|
||||||
|
Success metric: <successMetric>
|
||||||
|
Plan:
|
||||||
|
<convoy.description — full plan markdown>
|
||||||
|
```
|
||||||
|
|
||||||
|
- User prompt defaults to: `Summarize this convoy for the operator and suggest the best gate action.` Override via `input.question`.
|
||||||
|
- Model params: `temperature 0.4`, `maxTokens 1500`.
|
||||||
|
- Grounding: convoy row only; no backlog or fleet tools.
|
||||||
|
|
||||||
|
## `ops copilot` — ops copilot (`apps/web/server/routers/copilot.ts`)
|
||||||
|
|
||||||
|
Used by `opsCopilot.generateText` / `copilot.generateText`. Resolved via `resolveModel({ roleId: "gate-copilot" })` (shared route alias). Read-only, workspace-scoped.
|
||||||
|
|
||||||
|
System prompt:
|
||||||
|
|
||||||
|
```
|
||||||
|
You are ops copilot for second brain OS. You help owners triage "what needs me?" across fleet, convoys and backlog. Use the provided tools (fleetStatus, convoyGet, backlogListTree) to ground answers. Always respond with links in form [workspaceSlug/convoySlug] for convoys and [workspaceSlug/planSlug/epicSlug/taskSlug] for backlog items. Show fleet → needs you citations when answering gate questions. Be concise, actionable, no secrets.
|
||||||
|
Current workspace: <workspace.name> (<workspace.slug>)
|
||||||
|
Workspace id: <workspace.id>
|
||||||
|
```
|
||||||
|
|
||||||
|
Tools (bounded, workspace-scoped, read-only):
|
||||||
|
|
||||||
|
- `fleetStatus()` — gate inbox (`GATE_INBOX_STATUSES`) up to 10, returns `totalNeedingYou` + items with `link: workspaceSlug/convoySlug`.
|
||||||
|
- `convoyGet({ convoyId: uuid })` — one convoy, returns `link: workspaceSlug/convoySlug`.
|
||||||
|
- `backlogListTree()` — `markdown_backlog_items` for workspace, bounded 20, returns `link: workspaceSlug/planSlug/epicSlug/taskSlug`.
|
||||||
|
|
||||||
|
Model params: `temperature 0.4`, `maxTokens 1200`, `maxSteps 3`.
|
||||||
|
|
||||||
|
## Rules (both copilots)
|
||||||
|
|
||||||
|
- Workspace-scoped — never read across `workspace_id`.
|
||||||
|
- Never emit secrets, API keys, or `FORGE_WEBHOOK_SECRET`.
|
||||||
|
- Link format is mandatory for grounding: `workspaceSlug/convoySlug` and `workspaceSlug/planSlug/epicSlug/taskSlug`.
|
||||||
|
- Concise, actionable; cite `fleetStatus` when answering gate questions.
|
||||||
|
|
||||||
|
## Versioning
|
||||||
|
|
||||||
|
| Version | Date | Change |
|
||||||
|
|---------|------|--------|
|
||||||
|
| v1 | 2026-08-25 | Initial versioned snapshot from `convoys.ts:gateCopilot` + `copilot.ts:OPS_SYSTEM_PROMPT`. |
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Gate copilot impl: `apps/web/server/routers/convoys.ts`
|
||||||
|
- Ops copilot impl: `apps/web/server/routers/copilot.ts`
|
||||||
|
- Model routing: `packages/shared/src/model-routing/constants.ts` (`gate-copilot → claude-planning` default), `docs/adr-model-routing.md`
|
||||||
|
- Convoy states: `docs/convoy-lifecycle.md`, `packages/shared/src/convoy-lifecycle/state-machine.ts`
|
||||||
55
docs/examples/sop-published-flow.md
Normal file
55
docs/examples/sop-published-flow.md
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
# SOP published flow — `Task-write-sop → doc_review → published → artifact gallery` e2e
|
||||||
|
|
||||||
|
Fixture for `role-doc-writer` via harness `doc-writer` (`apps/worker/src/harness.ts`, `packages/shared/src/convoy-lifecycle/state-machine.ts:9`).
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Standard path for a doc-only SOP: write markdown under `docs/`, pass `doc_review`, publish, and verify artifact gallery.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Convoy in `ready` or `in_progress` with a doc brief (`role-doc-writer`).
|
||||||
|
- Workspace has `harness_providers alias: doc-writer` (`minimal`, `needsWorktree: false`, `allowedCommands: []`) and `role_model_routes role-doc-writer → qwen-coder` (local).
|
||||||
|
- `MARKDOWN_BACKLOG_WORKSPACE_ID` set if backlog import needed.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. **Claim** — orchestrator assigns `role-doc-writer`; worker resolves `doc-writer` harness (`needsWorktree: false` — no worktree under `/tmp/echodo-worker-worktrees`).
|
||||||
|
2. **Write** — worker tool loop uses only `read_file`, `grep`, `write_file` (no `run_command`). Write target is `docs/...` or SOP file:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
write_file({ path: "docs/operations/webhook-secret-rotation.md", content: "..." })
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Gate: `in_progress → doc_review`** — automation or `role-doc-writer` transitions via `transitionConvoy` (`packages/shared/src/convoy-lifecycle/state-machine.ts:104`). Mission Control shows `doc_review`.
|
||||||
|
4. **Gate: `doc_review → published`** — `human-gate-2` / `doc-approver` approves (`docs/convoy-lifecycle.md:27`, `state-machine.ts:104`). Event `status_change` with `actor: human-gate-2` or `doc-approver`.
|
||||||
|
5. **Gallery: `published → released` + artifact** — `convoys.complete` (`apps/web/server/routers/convoys.ts:467`) transitions `published → released` via `human-gate-3`, creates `completionArtifacts` row (`summaryMd` + optional `screenshotUrl`). Listed by `convoys.listArtifacts`.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- [ ] Worker trace shows only `read_file`, `grep`, `write_file`; no `run_command` / `pnpm`.
|
||||||
|
- [ ] `convoy_events` contains `doc_review → published` by `human-gate-2` / `doc-approver`.
|
||||||
|
- [ ] File exists at `docs/...` with expected frontmatter/content.
|
||||||
|
- [ ] `convoys.listArtifacts` returns row with `summaryMd` and `screenshotUrl` (placeholder ok) and timeline events.
|
||||||
|
|
||||||
|
## Screenshots (placeholder)
|
||||||
|
|
||||||
|
Replace placeholders before marking `published → released` demo complete.
|
||||||
|
|
||||||
|
- `docs/examples/screenshots/sop-doc-review-gate.png` — Mission Control detail showing `doc_review` gate with Approve / Request changes.
|
||||||
|
- `docs/examples/screenshots/sop-published-badge.png` — Convoy kanban / detail showing `published` status.
|
||||||
|
- `docs/examples/screenshots/sop-artifact-gallery.png` — Gallery (`convoys.listArtifacts`) showing summary + screenshot.
|
||||||
|
|
||||||
|
> Capture via app at `/[workspaceSlug]/mission-control` and `/[workspaceSlug]/.../artifacts` or equivalent gallery route. Store originals at `docs/examples/screenshots/` (git-ignored if binary-heavy; link anyway).
|
||||||
|
|
||||||
|
## Counterparts
|
||||||
|
|
||||||
|
- Code flow mirror: `in_progress → pr_review → merged → released` via Forge webhook `POST /api/webhooks/forge` (`apps/web/app/api/webhooks/forge/route.ts`, `docs/operations/webhook-secret-rotation.md`).
|
||||||
|
- Template: `docs/templates/doc-sop.md`.
|
||||||
|
- Research fixture: `docs/examples/research-pipeline.md`.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Lifecycle: `docs/convoy-lifecycle.md`, `packages/shared/src/convoy-lifecycle/state-machine.ts`
|
||||||
|
- Worker doc harness: `docs/worker.md`, `apps/worker/src/harness.ts`
|
||||||
|
- Artifacts tRPC: `apps/web/server/routers/convoys.ts:complete`, `convoys.ts:listArtifacts`
|
||||||
|
|
@ -19,6 +19,7 @@ This is the **operator** checklist for `plans/Plan-automation-foundation/Epic-ap
|
||||||
5. Settings → Runs (`/[workspaceSlug]/settings/runs`) shows row with `model_alias` + `tokensTotal` for that `runId`.
|
5. Settings → Runs (`/[workspaceSlug]/settings/runs`) shows row with `model_alias` + `tokensTotal` for that `runId`.
|
||||||
6. Mission Control detail `apps/web/app/(app)/[workspaceSlug]/mission-control/page.tsx:12` shows `role_complete` event within 5s (SSE `/api/fleet/events` or poll fallback).
|
6. Mission Control detail `apps/web/app/(app)/[workspaceSlug]/mission-control/page.tsx:12` shows `role_complete` event within 5s (SSE `/api/fleet/events` or poll fallback).
|
||||||
7. (Preferred, after `Task-worker-draft-pr`) Draft PR exists with marker `echodo-convoy: <convoyId>` consumable by Epic 8 webhook; for doc tasks `doc_review → published` without PR but `published` visible.
|
7. (Preferred, after `Task-worker-draft-pr`) Draft PR exists with marker `echodo-convoy: <convoyId>` consumable by Epic 8 webhook; for doc tasks `doc_review → published` without PR but `published` visible.
|
||||||
|
8. (Epic 8 — forge webhook) Merge PR triggers `POST /api/webhooks/forge` with `X-Hub-Signature-256`/`X-Forgejo-Signature` HMAC `FORGE_WEBHOOK_SECRET` → convoy `pr_review→merged` (`automation`, `convoy_events` `status_change`) and Mission Control shows `merged` within 5s; invalid signature returns 401, missing marker returns 200 no-op.
|
||||||
|
|
||||||
## Record
|
## Record
|
||||||
|
|
||||||
|
|
@ -36,6 +37,12 @@ This is the **operator** checklist for `plans/Plan-automation-foundation/Epic-ap
|
||||||
- Forge webhooks (Epic 8) — verified separately
|
- Forge webhooks (Epic 8) — verified separately
|
||||||
- New worker features — use sibling tasks
|
- New worker features — use sibling tasks
|
||||||
|
|
||||||
|
## Docs
|
||||||
|
|
||||||
|
- Webhook rotation: [`docs/operations/webhook-secret-rotation.md`](./webhook-secret-rotation.md)
|
||||||
|
- Copilot prompts (gate + ops): [`docs/copilot-prompt.md`](../copilot-prompt.md)
|
||||||
|
- SOP published flow: [`docs/examples/sop-published-flow.md`](../examples/sop-published-flow.md)
|
||||||
|
|
||||||
## Links
|
## Links
|
||||||
|
|
||||||
- Task: `plans/Plan-automation-foundation/Epic-apps-worker-v1/Task-worker-live-e2e-dogfood.md`
|
- Task: `plans/Plan-automation-foundation/Epic-apps-worker-v1/Task-worker-live-e2e-dogfood.md`
|
||||||
|
|
|
||||||
78
docs/operations/webhook-secret-rotation.md
Normal file
78
docs/operations/webhook-secret-rotation.md
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
# Webhook secret rotation — `FORGE_WEBHOOK_SECRET`
|
||||||
|
|
||||||
|
One-page rotation SOP for `POST /api/webhooks/forge` HMAC secret.
|
||||||
|
|
||||||
|
## Where it lives
|
||||||
|
|
||||||
|
- **Coolify (prod)**: `web` service env `FORGE_WEBHOOK_SECRET` in `docker/docker-compose.coolify.yml:web.environment` → Coolify UI → Environment Variables. Not committed; value lives only in Coolify env store and Forgejo webhook config.
|
||||||
|
- **Local dev**: `.env` at repo root (`FORGE_WEBHOOK_SECRET=...`). Copy from `.env.example` pattern — never commit real value.
|
||||||
|
- **Consumer**: `apps/web/app/api/webhooks/forge/route.ts:62` reads `process.env.FORGE_WEBHOOK_SECRET` and verifies `X-Hub-Signature-256` (GitHub) or `X-Forgejo-Signature` / `X-Hub-Signature` HMAC-SHA256 with `timingSafeEqual`. Rate-limit 60/min per IP. Missing secret → 500 `webhook_not_configured`.
|
||||||
|
|
||||||
|
## Rotate
|
||||||
|
|
||||||
|
1. Generate new secret (64 hex chars = 32 bytes):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl rand -hex 32
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy output → new `FORGE_WEBHOOK_SECRET`.
|
||||||
|
|
||||||
|
2. Update Forgejo / GitHub webhook:
|
||||||
|
|
||||||
|
- Forgejo: Repo → Settings → Webhooks → target webhook → **Secret** → paste new value → Save.
|
||||||
|
- GitHub: Repo → Settings → Webhooks → target webhook → **Secret** → paste new value → Save / Update webhook.
|
||||||
|
|
||||||
|
3. Update Coolify:
|
||||||
|
|
||||||
|
- Coolify → `echodo` service `web` → Environment Variables → set `FORGE_WEBHOOK_SECRET=<new-hex>` → Save.
|
||||||
|
- Redeploy `web` (Coolify Deploy). Verify `docker/docker-compose.coolify.yml` not edited directly for prod; Coolify env overlay wins.
|
||||||
|
|
||||||
|
4. For local dev, update `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
FORGE_WEBHOOK_SECRET=<new-hex> pnpm --filter @tasks/web dev
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Keep old secret for 5 min overlap if Forgejo allows dual-secret testing; otherwise coordinate deploy + webhook save within same minute.
|
||||||
|
|
||||||
|
## Test
|
||||||
|
|
||||||
|
Generate signature and POST to local or prod URL. Route accepts `sha256=<hex>` or bare `<hex>`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SECRET="<new-hex>"
|
||||||
|
BODY='{"action":"closed","pull_request":{"merged":true,"body":"echodo-convoy: 00000000-0000-0000-0000-000000000000","number":123}}'
|
||||||
|
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')
|
||||||
|
|
||||||
|
# Local
|
||||||
|
curl -i -X POST http://localhost:3000/api/webhooks/forge \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-H "X-Hub-Signature-256: sha256=$SIG" \
|
||||||
|
-d "$BODY"
|
||||||
|
|
||||||
|
# Forgejo variant (same HMAC, different header)
|
||||||
|
curl -i -X POST http://localhost:3000/api/webhooks/forge \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-H "X-Forgejo-Signature: sha256=$SIG" \
|
||||||
|
-d "$BODY"
|
||||||
|
|
||||||
|
# Prod
|
||||||
|
curl -i -X POST https://echodo.stillwell.cloud/api/webhooks/forge \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-H "X-Hub-Signature-256: sha256=$SIG" \
|
||||||
|
-d "$BODY"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verify
|
||||||
|
|
||||||
|
- Valid HMAC + `action=closed` + `pull_request.merged=true` + `body` contains `echodo-convoy: <uuid>` matching a convoy in `pr_review` → 200 `{"ok":true}` and convoy transitions `pr_review → merged` (`automation`, `pr#N merged`) visible in `convoy_events` and Mission Control within 5s.
|
||||||
|
- Invalid signature → 401 `{"ok":false,"error":"invalid_signature"}`.
|
||||||
|
- Missing `echodo-convoy:` marker or `merged=false` or `status !== pr_review` → 200 `{"ok":true}` no-op (no leak).
|
||||||
|
- Missing `FORGE_WEBHOOK_SECRET` env → 500 `webhook_not_configured`.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Route: `apps/web/app/api/webhooks/forge/route.ts`
|
||||||
|
- State machine: `packages/shared/src/convoy-lifecycle/state-machine.ts:90`
|
||||||
|
- Checklist: `docs/operations/live-e2e-checklist.md`
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
CREATE TABLE "convoy_operator_messages" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"workspace_id" uuid NOT NULL,
|
||||||
|
"convoy_id" uuid NOT NULL,
|
||||||
|
"brief_id" uuid,
|
||||||
|
"agent_run_id" uuid,
|
||||||
|
"delivery" varchar(20) NOT NULL,
|
||||||
|
"body" text NOT NULL,
|
||||||
|
"author_user_id" uuid NOT NULL,
|
||||||
|
"delivered_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "completion_artifacts" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"workspace_id" uuid NOT NULL,
|
||||||
|
"convoy_id" uuid NOT NULL,
|
||||||
|
"summary_md" text NOT NULL,
|
||||||
|
"screenshot_url" text,
|
||||||
|
"created_by" uuid,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "convoy_operator_messages" ADD CONSTRAINT "convoy_operator_messages_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "convoy_operator_messages" ADD CONSTRAINT "convoy_operator_messages_convoy_id_objects_id_fk" FOREIGN KEY ("convoy_id") REFERENCES "public"."objects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "convoy_operator_messages" ADD CONSTRAINT "convoy_operator_messages_brief_id_objects_id_fk" FOREIGN KEY ("brief_id") REFERENCES "public"."objects"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "convoy_operator_messages" ADD CONSTRAINT "convoy_operator_messages_agent_run_id_agent_runs_id_fk" FOREIGN KEY ("agent_run_id") REFERENCES "public"."agent_runs"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "convoy_operator_messages" ADD CONSTRAINT "convoy_operator_messages_author_user_id_users_id_fk" FOREIGN KEY ("author_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "completion_artifacts" ADD CONSTRAINT "completion_artifacts_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "completion_artifacts" ADD CONSTRAINT "completion_artifacts_convoy_id_objects_id_fk" FOREIGN KEY ("convoy_id") REFERENCES "public"."objects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "completion_artifacts" ADD CONSTRAINT "completion_artifacts_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "convoy_operator_messages_workspace_id_convoy_id_delivered_at_idx" ON "convoy_operator_messages" USING btree ("workspace_id","convoy_id","delivered_at");--> statement-breakpoint
|
||||||
|
CREATE INDEX "completion_artifacts_workspace_id_idx" ON "completion_artifacts" USING btree ("workspace_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "completion_artifacts_convoy_id_idx" ON "completion_artifacts" USING btree ("convoy_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "completion_artifacts_created_at_idx" ON "completion_artifacts" USING btree ("created_at");
|
||||||
4557
packages/database/migrations/meta/0017_snapshot.json
Normal file
4557
packages/database/migrations/meta/0017_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -120,6 +120,13 @@
|
||||||
"when": 1787850000000,
|
"when": 1787850000000,
|
||||||
"tag": "0016_noble_operator_notes",
|
"tag": "0016_noble_operator_notes",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 17,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787698170280,
|
||||||
|
"tag": "0017_lively_squadron_supreme",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
27
packages/database/src/schema/completion_artifacts.ts
Normal file
27
packages/database/src/schema/completion_artifacts.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
|
import { objects } from "./objects";
|
||||||
|
import { users } from "./users";
|
||||||
|
import { workspaces } from "./workspaces";
|
||||||
|
|
||||||
|
export const completionArtifacts = pgTable(
|
||||||
|
"completion_artifacts",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
workspaceId: uuid("workspace_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => workspaces.id, { onDelete: "cascade" }),
|
||||||
|
convoyId: uuid("convoy_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => objects.id, { onDelete: "cascade" }),
|
||||||
|
summaryMd: text("summary_md").notNull(),
|
||||||
|
screenshotUrl: text("screenshot_url"),
|
||||||
|
createdBy: uuid("created_by").references(() => users.id, { onDelete: "set null" }),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
workspaceIdIdx: index("completion_artifacts_workspace_id_idx").on(table.workspaceId),
|
||||||
|
convoyIdIdx: index("completion_artifacts_convoy_id_idx").on(table.convoyId),
|
||||||
|
createdAtIdx: index("completion_artifacts_created_at_idx").on(table.createdAt),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
@ -20,3 +20,4 @@ export * from "./convoy_events";
|
||||||
export * from "./worker_checkpoints";
|
export * from "./worker_checkpoints";
|
||||||
export * from "./harness_providers";
|
export * from "./harness_providers";
|
||||||
export * from "./convoy_operator_messages";
|
export * from "./convoy_operator_messages";
|
||||||
|
export * from "./completion_artifacts";
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,7 @@ export const ALLOWED_TRANSITIONS: readonly TransitionRule[] = [
|
||||||
actors: ["human-gate-1", "role-conductor"],
|
actors: ["human-gate-1", "role-conductor"],
|
||||||
requiresReason: true,
|
requiresReason: true,
|
||||||
},
|
},
|
||||||
{ from: "pr_review", to: "merged", actors: ["human-gate-2"] },
|
{ from: "pr_review", to: "merged", actors: ["human-gate-2", "automation"] },
|
||||||
{ from: "pr_review", to: "in_progress", actors: ["human-gate-2", "role-implementer"] },
|
{ from: "pr_review", to: "in_progress", actors: ["human-gate-2", "role-implementer"] },
|
||||||
{
|
{
|
||||||
from: "pr_review",
|
from: "pr_review",
|
||||||
|
|
|
||||||
34
packages/shared/src/worker/completion-summary.ts
Normal file
34
packages/shared/src/worker/completion-summary.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import type { ConvoyClassification } from "../convoy-lifecycle/schemas";
|
||||||
|
|
||||||
|
export type CompletionSummaryInput = {
|
||||||
|
slug: string;
|
||||||
|
classification: ConvoyClassification;
|
||||||
|
successMetric: string;
|
||||||
|
briefs: Array<{ title: string; status: string | null }>;
|
||||||
|
modelAlias?: string | null;
|
||||||
|
harnessAlias?: string | null;
|
||||||
|
prUrl?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function generateCompletionSummary(input: CompletionSummaryInput): string {
|
||||||
|
const briefCount = input.briefs.length;
|
||||||
|
const briefTitles =
|
||||||
|
briefCount === 0
|
||||||
|
? "No briefs"
|
||||||
|
: input.briefs.map((b) => `${b.title} (${b.status ?? "—"})`).join(", ");
|
||||||
|
|
||||||
|
const modelLine = input.modelAlias ? `Model: ${input.modelAlias}` : "Model: n/a";
|
||||||
|
const harnessLine = input.harnessAlias ? `Harness: ${input.harnessAlias}` : "Harness: n/a";
|
||||||
|
const prLine = input.prUrl ? `PR: ${input.prUrl}` : "PR: n/a";
|
||||||
|
|
||||||
|
const lines: string[] = [
|
||||||
|
"## Summary",
|
||||||
|
`Convoy ${input.slug} ${input.classification} — ${input.successMetric}`,
|
||||||
|
`Briefs: ${briefCount} — ${briefTitles}`,
|
||||||
|
modelLine,
|
||||||
|
harnessLine,
|
||||||
|
prLine,
|
||||||
|
];
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
@ -31,3 +31,8 @@ export {
|
||||||
type WorkerCheckpointSummary,
|
type WorkerCheckpointSummary,
|
||||||
type WorkerCheckpointToolStep,
|
type WorkerCheckpointToolStep,
|
||||||
} from "./checkpoint";
|
} from "./checkpoint";
|
||||||
|
|
||||||
|
export {
|
||||||
|
generateCompletionSummary,
|
||||||
|
type CompletionSummaryInput,
|
||||||
|
} from "./completion-summary";
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue