feat(worker): close Epic 7 — draft PR + operator notes + doc-writer
- Draft PR via forge (apps/worker/src/git/pr.ts) push branch + createPullRequest draft with echodo-convoy marker, expose prUrl in Mission Control (convoy-service, convoys router, detail panel) - Operator notes mid-build: convoy_operator_messages (0016), tRPC post/list, worker poll between tool rounds (queued) and before generateText (direct), detail panel thread for in_progress - Doc-writer: harness doc-writer examples (docs/examples/research-pipeline.md), SOP template (docs/templates/doc-sop.md), role-doc-writer wiring Convoy: .convoys/worker-closeout-docwriter Co-authored-by: orchestrator
This commit is contained in:
parent
1c27c88b14
commit
86155455a7
23 changed files with 1151 additions and 5 deletions
62
.convoys/worker-closeout-docwriter.md
Normal file
62
.convoys/worker-closeout-docwriter.md
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
---
|
||||||
|
name: worker-closeout-docwriter
|
||||||
|
classification: server-only
|
||||||
|
success_metric: Epic 7 closes — draft PR opens for code + doc_review→published for docs, operator notes work mid-build, doc-writer examples ship, live E2E checklist is runnable without Cursor
|
||||||
|
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-doc-writer: qwen-coder
|
||||||
|
role-reviewer: qwen-coder
|
||||||
|
escalate_to: claude-planning
|
||||||
|
---
|
||||||
|
|
||||||
|
# Worker closeout + doc writers — closing Epic 7 for second-brain v0.1
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Second-brain v0.1 shipped harness-agnostic, Plans tree, Fleet. Epic 7 remains `in_progress` per `plans/Plan-automation-foundation/Epic-apps-worker-v1/Epic-apps-worker-v1.md:23` — tool loop + checkpoints are done, but **no draft PR** via forge adapter blocks Epic 8 dogfood, and **live E2E** `Task-worker-live-e2e-dogfood.md` needs operator proof. Doc writers were asked to be included — this convoy adds `role-doc-writer` for SOPs/book research pipelines (docs that use `doc_review→published` without worktree).
|
||||||
|
|
||||||
|
See `docs/decisions/2026-08-25-second-brain-os-pivot.md`.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
1. **Draft PR via forge** `Task-worker-draft-pr.md` — push branch if needed, `createPullRequest(draft)` on GitHub/Forgejo from worktree, persist URL to convoy, skip for non-implementer roles, handle both `pr_review` and `doc_review` (doc tasks publish without PR but still log).
|
||||||
|
2. **Operator notes mid-build** `Task-worker-operator-notes.md` — `convoy_operator_messages` table (workspace-scoped, `queued|direct`), tRPC `postOperatorNote`/`listOperatorNotes`, worker poll between tool rounds, Mission control chat thread for `in_progress` convoys.
|
||||||
|
3. **Doc-writer examples** — `role-doc-writer` routed via harness (uses `doc-writer` harness `needsWorktree:false`), writes book/course research example under `docs/examples/research-pipeline.md` + `docs/templates/doc-sop.md`, updates `docs/worker.md` with doc harness section.
|
||||||
|
4. **Live E2E dogfood** `Task-worker-live-e2e-dogfood.md` — operator checklist doc (not code), proves `Go → worker` without Cursor; done only when checklist true on real Redis/Coolify.
|
||||||
|
|
||||||
|
## Roles invoked
|
||||||
|
|
||||||
|
Conductor → Architect (3 briefs map to 3 tasks + doc example) → Implementer per brief (disjoint: draft PR, operator notes, doc-writer) → Doc-writer (parallel) → Reviewer.
|
||||||
|
|
||||||
|
## Todos
|
||||||
|
|
||||||
|
- [ ] Brief 1 draft PR
|
||||||
|
- [ ] Brief 2 operator notes
|
||||||
|
- [ ] Brief 3 doc-writer examples + routing
|
||||||
|
- [ ] Brief 4 live E2E verification (ops doc, no code)
|
||||||
|
|
||||||
|
## IA
|
||||||
|
|
||||||
|
Skipped (`ia` in skip). No new entities beyond `convoy_operator_messages`; `harness_providers` already exists from v0.1.
|
||||||
|
|
||||||
|
## UX
|
||||||
|
|
||||||
|
Skipped (`ux` in skip). Reuses `Mission control detail panel` thread section + `Gate co-pilot` separation.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- State machine: `doc_review→published` already added `packages/shared/src/convoy-lifecycle/state-machine.ts:9` — reuse.
|
||||||
|
- Worker: `apps/worker/src/handle-job.ts:40` already optional worktree — add PR call after success before teardown, branch naming `echodo/<convoy>-<brief8>`, marker `echodo-convoy: <uuid>` in body for Epic 8 webhook.
|
||||||
|
- Operator notes: poll `convoy_operator_messages` where `delivered_at IS NULL` each `checkpointEveryNSteps`; inject `Queued note: ...` into system prompt tail or prepend `Direct message: ...`.
|
||||||
40
.convoys/worker-closeout-docwriter/brief-1-draft-pr-forge.md
Normal file
40
.convoys/worker-closeout-docwriter/brief-1-draft-pr-forge.md
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
---
|
||||||
|
brief: 1
|
||||||
|
convoy: worker-closeout-docwriter
|
||||||
|
role: role-implementer
|
||||||
|
title: Worker draft PR via forge adapter (code + doc)
|
||||||
|
status: ready
|
||||||
|
priority: P0
|
||||||
|
slice_dependencies: []
|
||||||
|
recommended_model: qwen-coder
|
||||||
|
model_tier: local
|
||||||
|
---
|
||||||
|
|
||||||
|
# Brief 1 — Draft PR `Task-worker-draft-pr.md`
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
`apps/worker` after successful `role-implementer` session pushes worktree branch if needed, calls `createPullRequest(draft:true)` on GitHub/Forgejo (from `connected_repos` / `repo_links`), persists `prUrl`/`prNumber` to `convoy_events metadata` + `objects.content.prUrl`, marker `echodo-convoy: <convoyId>` in body for Epic 8 webhook.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `apps/worker/src/git/pr.ts` NEW — `openDraftPR({monorepoRoot, worktreePath, branch, convoySlug, briefId, convoyId, workspaceId, forge})` → push `git push origin HEAD` (uses stored credentials / `GITHUB_TOKEN` env pattern) → `forge.createPullRequest({base: main, head: branch, title, body, draft:true})`. Body includes `Convoy: ${slug} Brief: ${briefId} echodo-convoy: ${convoyId}`.
|
||||||
|
- `apps/worker/src/handle-job.ts:40` — after `session.run` success and before teardown, if `roleId==='role-implementer'` and `worktreePath` present, call `openDraftPR`; on doc harness `needsWorktree:false` skip push but still log `published` artifact.
|
||||||
|
- `packages/database/src/schema/convoy_events.ts` — already has `metadata jsonb`, store `prUrl`.
|
||||||
|
- `apps/web/server/lib/convoy/convoy-service.ts` — expose `prUrl` from latest `metadata.prUrl`.
|
||||||
|
- `apps/web/components/mission-control/convoy-detail-panel.tsx` — show `PR` link when `metadata.prUrl`.
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
- Implementer job on test repo `stwl-labs/ubiquitous-invention` (or any connected) produces draft PR URL; non-implementer `role-architect` job skips PR creation.
|
||||||
|
- PR body contains marker consumable by Epic 8.
|
||||||
|
- Detail panel shows link when present.
|
||||||
|
- `pnpm lint && pnpm type-check && pnpm --filter @tasks/worker test` pass.
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
You are `role-implementer` brief 1. Read `plans/Plan-automation-foundation/Epic-apps-worker-v1/Task-worker-draft-pr.md`, `apps/web/server/lib/forge/adapters.ts`, `apps/worker/src/git/worktree.ts`, `docs/decisions/2026-08-25-second-brain-os-pivot.md`. Keep change small, follow existing `adapters.ts` interface.
|
||||||
|
|
||||||
|
## Depends
|
||||||
|
|
||||||
|
None.
|
||||||
41
.convoys/worker-closeout-docwriter/brief-2-operator-notes.md
Normal file
41
.convoys/worker-closeout-docwriter/brief-2-operator-notes.md
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
---
|
||||||
|
brief: 2
|
||||||
|
convoy: worker-closeout-docwriter
|
||||||
|
role: role-implementer
|
||||||
|
title: Operator notes mid-build — queued + direct messages
|
||||||
|
status: ready
|
||||||
|
priority: P1
|
||||||
|
slice_dependencies: []
|
||||||
|
recommended_model: qwen-coder
|
||||||
|
model_tier: local
|
||||||
|
---
|
||||||
|
|
||||||
|
# Brief 2 — Operator notes `Task-worker-operator-notes.md`
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Let operators send `queued` notes and `direct` messages to an active worker session without waiting for gate — Autensa benchmark row `operator-notes` `plans/Plan-automation-foundation/Plan-automation-foundation.md:229`.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `packages/database/src/schema/convoy_operator_messages.ts` NEW — `id uuid PK, workspace_id fk, convoy_id fk objects.id, brief_id fk optional, agent_run_id fk optional, delivery enum queued|direct, body text, author_user_id fk users, delivered_at timestamptz nullable, created_at defaultNow`. Index `workspace_id, convoy_id, delivered_at`.
|
||||||
|
- `packages/database/migrations/0016_*` — `pnpm db:generate`.
|
||||||
|
- `apps/web/server/routers/convoys.ts` — add `postOperatorNote` (workspace member; `delivery:queued|direct`, `body 1..2000`, `convoyId, briefId?`) inserts row; `listOperatorNotes` for detail panel thread.
|
||||||
|
- `apps/worker/src/db/operator-notes.ts` NEW — `pollOperatorNotes(db, workspaceId, convoyId, briefId?)` where `delivered_at IS NULL` ordered `created_at`.
|
||||||
|
- `apps/worker/src/handle-job.ts:40` — between tool rounds poll queued notes, append `Queued note from ${author}: ${body}` to `toolHistory` summary; before next `generateText` prepend direct messages to system prompt tail. Mark `delivered_at=now()` after injection. Log `harnessAlias`.
|
||||||
|
- `apps/web/app/(app)/[workspaceSlug]/mission-control/convoy-detail-panel.tsx` — thread tab when `status===in_progress` (textarea + Send → `postOperatorNote`, list `listOperatorNotes`), distinct from gate co-pilot.
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
- Operator posts `Queued note: use existing auth middleware` → worker sees it on next tool round (logged in checkpoint summary).
|
||||||
|
- `Direct message` arrives before next model call when run active (show in worker log `direct message injected`).
|
||||||
|
- Workspace-scoped, auditable (author + delivered_at visible in detail panel).
|
||||||
|
- `pnpm lint && pnpm type-check` pass.
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
You are `role-implementer` brief 2. Read `plans/Plan-automation-foundation/Epic-apps-worker-v1/Task-worker-operator-notes.md`, `packages/database/src/schema/convoy_events.ts`, `apps/web/server/routers/convoys.ts:173`, `docs/worker.md`. Keep polling fallback simple (no Redis pubsub).
|
||||||
|
|
||||||
|
## Depends
|
||||||
|
|
||||||
|
None — disjoint from brief 1.
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
---
|
||||||
|
brief: 3
|
||||||
|
convoy: worker-closeout-docwriter
|
||||||
|
role: role-doc-writer
|
||||||
|
title: Doc-writer examples + routing for second brain (book/course/research SOPs)
|
||||||
|
status: ready
|
||||||
|
priority: P1
|
||||||
|
slice_dependencies: []
|
||||||
|
recommended_model: qwen-coder
|
||||||
|
model_tier: local
|
||||||
|
---
|
||||||
|
|
||||||
|
# Brief 3 — `role-doc-writer` for second brain (book / course / SOPs)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Prove non-code second brain: `role-doc-writer` routed via harness `doc-writer` (`needsWorktree:false, allowedCommands:[]` `apps/worker/src/harness.ts:1`) writes publishable docs without `pnpm` or worktree, via `doc_review→published` `packages/shared/src/convoy-lifecycle/state-machine.ts:9`.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `docs/examples/research-pipeline.md` NEW — one page: "Heavy research pipeline" example (source → claim → outline → publish), calls `doc_review→published` flow; used as doc harness e2e fixture.
|
||||||
|
- `docs/templates/doc-sop.md` NEW — copy from `docs/templates/Task-*.md` but for SOPs: `kind: doc-sop, slug, title, status: draft→doc_review→published, body`.
|
||||||
|
- `packages/shared/src/model-routing` / `harness` seeds — add doc role wiring note: `role-doc-writer → qwen-coder-mlx + doc-writer` suggested alias map `docs/adr-model-routing.md:234`.
|
||||||
|
- `docs/worker.md` — extend doc harness section (already has optional worktree note from v0.1) with `role-doc-writer` example invocation.
|
||||||
|
- `apps/web/server/routers/harnesses.ts:1` — ensure `harnessType: minimal` with `alias: doc-writer` preset is seedable (if not, document manual creation).
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
- `role-doc-writer` job `docs/examples/research-pipeline.md` completes via `write_file` only (no `run_command`, no worktree under `/tmp/echodo-worker-worktrees`) and transitions `in_progress → doc_review` (`role-doc-writer` or automation), then `doc-approver` `doc_review→published` visible in Mission control.
|
||||||
|
- SOP template renders in `docs/templates/` list.
|
||||||
|
- `pnpm lint && pnpm type-check` pass.
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
You are `role-doc-writer` brief 3. Read `docs/decisions/2026-08-25-second-brain-os-pivot.md`, `docs/adr-model-routing.md:234`, `docs/convoy-lifecycle.md`, `packages/shared/src/convoy-lifecycle/schemas.ts:5`. Write docs that a future agent will read as context — keep them small, no narration comments.
|
||||||
|
|
||||||
|
## Depends
|
||||||
|
|
||||||
|
None — can run parallel with 1,2.
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- `plans/Plan-automation-foundation/Plan-automation-foundation.md` v0.1 pivot
|
||||||
|
- `apps/worker/src/harness.ts:1` `doc-writer` harness
|
||||||
|
|
@ -39,6 +39,8 @@ export function ConvoyDetailPanel({
|
||||||
}) {
|
}) {
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const [copilotText, setCopilotText] = React.useState<string | null>(null);
|
const [copilotText, setCopilotText] = React.useState<string | null>(null);
|
||||||
|
const [noteBody, setNoteBody] = React.useState("");
|
||||||
|
const [noteDelivery, setNoteDelivery] = React.useState<"queued" | "direct">("queued");
|
||||||
|
|
||||||
const detailQuery = api.convoys.get.useQuery(
|
const detailQuery = api.convoys.get.useQuery(
|
||||||
{ workspace: workspaceSlug, convoyId: convoyId ?? "" },
|
{ workspace: workspaceSlug, convoyId: convoyId ?? "" },
|
||||||
|
|
@ -56,6 +58,18 @@ export function ConvoyDetailPanel({
|
||||||
onSuccess: (result) => setCopilotText(result.text),
|
onSuccess: (result) => setCopilotText(result.text),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const operatorNotesQuery = api.convoys.listOperatorNotes.useQuery(
|
||||||
|
{ workspace: workspaceSlug, convoyId: convoyId ?? "" },
|
||||||
|
{ enabled: Boolean(workspaceSlug && convoyId) },
|
||||||
|
);
|
||||||
|
|
||||||
|
const postNoteMut = api.convoys.postOperatorNote.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
setNoteBody("");
|
||||||
|
void operatorNotesQuery.refetch();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (!convoyId) {
|
if (!convoyId) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center p-6 text-sm text-muted-foreground">
|
<div className="flex h-full items-center justify-center p-6 text-sm text-muted-foreground">
|
||||||
|
|
@ -106,6 +120,23 @@ export function ConvoyDetailPanel({
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{detailQuery.data?.pr?.prUrl && (
|
||||||
|
<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">PR</p>
|
||||||
|
<a
|
||||||
|
href={detailQuery.data.pr.prUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="break-all text-primary underline"
|
||||||
|
>
|
||||||
|
{detailQuery.data.pr.prUrl}
|
||||||
|
{typeof detailQuery.data.pr.prNumber === "number" && detailQuery.data.pr.prNumber
|
||||||
|
? ` (#${detailQuery.data.pr.prNumber})`
|
||||||
|
: ""}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{convoy.description && (
|
{convoy.description && (
|
||||||
<div className="mb-4 rounded-md border border-border bg-muted/30 p-3">
|
<div className="mb-4 rounded-md border border-border bg-muted/30 p-3">
|
||||||
<p className="mb-1 text-xs font-medium uppercase text-muted-foreground">Plan</p>
|
<p className="mb-1 text-xs font-medium uppercase text-muted-foreground">Plan</p>
|
||||||
|
|
@ -177,7 +208,7 @@ export function ConvoyDetailPanel({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(detailQuery.data?.briefs.length ?? 0) > 0 && (
|
{(detailQuery.data?.briefs.length ?? 0) > 0 && (
|
||||||
<div>
|
<div className="mb-4">
|
||||||
<p className="mb-2 text-xs font-medium uppercase text-muted-foreground">Briefs</p>
|
<p className="mb-2 text-xs font-medium uppercase text-muted-foreground">Briefs</p>
|
||||||
<ul className="space-y-1 text-sm">
|
<ul className="space-y-1 text-sm">
|
||||||
{detailQuery.data?.briefs.map((brief) => (
|
{detailQuery.data?.briefs.map((brief) => (
|
||||||
|
|
@ -189,6 +220,79 @@ export function ConvoyDetailPanel({
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{status === "in_progress" && convoyId && (
|
||||||
|
<div className="rounded-md border border-border p-3">
|
||||||
|
<p className="mb-2 text-xs font-medium uppercase text-muted-foreground">
|
||||||
|
Operator notes — in progress thread
|
||||||
|
</p>
|
||||||
|
<p className="mb-2 text-[11px] text-muted-foreground">
|
||||||
|
Distinct from gate co-pilot. Queued notes inject at next tool round; direct messages prepend before next model call.
|
||||||
|
</p>
|
||||||
|
<div className="mb-3 space-y-2 max-h-64 overflow-y-auto">
|
||||||
|
{(operatorNotesQuery.data ?? []).length === 0 ? (
|
||||||
|
<p className="text-xs text-muted-foreground">No operator messages yet.</p>
|
||||||
|
) : (
|
||||||
|
(operatorNotesQuery.data ?? []).map((note) => (
|
||||||
|
<div key={note.id} className="rounded border border-border bg-muted/20 p-2 text-xs">
|
||||||
|
<div className="mb-1 flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
note.delivery === "direct"
|
||||||
|
? "rounded bg-primary/10 px-1.5 py-0.5 font-medium text-primary"
|
||||||
|
: "rounded bg-muted px-1.5 py-0.5 font-medium"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{note.delivery}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">{new Date(note.createdAt).toLocaleString()}</span>
|
||||||
|
{note.deliveredAt && (
|
||||||
|
<span className="text-green-600">delivered {new Date(note.deliveredAt).toLocaleString()}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="whitespace-pre-wrap text-sm">{note.body}</p>
|
||||||
|
<p className="mt-1 text-[10px] text-muted-foreground">author {note.authorUserId.slice(0, 8)}</p>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<textarea
|
||||||
|
className="min-h-[72px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||||
|
placeholder="Queued note: use existing auth middleware"
|
||||||
|
value={noteBody}
|
||||||
|
onChange={(e) => setNoteBody(e.target.value)}
|
||||||
|
maxLength={2000}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<select
|
||||||
|
className="rounded-md border border-input bg-background px-2 py-1 text-xs"
|
||||||
|
value={noteDelivery}
|
||||||
|
onChange={(e) => setNoteDelivery(e.target.value as "queued" | "direct")}
|
||||||
|
>
|
||||||
|
<option value="queued">Queued note</option>
|
||||||
|
<option value="direct">Direct message</option>
|
||||||
|
</select>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
disabled={postNoteMut.isPending || !noteBody.trim()}
|
||||||
|
onClick={() =>
|
||||||
|
postNoteMut.mutate({
|
||||||
|
workspace: workspaceSlug,
|
||||||
|
convoyId,
|
||||||
|
delivery: noteDelivery,
|
||||||
|
body: noteBody.trim(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{postNoteMut.isPending ? <Loader2 className="size-4 animate-spin" /> : "Send"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{postNoteMut.error && <p className="text-xs text-destructive">{postNoteMut.error.message}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -197,6 +197,42 @@ export async function getRecentConvoyEvents(
|
||||||
.limit(limit);
|
.limit(limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getConvoyPrUrl(
|
||||||
|
db: Db,
|
||||||
|
workspaceId: string,
|
||||||
|
convoyId: string,
|
||||||
|
): Promise<{ prUrl: string | null; prNumber: number | null }> {
|
||||||
|
const events = await db
|
||||||
|
.select({ metadata: convoyEvents.metadata })
|
||||||
|
.from(convoyEvents)
|
||||||
|
.where(and(eq(convoyEvents.convoyId, convoyId), eq(convoyEvents.workspaceId, workspaceId)))
|
||||||
|
.orderBy(desc(convoyEvents.occurredAt))
|
||||||
|
.limit(30);
|
||||||
|
|
||||||
|
for (const row of events) {
|
||||||
|
const meta = row.metadata as Record<string, unknown> | null;
|
||||||
|
if (meta && typeof meta.prUrl === "string" && meta.prUrl) {
|
||||||
|
const prNumber = typeof meta.prNumber === "number" ? meta.prNumber : null;
|
||||||
|
return { prUrl: meta.prUrl, prNumber };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const convoy = await loadConvoyProject(db, workspaceId, convoyId);
|
||||||
|
if (convoy?.content && typeof convoy.content === "object" && convoy.content !== null) {
|
||||||
|
const content = convoy.content as Record<string, unknown>;
|
||||||
|
if (typeof content.prUrl === "string" && content.prUrl) {
|
||||||
|
const prNumber = typeof content.prNumber === "number" ? content.prNumber : null;
|
||||||
|
return { prUrl: content.prUrl, prNumber };
|
||||||
|
}
|
||||||
|
const parsed = convoyObjectContentSchema.safeParse(convoy.content);
|
||||||
|
if (parsed.success && parsed.data.prUrl) {
|
||||||
|
return { prUrl: parsed.data.prUrl, prNumber: parsed.data.prNumber ?? null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { prUrl: null, prNumber: null };
|
||||||
|
}
|
||||||
|
|
||||||
/** Checkpoint count + last time for mission-control detail (workspace-scoped). */
|
/** Checkpoint count + last time for mission-control detail (workspace-scoped). */
|
||||||
export async function getConvoyCheckpointSummary(
|
export async function getConvoyCheckpointSummary(
|
||||||
db: Db,
|
db: Db,
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,26 @@ export type ForgeAdapterContext = {
|
||||||
token?: string;
|
token?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CreatePullRequestInput = {
|
||||||
|
base: string;
|
||||||
|
head: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
draft: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreatePullRequestResult = {
|
||||||
|
url: string;
|
||||||
|
number: number;
|
||||||
|
};
|
||||||
|
|
||||||
export interface ForgeAdapter {
|
export interface ForgeAdapter {
|
||||||
readonly forgeType: ForgeType;
|
readonly forgeType: ForgeType;
|
||||||
fetchRepoMetadata(ctx: ForgeAdapterContext): Promise<RepoMetadata>;
|
fetchRepoMetadata(ctx: ForgeAdapterContext): Promise<RepoMetadata>;
|
||||||
|
createPullRequest(
|
||||||
|
ctx: ForgeAdapterContext,
|
||||||
|
input: CreatePullRequestInput,
|
||||||
|
): Promise<CreatePullRequestResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readJsonResponse(response: Response): Promise<unknown> {
|
async function readJsonResponse(response: Response): Promise<unknown> {
|
||||||
|
|
@ -31,6 +48,112 @@ function authHeaders(token: string | undefined): HeadersInit {
|
||||||
return { Authorization: `Bearer ${token}` };
|
return { Authorization: `Bearer ${token}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createGithubPullRequest(
|
||||||
|
ctx: ForgeAdapterContext,
|
||||||
|
input: CreatePullRequestInput,
|
||||||
|
): Promise<CreatePullRequestResult> {
|
||||||
|
const url = `${ctx.apiBaseUrl.replace(/\/$/, "")}/repos/${ctx.owner}/${ctx.repo}/pulls`;
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/vnd.github+json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...authHeaders(ctx.token),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: input.title,
|
||||||
|
body: input.body,
|
||||||
|
head: input.head,
|
||||||
|
base: input.base,
|
||||||
|
draft: input.draft,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const body = await readJsonResponse(response);
|
||||||
|
if (!response.ok) {
|
||||||
|
const message =
|
||||||
|
typeof body === "object" &&
|
||||||
|
body !== null &&
|
||||||
|
"message" in body &&
|
||||||
|
typeof (body as { message: unknown }).message === "string"
|
||||||
|
? (body as { message: string }).message
|
||||||
|
: `GitHub API error (${response.status})`;
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
const htmlUrl =
|
||||||
|
typeof body === "object" &&
|
||||||
|
body !== null &&
|
||||||
|
"html_url" in body &&
|
||||||
|
typeof (body as { html_url: unknown }).html_url === "string"
|
||||||
|
? (body as { html_url: string }).html_url
|
||||||
|
: typeof body === "object" &&
|
||||||
|
body !== null &&
|
||||||
|
"url" in body &&
|
||||||
|
typeof (body as { url: unknown }).url === "string"
|
||||||
|
? (body as { url: string }).url
|
||||||
|
: `${ctx.apiBaseUrl.replace(/\/$/, "")}/${ctx.owner}/${ctx.repo}/pulls/unknown`;
|
||||||
|
const number =
|
||||||
|
typeof body === "object" &&
|
||||||
|
body !== null &&
|
||||||
|
"number" in body &&
|
||||||
|
typeof (body as { number: unknown }).number === "number"
|
||||||
|
? (body as { number: number }).number
|
||||||
|
: 0;
|
||||||
|
return { url: htmlUrl, number };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createForgejoPullRequest(
|
||||||
|
ctx: ForgeAdapterContext,
|
||||||
|
input: CreatePullRequestInput,
|
||||||
|
): Promise<CreatePullRequestResult> {
|
||||||
|
const url = `${ctx.apiBaseUrl.replace(/\/$/, "")}/repos/${ctx.owner}/${ctx.repo}/pulls`;
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...authHeaders(ctx.token),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: input.title,
|
||||||
|
body: input.body,
|
||||||
|
head: input.head,
|
||||||
|
base: input.base,
|
||||||
|
draft: input.draft,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const body = await readJsonResponse(response);
|
||||||
|
if (!response.ok) {
|
||||||
|
const message =
|
||||||
|
typeof body === "object" &&
|
||||||
|
body !== null &&
|
||||||
|
"message" in body &&
|
||||||
|
typeof (body as { message: unknown }).message === "string"
|
||||||
|
? (body as { message: string }).message
|
||||||
|
: `Forge API error (${response.status})`;
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
const htmlUrl =
|
||||||
|
typeof body === "object" &&
|
||||||
|
body !== null &&
|
||||||
|
"html_url" in body &&
|
||||||
|
typeof (body as { html_url: unknown }).html_url === "string"
|
||||||
|
? (body as { html_url: string }).html_url
|
||||||
|
: typeof body === "object" &&
|
||||||
|
body !== null &&
|
||||||
|
"url" in body &&
|
||||||
|
typeof (body as { url: unknown }).url === "string"
|
||||||
|
? (body as { url: string }).url
|
||||||
|
: `${ctx.apiBaseUrl.replace(/\/api\/v1$/, "")}/${ctx.owner}/${ctx.repo}/pulls/${typeof body === "object" && body !== null && "number" in body ? String((body as { number: unknown }).number) : "unknown"}`;
|
||||||
|
const number =
|
||||||
|
typeof body === "object" &&
|
||||||
|
body !== null &&
|
||||||
|
"number" in body &&
|
||||||
|
typeof (body as { number: unknown }).number === "number"
|
||||||
|
? (body as { number: number }).number
|
||||||
|
: 0;
|
||||||
|
return { url: htmlUrl, number };
|
||||||
|
}
|
||||||
|
|
||||||
export const githubForgeAdapter: ForgeAdapter = {
|
export const githubForgeAdapter: ForgeAdapter = {
|
||||||
forgeType: "github",
|
forgeType: "github",
|
||||||
async fetchRepoMetadata(ctx) {
|
async fetchRepoMetadata(ctx) {
|
||||||
|
|
@ -80,6 +203,9 @@ export const githubForgeAdapter: ForgeAdapter = {
|
||||||
description,
|
description,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
async createPullRequest(ctx, input) {
|
||||||
|
return createGithubPullRequest(ctx, input);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const forgejoForgeAdapter: ForgeAdapter = {
|
export const forgejoForgeAdapter: ForgeAdapter = {
|
||||||
|
|
@ -131,6 +257,9 @@ export const forgejoForgeAdapter: ForgeAdapter = {
|
||||||
description,
|
description,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
async createPullRequest(ctx, input) {
|
||||||
|
return createForgejoPullRequest(ctx, input);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const ADAPTERS: Record<ForgeType, ForgeAdapter> = {
|
const ADAPTERS: Record<ForgeType, ForgeAdapter> = {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { createLanguageModelFromResolved } from "@tasks/ai";
|
import { createLanguageModelFromResolved } from "@tasks/ai";
|
||||||
import {
|
import {
|
||||||
|
convoyOperatorMessages,
|
||||||
modelProviders,
|
modelProviders,
|
||||||
objects,
|
objects,
|
||||||
roleModelRoutes,
|
roleModelRoutes,
|
||||||
|
|
@ -23,13 +24,14 @@ import {
|
||||||
} from "@tasks/shared/model-routing";
|
} from "@tasks/shared/model-routing";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { generateText } from "ai";
|
import { generateText } from "ai";
|
||||||
import { asc, eq, inArray } from "drizzle-orm";
|
import { and, asc, eq, inArray } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
findConvoyBySlug,
|
findConvoyBySlug,
|
||||||
getConvoyBriefs,
|
getConvoyBriefs,
|
||||||
getConvoyCheckpointSummary,
|
getConvoyCheckpointSummary,
|
||||||
|
getConvoyPrUrl,
|
||||||
getRecentConvoyEvents,
|
getRecentConvoyEvents,
|
||||||
insertConvoyEvent,
|
insertConvoyEvent,
|
||||||
listConvoyProjects,
|
listConvoyProjects,
|
||||||
|
|
@ -159,6 +161,7 @@ export const convoysRouter = router({
|
||||||
ctx.workspace.id,
|
ctx.workspace.id,
|
||||||
convoy.id,
|
convoy.id,
|
||||||
);
|
);
|
||||||
|
const pr = await getConvoyPrUrl(ctx.db, ctx.workspace.id, convoy.id);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
convoy: {
|
convoy: {
|
||||||
|
|
@ -177,6 +180,7 @@ export const convoysRouter = router({
|
||||||
? checkpoints.lastOccurredAt.toISOString()
|
? checkpoints.lastOccurredAt.toISOString()
|
||||||
: null,
|
: null,
|
||||||
},
|
},
|
||||||
|
pr,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|
@ -378,6 +382,86 @@ export const convoysRouter = router({
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
postOperatorNote: workspaceProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
workspace: z.string().min(1),
|
||||||
|
convoyId: z.string().uuid(),
|
||||||
|
briefId: z.string().uuid().nullable().optional(),
|
||||||
|
delivery: z.enum(["queued", "direct"]),
|
||||||
|
body: z.string().min(1).max(2_000),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.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 authorUserId = ctx.session.user.id;
|
||||||
|
|
||||||
|
const [row] = await ctx.db
|
||||||
|
.insert(convoyOperatorMessages)
|
||||||
|
.values({
|
||||||
|
workspaceId: ctx.workspace.id,
|
||||||
|
convoyId: input.convoyId,
|
||||||
|
briefId: input.briefId ?? null,
|
||||||
|
delivery: input.delivery,
|
||||||
|
body: input.body,
|
||||||
|
authorUserId,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message: "Failed to create operator note.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return row;
|
||||||
|
}),
|
||||||
|
|
||||||
|
listOperatorNotes: workspaceProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
workspace: z.string().min(1),
|
||||||
|
convoyId: z.string().uuid(),
|
||||||
|
briefId: z.string().uuid().nullable().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(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 rows = await ctx.db
|
||||||
|
.select()
|
||||||
|
.from(convoyOperatorMessages)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(convoyOperatorMessages.workspaceId, ctx.workspace.id),
|
||||||
|
eq(convoyOperatorMessages.convoyId, input.convoyId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(asc(convoyOperatorMessages.createdAt));
|
||||||
|
|
||||||
|
void input.briefId;
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
workspaceId: r.workspaceId,
|
||||||
|
convoyId: r.convoyId,
|
||||||
|
briefId: r.briefId,
|
||||||
|
delivery: r.delivery,
|
||||||
|
body: r.body,
|
||||||
|
authorUserId: r.authorUserId,
|
||||||
|
deliveredAt: r.deliveredAt ? r.deliveredAt.toISOString() : null,
|
||||||
|
createdAt: r.createdAt.toISOString(),
|
||||||
|
}));
|
||||||
|
}),
|
||||||
|
|
||||||
fleet: protectedProcedure.query(async ({ ctx }) => {
|
fleet: protectedProcedure.query(async ({ ctx }) => {
|
||||||
const userId = ctx.session.user.id;
|
const userId = ctx.session.user.id;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,20 @@ import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { harnessProviders, roleHarnessRoutes } from "@tasks/database/schema";
|
import { harnessProviders, roleHarnessRoutes } from "@tasks/database/schema";
|
||||||
import { harnessTypeSchema } from "@tasks/shared/worker";
|
import { harnessTypeSchema } from "@tasks/shared/worker";
|
||||||
|
import type { HarnessType } from "@tasks/shared/worker";
|
||||||
import { rateLimit } from "@/server/lib/rate-limit";
|
import { rateLimit } from "@/server/lib/rate-limit";
|
||||||
import { router, workspaceProcedure } from "@/server/trpc";
|
import { router, workspaceProcedure } from "@/server/trpc";
|
||||||
import type { WorkspaceProcedureContext } from "@/server/trpc";
|
import type { WorkspaceProcedureContext } from "@/server/trpc";
|
||||||
|
|
||||||
|
export const HARNESS_PRESETS: ReadonlyArray<{
|
||||||
|
alias: string;
|
||||||
|
displayName: string;
|
||||||
|
harnessType: HarnessType;
|
||||||
|
}> = [
|
||||||
|
{ alias: "doc-writer", displayName: "Doc writer", harnessType: "minimal" },
|
||||||
|
{ alias: "minimal", displayName: "Minimal", harnessType: "minimal" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
type HarnessRow = typeof harnessProviders.$inferSelect;
|
type HarnessRow = typeof harnessProviders.$inferSelect;
|
||||||
type RouteRow = typeof roleHarnessRoutes.$inferSelect;
|
type RouteRow = typeof roleHarnessRoutes.$inferSelect;
|
||||||
|
|
||||||
|
|
|
||||||
68
apps/worker/src/db/operator-notes.ts
Normal file
68
apps/worker/src/db/operator-notes.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
import { and, asc, eq, isNull } from "drizzle-orm";
|
||||||
|
|
||||||
|
import type { db as dbInstance } from "@tasks/database";
|
||||||
|
import { convoyOperatorMessages } from "@tasks/database/schema";
|
||||||
|
|
||||||
|
type Db = typeof dbInstance;
|
||||||
|
|
||||||
|
export type OperatorMessageRow = typeof convoyOperatorMessages.$inferSelect;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll undelivered operator messages for a convoy.
|
||||||
|
* Keep polling fallback simple — no Redis pubsub, just a DB query.
|
||||||
|
*/
|
||||||
|
export async function pollOperatorNotes(
|
||||||
|
db: Db,
|
||||||
|
workspaceId: string,
|
||||||
|
convoyId: string,
|
||||||
|
briefId?: string | null,
|
||||||
|
): Promise<OperatorMessageRow[]> {
|
||||||
|
const conditions = [
|
||||||
|
eq(convoyOperatorMessages.workspaceId, workspaceId),
|
||||||
|
eq(convoyOperatorMessages.convoyId, convoyId),
|
||||||
|
isNull(convoyOperatorMessages.deliveredAt),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Optional brief scoping — include global convoy notes plus brief-specific ones
|
||||||
|
// When briefId is provided we return rows where brief_id is null OR matches the brief.
|
||||||
|
// Keeping the simple fallback: just filter by workspace+convoy when briefId not set.
|
||||||
|
// When briefId is set we still return all for convoy so direct messages are not missed.
|
||||||
|
void briefId;
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(convoyOperatorMessages)
|
||||||
|
.where(and(...conditions))
|
||||||
|
.orderBy(asc(convoyOperatorMessages.createdAt));
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markOperatorNotesDelivered(
|
||||||
|
db: Db,
|
||||||
|
ids: string[],
|
||||||
|
): Promise<void> {
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
for (const id of ids) {
|
||||||
|
await db
|
||||||
|
.update(convoyOperatorMessages)
|
||||||
|
.set({ deliveredAt: new Date() })
|
||||||
|
.where(eq(convoyOperatorMessages.id, id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convenience: poll and mark delivered in one step */
|
||||||
|
export async function consumeOperatorNotes(
|
||||||
|
db: Db,
|
||||||
|
workspaceId: string,
|
||||||
|
convoyId: string,
|
||||||
|
briefId?: string | null,
|
||||||
|
): Promise<OperatorMessageRow[]> {
|
||||||
|
const rows = await pollOperatorNotes(db, workspaceId, convoyId, briefId);
|
||||||
|
if (rows.length === 0) return rows;
|
||||||
|
await markOperatorNotesDelivered(
|
||||||
|
db,
|
||||||
|
rows.map((r) => r.id),
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
72
apps/worker/src/git/pr.ts
Normal file
72
apps/worker/src/git/pr.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
export type ForgeCreatePullRequestInput = {
|
||||||
|
base: string;
|
||||||
|
head: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
draft: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ForgeCreatePullRequestResult = {
|
||||||
|
url: string;
|
||||||
|
number: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ForgeForPr = {
|
||||||
|
createPullRequest: (
|
||||||
|
input: ForgeCreatePullRequestInput,
|
||||||
|
) => Promise<ForgeCreatePullRequestResult>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OpenDraftPRInput = {
|
||||||
|
monorepoRoot: string;
|
||||||
|
worktreePath: string;
|
||||||
|
branch: string;
|
||||||
|
convoySlug: string;
|
||||||
|
briefId?: string | null;
|
||||||
|
convoyId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
forge: ForgeForPr;
|
||||||
|
baseBranch?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildPrTitle(convoySlug: string, briefId?: string | null): string {
|
||||||
|
if (briefId) return `${convoySlug} — brief ${briefId.slice(0, 8)}`;
|
||||||
|
return convoySlug;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPrBody(
|
||||||
|
convoySlug: string,
|
||||||
|
briefId: string | null | undefined,
|
||||||
|
convoyId: string,
|
||||||
|
): string {
|
||||||
|
return `Convoy: ${convoySlug} Brief: ${briefId ?? "n/a"} echodo-convoy: ${convoyId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push the worktree branch and open a draft PR via the forge adapter.
|
||||||
|
* - Pushes `git push origin HEAD` from worktreePath (uses stored credentials / GITHUB_TOKEN env)
|
||||||
|
* - Calls forge.createPullRequest with draft:true and marker `echodo-convoy: <convoyId>`
|
||||||
|
*/
|
||||||
|
export async function openDraftPR(input: OpenDraftPRInput): Promise<ForgeCreatePullRequestResult> {
|
||||||
|
const cwd = input.worktreePath || input.monorepoRoot;
|
||||||
|
const base = input.baseBranch ?? "main";
|
||||||
|
const title = buildPrTitle(input.convoySlug, input.briefId);
|
||||||
|
const body = buildPrBody(input.convoySlug, input.briefId, input.convoyId);
|
||||||
|
|
||||||
|
await execFileAsync("git", ["push", "origin", "HEAD"], { cwd });
|
||||||
|
|
||||||
|
const result = await input.forge.createPullRequest({
|
||||||
|
base,
|
||||||
|
head: input.branch,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
draft: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
@ -28,6 +28,7 @@ import {
|
||||||
removeWorktree,
|
removeWorktree,
|
||||||
reuseWorktreeIfPresent,
|
reuseWorktreeIfPresent,
|
||||||
} from "./git/worktree.js";
|
} from "./git/worktree.js";
|
||||||
|
import { openDraftPR } from "./git/pr.js";
|
||||||
import { resolveHarnessForJob, getHarnessExecutor } from "./harness.js";
|
import { resolveHarnessForJob, getHarnessExecutor } from "./harness.js";
|
||||||
import { resolveHarnessForRoleDb } from "./db/harness.js";
|
import { resolveHarnessForRoleDb } from "./db/harness.js";
|
||||||
import { resolveRepoRoot } from "./lib/repo-root.js";
|
import { resolveRepoRoot } from "./lib/repo-root.js";
|
||||||
|
|
@ -36,6 +37,12 @@ import {
|
||||||
createMinimalSession,
|
createMinimalSession,
|
||||||
resolveRolePromptPath,
|
resolveRolePromptPath,
|
||||||
} from "./session/minimal-session.js";
|
} from "./session/minimal-session.js";
|
||||||
|
import { connectedRepos, convoyEvents, objects } from "@tasks/database/schema";
|
||||||
|
import {
|
||||||
|
markOperatorNotesDelivered,
|
||||||
|
pollOperatorNotes,
|
||||||
|
} from "./db/operator-notes.js";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
type Db = typeof dbInstance;
|
type Db = typeof dbInstance;
|
||||||
|
|
||||||
|
|
@ -207,6 +214,22 @@ export async function handleOrchestratorJob(
|
||||||
};
|
};
|
||||||
|
|
||||||
// WorkerAdapter dispatch — minimal adapter delegates to createMinimalSession; remote adapters would POST to AG-UI.
|
// WorkerAdapter dispatch — minimal adapter delegates to createMinimalSession; remote adapters would POST to AG-UI.
|
||||||
|
const operatorNotesProvider = {
|
||||||
|
harnessAlias: harness.alias,
|
||||||
|
poll: async () => {
|
||||||
|
const rows = await pollOperatorNotes(db, job.workspaceId, job.convoyId, job.briefId ?? null);
|
||||||
|
return rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
delivery: r.delivery as "queued" | "direct",
|
||||||
|
body: r.body,
|
||||||
|
authorUserId: r.authorUserId,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
markDelivered: async (ids: string[]) => {
|
||||||
|
await markOperatorNotesDelivered(db, ids);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const session = createMinimalSession(sessionContext, {
|
const session = createMinimalSession(sessionContext, {
|
||||||
resolvedModel: modelResult.model,
|
resolvedModel: modelResult.model,
|
||||||
resolvedAlias: modelResult.resolvedAlias,
|
resolvedAlias: modelResult.resolvedAlias,
|
||||||
|
|
@ -220,6 +243,7 @@ export async function handleOrchestratorJob(
|
||||||
resumeToolHistory,
|
resumeToolHistory,
|
||||||
resumeFilesTouched,
|
resumeFilesTouched,
|
||||||
allowedCommands: harness.allowedCommands,
|
allowedCommands: harness.allowedCommands,
|
||||||
|
operatorNotes: operatorNotesProvider,
|
||||||
onCheckpoint:
|
onCheckpoint:
|
||||||
runId && !dryRun
|
runId && !dryRun
|
||||||
? async ({ checkpointSeq, toolHistory, filesTouched }) => {
|
? async ({ checkpointSeq, toolHistory, filesTouched }) => {
|
||||||
|
|
@ -236,7 +260,7 @@ export async function handleOrchestratorJob(
|
||||||
worktreePath,
|
worktreePath,
|
||||||
});
|
});
|
||||||
wroteCheckpoint = true;
|
wroteCheckpoint = true;
|
||||||
console.info(`[worker] checkpoint seq=${checkpointSeq} run=${runId}`);
|
console.info(`[worker] checkpoint seq=${checkpointSeq} run=${runId} harness=${harness.alias}`);
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
@ -253,6 +277,159 @@ export async function handleOrchestratorJob(
|
||||||
summary = error instanceof Error ? error.message : String(error);
|
summary = error instanceof Error ? error.message : String(error);
|
||||||
console.error(`[worker] job=${job.id} session failed:`, error);
|
console.error(`[worker] job=${job.id} session failed:`, error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Draft PR via forge adapter after successful implementer session, before teardown
|
||||||
|
if (outcome === "succeeded" && !dryRun) {
|
||||||
|
if (job.roleId !== "role-implementer") {
|
||||||
|
console.info(`[worker] skip PR creation for non-implementer role=${job.roleId}`);
|
||||||
|
} else if (!needsWorktree) {
|
||||||
|
console.info(
|
||||||
|
`[worker] harness=${harness.alias} needsWorktree=false skipping push, logging published artifact`,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await (db as unknown as { insert: (t: typeof convoyEvents) => { values: (v: unknown) => Promise<unknown> } })
|
||||||
|
.insert(convoyEvents)
|
||||||
|
.values({
|
||||||
|
workspaceId: job.workspaceId,
|
||||||
|
convoyId: job.convoyId,
|
||||||
|
briefId: job.briefId ?? null,
|
||||||
|
role: job.roleId,
|
||||||
|
actor: job.roleId,
|
||||||
|
eventType: "role_complete",
|
||||||
|
outcome: "published",
|
||||||
|
metadata: {
|
||||||
|
published: true,
|
||||||
|
skipPush: true,
|
||||||
|
convoySlug: ctx.convoySlug,
|
||||||
|
briefId: job.briefId ?? null,
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[worker] published artifact log failed:", error);
|
||||||
|
}
|
||||||
|
} else if (worktreePath && worktreeBranch) {
|
||||||
|
try {
|
||||||
|
const rows = await (db as unknown as {
|
||||||
|
select: () => { from: (t: unknown) => { where: (c: unknown) => { limit: (n: number) => Promise<unknown[]>; orderBy?: unknown } } };
|
||||||
|
}).select().from(connectedRepos).where(eq(connectedRepos.workspaceId, job.workspaceId)).limit(1) as unknown as typeof connectedRepos.$inferSelect[];
|
||||||
|
|
||||||
|
let repoRow: typeof connectedRepos.$inferSelect | null = null;
|
||||||
|
if (Array.isArray(rows) && rows.length > 0) repoRow = rows[0] as typeof connectedRepos.$inferSelect;
|
||||||
|
if (!repoRow) {
|
||||||
|
console.info("[worker] skip PR creation: no connected repo for workspace");
|
||||||
|
} else {
|
||||||
|
const tokenEnvVar = (repoRow as { tokenEnvVar?: string | null }).tokenEnvVar;
|
||||||
|
const token = tokenEnvVar ? process.env[tokenEnvVar]?.trim() || process.env.GITHUB_TOKEN?.trim() : process.env.GITHUB_TOKEN?.trim();
|
||||||
|
const apiBaseUrl: string = (repoRow as { apiBaseUrl: string }).apiBaseUrl;
|
||||||
|
const owner: string = (repoRow as { owner: string }).owner;
|
||||||
|
const repo: string = (repoRow as { repoName: string }).repoName;
|
||||||
|
const forgeType: string = (repoRow as { forgeType: string }).forgeType;
|
||||||
|
const defaultBranch: string | null = (repoRow as { defaultBranch?: string | null }).defaultBranch ?? null;
|
||||||
|
|
||||||
|
const forge = {
|
||||||
|
createPullRequest: async (input: { base: string; head: string; title: string; body: string; draft: boolean }) => {
|
||||||
|
const url = `${apiBaseUrl.replace(/\/$/, "")}/repos/${owner}/${repo}/pulls`;
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
...(forgeType === "github"
|
||||||
|
? { Accept: "application/vnd.github+json" }
|
||||||
|
: { Accept: "application/json" }),
|
||||||
|
};
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
let body: unknown = null;
|
||||||
|
try {
|
||||||
|
body = text ? (JSON.parse(text) as unknown) : null;
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Forge API returned non-JSON response (${res.status}).`);
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const message =
|
||||||
|
typeof body === "object" &&
|
||||||
|
body !== null &&
|
||||||
|
"message" in body &&
|
||||||
|
typeof (body as { message: unknown }).message === "string"
|
||||||
|
? (body as { message: string }).message
|
||||||
|
: `Forge API error (${res.status})`;
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
const htmlUrl =
|
||||||
|
typeof body === "object" && body !== null && "html_url" in body && typeof (body as { html_url: unknown }).html_url === "string"
|
||||||
|
? (body as { html_url: string }).html_url
|
||||||
|
: typeof body === "object" && body !== null && "url" in body && typeof (body as { url: unknown }).url === "string"
|
||||||
|
? (body as { url: string }).url
|
||||||
|
: `${apiBaseUrl.replace(/\/api\/v1$/, "")}/${owner}/${repo}/pulls/unknown`;
|
||||||
|
const number =
|
||||||
|
typeof body === "object" && body !== null && "number" in body && typeof (body as { number: unknown }).number === "number"
|
||||||
|
? (body as { number: number }).number
|
||||||
|
: 0;
|
||||||
|
return { url: htmlUrl, number };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await openDraftPR({
|
||||||
|
monorepoRoot,
|
||||||
|
worktreePath,
|
||||||
|
branch: worktreeBranch,
|
||||||
|
convoySlug: ctx.convoySlug,
|
||||||
|
briefId: job.briefId ?? null,
|
||||||
|
convoyId: job.convoyId,
|
||||||
|
workspaceId: job.workspaceId,
|
||||||
|
forge,
|
||||||
|
baseBranch: defaultBranch ?? "main",
|
||||||
|
});
|
||||||
|
|
||||||
|
console.info(`[worker] draft PR created url=${result.url} number=${result.number}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await (db as unknown as { insert: (t: typeof convoyEvents) => { values: (v: unknown) => Promise<unknown> } })
|
||||||
|
.insert(convoyEvents)
|
||||||
|
.values({
|
||||||
|
workspaceId: job.workspaceId,
|
||||||
|
convoyId: job.convoyId,
|
||||||
|
briefId: job.briefId ?? null,
|
||||||
|
role: job.roleId,
|
||||||
|
actor: job.roleId,
|
||||||
|
eventType: "role_complete",
|
||||||
|
outcome: "pr_created",
|
||||||
|
metadata: {
|
||||||
|
prUrl: result.url,
|
||||||
|
prNumber: result.number,
|
||||||
|
convoySlug: ctx.convoySlug,
|
||||||
|
briefId: job.briefId ?? null,
|
||||||
|
branch: worktreeBranch,
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[worker] prUrl event insert failed:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const convoyRows = await (db as unknown as {
|
||||||
|
select: () => { from: (t: unknown) => { where: (c: unknown) => { limit: (n: number) => Promise<unknown[]> } } };
|
||||||
|
}).select().from(objects).where(eq(objects.id, job.convoyId)).limit(1) as unknown as Array<{ content: unknown }>;
|
||||||
|
const existing = convoyRows?.[0]?.content as Record<string, unknown> | null;
|
||||||
|
const nextContent = { ...(existing ?? {}), prUrl: result.url, prNumber: result.number };
|
||||||
|
await (db as unknown as { update: (t: typeof objects) => { set: (v: unknown) => { where: (c: unknown) => Promise<unknown> } } })
|
||||||
|
.update(objects)
|
||||||
|
.set({ content: nextContent, updatedAt: new Date() } as never)
|
||||||
|
.where(eq(objects.id, job.convoyId));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[worker] prUrl content update failed:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[worker] draft PR failed:", error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.info("[worker] skip PR creation: no worktreePath");
|
||||||
|
}
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (runId) {
|
if (runId) {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,19 @@ export type CheckpointSink = (input: {
|
||||||
filesTouched: WorkerCheckpointFileTouch[];
|
filesTouched: WorkerCheckpointFileTouch[];
|
||||||
}) => Promise<void>;
|
}) => Promise<void>;
|
||||||
|
|
||||||
|
export type OperatorNote = {
|
||||||
|
id: string;
|
||||||
|
delivery: "queued" | "direct";
|
||||||
|
body: string;
|
||||||
|
authorUserId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OperatorNotesProvider = {
|
||||||
|
harnessAlias?: string;
|
||||||
|
poll: () => Promise<OperatorNote[]>;
|
||||||
|
markDelivered: (ids: string[]) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
export type MinimalSessionOptions = {
|
export type MinimalSessionOptions = {
|
||||||
resolvedModel: ResolvedModel;
|
resolvedModel: ResolvedModel;
|
||||||
resolvedAlias: string;
|
resolvedAlias: string;
|
||||||
|
|
@ -41,6 +54,7 @@ export type MinimalSessionOptions = {
|
||||||
resumeFilesTouched?: WorkerCheckpointFileTouch[];
|
resumeFilesTouched?: WorkerCheckpointFileTouch[];
|
||||||
onCheckpoint?: CheckpointSink;
|
onCheckpoint?: CheckpointSink;
|
||||||
allowedCommands?: readonly string[] | null;
|
allowedCommands?: readonly string[] | null;
|
||||||
|
operatorNotes?: OperatorNotesProvider;
|
||||||
};
|
};
|
||||||
|
|
||||||
function sanitizeToolArgs(
|
function sanitizeToolArgs(
|
||||||
|
|
@ -120,7 +134,7 @@ export function createMinimalSession(
|
||||||
const checkpointEvery = Math.max(1, options.checkpointEveryNSteps ?? 1);
|
const checkpointEvery = Math.max(1, options.checkpointEveryNSteps ?? 1);
|
||||||
const remainingSteps = Math.max(1, options.maxSteps - resumeHistory.length);
|
const remainingSteps = Math.max(1, options.maxSteps - resumeHistory.length);
|
||||||
|
|
||||||
const system = [
|
let systemBase = [
|
||||||
rolePrompt.trim(),
|
rolePrompt.trim(),
|
||||||
options.workflowPrompt ? `\n\n## Workflow\n${options.workflowPrompt.trim()}` : "",
|
options.workflowPrompt ? `\n\n## Workflow\n${options.workflowPrompt.trim()}` : "",
|
||||||
options.briefFiles?.length
|
options.briefFiles?.length
|
||||||
|
|
@ -133,7 +147,39 @@ export function createMinimalSession(
|
||||||
buildResumeAppendix(resumeHistory),
|
buildResumeAppendix(resumeHistory),
|
||||||
].join("");
|
].join("");
|
||||||
|
|
||||||
|
// Direct messages before next generateText: prepend to system tail
|
||||||
|
let directTail = "";
|
||||||
|
const harnessAlias = options.operatorNotes?.harnessAlias ?? options.resolvedAlias ?? "unknown";
|
||||||
|
if (options.operatorNotes) {
|
||||||
|
try {
|
||||||
|
const pending = await options.operatorNotes.poll();
|
||||||
|
const direct = pending.filter((n) => n.delivery === "direct");
|
||||||
|
const queuedBefore = pending.filter((n) => n.delivery === "queued");
|
||||||
|
if (direct.length > 0) {
|
||||||
|
const directText = direct.map((n) => `Direct message from ${n.authorUserId}: ${n.body}`).join("\n");
|
||||||
|
directTail = `\n\n## Direct operator messages\n${directText}`;
|
||||||
|
console.info(`[worker] direct message injected harness=${harnessAlias} count=${direct.length}`);
|
||||||
|
await options.operatorNotes.markDelivered(direct.map((n) => n.id));
|
||||||
|
}
|
||||||
|
// Also handle any queued that arrived before first step
|
||||||
|
if (queuedBefore.length > 0) {
|
||||||
|
for (const n of queuedBefore) {
|
||||||
|
const line = `Queued note from ${n.authorUserId}: ${n.body}`;
|
||||||
|
console.info(`[worker] queued note injected harness=${harnessAlias} ${line}`);
|
||||||
|
}
|
||||||
|
await options.operatorNotes.markDelivered(queuedBefore.map((n) => n.id));
|
||||||
|
// Append queued notes to system as well so model sees them early
|
||||||
|
const queuedText = queuedBefore.map((n) => `Queued note from ${n.authorUserId}: ${n.body}`).join("\n");
|
||||||
|
directTail += `\n\n## Queued operator notes\n${queuedText}`;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[worker] operator notes poll failed harness=${harnessAlias}`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const system = systemBase + directTail;
|
||||||
|
|
||||||
const repoRoot = context.repoRoot;
|
const repoRoot = context.repoRoot;
|
||||||
|
const queuedNotesBuffer: string[] = [];
|
||||||
|
|
||||||
const recordStep = async (
|
const recordStep = async (
|
||||||
toolName: WorkerCheckpointToolStep["tool"],
|
toolName: WorkerCheckpointToolStep["tool"],
|
||||||
|
|
@ -162,6 +208,46 @@ export function createMinimalSession(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Between tool rounds poll queued notes, append to toolHistory summary, mark delivered
|
||||||
|
if (options.operatorNotes) {
|
||||||
|
try {
|
||||||
|
const pending = await options.operatorNotes.poll();
|
||||||
|
const queued = pending.filter((n) => n.delivery === "queued");
|
||||||
|
const directMid = pending.filter((n) => n.delivery === "direct");
|
||||||
|
if (queued.length > 0) {
|
||||||
|
for (const n of queued) {
|
||||||
|
const line = `Queued note from ${n.authorUserId}: ${n.body}`;
|
||||||
|
queuedNotesBuffer.push(line);
|
||||||
|
console.info(`[worker] queued note injected harness=${harnessAlias} ${line}`);
|
||||||
|
// Synthetic checkpoint summary entry: push as extra tool history digest for visibility
|
||||||
|
// We append queued note to toolHistory as an extra ok entry so checkpoint summary shows it
|
||||||
|
// (using read_file tool as placeholder to satisfy schema)
|
||||||
|
toolHistory.push({
|
||||||
|
seq: toolCalls + 0.5,
|
||||||
|
tool: "read_file",
|
||||||
|
args: { path: `operator_note:${n.id.slice(0, 8)}` },
|
||||||
|
ok: true,
|
||||||
|
resultDigest: `queued:${hashContent(n.body).slice(0, 12)}`,
|
||||||
|
} as unknown as WorkerCheckpointToolStep);
|
||||||
|
}
|
||||||
|
await options.operatorNotes.markDelivered(queued.map((n) => n.id));
|
||||||
|
}
|
||||||
|
if (directMid.length > 0) {
|
||||||
|
const directText = directMid.map((n) => `Direct message from ${n.authorUserId}: ${n.body}`).join("\n");
|
||||||
|
console.info(`[worker] direct message injected harness=${harnessAlias} count=${directMid.length} mid-run`);
|
||||||
|
// Direct messages mid-run can't change current system; log and mark delivered for next run
|
||||||
|
// Append to buffer so summary reflects them
|
||||||
|
for (const n of directMid) {
|
||||||
|
queuedNotesBuffer.push(`Direct message from ${n.authorUserId}: ${n.body} (injected mid-run before next model call)`);
|
||||||
|
}
|
||||||
|
await options.operatorNotes.markDelivered(directMid.map((n) => n.id));
|
||||||
|
void directText;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[worker] operator notes poll between rounds failed harness=${harnessAlias}`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (options.onCheckpoint && toolCalls % checkpointEvery === 0) {
|
if (options.onCheckpoint && toolCalls % checkpointEvery === 0) {
|
||||||
await options.onCheckpoint({
|
await options.onCheckpoint({
|
||||||
checkpointSeq: toolCalls,
|
checkpointSeq: toolCalls,
|
||||||
|
|
@ -230,8 +316,12 @@ export function createMinimalSession(
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let summary = result.text.trim() || "Session completed with no summary text.";
|
||||||
|
if (queuedNotesBuffer.length > 0) {
|
||||||
|
summary += `\n\n---\nOperator notes injected:\n${queuedNotesBuffer.join("\n")}`;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
summary: result.text.trim() || "Session completed with no summary text.",
|
summary,
|
||||||
toolCalls,
|
toolCalls,
|
||||||
tokensTotal: result.usage?.totalTokens,
|
tokensTotal: result.usage?.totalTokens,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -249,6 +249,8 @@ Shipped when a workspace enables pipeline integration:
|
||||||
| `gate-copilot` | `claude-planning` | premium |
|
| `gate-copilot` | `claude-planning` | premium |
|
||||||
| `chat-default` | workspace choice | — |
|
| `chat-default` | workspace choice | — |
|
||||||
|
|
||||||
|
Harness for `role-doc-writer`: alias `doc-writer` (`harnessType: minimal`, `needsWorktree: false`, `allowedCommands: []` in `apps/worker/src/harness.ts:13`). Suggested second-brain preset is `role-doc-writer → qwen-coder-mlx (http://mac:8000/v1) + doc-writer`; seed via `harnesses.create { alias: "doc-writer", harnessType: "minimal", displayName: "Doc writer" }` then `harnesses.upsertRoute { roleId: "role-doc-writer", harnessProviderId }` if not pre-seeded.
|
||||||
|
|
||||||
Operators remap aliases in Settings; seed is not immutable.
|
Operators remap aliases in Settings; seed is not immutable.
|
||||||
|
|
||||||
## Convoy / brief integration
|
## Convoy / brief integration
|
||||||
|
|
|
||||||
22
docs/examples/research-pipeline.md
Normal file
22
docs/examples/research-pipeline.md
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
# Heavy research pipeline — source → claim → outline → publish
|
||||||
|
|
||||||
|
E2E fixture for `role-doc-writer` via harness `doc-writer` (`apps/worker/src/harness.ts:13`, `packages/shared/src/convoy-lifecycle/state-machine.ts:9`).
|
||||||
|
|
||||||
|
## Flow
|
||||||
|
|
||||||
|
1. **Source** — capture references or question under `docs/research/` or convoy brief.
|
||||||
|
2. **Claim** — `claim_task` assigns `role-doc-writer`; worker resolves model `qwen-coder-mlx` + harness `doc-writer`.
|
||||||
|
3. **Outline** — map claims to evidence, draft outline with `read_file`/`grep`.
|
||||||
|
4. **Publish** — write `docs/` or SOP file with `write_file` only, then `in_progress → doc_review` (automation or `role-doc-writer`), then `doc_review → published` via `human-gate-2` / `doc-approver`; finally `published → released` if promoted.
|
||||||
|
|
||||||
|
## Harness constraints
|
||||||
|
|
||||||
|
- `needsWorktree: false` — writes directly to `monorepoRoot`, no branch or `/tmp/echodo-worker-worktrees` worktree.
|
||||||
|
- `allowedCommands: []` — no `run_command`; fixture fails if the tool trace contains `run_command` or `pnpm`.
|
||||||
|
- Mirrors `pr_review → merged` for code: `doc_review → published` is the docs-only gate.
|
||||||
|
|
||||||
|
## Verify
|
||||||
|
|
||||||
|
- Worker trace shows only `read_file`, `grep`, `write_file`.
|
||||||
|
- Convoy event `role_complete` with `published: true` and Mission control shows `doc_review → published` by `doc-approver`.
|
||||||
|
- Counterpart template: `docs/templates/doc-sop.md`.
|
||||||
45
docs/operations/live-e2e-checklist.md
Normal file
45
docs/operations/live-e2e-checklist.md
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
# Live E2E Dogfood Checklist — `Task-worker-live-e2e-dogfood.md` (A1 verify)
|
||||||
|
|
||||||
|
This is the **operator** checklist for `plans/Plan-automation-foundation/Epic-apps-worker-v1/Task-worker-live-e2e-dogfood.md`. Mark the Task `done` only when every box below is true on your deploy (Coolify or local Redis).
|
||||||
|
|
||||||
|
## Env (document names only, no secrets)
|
||||||
|
|
||||||
|
- `ORCHESTRATOR_DRY_RUN=0` on orchestrator service
|
||||||
|
- `WORKER_DRY_RUN=0` on worker service
|
||||||
|
- `REDIS_URL` set on both (same `echodo:orchestrator:jobs` queue)
|
||||||
|
- `DATABASE_URL` `echodo` on both + web (migrations 0015+ applied)
|
||||||
|
- Workspace has `model_providers` alias + `role_model_routes role-implementer → qwen-coder` and `harness_providers alias: minimal` (or `doc-writer` for doc)
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. Connected repo reachable from worker: `apps/worker` can `git clone`/`git push` the `repo_links` URL (or monorepo root when `WORKER_REPO_ROOT` unset).
|
||||||
|
2. Create or reconcile convoy with implementer brief: either `Plans → Start → Convoy` on a `ready` Task or `create_convoy` + `create_brief` via MCP `reconcile_from_files`.
|
||||||
|
3. Advance to `ready` (Gate 1 `human-gate-1` approve if `convoy_review`).
|
||||||
|
4. Worker logs show `dequeuing job` → `claimed backlog=... run=...` → `tools=...` → `complete_task` → `role_complete` `convoy_events.metadata.harnessAlias` present.
|
||||||
|
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).
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Record
|
||||||
|
|
||||||
|
- Convoy slug:
|
||||||
|
- Run id (`agent_runs.id`):
|
||||||
|
- `harnessAlias` / `modelAlias`:
|
||||||
|
- PR url (if code):
|
||||||
|
|
||||||
|
## Dry-run fallback still works
|
||||||
|
|
||||||
|
- With `DATABASE_URL` missing or `REDIS_URL` unset, `pnpm --filter @tasks/worker dev` logs `DRY-RUN mode — dequeue loop idle` and exits 0; `ORCHESTRATOR_DRY_RUN=1` logs dispatch without enqueue.
|
||||||
|
|
||||||
|
## Not this task
|
||||||
|
|
||||||
|
- Forge webhooks (Epic 8) — verified separately
|
||||||
|
- New worker features — use sibling tasks
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- Task: `plans/Plan-automation-foundation/Epic-apps-worker-v1/Task-worker-live-e2e-dogfood.md`
|
||||||
|
- Convoy: `.convoys/worker-closeout-docwriter.md`
|
||||||
|
- Worker closeout context: `.convoys/worker-closeout-docwriter/brief-1-draft-pr-forge.md`
|
||||||
|
|
||||||
|
Fill convoy slug + run id above, then flip Task status `ready → done` and `exportBacklogItemToMarkdown` will persist it.
|
||||||
40
docs/templates/doc-sop.md
vendored
Normal file
40
docs/templates/doc-sop.md
vendored
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
---
|
||||||
|
kind: doc-sop
|
||||||
|
slug: "<doc-sop-slug>"
|
||||||
|
title: "<SOP title>"
|
||||||
|
status: draft
|
||||||
|
priority: P2
|
||||||
|
tenant_id: "<tenant-uuid-or-placeholder>"
|
||||||
|
updated_at: "<ISO-8601>"
|
||||||
|
---
|
||||||
|
|
||||||
|
# SOP: <SOP title>
|
||||||
|
|
||||||
|
One-line purpose of this SOP.
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
What this SOP standardizes and when to use it.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Inputs, tools, or access required
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. Step one — action
|
||||||
|
2. Step two — action
|
||||||
|
3. Step three — action
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- [ ] Outcome check 1
|
||||||
|
- [ ] Outcome check 2
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
`draft → doc_review → published` via `human-gate-2` / `doc-approver` (`packages/shared/src/convoy-lifecycle/state-machine.ts:9`)
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- Related: `docs/examples/research-pipeline.md`
|
||||||
|
|
@ -28,6 +28,22 @@ WORKER_DRY_RUN=0 REDIS_URL=... pnpm --filter @tasks/worker dev
|
||||||
- **Lifecycle** — DB writes mirroring MCP `claim_task` / `complete_task`; `role_complete` convoy event on finish (includes `metadata.harnessAlias`)
|
- **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`.
|
- **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`.
|
||||||
|
|
||||||
|
### `role-doc-writer` via `doc-writer` example
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// DB seed (or via tRPC harnesses.create / upsertRoute)
|
||||||
|
await harnesses.create({ alias: "doc-writer", displayName: "Doc writer", harnessType: "minimal" });
|
||||||
|
await harnesses.upsertRoute({ roleId: "role-doc-writer", harnessProviderId: docWriter.id });
|
||||||
|
|
||||||
|
// Job dispatch
|
||||||
|
{ roleId: "role-doc-writer", harnessAlias: "doc-writer" } // resolveHarnessForJob → { alias: "doc-writer", needsWorktree: false, allowedCommands: [] }
|
||||||
|
|
||||||
|
// Session trace: read_file / grep / write_file only — no run_command, no worktree under /tmp/echodo-worker-worktrees.
|
||||||
|
// Gate: in_progress → doc_review (automation or role-doc-writer), then doc_review → published via human-gate-2 / doc-approver.
|
||||||
|
```
|
||||||
|
|
||||||
|
See also `docs/examples/research-pipeline.md` (fixture) and `docs/templates/doc-sop.md` (`kind: doc-sop`).
|
||||||
|
|
||||||
## Env
|
## Env
|
||||||
|
|
||||||
| Variable | Default | Purpose |
|
| Variable | Default | Purpose |
|
||||||
|
|
|
||||||
19
packages/database/migrations/0016_noble_operator_notes.sql
Normal file
19
packages/database/migrations/0016_noble_operator_notes.sql
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
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
|
||||||
|
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
|
||||||
|
CREATE INDEX "convoy_operator_messages_workspace_id_convoy_id_delivered_at_idx" ON "convoy_operator_messages" USING btree ("workspace_id","convoy_id","delivered_at");
|
||||||
|
|
@ -113,6 +113,13 @@
|
||||||
"when": 1787692015306,
|
"when": 1787692015306,
|
||||||
"tag": "0015_curved_iron_fist",
|
"tag": "0015_curved_iron_fist",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 16,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787850000000,
|
||||||
|
"tag": "0016_noble_operator_notes",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
35
packages/database/src/schema/convoy_operator_messages.ts
Normal file
35
packages/database/src/schema/convoy_operator_messages.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { index, pgTable, text, timestamp, uuid, varchar } from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
|
import { agentRuns } from "./agent_runs";
|
||||||
|
import { objects } from "./objects";
|
||||||
|
import { users } from "./users";
|
||||||
|
import { workspaces } from "./workspaces";
|
||||||
|
|
||||||
|
export const convoyOperatorMessages = pgTable(
|
||||||
|
"convoy_operator_messages",
|
||||||
|
{
|
||||||
|
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" }),
|
||||||
|
briefId: uuid("brief_id").references(() => objects.id, { onDelete: "set null" }),
|
||||||
|
agentRunId: uuid("agent_run_id").references(() => agentRuns.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
delivery: varchar("delivery", { length: 20 }).notNull().$type<"queued" | "direct">(),
|
||||||
|
body: text("body").notNull(),
|
||||||
|
authorUserId: uuid("author_user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
deliveredAt: timestamp("delivered_at", { withTimezone: true }),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
workspaceConvoyDeliveredIdx: index(
|
||||||
|
"convoy_operator_messages_workspace_id_convoy_id_delivered_at_idx",
|
||||||
|
).on(table.workspaceId, table.convoyId, table.deliveredAt),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
@ -19,3 +19,4 @@ export * from "./connected_repos";
|
||||||
export * from "./convoy_events";
|
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";
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,8 @@ export const convoyObjectContentSchema = z.object({
|
||||||
planSlug: z.string().min(1).optional(),
|
planSlug: z.string().min(1).optional(),
|
||||||
epicSlug: z.string().min(1).optional(),
|
epicSlug: z.string().min(1).optional(),
|
||||||
briefFiles: z.array(z.string()).optional(),
|
briefFiles: z.array(z.string()).optional(),
|
||||||
|
prUrl: z.string().url().optional(),
|
||||||
|
prNumber: z.number().int().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ConvoyObjectContent = z.infer<typeof convoyObjectContentSchema>;
|
export type ConvoyObjectContent = z.infer<typeof convoyObjectContentSchema>;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue