From 5e1c6b3681c270ad8a305c78c97587673c122a7e Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 8 Aug 2026 09:03:35 +0100 Subject: [PATCH 01/32] docs: unified agentic-teams design document Consolidates agentic-teams (rounds 1-2), vision-fidelity, Epic/artifact model, and session summary into one canonical design record. Supersedes: agentic-teams-review.md, vision-fidelity.md, epic-task-artifact-model.md, session-summary-vision-fidelity.md --- docs/agentic-teams.md | 782 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 782 insertions(+) create mode 100644 docs/agentic-teams.md diff --git a/docs/agentic-teams.md b/docs/agentic-teams.md new file mode 100644 index 0000000..6e65072 --- /dev/null +++ b/docs/agentic-teams.md @@ -0,0 +1,782 @@ +# Agentic Teams — Unified Design Document + +> **Status:** Consolidated design record — one authoritative document unifying +> every round of discussion. Supersedes the earlier split files +> (`agentic-teams-review.md`, `vision-fidelity.md`, `epic-task-artifact-model.md`, +> `session-summary-vision-fidelity.md`), which have been folded into this single +> living doc. +> +> This is **feature- and decision-focused**, not code. Where a technical shape +> is mentioned it is indicative; the aim is to record *what* we agreed, *why*, +> and *which decisions are still open*. +> +> The one paragraph: mcpctl should orchestrate a software-team-shaped workflow +> where human developers and autonomous agents are **peers in the same project**, +> with mcpctl as the orchestration, state, and "team lead" layer. Agents work +> real code in isolated pods, verify against a living vision, and escalate +> decisions to a single human supervisor. + +--- + +## Table of contents + +1. The vision & motivation +2. Core design principles +3. The two-level verification model (correctness + vision) +4. The person-model: agents as first-class "people" +5. The Epic / epictask / task data model +6. The task lifecycle & QA/Security/SRE stages +7. Repositories & per-session pods +8. The confidence signal +9. Escalation & the decision dashboard +10. `mcpctl exec` — the human in the pod, live +11. Artifacts & context management; script artifacts, agent-built tools & the + baseline scorecard +12. Delegation strategy — what NOT to rebuild +13. The whole system, drawn together +14. Cross-cutting concerns & hard problems +15. Consolidated open questions +16. Suggested MVP path + +--- + +## 1. The vision & motivation + +### 1a. The agentic-teams vision (rounds 1–2) + +Instead of a stateless chatbot, each **project** gains an **agentic flow**: +first-class **Tasks** and **Repositories** entities, multiple **agent personas** +(QA, security, engineers) that work real code in **ephemeral per-session pods**, +a **human-in-the-loop** escalation + approval gate, and — critically — +**persistent, per-persona memory** so agents learn and get cheaper/smarter per +task over a project's lifetime. The goal is to **replicate how the human team +works today**: standups, joint debugging/architecting sessions, autonomous solo +work when confident, and a single human supervisor clearing a queue of +escalated decisions. + +Origin (from the original idea): a flow of **Plan → Refine Plan → Draw Roadmap → +Discuss Tasks from the first roadmap position**, i.e. how a human team decomposes +and executes work. Note: we decided **against** hardcoding this as a rigid state +machine (see §5); it is the motivating scenario, not a fixed pipeline. + +### 1b. The vision-fidelity problem (round 3) + +A distinct, compounding motivation observed in real use: + +> 1. you had a grand vision +> 2. we split the grand vision into tasks +> 3. we completed all the tasks +> 4. we are left with a stick with wheels instead of a car + +Every task completed, yet no car. The diagnosis: **this is not an execution +problem — it's decomposition + verification.** Root causes: + +1. **Decomposition is lossy** — a multidimensional vision compresses into flat + imperatives; the *why* and *what-good-looks-like* get dropped. +2. **Tasks are graded mechanically** — "compiles / lint / renders," never "does + this serve the vision / is this designed / does this feel finished." +3. **Integration is nobody's job** — a car is parts *working together*, but task + systems execute a list. +4. **No feedback loop mid-flight** — the agent assumes task 3 was fine and never + re-checks against the original vision. +5. **Design/product review is a phase, not a gate** — applied at the end, when + the wrong shape is already baked. + +**Research synthesis that shapes the design:** vertical slices / walking +skeleton (decompose into user-visible `mini-wholes`, not layers — always have *a* +small car, never a pile of parts); a **two-tier Definition of Done** +(mechanical + systemic); review-gate HITL over per-tool approval; +spec-driven + **media-native** verification (judge an artifact in its native +medium — render/run, not read code); and the **critic-agent pattern** (a +non-executing persona that reviews against the vision catches drift the working +agent's confirmation bias misses). + +--- + +## 2. Core design principles (agreed) + +1. **Don't build everything from scratch.** If something is already solved well + (permissions, worktree mechanics, multi-turn agent execution, streaming), + delegate to it. Avoid rabbit holes. Explicit top priority. +2. **Agents are like people.** A persistent identity, personal memory, and the + ability to be *invited* to zero, one, or many projects. Not welded to a + single project or a single brain. +3. **Context is scoped, globally + per-project.** One global personal memory plus + *separate per-project* knowledge, so an agent doesn't cross-contaminate one + project's conventions or secrets into another. +4. **Isolation without shared live workspaces.** Agents do **not** share a live + filesystem with each other. Handoffs happen via git (source + verdicts) and an + immutable **build-artifact store** (terminology per §11). A single human *can* + share an environment with a single agent (via `exec`). +5. **Efficiency must be evidence-backed, and evidence must be honest.** Memory, + confidence, and verification scoping rely on retrievable evidence; any "still + valid" claim must be invalidated when the covered region changes — otherwise + memory becomes *false confidence*. +6. **The human is a peer and a supervisor.** A single queue of decisions; + fine-grained per-tool permission approval is delegated to the runtime; mcpctl + owns coarse-grained gates. +7. **Async, artifact-driven, human-as-fuse** (the through-line). Default flow is + asynchronous over frozen artifacts + a decision queue + disposable pods; + `exec` is the deliberate opt-in exception for live joint work. +8. **Vision is a living, versioned artifact** (round 3). There is an + authoritative Epic/VISION that tasks reference by version, so drift is + *detectable* and *auditable*. + +--- + +## 3. The two-level verification model + +This is the mental model that unifies rounds 1–3. There are **two distinct +levels of verification**, addressing the two distinct failure classes: + +| Level | What it checks | Who runs it | When | Native medium | +|---|---|---|---|---| +| **Tier 1 — Correctness/Security/Ops** | Does it build, run, pass tests, stay secure, and is the **deployment-as-code correct for this project's real infra** | Developer + QA + Security + SRE/Release personas | **Inside the task's own lifecycle** (stages before Done) | code / diff / tests / deployment manifests | +| **Tier 2 — Vision Fidelity / Design** | Does the assembled result match the vision? Is it designed, does it feel finished, does it integrate | **Design/Product critic** (review-only, non-executing) | **At the Epic level**, after tasks reach Done | render / run / walkthrough (media-native) | + +**These are different levels working at different points — they do not +replace each other.** QA/Security/SRE are correctness gates on the *task*; the +critic is a vision gate on the *Epic/whole*. Both feed the human accept gate. + +--- + +## 4. The person-model: agents as first-class "people" + +### Decisions + +- An agent exists **independently of any project** — a persistent entity with a + name, description, system prompt, and its own accumulated memory. +- An agent can **join zero, one, or many projects**, declared with + `mcpctl apply -f agent.yaml`. Intuitive shape: + + ```yaml + agents: + - name: agent001 + description: "Full-stack engineer persona" + systemPrompt: "…" + llmRef: agent001-brain # optional default/home brain + projects: [sre, payments] # many-to-many membership + ``` + +- This generalises today's model (Agent pinned to one `Llm` + one optional + `projectId`) into a **many-to-many membership** (an `AgentProject` junction, + analogous to the existing `ProjectServer` junction). The junction is also where + per-project *role / scope* (and RBAC) would hang. + +### Two lenses on the same person (both modes work) + +- `mcpctl chat agent001` — the person **on its own**: `agent.systemPrompt` + + **global personal memory**. General conversation, no project attached. +- `mcpctl chat agent001 --project sre` — the **same person, viewing project sre**: + systemPrompt **+ global personal memory (always present — it's who they are)** + **+ project-sre knowledge** (topology, coverage ledger, project Prompts) + **+ project-sre tools** (attached MCP servers as live tools) + **+ threads scoped to {person, sre}**. + +Key subtlety: **global personal memory is present in BOTH modes** — `--project` +adds a context layer, it does not create a different agent. **Threads** are +scoped to `(agent, optional project)` — same person, separate "meeting rooms." + +### Memory: global generalist + per-project context + +- **Global personal memory** — transferred expertise (methods, worldview). Owned + by the identity; travels with it. +- **Per-project knowledge** — project topology, coverage ledgers, "auth lives + here," **and operational/deployment model** (target infra, methods, tooling, + network policies, known gotchas — the SRE persona's bread and butter, §6). + Keyed to the project, retrieved in the right context only. +- **Both owned by the same identity, separately stored/scoped**, so one project's + quirks don't pollute another's judgment — like a consultant with general skills + plus per-client context. + +### The motivating example: the hotfix + +If an agent just did a **full security review of the app**, but a **tiny hotfix** +comes in, there is **no need to recheck everything — only what changed**. The +agent's own past verification of unchanged regions (pinned to a commit, still +valid) lets it **delta-check** rather than full-rescan. This is the economic +argument for memory + coverage-ledger, and it only holds if unchanged-after- +verification coverage can be *trusted* — hence non-negotiable invalidation (§14). + +### The "brain" — open knot + +Who owns the brain (which Llm powers the agent) is the one real open question. +Lean: **home brain for bare chat; the project's `llmProvider` in `--project` +mode** (portability, matches project-chat). Alternative: agent keeps its own +brain everywhere (strict persona consistency, less portable). **[OPEN — see §15]** + +--- + +## 5. The Epic / epictask / task data model + +The vision-fidelity fix needs an authoritative, versioned source of truth, so we +introduce a **vision layer** above the task layer (round 3, Jira-style +nomenclature). The through-line (async, human-as-fuse) is preserved. + +``` +Epic (the vision — its own resource) + └── epictask — a roadmap node; tasks hang off it; chains to other epictasks + └── task — the unit of work / execution (own lifecycle §6) +``` + +### Epic = the vision + +- The **back-and-forth before coding starts** *is* the vision; the Epic captures + its conclusion. Project-scoped. +- Holds the **agreed "done" criteria** (checkable outcomes + intent/quality + attributes) written at vision time, **scope-change history** (revisions, so + creep/drift is visible), and **accepted-tradeoff decisions**. +- **An Epic is "done" ONLY when:** 100% of the vision is delivered, **OR** the + user explicitly accepted tradeoffs (recorded so done is honest + auditable). + +### epictask + +- A **roadmap node**. Tasks link to an epictask; **epictasks chain to each other** + (dependencies) → visualizing all epictasks yields a **spider-web roadmap** + (dependency graph, not a flat sequence) — the planning/navigation layer. +- Lean: epictasks carry their own (decomposed) acceptance criteria so the web + shows per-node completeness. **[OPEN — §15]** + +### task + +- The unit of work, **owned by an epictask**, with the standard lifecycle (§6). +- Because tasks link to epictasks → Epic, every task can resolve **"what am I + serving and what does done-for-this look like"** by walking up the chain. + **Tier-2 (vision) DoD is reachable from every task.** + +### Reopen vs new-task vs exec (the Epic-comparison mechanism) + +After agents report done, the delivered whole is compared against the Epic: + +| Gap cause | Outcome | +|---|---| +| A task **claimed** a piece but didn't deliver it right | **Reopen** the task (directional comment back) | +| A piece was **never decomposed** into a task (breakdown gap) | **New task** under the relevant epictask | +| Hard problem / quality gap / "we couldn't fix X" | **`exec` joint session** (optional reopen/new task after) | + +The loop repeats until the Epic reaches 100%-delivered or delivered-with- +accepted-tradeoffs. **This comparison moment is the principled trigger for +`exec`** (§10). + +### Flow is NOT a rigid state machine + +Decided: do **not** hardcode Plan→Refine→Roadmap→Work as a pipeline. Tasks have +state; agents and the human move between **collaboration modes** based on +**confidence** and **collaboration need**. A rigid pipeline would force every +task (including a one-line docs typo) through the same heavyweight flow. The flow +is a set of *modes*, not a fixed sequence. + +### Collaboration modes (the "team dynamics") + +- **Standup** — on-demand, artifact-driven introspection: `mcpctl standup + --project sre` pulls each agent's per-task progress/notes since last and + summarizes; each agent's requested decisions surface as dashboard items. Not a + synchronous ritual. +- **Joint debugging / architecting** — the human works *with* an agent on a + challenge (via `exec`, §10). +- **Present architectural decisions** — agents surface decisions (→ dashboard), + not just completed work. +- **Ask the human for help** — distinguished from hot escalation: a collaboration + request, a mode of its own. **[OPEN — §15]** +- **Work alone when confident** — driven by the confidence signal (§8). + +--- + +## 6. The task lifecycle & QA/Security/SRE stages + +This refines the earlier `SelfCheck` stage into a richer set of **correctness +gates that run INSIDE the task's own loop, before "Done."** QA/Security/SRE work +with the developer *during* the task, as stages — feedback bounces the task back +to InProgress until it passes: + +``` +InProgress (developer) + → Build / Run (compile, run, smoke) + → Self-Testing (developer's own tests) + → QA Testing (QA persona checks; feedback → back to InProgress) + → Security Review (security persona checks; feedback → back to InProgress) + → SRE / Release Review (is the deployment-as-code correct for our real + infra — right target, right method, right tooling; + e.g. Kubernetes vs Portainer/EC2/local; + feedback → back to InProgress) + → Done (leaves developer's loop) + → AwaitingReview (human accept gate — your accept/reject + comment) + accept → Merged (PR merged via provider API) + reject → InProgress (you wrote a comment → developer reworks) + → Merged + post-merge failure (webhook) → Reopened (same agent; revert as fast net) +``` + +- **QA/Security/SRE are stages, not post-submit reviewers** — decided explicitly. + QA (quality) and Security (security) are Tier-1 correctness/security gates. An + **SRE/Release review stage** sits alongside them. It reviews the + **deployment-as-code** (IaC, release manifests, container/Docker config) to + ensure it matches the project's **actual deployment model and infrastructure** + — the right target (e.g. our Kubernetes cluster, not an EC2 VM or Portainer or + a local build), the right method (run in Docker, not build locally), and the + right tooling. Its feedback is directional ("no, this should go to Kubernetes, + not Portainer") and bounces the task back to InProgress like QA/Security. It + reviews the deployment *definition*; it is **not** the act of deploying. + The **Design/Product critic** (Tier-2, §3) is a different level operating at the + Epic, *not* one of these stages. + + **Why SRE must know the project's operational gotchas.** A large part of SRE + review is pre-empting failures whose root cause is an *implicit operational + rule* the developer LLM doesn't know. Example (from the user): the cluster has + **network policies**, so any new workload needs **egress and ingress rules**. + A developer LLM watching a connection fail will naturally go "cut a hole in + the floor to check the pipes" — deep-diving the network debugging path — when + the real cause is a **missing egress policy** silently blocking traffic at the + policy layer. The SRE persona, knowing the project's network-policy model, can + flag the missing egress/ingress rule in review and save the developer from the + wrong debugging path and from shipping something that fails obscurely at + runtime. This is why the SRE persona must carry rich **per-project infra + knowledge** (§4): deployment topology, targets, methods, tooling, policies, + and known gotchas. +- **PR linkage:** when a task is done, the agent **pushes and opens a PR**; the + Task stores the link (branch, commit, PR URL). The human should **not have to + go to git** — accept/reject happens inside mcpctl. +- **Merge** is a git-provider API call (e.g., Gitea), gated by CI + the human's + accept. It is **not** mcpctl executing git itself. + +### The human approval gate (decided — important) + +**NOT** Claude's per-tool manual/auto/bypass tri-state. It's a **task-level +acceptance gate**: a yes/no + comment on the *resolved outcome*, which fires the +merge. Once the Tier-1 stages pass, the task enters `AwaitingReview`; the agent +acts autonomously up to that point; the human's whole role is **one decisive +accept/reject + comment**. So **we do not rebuild a permission engine** — mcpctl +enforces coarse gates (who may transition `AwaitingReview→Merged/InProgress`, +what RBAC a session needs); the agent runtime handles fine-grained per-tool +approval. + +### Pre-submission verification — risk-scaled, not rigid + +Decided **against** a hardcoded pipeline. Verification effort **scales with task +risk** and the personas decide the level: +- **Trivial** (docs, formatting) → a light self-check may suffice. +- **Significant/risky** (auth, secrets, risk-relevant paths) → QA, Security + and/or SRE stages are engaged. +- Driven by the **confidence signal** (§8). + +--- + +## 7. Repositories & per-session pods + +### Repositories entity + +A **Repository** attaches git repos to a project and is where Tasks happen. It is +a metadata resource (which repos a project owns). **[Note: this entity was +defined in rounds 1–2; carry it forward into the Epic model as the execution +substrate for epictasks.]** + +### Per-session pods + +- **Each agent session gets its own ephemeral pod/container** (via the K8s + orchestrator mcpctl already has). The pod **is** the workspace. +- An **init container pulls all repos** the project expects → the session is + **project-aware**: right repos checked out, project Prompts, project MCP tools, + scoped secrets. +- No central worktree manager: the pod *is* the worktree (checked out to the task + branch). "Multiple agents on one repo" collapses into each pod checking out its + own branch, working, pushing. Pod dies at session end. +- **Agents do NOT share a live filesystem with each other** — buys independence + (fresh eyes) + provenance. + +### Handing work between personas — git is a feature, not a tax + +- The handoff is **immutable information about a commit** (branch + diff, base, + evidence, verdict) — not a shared live workspace. +- **Git is the audit trail + provenance:** a verdict pinned to a commit + ("QA verified 3f2ab against current main") is reproducible forever — exactly + the "records of similar work" the confidence signal needs. +- **The pod boundary buys true independence** (fresh eyes, no inherited + assumptions). +- **Reviewing a diff is closer to real review** than a shared live tree. +- One deliberate addition: an **immutable *build-artifact store*** keyed by + commit/branch for binaries/build outputs git shouldn't carry (see §11 for the + naming collision). + +### Verification session shape — cheap default, heavy only when risk-gated + +- **Lightweight review session (default):** QA/security runs a session against + the branch, ideally on a **warm base image (deps pre-installed)**. Reads the + diff, runs targeted checks, writes a verdict. No push. Cheap. +- **Heavy verification session (risk-gated only):** full clean pod checks out the + merged-to-current-main branch and runs full CI — the "verify against current + main" gate. Only for high-risk tasks (security paths, migrations, + cross-contract). + +So "separate pods per persona" ≠ "N× full CI every task." + +--- + +## 8. The confidence signal — evidence-anchored, not vibes + +This is what makes "autonomous when confident, escalate when not" *trustworthy*. +Protocol (on a **frozen task artifact**, not live chat): + +1. Frozen task artifact with all info attached (description, proposed resolution, + evidence). +2. **Retrieve K similar past tasks** (semantic similarity, weighted by recency) — + e.g. "we deployed to k8s yesterday" → similar deploys; "update this secret in + vault" → the exact same class. +3. Each attached **with description + resolution + effect** (what *actually* + happened — succeeded / follow-up / revert). +4. Ask the LLM: **"Given these K similar tasks and their outcomes, how confident + are you that THIS task will resolve without issues?"** citing which precedents. + +Refinements: +- **Any evidence can be attached, not just tasks** (docs, PRs, incident records, + QA coverage). +- **Outcome-awareness is what makes it honest** — the *effect* is load-bearing. +- **Staleness matters**: weight recent precedent higher (ties to ledger + invalidation). +- **Failures are bounded and auditable**: the decision cites its evidence, so you + can see *why* it was confident and improve retrieval. +- **Vision-fidelity integration (round 3):** confidence should ALSO score whether + the result serves the vision, not just "will it merge cleanly." The + critic/Epic-comparison (§5) is the Tier-2 half of the same honesty. + +### Thresholds drive autonomy + +``` +confidence(task, evidence[], threshold): + high → proceed solo, light verification + mid → proceed but route QA/security to delta-verify + low → ESCALATE to the human (packaged question) +``` + +### Outcome shaping (refinement) + +Make the question bidirectional/risk-aware: not just "will it go smoothly?" but +"**if it fails, where and how severe, given those precedents?**" — turning +confidence into preemptive review. + +--- + +## 9. Escalation & the decision dashboard (HITL) + +- **When confidence is low, the agent escalates a question to the user** — as a + **packaged, first-class escalation item** with full context: task, project, + proposed resolution, evidence/precedents, rationale, what it needs. +- **A dashboard** surfaces **all open questions from all agents across all + projects**, with context about each task/project. The user answers one by one — + **triaging a queue**, not being pulled into each agent's context. One human + supervises N agents across M projects from a single inbox. +- Each question is a **frozen, evidence-attached artifact** → auditable, + resumable, comparable. Actions: accept / reject-with-comment / direct / + route-to-another-agent. +- Confidence question and escalation are the **same artifact** — a low-confidence + agent packages the escalation rather than stopping. + +--- + +## 10. `mcpctl exec` — the human in the pod, live + +`mcpctl exec --project NAME AGENTNAME -- bash` must be a **first-class citizen**. +The human/agent relationship is **hybrid**: + +1. **Async, artifact-based** (confidence/escalation): most decisions vs frozen + artifacts, answered from the dashboard. +2. **Live, interactive** (`mcpctl exec … -- bash`): joint debugging / architecting + / real-time hands-on — drop into the agent's session as a shell (like + `kubectl exec` / `docker exec`). + +### Semantic (recommended) + +"Exec into a pod" should mean **into the working environment (repo/workspace)**, +NOT **into the agent's reasoning loop**: +- **A — working environment:** cloned repo, branches, tools, deps; inspect, run + commands, maybe manually fix. The agent is a separate process you can still talk + to. Robust, supports joint debugging. **Recommended default.** +- **B — attach to the agent's live session/internals:** invasive and fragile. NOT + recommended. + +Because `exec` lets a **single human share an environment with a single agent**, +it's a safe, deliberate coupling — not the multi-agent-shared-filesystem +anti-pattern. + +### The principled trigger (from §5) + +The **Epic-comparison moment** is what gives `exec` its concrete, principled +trigger: when a quality/design gap or a hard "we couldn't fix X" arises, that's +the natural time to `exec` and work it together, rather than filing more async +work or accepting a reduction. **[Note: reconciles the round-2 async decision with +this deliberate exec carve-out — name it so it doesn't conflict with LE-4.]** + +--- + +## 11. Artifacts & context management — and the "artifact" term collision + +### The collision, named + +In the **agentic-teams rounds**, an **"artifact store"** is a *storage* +mechanism: durable, server-side storage keyed by commit/branch for build outputs +and binaries git shouldn't carry. In the **prime-agent / Epic rounds**, an +**"artifact"** is a *context* mechanism: a structured object in the LLM context +(a PR, file, log) with an on-demand `expand`, never auto-injected, with an +eviction priority (user > agent > artifact-context). **These are two different +axes sharing one noun** — one is "where durable files live," the other is "what +gets lazily loaded into / flushed from the LLM's window." They must not be +conflated by reader or implementer. + +### Proposed terminology (to resolve the collision) + +- **Build-artifact store** — the durable storage for binaries/build outputs + (Wave-A meaning). Server-side, mcpctl-owned, keyed by commit/task. +- **Context-artifact** — a lazily-loaded, expandable object in the LLM context, + with eviction priority (Wave-B meaning). + +### Context-artifact philosophy (prime-agent) + +- Artifacts are **objects, not plain text** — `files|test`, logs, **PR & commits** + as structured objects with an `expand` operation, not dumped into context. +- **"Cat" → variable/artifact** — a named, retrievable reference, **not + auto-injected**. +- **Rule: context does NOT auto-populate from artifact content.** +- **Eviction / flush priority (recoverability-driven):** user's words (highest) > + agent's words > artifact-context (evict first, because artifact content is + durably stored and recoverable via `expand`). **Eviction follows recoverability.** +- Lean: an `expand`ed artifact that has been pulled in returns to + stored-only after the turn ends (safe to evict first). **[OPEN — §15]** + +### Script artifacts & agent-built tools + +Beyond `files|test` and logs, add a **`script` artifact kind** — a named, +reusable piece of executable code (e.g. "merging a PR", "deploying to the +cluster"). A script is a **tool-as-a-script**: instead of the agent re-figuring +out a recurring procedure every time ("how do we deploy?"), it encodes that +procedure once as a reusable, `expand`able artifact. + +**Scoping (nested):** a script artifact can live at **three scopes**: +- **Task-scoped** — specific to one task's work. +- **Project-scoped** — shared by everyone working in that project (e.g. the + project's canonical "deploy" or "merge PR" script). +- **Agent-scoped** — carried by a persona between projects (e.g. a security + code reviewer keeps its review tooling inside the *agent*, so it moves with + the agent to any project it joins). + +**Why agents should build their own tools (analogy to prime-agent / skills):** +like a skill but *as code*. A script is (a) a durable procedure captured once +instead of re-derived; (b) **context-saving** — because it's code, the full +procedure is not re-loaded into the prompt; it's an `expand`able/executable +artifact, so the agent invokes it rather than re-deriving it (ties to the +eviction/`expand` model above); and (c) **learned-and-reused** — once built, it +persists in the relevant scope and gets cheaper to re-use over time (mirrors +the coverage-ledger / knowledge-accumulation logic from §4/§14). + +**Incentive to build tools.** There should be a genuine motivation for an agent +to invest in tooling rather than doing things ad hoc each time. Candidates +(decide): +- building a reusable script earns the agent (or persona) **credit / proven + track-record** that raises future confidence scores (§8); +- reusing an existing script is **preferred / discounted** over ad-hoc + re-derivation; and +- a **scorecard (§11b)** can explicitly reward tool reuse / authorship. + +### 11b. The scorecard & default "basics-rules" governance + +Proposal: a **scorecard** mechanism — a "basics rules agent" that checks a +**provisioned default list of baseline rules** we want every agent to follow. +These defaults ship with the system (provisioned, not invented ad hoc). Example: +- **"Always make unit tests for everything you make."** + +The scorecard agent reviews work/productivity against these baseline rules and +scores it, surfacing violations/omissions (e.g. "missing unit tests") as +escalation/feedback. It is distinct from the Tier-1 QA/Security/SRE stages and +the Tier-2 critic — it's a **baseline-behaviour / standards gate**: a checklist +of minimum expectations rather than a judgement of correctness or vision. + +Open design points **[see §15]**: which rules are provisioned as defaults (and +where they live — inherited from the project? a global baseline?), how the +score influences autonomy/confidence vs. just reporting, and whether the +scorecard is a persona ("basics rules agent") or a passive framework the +running agents consult. + +--- + +## 12. Delegation strategy — what NOT to rebuild + +### mcpctl owns (the differentiator) + +- Task + Repo + Epic/epictask entities, task graph / roadmap. +- Project/repo awareness; per-session pod provisioning + repo-clone init. +- Session orchestration (spawn/kill, attach prompts/secrets). +- Coarse-grained RBAC → runtime binding; task lifecycle, acceptance gate, + escalation packaging, dashboard. +- Memory / confidence / coverage + vision (Epic) layer. +- **Script-artifact registry + baseline scorecard** (§11/§11b). + +### Delegate (the table stakes) + +| Piece | Decision | +|---|---| +| Multi-turn agent execution / tool loop | ✅ delegate to an agent runtime | +| Permission tri-state (manual/auto/bypass) | ✅ delegate to runtime (mcpctl does coarse gates) | +| Git worktree / branch / PR mechanics | ✅ pod-per-session + runtime | +| Streaming / REPL / approval UX | ✅ delegate (thin client) | +| Post-merge failure recovery | ⚠️ mcpctl routes/reopens; runtime/API does mechanics | + +### Agent runtime — build an interface, not a runtime + +**`AgentRuntime` interface** with operations the orchestrator needs (start +session, send message/tool, get status, request approval, push result, end +session), with **multiple implementations**: +- **#1 (recommended first): a battle-tested coding agent (e.g. Claude Code + headless) running inside the pod** — solves permission tri-state, worktree/git, + subagents, streaming, MCP client. Prompt-file/project-aware, fits + persona-as-prompt. +- **#2 (later, optional): `pi`** — attractive for its lean design and + **extensions/plugin ecosystem**, but younger; adopting it first means paying for + harness maturation, contradicting "don't rebuild." **Second adapter, not first + bet.** + +--- + +## 13. The whole system, drawn together + +``` +mcpctl (orchestrator / state / "team lead") + │ provisions session pods (existing orchestrator) + ▼ +Agent session pod (ephemeral) ← the "desk"; per-agent; isolated + │ init pulls project repos; project prompts/tools/secrets attached + ▼ +Agent runtime (delegated: Claude Code headless / pi / ChatService) ← swappable + │ edits files, runs tests, talks to humans + ▼ +git push / PR / artifact ← the contract the pod returns + ↓ +Task lifecycle (InProgress → Build/Run → Self-Test → QA → Security → SRE → + Done → AwaitingReview → (accept) Merged / (reject) InProgress) + │ QA/Security/SRE feedback→ back to InProgress + ↓ +Confidence check (evidence-anchored: retrieve similar tasks + outcomes) + ↓ low confidence +Escalation → decision dashboard (single human queue, accept/reject/comment) + ↓ accept +Merge via git-provider API (gated by current-main CI + human accept) + ↓ +Epic comparison (Tier-2 vision gate → Design/Product critic, media-native) + ↓ gaps +Reopen / new-task / exec joint session (until Epic done or tradeoffs accepted) +``` + +Layered summary: +- **Epic/epictask/task** = the vision + roadmap + work units. +- **Agent/persona** = person-like entity with global + per-project memory. +- **Pod/session** = agent's isolated workspace; `exec` lets a human join live. +- **Confidence** = evidence-anchored; thresholds drive autonomy. +- **Escalation/dashboard** = the single human decision queue. +- **QA/Security/SRE** = Tier-1 stages inside the task loop; **critic** = Tier-2 at + the Epic. +- **Merge** = current-main verified + human accept → provider API; auto-reopen on + post-merge failure. + +--- + +## 14. Cross-cutting concerns & hard problems + +1. **Retrieval quality is the crux of the confidence story.** Finding the *right* + similar tasks (semantic similarity + recency + outcomes) makes the whole + autonomous/confidence model trustworthy; weak retrieval quietly poisons it. +2. **Coverage-ledger invalidation.** Memory/coverage is only honest if "this + region is still verified" is *invalidated* when that region (or its deps) + changes — otherwise **false confidence** (agent skips a check because "it's + covered," but coverage is stale). + +Additional: +- **Memory staleness/decay** applies to confidence retrieval too (weight recent). +- **Script-artifact hygiene & trust.** Agent-authored scripts are executable code + with their own failure/security surface — they need versioning, review + (reuse of an agent's own tool = confirmation-bias risk on the *tool itself*), + and scoping boundaries (does a task-scoped script get promoted to + project/agent scope deliberately, or accidentally?). The context-savings of + "script not prompt" must not hide a broken or unsafe tool (§11). +- **Incentive integrity.** A scorecard that rewards tool-building must reward + *good, reused* tools, not just tool proliferation; otherwise agents gold-plate + scripts for score rather than reuse existing ones (§11b). +- **Retrieval / RAG failure:** notes are only as good as finding the right ones — + "I checked but checked the wrong thing" is a real failure mode in + security-relevant domains. +- **Trust boundary on agent-authored notes:** coverage ledger is + **platform-verified** (commit-pinned, change-invalidated = machine facts); + prose notes are **agent-opinion** (retrievable, never the *gate*) — avoids a + self-confirming-agent loop. +- **Tier-2 media-native verification in a headless agent** — do we need + render/screenshot infra, or can a design-aware persona judge from a structured + artifact without pixels? **[OPEN — §15]** +- **Cost:** review-only persona + integration gates add tokens/time — worth it on + product/UI work, overkill on pure backend APIs. Risk-gate it. + +--- + +## 15. Consolidated open questions + +Closed now vs. earlier rounds: **QA/Security as in-task stages** (not +post-submit); **critic as Tier-2 at Epic level** (additive, different level); +**artifact term collision** → build-artifact vs context-artifact (§11). + +Still open: + +1. **Brain ownership** — home brain for bare chat, project's `llmProvider` in + `--project` mode? (Lean yes.) Blocks the data model. +2. **Naming (LE-7)** — `epictask` spelling; and whether `task` collides with the + repo's `.taskmaster` tasks. +3. **Prompt delivery to the pod** — MCP vs files-in-container vs LLM injection. + (Affects pod image + runtime; lean: files for the coding-agent runtime.) +4. **Ask-for-help / joint problem-solving mode** — how it's structured (an + escalation tagged help-request?). +5. **`exec` carve-out naming** — reconcile the deliberate Epic-gap `exec` trigger + with the async default (§10). +6. **Epic vs epictask "done"** — do epictasks carry their own acceptance criteria + (spider-web per-node completeness)? Lean yes. +7. **Epic as a resource vs a prompt** — own resource type (RBAC + revision) vs + reuse project prompt/skill. Lean own resource. +8. **Comparison unit** — per-task or per-epictask-chain; a gap can cross linked + epictasks. +9. **Accept-tradeoffs structure** — record *what, why, who, de-scoped-by* as a + revision on the Epic. +10. **Artifact-store location + context return** — where binaries live + (server-side per LE-8/LE-5); whether an `expand`ed artifact returns to + stored-only after the turn (lean yes). +11. **Merge recovery preference** — fast revert of task-sized merge vs always + forward-fix on main (lean: revert as escape hatch). +12. **Dashboard surface** — web? TUI? CLI? +13. **Tier-2 media-native infra** — render/screenshot infra vs structured-artifact + judgment. +14. **MVP vertical-slice order** — confirm/adjust the path below. +15. **Script-artifact design** — the `script` artifact kind, its three scopes + (task/project/agent), versioning, and how reuse/`expand`/eviction apply (§11). +16. **Incentive & scorecard** — how to make tool-building genuinely worthwhile and + how the scorecard feeds autonomy vs. reporting: which baseline rules are + provisioned as defaults, where they live (global vs per-project), and whether + the “basics rules agent” is a persona or a passive framework (§11b). + +--- + +## 16. Suggested MVP path + +1. **Epic + epictask + task + Repo entities** + project linkage (Prisma models, + repository, routes, CLI). mcpctl-native, small, high clarity. (Now includes the + vision layer.) +2. **AgentRuntime adapter interface** + first runtime (existing coding agent + headless) inside a pod. Prove: pod spawn → clone → work task branch → push → + report. +3. **Task lifecycle + QA/Security/SRE stages + PR linkage + human accept gate** → + merge via provider API (current-main CI + Reopened-on-failure). +4. **Tier-2 / vision:** Design/Product critic persona + Epic-comparison → + reopen / new-task / `exec` joint session. +5. **Collaboration modes:** on-demand async standup + `exec` joint mode. +6. **Confidence-driven autonomy + escalation dashboard.** +7. **Memory + coverage ledger + context-artifact management** (invalidation- + aware). +8. **Script artifacts & baseline scorecard** — reusable `script` artifacts at + task/project/agent scope, plus the provisioned baseline-rules scorecard (§11). + +--- + +*Unified record of all rounds (agentic-teams + vision-fidelity + Epic/artifact). +Supersedes the four earlier design files. Feature/decision-focused; schema and +code intentionally indicative. Open questions in §15 are the design backlog.* -- 2.49.1 From d24c30761bb59817cea829c2b014a900d63fc641 Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 8 Aug 2026 09:03:58 +0100 Subject: [PATCH 02/32] docs: keep superseded design-round doc history Supporting documents folded into agentic-teams.md (the canonical unified design record), retained as historical context: - agentic-teams-review.md (round 2) - vision-fidelity.md (round 3 diagnosis + research) - epic-task-artifact-model.md (Epic/epictask/task + context philosophy) - session-summary-vision-fidelity.md (session narrative) All superseded by docs/agentic-teams.md, which remains the source of truth. --- docs/agentic-teams-review.md | 246 ++++++++++++++++++++++++ docs/epic-task-artifact-model.md | 156 +++++++++++++++ docs/session-summary-vision-fidelity.md | 230 ++++++++++++++++++++++ docs/vision-fidelity.md | 222 +++++++++++++++++++++ 4 files changed, 854 insertions(+) create mode 100644 docs/agentic-teams-review.md create mode 100644 docs/epic-task-artifact-model.md create mode 100644 docs/session-summary-vision-fidelity.md create mode 100644 docs/vision-fidelity.md diff --git a/docs/agentic-teams-review.md b/docs/agentic-teams-review.md new file mode 100644 index 0000000..05c3a91 --- /dev/null +++ b/docs/agentic-teams-review.md @@ -0,0 +1,246 @@ +# Agentic Teams — Second Review Round + +> **Status:** Second design review round. Complements and refines +> `docs/agentic-teams.md` (the primary design record). This file captures the +> loose ends surfaced by reviewing the first draft, and the decisions/recommen- +> dations that followed. +> +> Date of round: second pass. Like the base doc, this is **feature/decision- +> focused**, not code-focused. Items marked **[OPEN]** are not yet decided and +> block or shape implementation. Items under **"DECIDED in this round"** are +> resolutions reached here. + +--- + +## 0. What this round covered + +Reviewing the first draft surfaced several **loose ends, tensions, and undefined +mechanics** that would have bitten during implementation. They cluster into one +through-line question (below), then several specific items. The single most +important framing decision this round is **async-vs-sync**, because it resolves +most of the others. + +--- + +## 1. THE through-line decision: async vs sync [mostly DECIDED — see below] + +Most of the loose ends collapse into one question: + +> **Is the system fundamentally synchronous-team (everyone—agents and +> human—working "together" in near-real-time, waiting on each other), or +> asynchronous (frozen artifacts + a decision queue + disposable pods)?** + +**Where this landed (DECIDED in this round):** + +The system is **fundamentally asynchronous and artifact-driven**, with `exec` +being the *deliberate exception* the human opts into for joint active work. +Evidence from the whole conversation supports this: + +- Confidence checks run on **frozen task artifacts**, not live chat. +- The human supervises from a **single dashboard/inbox** of packaged decisions. +- Pods are **ephemeral and disposable** by design. +- State lives in **mcpctl's database** (not in pods). +- Agents escalate **packaged questions**, not live interjections. + +Locking this resolves loose ends 1, 2, 3 and 4 (below) together, and makes the +design **more robust** (no idle synchronous chains, no live-session +coordination, everything auditable and resumable). `exec` remains available, +but it is a **different activity** (joint active collaboration) rather than the +default way work proceeds. + +--- + +## 2. Loose ends surfaced & resolution status + +### 🔴 LE-1: Do reviewers (QA/security) block the working agent, or run async? + +**The tension:** if a working agent submits and reviewers run in *their own +pods*, does the working agent **block** (idle for minutes/hours) or **end its +session** and get resumed only if rejected? + +**DECIDED (recommendation, consistent with async model):** reviewers run +**asynchronously against the frozen branch/artifact**. The working agent's pod +can end after submission. A **rejection re-spawns a working session** with the +rejection comment as new input. No idle blocking chains. + +This also means we do **not** need live multi-pod coordination — the handoff is +artifacts + verdicts in mcpctl's DB, not a live rendezvous between pods. + +### 🔴 LE-2: Are QA/security verdicts a hard gate, or advisory? + +**The tension:** "QA checks, security looks, CI passes, then submit" implies a +**gate** (all must pass); but the confidence model and "human accept is the only +true fuse" imply they're **advisory**. + +**DECIDED (recommendation):** QA/security review is **primarily for the working +agent's self-correction** (catching issues before it reaches you) and **as +attached evidence for the human's accept decision**. Their verdicts are +**informative, not hard autonomous gates**. The **only hard autonomous gate is +CI-against-current-main**; the **human's accept remains the one true fuse.** + +Reasoning: if reviewer verdicts were hard gates, they'd effectively be an +autonomous pre-approval pipeline — the rigid-pipeline shape we explicitly +rejected, and the human's accept would become rubber-stamping. Keeping them +advisory preserves the human's genuine authority while still giving agents a +self-correction loop and giving the human good evidence. + +### 🔴 LE-3: Did we actually decide "standup," and how does it fit the async model? + +**The tension:** "standup" was described early ("standup where we discuss +progress since yesterday") but was never designed. It also risks implying a +synchronous ritual. + +**DECIDED (recommendation):** a standup is an **on-demand, artifact-driven +instrospective flow**, not a recurring synchronous call. Example shape: + +- `mcpctl standup --project sre [--since ]` — pull each agent's + per-task progress/notes since the last standup and summarize. +- Each agent's **requested decisions surface as dashboard items** (one queue). +- The human reviews the summary and works the queue asynchronously. + +So standup **pulls from per-agent per-task notes** (where progress actually +lives) and **feeds the decision dashboard**. It does not require anyone to be +live simultaneously. + +### 🟡 LE-4: `exec` vs the async review model — clarify the boundary. + +**DECIDED (recommendation):** `exec` is for **joint active collaboration** +(debugging / architecting / real-time hands-on), *not* for inserting into a +task already under async review. A task under review is a **frozen artifact**; +`exec` is the opt-in exception when the human *chooses* to be live with one +agent. This keeps the two modes coherent and avoids expecting to `exec` into +the middle of a review pipeline. + +### 🟡 LE-5: Where does state live given pods are disposable? + +**DECIDED:** State lives in **mcpctl's database** — task artifacts, verdicts, +per-agent notes, coverage ledgers. **Pods are stateless workers** that read +from / write to that state. Nothing is lost on pod death; pods are cheap to +re-spawn. This is a strength of the design and worth stating explicitly. + +### 🟡 LE-6: The "brain" question — who powers the agent's LLM? [OPEN → resolve] + +The one real open knot from the first round, still not closed. + +**Recommendation (decided-leaning):** +- **Bare chat** (`mcpctl chat agent001`): the agent's **home brain** (agent-owned + Llm default). +- **`--project` mode**: the **project's `llmProvider`** takes over (portability; + matches today's project-chat; a project can invite any agent and run it on the + project's model of choice). + +**This blocks the data model**, so it should be closed with one decision. It is +the single most important unresolved item. + +### 🟢 LE-7 (naming): "Task" may collide with the repo's Task Master workflow. + +The mcpctl "Task" resource could conceptually collide with this repo's +`.taskmaster` tasks. **Flag for a naming check** — e.g., `workitem` / `item` / +`assignment` — so the two don't confuse. Not an implementation blocker; flagged +because a second pass is the right time to settle naming. + +### 🟢 LE-8 (minor): Where does the artifact store (non-git binaries) live? + +Decided earlier that git carries source+verdicts and an **immutable artifact +store keyed by commit** carries binaries/build outputs. **Recommendation:** that +store also lives server-side (mcpctl-owned), keyed by commit/branch, so it +survives pod death and pods pull exactly what they need. Consistent with LE-5. + +### 🟢 LE-9 (open): How are project/persona prompts delivered to the pod? + +**[OPEN]** The user explicitly raised this early and we never chose between: + +- **exposed via MCP** (prompts as MCP calls), +- **as files placed in the container** (prompt files on disk in the pod), +- **LLM query injection directly** (system-prompt injection at call time). + +This affects the pod image and the runtime interface. Lean: a prompt-file-aware +coding-agent runtime → *files in the container*; MCP as an alternative surface; +injection-at-call is how today's `chat` behaves. Should be closed at design time. + +### 🟢 LE-10 (open): The explicit "ask-for-help / joint problem-solving" mode + +**The user described wanting a mode where they help an agent with a challenge +and work on it together** — “you can ask me to help you with some challenge and +we can work for it together.” This is related to but distinct from `exec`-into- +a-workspace and from hot escalation. It is not fully designed. + +Lean: an agent that is stuck or lacks knowledge can **request help** (an +escalation tagged as a help-request), routed to the dashboard or an `exec` +session, rather than the human having to discover it independently. + +--- + +## 3. Confirmed strengths worth preserving (from round-one, re-affirmed) + +While reviewing, we confirmed these hold up and should survive any refinement: + +- **Agents as persistent "people"** (global memory + per-project context + + multi-project membership). [DECIDED round one] +- **Confidence = evidence-anchored**, outcome-aware (attach similar tasks' + description + resolution + *effect*). [DECIDED round one] +- **Task-level accept gate** (one decisive human accept/reject + comment) rather + than per-tool permission — no permission engine rebuild. [DECIDED round one] +- **Merge gated on current-main CI + human accept** via provider API, with + **auto-reopen/revert** on post-merge failure. [DECIDED round one] +- **Coverage-ledger invalidation** is the honesty backbone of the memory. [MVP + prerequisite] +- **Delegation over reinvention** — build an `AgentRuntime` interface; first + runtime = battle-tested coding agent headless; `pi` as a later optional + adapter. [DECIDED round one] + +--- + +## 4. Post-merge concern — revisited and reinforced + +The "stuff only fails after merge to master" worry got a concrete resolution in +round one, and the async framing **reinforces** it: + +- Pre-merge acceptance must verify **against current main tip**, not the stale + base. +- The **human's accept is conditioned** on that current-main verification; if + main moved and it's stale, stay `AwaitingReview` flagged rebase-needed (agent + refreshes + re-verifies). +- Post-merge, `Merged → Reopened` on a failure webhook, with **fast revert of + the task-sized merge** as the cheapest recovery. + +The async model strengthens this: because each step is a frozen artifact + state +transition, a post-merge failure is a *routable state* (auto-reopen), not a +surprise to be debugged live. + +--- + +## 5. Remaining open questions (consolidated) + +To make the design "ready to design," close these: + +1. **[LE-6] Brain ownership** — closed-leaning but not confirmed: home brain for + bare chat; project's `llmProvider` in `--project` mode. +2. **[LE-7] Naming** — is the work entity called a "Task" or something else? +3. **[MVP-scope] Which vertical slice first** — the base doc proposes + Task+Repo entities → runtime adapter → accept gate → collaboration modes → + confidence/dashboard → memory/ledger. Confirm or adjust the order. +4. **[LE-9] Prompt delivery** — MCP vs files-in-container vs LLM injection (see + LE-9). +5. **[LE-10] Ask-for-help / joint problem-solving mode** (see LE-10). + +--- + +## 6. Suggested MVP path (unchanged from base doc, re-listed for completeness) + +1. Task + Repo entities + project linkage. +2. AgentRuntime adapter interface + first runtime (existing coding agent + headless) inside a pod. Prove: pod spawn → clone → work task branch → push → + report. +3. Task lifecycle + PR linkage + human accept gate → merge via provider API + (current-main CI + Reopened-on-failure). +4. Collaboration modes: on-demand async standup + `exec` joint mode. +5. Confidence-driven autonomy + escalation dashboard. +6. Memory + coverage ledger (invalidation-aware). + +--- + +*This file records the second review round and its outcome. It should be read +together with `docs/agentic-teams.md`; together they form the durable design +record. The async-vs-sync through-line (section 1) is the key decision to carry +forward.* diff --git a/docs/epic-task-artifact-model.md b/docs/epic-task-artifact-model.md new file mode 100644 index 0000000..3953ae7 --- /dev/null +++ b/docs/epic-task-artifact-model.md @@ -0,0 +1,156 @@ +# Epic / Task / EpicTask + Artifact Context Model + +> **Status:** Design discussion — capturing decisions from the conversation. +> **Purpose:** Durably record the data model (Epic, epictask, task) and the +> IPython-style artifact/context philosophy the user is designing. Builds on +> `docs/agentic-teams.md` (round 1) + `docs/agentic-teams-review.md` (round 2). +> +> The through-line from round 2 — **async, artifact-driven, human-as-fuse** — +> is preserved here. This doc adds the *vision layer* (the Epic) and the +> *context-management layer* (artifacts + eviction priorities) that the earlier +> rounds never defined. + +--- + +## 1. The Epic — the vision, made a first-class thing + +An **Epic** is the agreed statement of what we want to achieve. It is the +result of the back-and-forth **before coding starts**: the discussion *is* the +vision, and the Epic captures its conclusion. It is project-scoped. + +### What an Epic holds +- The **agreed "done" criteria** — the checkable outcomes plus the intent/quality + attributes that define 100% delivery. Written at vision time so "compare + delivered vs vision" has something authoritative to diff against. +- Its **scope changes** over time (revisions), so creep/drift is visible too — + not just under-delivery. +- **Tradeoff decisions** the user makes along the way. + +### When is an Epic "done"? +An Epic is **done ONLY when one of two things is true**: +1. **100% of the vision is delivered**, OR +2. The **user explicitly accepted tradeoffs** — e.g. a known issue we couldn't + fix ("we accept this gap because"). The accepted-tradeoffs are recorded on + the Epic so the "done" is honest and auditable. + +### The Epic-comparison moment → the joint-debug trigger +After agents report their work done, the delivered whole is **compared against +the Epic**. Two classes of gap: + +- A **listed must-have isn't shipped** → reopen the under-delivering task, or + add a new task for the missing piece (see §3). +- A quality/design gap or a **"we couldn't fix X"** situation → this is the + natural moment to **`exec`**: the user drops into the agent's workspace and + does a **joint debugging session** (the pi / Claude-Code live experience), + working the hard problem together rather than accepting a reduction or + filing more async work. + +So "deliver → compare → gap" routes not only to reopen/new-task, but also to +**live collaboration** — giving `exec` a concrete, principled trigger point +that round 2 noted was missing (LE-10 / LE-4 boundary). + +--- + +## 2. The data model — project resources + +Three project-scoped resources: + +``` +Epic (the vision — its own resource, §1) + └── epictask — a roadmap node; tasks hang off it + ├── task — the unit of work / execution + └── task + └── epictask ── chains to other epictasks (the roadmap web) +``` + +### `epictask` +- A roadmap node. **Tasks link to an epic task** (a task belongs-to / serves an + epic task). +- **Epic tasks chain to each other** (dependencies). Visualizing all epic tasks + yields a **spider web of roadmap** — not a flat sequence, but a dependency + graph. This is the navigational/planning layer. + +### `task` +- The unit of work, owned by an epictask. +- Carries the standard lifecycle from round 1 (InProgress → SelfCheck → + AwaitingReview → accept → Merged → Reopened-on-failure). +- **Contains artifacts** (see §4). + +### Threading the vision through +Because tasks link to epictasks and epictasks belong to an Epic, every task can +resolve "**what am I serving and what does done-for-this look like**" by walking +up the chain to the Epic + its agreed "done" criteria. Tier-2 (vision) DoD is +therefore **reachable from every task**, not bolted on. + +--- + +## 3. Reopen vs new-task — the mechanism + +The Epic-comparison diff produces both, distinguished by *why* the gap exists: + +| Gap cause | Outcome | +|---|---| +| A task **claimed** to cover a piece but didn't deliver it right (acceptance criteria unmet) | **Reopen** the task (send back with the directional comment) | +| A piece of the Epic was **never decomposed into a task** at all (breakdown gap) | **New task** under the relevant epictask | +| A piece needs **joint effort / the fix is hard** | **`exec` joint session** (plus optionally reopen/new task after) | + +The loop repeats until the Epic reaches a terminal state: 100% delivered, or +delivered-with-accepted-tradeoffs. + +--- + +## 4. Artifacts + IPython-style context (prime-agent philosophy) + +Borrowing from +[PrimeIntellect prime-agent](https://github.com/PrimeIntellect-ai/prime-agent)'s +**"context as variables / prompt-as-a-variable"** model: + +### Artifacts are objects, not plain text +Each task can contain **artifacts**: `files|test`, logs, and also **PR and +commits** — but as **structured objects with an `expand` operation**, **not +stored/loaded as plain text**. + +- A PR or commit is an object; the LLM can `expand` it *on demand* to get the + detail (diff, files, messages). It is not dumped into context eagerly. +- "Catting" something **materializes it into a variable/artifact** — a named, + retrievable reference — rather than auto-injecting its full text into the + LLM context. + +**The rule: context does NOT auto-populate from artifact content.** You pull +what you need; nothing is eagerly loaded. + +### Eviction / flush priority (what to drop first) +When context must be flushed/compacted, the retention hierarchy is: + +1. **Keep the user's words the longest** — highest retention. +2. Next, keep what the **agent said**. +3. Next, the **artifact context** — and this is *safe to evict first* because + artifacts are **durably stored**; their content can always be **recovered + on demand via `expand`**. + +So eviction order is **user > agent > artifact-context**, justified precisely +by recoverability: artifact content isn't lost, it's retrievable — so dropping +it from live context costs nothing permanent. + +--- + +## 5. Open questions / tensions to resolve + +1. **Epic as a resource vs a "prompt"** — does the Epic get its own resource + type (with RBAC + `ResourceRevision`), or reuse the project `prompt`/`skill` + mechanism? Lean: own resource — it's the audit/source-of-truth for "is this + done," distinct from context-injection prompts. +2. **Epic-task `done` criteria** — do epictasks also carry their own + acceptance criteria, or is all "done" defined at the Epic level? Lean: Epic + defines overall done; epictasks carry the *decomposed* subset, so the spider + web can show per-node completeness. +3. **Artifact storage location** — consistent with LE-8/LE-5: server-side, + mcpctl-owned, keyed by commit/task. Confirm artifacts live outside git (for + binaries/build outputs) vs git-native for commits/PRs (which stay in the + repo/PR API and are referenced as objects, not copied). +4. **What counts as "artifact context" for eviction** — an `expand`ed artifact + that *has* been pulled into context: once the turn ends, does it return to + "stored-only" (safe to evict) or persist as agent-context? Lean: after the + turn it is stored-only again (evict first), because it's recoverable. +5. **Naming (LE-7)** — `epictask` vs `epic-task` vs `epic_task`; and whether + `task` collides with the repo's `.taskmaster` tasks. Needs a naming decision. diff --git a/docs/session-summary-vision-fidelity.md b/docs/session-summary-vision-fidelity.md new file mode 100644 index 0000000..0bc9bac --- /dev/null +++ b/docs/session-summary-vision-fidelity.md @@ -0,0 +1,230 @@ +# Session Summary — Vision Fidelity in Agentic Teams (full discussion) + +> **Status:** Consolidated session record — captures everything discussed, in +> order, so it reads as one coherent thread. +> **Date:** 2026-08-08 · pi session · mcpctl repo +> +> **IMPORTANT — canonical doc:** since this summary was written, +> `agentic-teams.md` was updated and is now the **single authoritative, unified +> design document** that **supersedes** this summary plus `vision-fidelity.md`, +> `epic-task-artifact-model.md` and `agentic-teams-review.md`. Read +> `agentic-teams.md` for the source of truth; this summary is the session +> narrative and highlights the sections the canonical doc added beyond it. + +--- + +## Part 1 — The problem statement + +We started with a recurring failure with LLM coding agents: + +> 1. you had a grand vision +> 2. we split the grand vision into tasks +> 3. we completed all the tasks +> 4. we are left with a stick with wheels instead of a car + +Every task completed, yet no car. The diagnosis (see `vision-fidelity.md` for +the full write-up): **this is not an execution problem — it's decomposition + +verification.** + +### Root causes +1. **Decomposition is lossy** — a multidimensional vision compresses into flat + imperatives; the *why* and *what-good-looks-like* get dropped. +2. **Tasks are graded mechanically** — "compiles / lint / renders," never "does + this serve the vision / is this designed / does this feel finished." +3. **Integration is nobody's job** — a car is parts *working together*, but + task systems execute a list. +4. **No feedback loop** — the agent assumes task 3 was fine and never re-checks + against the original vision. +5. **Design/product review is a phase, not a gate** — applied at the end, when + the wrong shape is already baked. + +### Research synthesis (from the write-up) +- **Vertical slices / walking skeleton** — decompose into `mini-wholes` + (user-visible, end-to-end slices), not layers. Always have *a* small car, not + a pile of parts. +- **Two-tier Definition of Done** — mechanical tier + systematic tier (matches + vision, integrates, is designed, feels finished). Tier-2 evaluated by running + the medium, not reading code. +- **Review-gate HITL over per-tool approval** — consistent with round-2. +- **Spec-driven + media-native verification** — judge an artifact in its native + medium (render/run for UI). +- **Critic-agent pattern** — a non-executing persona that reviews against the + vision catches drift the working agent's confirmation bias misses. + +--- + +## Part 2 — Grounding in the repo (the "other window") + +We confirmed this connects to real, existing machinery: + +- **mcpctl agent platform** — persistent agent personas, project prompts, + `ResourceRevision` (versioned log), `ResourceProposal` (review/approve + queue), personalities (same agent, different judgment via prompt overlays). +- **gstack skills** (installed skill library) — `/autoplan` (auto-review + pipeline: CEO → Design → Eng → DX), `/plan-ceo-review` (grand-vision + guardian, nuclear scope challenge, 10x vision), `/plan-design-review` + (rates each design dimension 0-10, generates visual mockups, "text + descriptions are not a substitute for showing what it looks like"), `/spec`. +- **`agentic-teams.md` + `agentic-teams-review.md`** — the durable design + record, landed on **async, artifact-driven, human-as-fuse** (section 1 of + the review round is the through-line). + +**Key realization:** the platform reviews the *plan* hard before build, but +**nothing reviews the *built result* against the vision after implementation**, +and **no vision artifact exists as a first-class, versioned resource**. That's +the gap today's design fills. + +--- + +## Part 3 — Where we landed: the Epic model + +We reframed VISION.md → **Epic**, in Jira nomenclature — and it's the thing we +discussed in *this* window's second half. Full spec in +`epic-task-artifact-model.md`. + +### The Epic is the vision +- The **back-and-forth discussion before coding starts** *is* the vision; the + Epic captures its conclusion. It's project-scoped. +- Holds the **agreed "done" criteria** (checkable outcomes + intent/quality + attributes) written at vision time, plus scope-change history (revisions) and + **accepted-tradeoff decisions**. + +### Epic lifecycle — "done" only two ways +An Epic reaches **done** only when: +1. **100% of the vision is delivered**, OR +2. The **user explicitly accepted tradeoffs** (e.g. an issue we couldn't fix) — + recorded on the Epic, so done is honest + auditable. + +**The comparison moment is the `exec` trigger.** Delivered-whole vs Epic → gaps +route to: +- **reopen** under-delivering tasks, or +- **new tasks** for never-decomposed pieces, or — when it's a hard "we + couldn't fix X" — a **joint `exec` debugging session** (the pi / Claude-Code + live experience). This gives `exec` a concrete, principled trigger. + +--- + +## Part 4 — Data model: task / epictask / Epic + +Hierarchy (details in `epic-task-artifact-model.md` §2): + +``` +Epic (the vision) + └── epictask — roadmap node; tasks hang off it; chains to other epictasks + └── task — the unit of work (own lifecycle from round 1) +``` + +- **tasks link to epictasks**; **epictasks chain to each other** → visualizing + all epictasks gives a **spider-web roadmap** (dependency graph, not flat). +- Walking any task up the chain reaches the Epic's "done" criteria, so + **tier-2 / vision DoD is reachable from every task**. + +### Reopen vs new-task +| Gap cause | Outcome | +|---|---| +| A task under-delivered its acceptance criteria | **Reopen** (directional comment back) | +| A piece was never decomposed (breakdown gap) | **New task** under the epictask | +| Hard problem / needs joint effort | **`exec` joint session** | + +--- + +## Part 5 — Artifacts + IPython-style context (prime-agent philosophy) + +Borrowed from [prime-agent](https://github.com/PrimeIntellect-ai/prime-agent)'s +**"context as variables / prompt-as-a-variable"** (details in §4 of the model +doc): + +- **Artifacts are objects, not plain text** — `files|test`, logs, and also + **PR & commits** as structured objects with an `expand` operation (not + dumped into context). +- **"Cat" → variable/artifact** — a named, retrievable reference, **not + auto-injected**. +- **Rule: context does NOT auto-populate from artifact content.** + +### Eviction / flush priority (recoverability-driven) +1. **User's words** — highest retention. +2. **Agent's words** — next. +3. **Artifact context** — evict first, *because artifacts are durably stored + and recoverable via `expand`*. **Eviction priority follows recoverability.** + +The elegant win: dropping artifact-context first is *cheap* because it's +recoverable — most compaction schemes don't formalize this, and it turns a +hand-wavy "compaction" into a principled hierarchy. + +--- + +## Part 6 — Open questions / tensions (carried forward) + +1. **Epic as a resource vs a prompt** — own resource type (RBAC + revisions) or + reuse project prompt/skill? Lean: own resource. +2. **Epic-level vs epictask-level "done"** — should epictasks carry their own + acceptance criteria so the spider web shows per-node completeness? Lean: yes. +3. **Comparison unit: task-level or epictask-chain-level?** A gap can cross + multiple linked epictasks; a single "reopen" may not express "this chain is + incomplete." Decide whether comparison happens per-task or per-chain. +4. **`exec` as a deliberate carve-out** — the async model is default, but the + Epic-gap triggers sync `exec` *by design*. Name this so it doesn't conflict + with the round-2 async decision (LE-4). +5. **Accept-tradeoffs needs structure** — record *what, why, who, de-scoped-by* + as a revision on the Epic. +6. **Naming (LE-7)** — `epictask` spelling; and `task` vs repo's `.taskmaster` + tasks. +7. **Artifact storage** — server-side mcpctl-owned for binaries/builds (per + LE-8/LE-5); commits/PRs stay in git/repo and are referenced as objects. +8. **Post-build re-verification** — the remaining product-side gap: nothing + re-checks the rendered/built result against the reviewed mockup/vision after + implementation (the `design-review`/`qa` path is opt-in, not wired into the + Epic-comparison loop). + +--- + +## Referenced artifacts produced today + +| File | Purpose | +|---|---| +> See `docs/agentic-teams.md` — now the single unified document (supersedes +> `vision-fidelity.md`, `epic-task-artifact-model.md`, `agentic-teams-review.md`, +> and this summary). + +## Part 7 — What the canonical `agentic-teams.md` added beyond today's thread + +When reviewing the updated `docs/agentic-teams.md` (which folds in everything +we discussed), these sections were found to be **new / more evolved** than the +session thread above — worth reading there: + +1. **Two-level verification model (§3)** — formalised table: **Tier 1** + Correctness/Security/Ops (inside the task lifecycle) vs **Tier 2** Vision + Fidelity/Design (at the Epic, media-native). They do not replace each other. +2. **SRE/Release review stage (§6)** — Tier-1 stage inside the task loop that + reviews **deployment-as-code** (IaC, release manifests, container config) + against the project's **actual deployment model/infra** (Kubernetes vs + Portainer vs EC2 vs local). Worked example: a **missing network egress + policy** silently blocks traffic and a developer LLM deep-dives the wrong + debugging path — so the SRE persona must carry per-project infra gotchas. +3. **Task lifecycle expanded (§6)** — `InProgress → Build/Run → Self-Testing → + QA → Security → SRE/Release → Done → AwaitingReview → accept→Merged / reject→InProgress`, with stage feedback bouncing back to InProgress. +4. **Script artifacts (§11)** — a `script` artifact kind (tool-as-a-script, + like a skill but *as code*) at three scopes: task / project / agent. Captures + a procedure once instead of re-deriving it; context-saving via `expand`; + learned-and-reused. +5. **Baseline scorecard / "basics rules agent" (§11b)** — a provisioned + default list of baseline rules (e.g. "always make unit tests") checked by a + scorecard agent; distinct from Tier-1 QA/Security/SRE and Tier-2 critic — a + baseline-behaviour/standards gate. +6. **Artifact term collision (§11)** — the noun collision resolved: **build-artifact + store** (durable storage for binaries) vs **context-artifact** (lazily-loaded, + expandable object in the LLM window). Two axes, one noun; must not be conflated. +7. **Hotfix motivating example (§4)** — delta-check vs full-rescan economic + argument for memory + coverage ledger. +8. **Collaboration modes (§5)** — standup (on-demand, artifact-driven), joint + debugging/architecting, present architectural decisions, ask-the-human-for-help, + work-alone-when-confident. +9. **Consolidated open questions (§15)** — now 16 items, including brain + ownership, naming (LE-7), prompt delivery, exec carve-out, Epic vs epictask + done, Epic-as-resource, comparison unit, accept-tradeoffs structure, artifact + storage, merge recovery, dashboard surface, Tier-2 media-native infra, MVP + order, script-artifact design, incentive & scorecard. +10. **MVP path (§16)** — 8 steps now include the vision layer (Epic/epictask/task) + first, then runtime adapter, lifecycle + QA/Security/SRE, Tier-2 critic + + Epic-comparison, collaboration modes, confidence/dashboard, memory/coverage + + context-artifacts, then script artifacts + baseline scorecard. diff --git a/docs/vision-fidelity.md b/docs/vision-fidelity.md new file mode 100644 index 0000000..0098980 --- /dev/null +++ b/docs/vision-fidelity.md @@ -0,0 +1,222 @@ +# Vision Fidelity — why agents hand you a plank with a steering wheel, and how to fix it + +> **Status:** Discussion / design brainstorm (no code commitments). +> **Purpose:** Capture the recurring failure — "we split a grand vision into +> tasks, completed every task, and got *parts* instead of a *whole*" — diagnose +> the root causes, survey what the field already knows, and shape a concrete +> answer that fits mcpctl's existing `agentic-teams` architecture. +> +> This connects directly to `docs/agentic-teams.md`. The key claim: that doc +> designed *technical correctness* verification very well (confidence signal, +> verification sessions, acceptance gate) but **never addressed vision-fidelity, +> product, or design verification** — the exact axis this failure lives on. + +--- + +## 1. The failure, named precisely + +You said it almost perfectly. The meat: + +> 1. you had a grand vision +> 2. we split the grand vision into tasks +> 3. we completed all the tasks +> 4. we are left with a stick with wheels instead of a car + +This is **not an execution problem**. Every task *was* completed. The failure is +upstream (decomposition) and downstream (verification): + +- **Decomposition is lossy.** Splitting "build a car" into tasks compresses a + rich, multidimensional vision into flat imperatives ("attach wheels", + "install steering column"). The *why* and the *what-good-looks-like* don't fit + in the task form, so they get dropped. Each split is a lossy compression + step, and the errors compound. +- **Tasks are auto-graded on the wrong axis.** The generated checks are + mechanical ("compiles", "lint passes", "renders"). Nothing checks "does this + serve the vision / is this genuinely designed / does this feel finished." + Minimum-viable-task becomes the ceiling because that's all that's verified. +- **Integration is nobody's job.** A car is not wheels + chassis + steering + + engine *as a list* — it's those things *working together*. Task systems + execute a list; "the thing that happens when it all comes together" is an + afterthought task or nothing at all. +- **No feedback loop mid-flight.** The agent finishes task 3 and starts task 4 + assuming task 3 was right. Nothing re-contextualizes: "here is the original + vision, here is what you actually built — do they still match?" + +--- + +## 2. Root-cause mechanisms + +### M1. The "task" is a lossy channel +A vision is high-dimensional (function, feel, aesthetic, UX, business intent, +constraints). A task is low-dimensional (one imperative sentence + maybe a +check). Information *has* to be discarded in the split, and standard task +managers don't attach a pointer back to the vision — so the discard is +unrecoverable later. + +### M2. Verification defaults to the mechanical tier only +Two-tier "definition of done" is rare. Most DoD = "does it compile / pass." +The second tier — "is it right *for the thing we're building*" — is either +absent or not media-native (you can't judge a UI by reading code). + +### M3. Design/product review is a phase, not a gate +Graphic design, if present at all, is bolted on at the end ("make it pretty +after it works"). That's the wrong place — by then the *structure* that design +informs is already baked, and you're only re-skinning a wrong-shaped thing. + +### M4. No "whole-system" (integration) gate +Nothing periodically assembles the partial results, runs the thing, and asks +"is this converging toward a car, or drifting toward a collection of parts?" + +### M5. The vision isn't a living artifact +The vision lives in the first prompt, then decays. There's no versioned, +mutating vison document that tasks reference by version, so drift isn't even +*detectable* — there's nothing authoritative to diff against. + +--- + +## 3. What the field already knows (research synthesis) + +### 3a. Vertical slices / walking skeleton — the single most relevant idea +Software architecture has spent decades on *your exact metaphor*. The core +anti-pattern to "steering wheel on a plank" is: + +- **Walking skeleton** — build the thinnest *vertical* slice that touches every + layer of the whole, end-to-end, FIRST. Not "car engine" then "car chassis" + (horizontal layers), but "a single car that moves one meter" (vertical slice), + then grow it. +- **Vertical slices** — every increment delivers a *user-visible, working* slice + across all layers, not a completed layer. This is the Webnesday/Agile/DASA + pattern, and it directly answers your #4. + +**Implication: don't decompose the vision into tasks by layer.** Decompose into +**vertical slices, each of which is a self-contained mini-car.** Then "car" is +never far away — you always have *a* car, just a small one you grow. + +### 3b. Definition of Done, two-tiered +The DoD concept from Scrum already says "a task isn't done until *all* criteria +(NOT just code) pass." The field reinforces: split DoD into +- **Tier 1 mechanical** (builds, tests pass, runs) and +- **Tier 2 systemic** (matches vision, integrates, is designed, feels finished). + +Tier 2 is the one nobody implements, and the one your failure needs. + +### 3c. Human-in-the-loop as a *review gate*, not per-tool approval +Your own `agentic-teams.md` already reached the industry-validated conclusion: +avoid fine-grained per-tool approval; instead gate at the *task outcome* with a +human accept/reject + comment. The gap it didn't close: the accept/reject is a +*binary + comment*. For vision fidelity, the review gate must be able to say +**what to change and why**, and ideally be **media-native** (look at the render, +not just the diff). + +### 3d. Spec-driven development +When a system has a spec, tasks are checkable against the spec, not against a +vibe-diff. For vision fidelity, the "spec" is a **vision document with +acceptance criteria expressed as observable behaviors**, so the second tier of +DoD has something concrete to check against. + +### 3e. The "critic" / adversarial agent pattern +Multi-agent research and practice (including the "GPT critic", red-team agents, +and design-review agents) show that a **separate, non-executing agent that +reviews against the vision** catches fidelity drift that the working agent's own +confirmation bias misses. This maps cleanly onto your `agentic-teams` persona +model: a **Design/Product critic persona** that *doesn't* execute tasks but +reviews them. + +### 3f. Media-native verification +A core, under-appreciated principle: **judge an artifact in its native medium.** +- UI → render / screenshot / record → *look* at it +- Logic → scenario playthrough +- Product → user-journey walkthrough +- Motion → watch it + +"Graphic design session" from your hypothesis is really this: a *verification +modality*, not a phase. You can't code-review your way to "feels designed." + +--- + +## 4. The proposed shape (how I'd answer your hypothesis) + +You guessed "a better task management system + a design session per task." I +agree with 80% but I'd reframe the 20% that matters most: + +> **The fix is not "better task management." It's (a) decomposing into vertical +> slices instead of layers, and (b) making vision-fidelity a first-class, +> media-native *review gate* — not a phase and not a binary accept/reject.** + +### Design principle set + +1. **Vision is a versioned, living artifact.** + A `VISION.md` per project, versioned. Tasks reference *vision version N*. + Drift = diff artifact-vs-vision, which is now detectable. (This maps to + mcpctl's `proposal`/`revision`/`prompt` resource family — revisions are + exactly the append-only, semver-bumping log you already built.) + +2. **Decompose into vertical slices, not layers.** + Each slice = a self-contained mini-car: touches enough layers to be + user-visible and testable end-to-end. Every increment leaves you with *a* + working (small) whole, never a pile of parts. The "car" is always present, + just small. + +3. **Two-tier DoD on every task/slice.** + - Tier 1: mechanical (builds, tests, runs) + - Tier 2: systemic (matches vision vN, integrates with siblings, is + *designed*, feels finished — each with concrete observable criteria). + Tier 2 is **evaluated by running the medium**, not reading code. + +4. **A Design/Product critic persona (no execute — only review).** + Add a persona to `agentic-teams` that does *not* edit code. Its job: after a + slice reaches `AwaitingReview`, it plays the artifact in its medium + (render/view/run), compares against VISION.md vN, and returns a **verdict + with specifics** (what to change + why), not a bare pass/fail. This slots + into the existing lifecycle as a review session between + `SelfCheck → AwaitingReview`. + +5. **Integration (whole-system) gate on a cadence.** + Every N slices, run an assembly review: build all merged slices, run the + thing, ask "is this a car yet? what's the next smallest thing that makes it + more of a car?" Feed the answer back as new slices. This answers your + failure #4 directly. + +6. **Review gate feedback is directional, not binary.** + Rejecting isn't "no." It's "here's VISION vN, here's what you built, here's + the specific delta." Fed back into the task as rework, exactly like your + existing `reject → InProgress (comment fed back)` — but with a *richer, + media-native, design-specific* comment. + +--- + +## 5. How this maps onto mcpctl / agentic-teams + +| Your existing piece | The missing piece we add | +|---|---| +| `Task` entity + lifecycle | Two-tier DoD stored on the Task; `slice` > `layer` framing | +| Verification sessions (risk-scaled) | **Media-native** verification; Tier-2 check runs the artifact, not just tests | +| Confidence signal / escalation | Confidence ALSO scores vision-fidelity, not just "will it merge cleanly" | +| Persona model (QA, security engineers) | New **Design/Product critic** persona (review-only, non-executing) | +| Accept/reject gate + comment | **Directional, media-anchored** feedback ("here's the delta vs vision vN"), not binary | +| `revision` resource (semver append-only) | Vision as a live, versioned artifact created/managed via the same mechanism | +| `proposal` queue / dashboard | Vision-fidelity escalations surface on the same dashboard as other QA | + +**Net:** the platform already has the bones of the answer. The single most +important *missing* concept is a **review-only persona whose job is to judge +the assembled artifact against a living vision document, in its native +medium, with directional feedback** — plus **decomposition into vertical slices** +so you're always a small car away from the real one, never a pile of parts. + +--- + +## 6. Open questions to discuss + +1. **Slices vs tasks in Task Master** — does Task Master's + `expand`/dependency model support the *vertical-slice* decomposition, or + does its default push you toward layers? Worth testing with a real example + ("build a car" → what tasks does it actually emit today?) +2. **Where does the Design/Product critic live** in the lifecycle — always, or + risk-gated like QA/security? +3. **How to make Tier-2 verification media-native in a headless agent** — do we + need render/screenshot infra, or can a design-aware persona judge from a + structured artifact without pixels? +4. **Vision as a resource** — is `prompt`/`revision` the right home for + VISION.md, or does it deserve its own resource type? +5. **Cost** — a review-only persona and integration gates add tokens/time. Where + is it worth it (product/UI work) vs. where it's overkill (pure backend APIs)? -- 2.49.1 From 797df2299d4a96033336f17a01690fa3c7c88b19 Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 8 Aug 2026 09:58:20 +0100 Subject: [PATCH 03/32] docs(taskmaster): PRD + task graph for agentic teams foundation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Converts docs/agentic-teams.md §16 steps 1-3 into a trackable PRD and 48 appended tasks (IDs 115-162) in the master tag. Steps 4-8 land as 5 low priority placeholders wired onto the spine, pending the §15 open questions. The PRD is grounded in the current codebase rather than the design doc's indicative sketches, which changed the shape of the work: - RBAC `tasks` is already owned by the inference queue, and bindings are normalized at parse time, so existing RbacDefinition rows literally hold "tasks". Taking the name for work items needs a one-shot guarded backfill to `inference-tasks` (task 116). - deploy/entrypoint.sh uses `prisma db push`, so migration SQL never runs in production. Invariants are service-layer, not CHECK constraints. - Ephemeral pods do not exist: restartPolicy is hardcoded Always and ContainerSpec has no volume/workdir fields (tasks 140-142). - `exec` is cheap: KubernetesOrchestrator already implements exec/attach over k8s.Exec/k8s.Attach; only an API + CLI surface is missing (tasks 144-145). - No git provider client exists in product code (task 148). - ChatService caps its tool loop at 12 iterations, so the AgentRuntime runs a headless coding agent in-pod rather than extending chat (tasks 146-147). Names the git-repo entity CodeRepo (CLI `repos`) to avoid colliding with the repository/ data-access layer; AgentProject carries an optional llmId so the brain-ownership question can be settled either way without a migration. Generated task_*.md files are left untracked, matching how this repo has treated them since the first commit. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p --- .taskmaster/docs/prd-agentic-teams.md | 808 ++++++++++++++++++++++++++ .taskmaster/tasks/tasks.json | 710 +++++++++++++++++++++- 2 files changed, 1510 insertions(+), 8 deletions(-) create mode 100644 .taskmaster/docs/prd-agentic-teams.md diff --git a/.taskmaster/docs/prd-agentic-teams.md b/.taskmaster/docs/prd-agentic-teams.md new file mode 100644 index 0000000..63a4064 --- /dev/null +++ b/.taskmaster/docs/prd-agentic-teams.md @@ -0,0 +1,808 @@ +# PRD: Agentic Teams — Foundation + +## Overview + +mcpctl today orchestrates MCP servers, projects, prompts, skills, and chat agents. It does not +orchestrate *work*. This PRD adds the foundation that lets human developers and autonomous agents +be peers in the same project: a versioned vision layer (Epic → epictask → task), git repositories +attached to projects, agents as many-to-many project members, ephemeral per-session pods where +agents do real work, a swappable agent runtime, a task lifecycle with QA/Security/SRE gates, and a +single human accept gate that fires a merge through the git provider's API. + +The design record is `docs/agentic-teams.md`. This PRD converts §16 steps 1–3 of that document +into an implementation spec. Steps 4–8 are named as deferred work at the end, deliberately +without detail — they depend on open design questions (`docs/agentic-teams.md` §15) that are not +closed. + +## Problem + +Two failure classes motivate this work. + +**Nothing tracks work.** There is no Epic, EpicTask, Task, Repository, or AgentProject model in +`src/db/prisma/schema.prisma`, and no repository, service, route, or CLI code for any of them. +Agents can chat and call tools, but there is no unit of work to assign, no lifecycle to advance, +no branch or PR to attach, and no gate for a human to accept. + +**Completed tasks do not add up to the vision.** From the design doc: you have a grand vision, you +split it into tasks, you complete all the tasks, and you are left with a stick with wheels instead +of a car. The root cause is not execution — it is that decomposition is lossy, tasks are graded +mechanically ("compiles / lint / renders") rather than against intent, and integration is nobody's +job. The fix is an authoritative, versioned Epic that every task can walk up to, so "what am I +serving, and what does done-for-this look like" is always answerable. + +A third, practical problem shapes the ordering: the design document's "don't rebuild, delegate" +principle is correct in spirit, but several primitives it assumes do not exist in mcpctl yet. +Those are enumerated under **Execution Substrate Changes** and must land before any agent can work +a task. + +## Scope & Non-Goals + +**In scope** (design doc §16 steps 1–3): + +1. Epic / EpicTask / Task / CodeRepo entities + AgentProject membership, with project linkage, + mcpd CRUD, CLI verbs, RBAC, backup, docs, and tests. +2. An `AgentRuntime` adapter interface and a first implementation running a headless coding agent + inside an ephemeral per-session pod. Prove: pod spawn → clone repos → work a task branch → + push → report. +3. Task lifecycle with QA / Security / SRE stages, PR linkage, the human accept gate, and merge + through the git provider API, with post-merge failure reopening the task. + +**Explicitly out of scope** — these are named in *Deferred* below and get placeholder tasks only: +the Tier-2 design/product critic and Epic-comparison loop; collaboration modes (standup, joint +`exec` sessions as a workflow); the confidence signal and escalation dashboard; per-persona memory +and the coverage ledger; script artifacts and the baseline scorecard. + +**Not being built at all:** a permission engine. mcpctl enforces coarse gates (who may transition +a task, what RBAC a session needs); fine-grained per-tool approval stays inside the agent runtime. + +## Decisions Already Made + +| Decision | Choice | Rationale | +|---|---|---| +| Work-item RBAC name | Work items take `tasks`; the inference queue moves to `inference-tasks` | Matches the design doc's vocabulary. The `inference-tasks` alias already exists, so the URL prefix `/api/v1/inference-tasks` is unchanged | +| Roadmap-node spelling | Model `EpicTask`, resource `epictasks`, aliases `epictask`, `et` | Design doc §15 Q2; one word, matches the doc's own usage | +| Git-repo resource name | Model `CodeRepo`, resource `repos`, aliases `repo`, `repository`, `repositories` | Avoids `repository.repository.ts` and the collision with the data-access-layer term used throughout `src/mcpd/src/repositories/` | +| Epic revisions | Epic joins the existing `ResourceRevision` system as `resourceType: 'epic'` | The Epic is the auditable source of truth for "is this done"; scope changes must be visible. `ResourceType` is a string union by design, so this is a non-migration change | +| Agent membership | New `AgentProject` junction; `Agent.projectId` retained and kept in sync for one release | Design doc §4. `ProjectMember` was deliberately dropped in migration `20260223120000_remove_project_members`; this is a narrower reversal — agent membership only, not human membership, which stays RBAC-driven | +| Invariants | Enforced in the service layer, not CHECK constraints | See *Deployment constraint* below | +| Agent brain | Home brain (`Agent.llmId`) for bare chat; the project's `llmProvider`/`llmModel` when the agent acts inside a project; optional per-membership override on `AgentProject.llmId` | Design doc §15 Q1 leans this way. **This is the one open question that blocks the data model** — the `AgentProject.llmId` column exists so the decision can be reversed without a migration | + +### Deployment constraint (affects every schema task) + +`deploy/entrypoint.sh` provisions the database with `prisma db push --accept-data-loss`, not +`prisma migrate deploy`. Hand-written SQL in `src/db/prisma/migrations/` therefore **never runs in +production**. The single existing CHECK constraint, `ChatThread_agent_xor_project` (migration +`20260717234728_add_project_chat_threads`), is absent from any `db push`-provisioned database and +is enforced only by `ChatService`. + +Consequence: every invariant introduced here must be either expressible in the Prisma schema +(unique constraints, non-null columns, enums, foreign keys) or enforced in the service layer with +a test. Do not write CHECK constraints and assume they hold. + +## Core Concepts + +### The vision layer + +``` +Epic (the vision — project-scoped, versioned, revision-tracked) + └── EpicTask — a roadmap node; chains to other epictasks (dependency graph) + └── Task — the unit of work, with its own lifecycle +``` + +An **Epic** holds the agreed done-criteria written at vision time, its scope-change history, and +accepted-tradeoff decisions. An Epic is done only when 100% of the vision is delivered, **or** the +user explicitly accepted the tradeoffs — recorded so "done" is honest and auditable. + +An **EpicTask** is a roadmap node. EpicTasks chain to one another, so visualising them yields a +dependency graph rather than a flat sequence. Each carries the decomposed subset of the Epic's +acceptance criteria, so per-node completeness is computable. + +A **Task** is the unit of work, owned by an EpicTask. Because Task → EpicTask → Epic, every task +can resolve what it is serving by walking up the chain. + +### Two-level verification + +| Level | Checks | Who | When | +|---|---|---|---| +| Tier 1 — correctness / security / ops | Builds, runs, passes tests, stays secure, and the deployment-as-code is right for this project's real infrastructure | Developer + QA + Security + SRE personas | Inside the task's own lifecycle, before Done | +| Tier 2 — vision fidelity | Does the assembled result match the vision | Design/product critic | At the Epic level — **deferred, not in this PRD** | + +Tier 1 stages are gates *inside* the task loop. Their feedback bounces the task back to +`InProgress`. They are not post-submit reviewers. + +The **SRE/Release stage** reviews the deployment-as-code — is it targeting the right +infrastructure, by the right method, with the right tooling. It reviews the deployment +*definition*; it does not deploy. It exists because a large share of failures come from implicit +operational rules a developer LLM does not know. Concrete example: this cluster runs default-deny +network policies, so any new workload needs explicit egress and ingress rules. A developer LLM +watching a connection fail will deep-dive the network-debugging path when the real cause is a +missing egress policy silently dropping traffic. The SRE persona, holding per-project infra +knowledge, flags that in review. + +### Agents as people + +An agent exists independently of any project — a persistent identity with a name, description, +system prompt, and a home brain. It joins zero, one, or many projects. Per-project role and +scope hang off the membership junction, not off the agent. + +### Pod per session + +Each agent session gets its own ephemeral pod. The pod *is* the workspace: an init container +clones the project's repositories, the session is attached to project prompts, MCP tools, and +scoped secrets, and the pod dies at session end. Agents do not share a live filesystem with each +other — handoffs happen via git (branch + diff + verdict pinned to a commit). A single human may +share one agent's environment via `exec`. + +## Schema Changes + +All new models follow existing house conventions: `id String @id @default(cuid())`, +`version Int @default(1)` as an optimistic-concurrency counter bumped unconditionally in the +repository layer (see `agent.repository.ts` `updateData.version = { increment: 1 }`), plus +`createdAt` / `updatedAt`. + +### Epic + +```prisma +model Epic { + id String @id @default(cuid()) + name String + description String @default("") + // The agreed statement of the vision. Body of the Epic. + content String @db.Text + // Checkable outcomes + intent/quality attributes that define 100% delivery. + // Array of { id, text, kind: 'outcome' | 'quality', met: boolean }. + doneCriteria Json @default("[]") + // Accepted-tradeoff records: { what, why, who, deScopedBy, at }. + acceptedTradeoffs Json @default("[]") + projectId String + status EpicStatus @default(open) + semver String @default("0.1.0") + currentRevisionId String? + ownerId String + version Int @default(1) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) + epicTasks EpicTask[] + + @@unique([name, projectId]) + @@index([projectId]) + @@index([name]) +} + +enum EpicStatus { + open + delivered // 100% of doneCriteria met + delivered_with_tradeoffs + cancelled +} +``` + +### EpicTask + +```prisma +model EpicTask { + id String @id @default(cuid()) + name String + description String @default("") + epicId String + // Decomposed subset of the Epic's doneCriteria this node is responsible for. + doneCriteria Json @default("[]") + status EpicTaskStatus @default(todo) + ownerId String + version Int @default(1) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + epic Epic @relation(fields: [epicId], references: [id], onDelete: Cascade) + owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) + tasks Task[] + dependsOn EpicTaskEdge[] @relation("EpicTaskEdgeFrom") + blocks EpicTaskEdge[] @relation("EpicTaskEdgeTo") + + @@unique([name, epicId]) + @@index([epicId]) +} + +// Chaining between roadmap nodes. Modelled as an explicit edge table rather +// than a self-referencing array so the graph can be walked in both directions +// and edges can later carry a type. +model EpicTaskEdge { + id String @id @default(cuid()) + fromId String // the dependent node + toId String // the prerequisite node + createdAt DateTime @default(now()) + + from EpicTask @relation("EpicTaskEdgeFrom", fields: [fromId], references: [id], onDelete: Cascade) + to EpicTask @relation("EpicTaskEdgeTo", fields: [toId], references: [id], onDelete: Cascade) + + @@unique([fromId, toId]) + @@index([fromId]) + @@index([toId]) +} + +enum EpicTaskStatus { + todo + in_progress + done + cancelled +} +``` + +Cycle prevention in the edge graph is a service-layer check (see *Deployment constraint*): before +creating an edge, walk `to`'s transitive dependencies and reject if `from` is reachable. + +### Task + +```prisma +model Task { + id String @id @default(cuid()) + name String + title String + description String @default("") @db.Text + epicTaskId String + repoId String? + // The agent currently responsible. Null while unassigned. + assigneeId String? + state TaskState @default(todo) + // Drives how much Tier-1 verification is engaged: trivial work may pass on a + // light self-check; risky work engages QA / Security / SRE. + risk TaskRisk @default(normal) + // Git linkage, populated when the agent pushes. + branch String? + headCommit String? + prUrl String? + prNumber Int? + // Free-form reviewer comment attached at the accept gate. + reviewNote String? @db.Text + ownerId String + version Int @default(1) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + epicTask EpicTask @relation(fields: [epicTaskId], references: [id], onDelete: Cascade) + repo CodeRepo? @relation(fields: [repoId], references: [id], onDelete: SetNull) + assignee Agent? @relation(fields: [assigneeId], references: [id], onDelete: SetNull) + owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) + verdicts TaskVerdict[] + sessions AgentSession[] + events TaskEvent[] + + @@unique([name, epicTaskId]) + @@index([epicTaskId]) + @@index([state]) + @@index([assigneeId]) +} + +enum TaskState { + todo + in_progress + building // compile, run, smoke + self_testing // developer's own tests + qa_review + security_review + sre_review + done // leaves the developer's loop + awaiting_review // human accept gate + merged + reopened // post-merge failure + cancelled +} + +enum TaskRisk { + trivial // docs, formatting — light self-check may suffice + normal + high // auth, secrets, migrations, cross-contract +} + +// A Tier-1 persona's verdict, pinned to the commit it judged. Immutable — +// this is the provenance record the design doc calls "QA verified 3f2ab". +model TaskVerdict { + id String @id @default(cuid()) + taskId String + stage TaskState // qa_review | security_review | sre_review + agentId String? + commit String + passed Boolean + summary String @db.Text + // Structured findings: [{ severity, message, path?, line? }] + findings Json @default("[]") + createdAt DateTime @default(now()) + + task Task @relation(fields: [taskId], references: [id], onDelete: Cascade) + agent Agent? @relation(fields: [agentId], references: [id], onDelete: SetNull) + + @@index([taskId]) + @@index([taskId, stage]) +} + +// Append-only transition log. Every state change writes one row. +model TaskEvent { + id String @id @default(cuid()) + taskId String + fromState TaskState? + toState TaskState + actorType String // 'user' | 'agent' | 'system' + actorId String? + note String? @db.Text + createdAt DateTime @default(now()) + + task Task @relation(fields: [taskId], references: [id], onDelete: Cascade) + + @@index([taskId, createdAt]) +} +``` + +### CodeRepo + +```prisma +model CodeRepo { + id String @id @default(cuid()) + name String + projectId String + // Clone URL. https for token auth, ssh for key auth. + url String + defaultBranch String @default("main") + // Relative path under the session workspace root. Defaults to `name`. + path String @default("") + // Git provider, for PR/merge API calls. 'gitea' is the only driver in v1. + provider String @default("gitea") + // Secret holding the provider credential. Resolved through SecretService at + // session-provision time — never stored in plaintext on the row. + credSecretId String? + credSecretKey String? + ownerId String + version Int @default(1) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + cred Secret? @relation(fields: [credSecretId], references: [id]) + owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) + tasks Task[] + + @@unique([name, projectId]) + @@index([projectId]) +} +``` + +### AgentProject + +Mirrors `ProjectServer` (`schema.prisma`): explicit join model with its own cuid `id`, `createdAt`, +cascade on both sides, `@@unique([a, b])`. Indexes on both columns follow `PersonalityPrompt`, +because both reverse lookups are needed ("agents of project", "projects of agent"). + +```prisma +model AgentProject { + id String @id @default(cuid()) + agentId String + projectId String + // Per-project persona role. TEXT, not an enum, so adding a role later is a + // non-migration change (same reasoning as ResourceProposal.resourceType). + // v1 values: 'developer' | 'qa' | 'security' | 'sre'. + role String @default("developer") + // Optional per-membership brain override. Present so the open question in + // docs/agentic-teams.md §15 Q1 can be answered either way without a migration. + llmId String? + createdAt DateTime @default(now()) + + agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + llm Llm? @relation(fields: [llmId], references: [id], onDelete: SetNull) + + @@unique([agentId, projectId]) + @@index([agentId]) + @@index([projectId]) +} +``` + +`Agent.projectId` is **retained** and written in sync with the junction for one release, so +existing `prepareContext` / `findByProjectId` paths keep working. A follow-up removes it. + +### AgentSession + +```prisma +model AgentSession { + id String @id @default(cuid()) + agentId String + projectId String + taskId String? + // Orchestrator handle — pod name on k8s, container id on docker. + containerId String? + state SessionState @default(pending) + image String + // Reaped when now() > expiresAt. Swept by the same style of GC loop as the + // inference-task sweep in main.ts. + expiresAt DateTime + lastError String? @db.Text + ownerId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + endedAt DateTime? + + agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + task Task? @relation(fields: [taskId], references: [id], onDelete: SetNull) + owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) + + @@index([state]) + @@index([taskId]) + @@index([agentId]) +} + +enum SessionState { + pending + provisioning + running + ended + failed +} +``` + +### Back-relations on existing models + +Prisma requires both sides of every relation. The blocks above show only the owning side; the +following fields must be added to existing models or `prisma generate` fails: + +| Model | Fields to add | +|---|---| +| `Project` | `epics Epic[]`, `codeRepos CodeRepo[]`, `agentMemberships AgentProject[]`, `sessions AgentSession[]` | +| `User` | `ownedEpics Epic[]`, `ownedEpicTasks EpicTask[]`, `ownedTasks Task[]`, `ownedRepos CodeRepo[]`, `ownedSessions AgentSession[]` | +| `Agent` | `projectMemberships AgentProject[]`, `assignedTasks Task[]`, `verdicts TaskVerdict[]`, `sessions AgentSession[]` | +| `Llm` | `agentProjects AgentProject[]` | +| `Secret` | `codeRepos CodeRepo[]` | + +`Agent.projectId` and its `project Project?` relation stay as-is during the transition release, so +`Project` carries both `agents Agent[]` (existing) and `agentMemberships AgentProject[]` (new). + +### Test helper + +`src/db/tests/helpers.ts` `clearAllTables` must gain `deleteMany` calls for every new model — +globals with nullable foreign keys do not cascade. + +## API Changes + +All routes sit under `/api/v1`, follow the existing Fastify + zod-in-the-service pattern, and +resolve `project` / `agent` name strings to ids at the route layer (as `routes/skills.ts` does). + +### New endpoints + +``` +# Vision layer +GET|POST /api/v1/epics ?project= +GET|PUT|DELETE /api/v1/epics/:id +GET /api/v1/epics/:id/roadmap → epictask graph + per-node completeness +POST /api/v1/epics/:id/tradeoffs → append an accepted-tradeoff record + +GET|POST /api/v1/epictasks ?epic=&project= +GET|PUT|DELETE /api/v1/epictasks/:id +POST|DELETE /api/v1/epictasks/:id/edges → { toId } add/remove a dependency edge + +# Work items +GET|POST /api/v1/tasks ?project=&epictask=&state=&assignee= +GET|PUT|DELETE /api/v1/tasks/:id +POST /api/v1/tasks/:id/transition → { toState, note? } +POST /api/v1/tasks/:id/verdict → record a QA/Security/SRE verdict +POST /api/v1/tasks/:id/accept → { note? } human accept gate → merge +POST /api/v1/tasks/:id/reject → { note } → back to in_progress +GET /api/v1/tasks/:id/events + +# Repositories +GET|POST /api/v1/repos ?project= +GET|PUT|DELETE /api/v1/repos/:id + +# Agent membership +GET /api/v1/agents/:name/projects +POST|DELETE /api/v1/agents/:name/projects/:project → { role?, llm? } +GET /api/v1/projects/:name/agents + +# Sessions & exec +GET|POST /api/v1/sessions ?project=&agent=&task= +GET|DELETE /api/v1/sessions/:id +POST /api/v1/sessions/:id/exec → one-shot exec, returns { exitCode, stdout, stderr } +GET /api/v1/sessions/:id/attach → WebSocket, bidirectional stdio + +# Git provider webhook +POST /api/v1/webhooks/git/:provider → post-merge failure reopens the task +``` + +### Changed endpoints + +`/api/v1/inference-tasks/*` is unchanged in shape. Only its RBAC resource name changes (see +below). + +## RBAC Changes + +### The `tasks` namespace split + +`src/mcpd/src/validation/rbac-definition.schema.ts` currently declares `tasks` in +`RBAC_RESOURCES` and aliases `task`, `inference-task`, and `inference-tasks` all onto it — with +the comment "the only 'task' we have is inference for now". That is no longer true. + +Required changes: + +1. `RBAC_RESOURCES` gains `epics`, `epictasks`, `tasks` (now meaning work items), `repos`, + `sessions`, and `inference-tasks`. +2. `RESOURCE_ALIASES` maps `task → tasks`, `epic → epics`, `epictask → epictasks`, + `et → epictasks`, `repo → repos`, `repository → repos`, `repositories → repos`, + `session → sessions`, and `inference-task → inference-tasks`. The entry + `'inference-tasks': 'tasks'` is **removed**. +3. `mapUrlToPermission` in `src/mcpd/src/main.ts` gains `epics`, `epictasks`, `tasks`, `repos`, + `sessions`, and maps `/api/v1/inference-tasks` to `inference-tasks`. Non-CRUD verbs need + explicit cases above the map: `POST /tasks/:id/accept|reject|transition` → `run:tasks:`, + `POST /sessions/:id/exec` and `GET /sessions/:id/attach` → `run:sessions:`. +4. `nameResolvers` (`main.ts`) gains `epics`, `epictasks`, `tasks`, `repos`, `sessions` so + name-scoped grants match when the URL carries a cuid. + +### One-shot binding backfill + +`ResourceBindingSchema` transforms `resource` through `normalizeResource` **at parse time** and +stores the normalized string, so existing `RbacDefinition.roleBindings` rows already contain the +literal `"tasks"` meaning *inference*. After the split, those grants would silently become +work-item grants. + +A one-shot startup migration must rewrite `resource: "tasks"` → `"inference-tasks"` across all +`RbacDefinition` rows. Model the walk on `migrateAdminRole` in `src/mcpd/src/main.ts` (it iterates +`rbacRepo.findAll()` and rewrites `roleBindings` in place). + +It must be **guarded so it runs exactly once** — a blanket rewrite on every boot would clobber +legitimate new work-item grants. Use the existing system-settings pattern: a boolean key in the +`mcpctl-system-settings` Secret (`SYSTEM_SETTINGS_SECRET` in +`src/mcpd/src/bootstrap/system-settings.ts`), read before the walk and written after. Wildcard +bindings (`resource: '*'`) are untouched — they already cover both. + +Document the rename in `docs/inference-tasks.md`. + +## CLI Changes + +Two project rules govern every resource added here: + +- Everything settable via `apply -f` **must** also be a `create` flag. +- `mcpctl get RESOURCE NAME -o yaml` **must** round-trip through `mcpctl apply -f`. + +### New commands + +``` +mcpctl get epics|epictasks|tasks|repos|sessions [--project NAME] +mcpctl describe epic|epictask|task|repo|session NAME +mcpctl create epic|epictask|task|repo ... +mcpctl edit epic|epictask|task|repo NAME +mcpctl delete epic|epictask|task|repo NAME + +mcpctl describe epic NAME # roadmap: epictask graph + per-node completeness +mcpctl tasks accept NAME [--note ..] # human accept gate → merge via provider API +mcpctl tasks reject NAME --note .. # → back to in_progress +mcpctl exec --project NAME AGENT -- bash +``` + +`mcpctl exec` semantics (design doc §10): exec drops the human into the **working environment** — +the cloned repos, branches, tools, and deps. It does **not** attach to the agent's reasoning loop. +The agent remains a separate process the human can still talk to. + +### Files to touch + +| Concern | File | +|---|---| +| Resource aliases | `src/cli/src/commands/shared.ts` `RESOURCE_ALIASES` | +| `get` columns + yaml kind | `src/cli/src/commands/get.ts` — row interfaces, `getColumnsForResource`, `RESOURCE_KIND`, `toApplyDocs` strip blocks | +| `describe` formatters | `src/cli/src/commands/describe.ts` — clean sections and tables, not key-value dumps | +| `create` subcommands | `src/cli/src/commands/create.ts` | +| `apply -f` | `src/cli/src/commands/apply.ts` — spec schemas, `ApplyConfigSchema` keys, `KIND_TO_RESOURCE`, dry-run counts, apply loops | +| `edit` | `src/cli/src/commands/edit.ts` `validResources` | +| Scoped fetch | `src/cli/src/index.ts` `fetchResource` / `fetchSingleResource` for `--project` scoping | +| Completions | `scripts/generate-completions.ts` `CANONICAL_RESOURCES` + `ALIAS_ENTRIES`, then `pnpm completions:generate` | + +Completions are generated, never hand-edited; `src/cli/tests/completions.test.ts` runs the +generator with `--check` and fails on drift. + +## Execution Substrate Changes + +These are the primitives the design document assumes but mcpctl does not have. They gate step 2. + +### 1. Ephemeral pods + +`ContainerSpec` in `src/mcpd/src/services/orchestrator.ts` has only image, name, command, env, +ports, limits, labels, and network. `generatePodSpec` in +`src/mcpd/src/services/k8s/manifest-generator.ts` hardcodes `restartPolicy: 'Always'`, and no code +anywhere uses the Kubernetes Job API. + +`ContainerSpec` must gain: + +- `restartPolicy?: 'Always' | 'Never' | 'OnFailure'` +- `workdir?: string` +- `volumes?: Array<{ name: string; mountPath: string; kind: 'emptyDir' }>` — the session + workspace +- `initContainers?: Array<{ name; image; command; env?; volumeMounts? }>` — the repo-clone step + +Both backends implement them: `KubernetesOrchestrator` and `DockerContainerManager`. + +### 2. Session provisioning + +A `SessionService` that: creates the `AgentSession` row; resolves the project's `CodeRepo` list and +their credentials through `SecretService` (resolution failure is fatal for the attempt, matching +`InstanceService.attemptStart`); builds a spec with an init container that clones each repo to its +`path` under the workspace volume and checks out the task branch; starts the pod; and transitions +the row to `running`. + +Reaping runs as a GC sweep in the same style as the existing inference-task sweep in +`src/mcpd/src/main.ts`: sessions past `expiresAt` are stopped, removed, and marked `ended`. Note +these loops assume a single mcpd replica — there is no leader election, and this inherits that. + +### 3. Exec surface + +The hard part already exists. `McpOrchestrator` declares `execInContainer`, `execInteractive`, and +`attachInteractive`; `KubernetesOrchestrator` implements the latter two over `k8s.Exec` and +`k8s.Attach`, and `src/mcpd/src/services/transport/persistent-stdio.ts` uses them in production +today. This work is an **API and CLI surface over a proven primitive**: a one-shot exec route, a +WebSocket attach route with terminal resize and proper exit-code propagation, and the +`mcpctl exec` client. + +### 4. Git provider client + +No Gitea or GitHub API client, PR creation, or webhook handling exists in product code. PRs are +made today by the root-level `pr.sh` (curl against the Gitea API) or by an agent calling the +`gitea-mcp-server` MCP tool. + +A `GitProvider` interface in mcpd with a Gitea driver: `createPullRequest`, `getPullRequest`, +`getCombinedStatus`, `mergePullRequest`. Credentials resolve from the `CodeRepo`'s Secret. +`pr.sh` is the reference for the endpoint shapes (`POST /api/v1/repos/:owner/:repo/pulls`). +A webhook receiver validates the provider signature and reopens the task on post-merge failure. + +## AgentRuntime Interface + +mcpctl builds an *interface*, not a runtime. `ChatService` is not a candidate — its tool loop is +capped at `MAX_ITERATIONS = 12` (clamped 1..50), which is a soft-DoS guard appropriate for chat +and far too small for a coding task. + +```ts +export interface AgentRuntime { + startSession(opts: RuntimeSessionOpts): Promise; + send(sessionId: string, message: string): Promise; + status(sessionId: string): Promise; + result(sessionId: string): Promise; // branch, head commit, summary + endSession(sessionId: string): Promise; +} +``` + +Implementation #1 runs a battle-tested headless coding agent inside the session pod. It already +solves per-tool permission approval, git and branch mechanics, subagents, streaming, and MCP +client behaviour — all things the design document explicitly declines to rebuild. The persona is +delivered as prompt files in the container, which suits a file-aware coding runtime. + +A second adapter is explicitly deferred; the interface exists so it stays possible. + +## Task Lifecycle + +``` +todo → in_progress → building → self_testing + → qa_review (feedback → in_progress) + → security_review (feedback → in_progress) + → sre_review (feedback → in_progress) + → done → awaiting_review + accept → merged + reject → in_progress + merged --post-merge failure (webhook)--> reopened +``` + +Verification effort scales with `Task.risk`: `trivial` work may pass on a light self-check; +`high` engages all three review stages. The stages decide their own depth. + +Transitions are guarded by a table of legal `(from, to)` pairs and applied as **compare-and-swap** +— `updateMany` with the expected `state` in the `where` clause, then check the affected-row count. +This is the same idiom `InferenceTaskRepository` uses so two racing claimants cannot both win. +Every transition appends a `TaskEvent`. + +The human approval gate is **not** a per-tool tri-state. It is a task-level yes/no plus a comment +on the resolved outcome, and accepting it fires the merge. The human should not have to go to git. + +## Testing Strategy + +### Unit — `src/db/tests/` +- Schema tests per model (pattern: `skill-schema.test.ts`): create, unique constraints, cascade + behaviour on project/epic/task delete, `AgentProject` uniqueness. +- `clearAllTables` in `helpers.ts` covers every new model. + +### Unit — `src/mcpd/tests/` +- Service tests: Epic done-criteria roll-up; EpicTask edge cycle rejection; task transition table + including every illegal pair; compare-and-swap under concurrent transition. +- Route tests per resource (pattern: `prompt-routes.test.ts`, `agent-routes.test.ts`). +- RBAC: the `tasks` → `inference-tasks` backfill runs once and is idempotent on a second boot; + a pre-existing `view:tasks` grant still authorizes `/api/v1/inference-tasks` afterwards and does + **not** authorize `/api/v1/tasks`; name-scoped grants resolve for all new resources. +- Orchestrator: `generatePodSpec` emits `restartPolicy`, workdir, volumes, and init containers. +- Git provider: Gitea driver against a mocked HTTP server. + +### Unit — `src/cli/tests/` +- `get -o yaml` → `apply -f` round-trip for every new resource. +- `create` flag coverage: every field settable in `apply` has a matching flag. +- `completions.test.ts` assertions for the new resource words. + +### Smoke — `src/mcplocal/tests/smoke/` +Every feature ships smoke tests. Follow the CRUD pattern in `personality.smoke.test.ts`: a +`Date.now().toString(36)` name suffix, a `healthz()` preflight that **skips rather than fails** +when the daemon is unreachable, `mcpctl --direct` shelling, and explicit `afterAll` cleanup. + +- `epic.smoke.test.ts` — Epic → EpicTask → Task create/list/describe/delete against live mcpd. +- `repo.smoke.test.ts` — CodeRepo CRUD, credential reference resolves. +- `session.smoke.test.ts` — provision a session pod against the real cluster, confirm the repo is + cloned, exec a command, confirm the pod is reaped. +- `task-lifecycle.smoke.test.ts` — drive a task through the full transition chain including a + rejected accept-gate bounce. + +When a smoke test fails, fix the environment (deploy, backend, RBAC) — never weaken the assertion. + +## Deferred + +Named here so the boundary is explicit. Each gets a single placeholder task with no detail, to be +expanded once the corresponding open questions in `docs/agentic-teams.md` §15 are closed. + +- **Tier-2 vision critic and Epic-comparison.** A review-only, non-executing design/product + persona that judges the assembled result against the Epic in its native medium, producing + reopen / new-task / `exec` outcomes. Blocked on §15 Q13 (does this need render/screenshot + infrastructure, or can a design-aware persona judge from a structured artifact?). +- **Collaboration modes.** On-demand async standup (`mcpctl standup --project NAME`) and `exec` + as a workflow rather than a command. Blocked on §15 Q4 and Q5. +- **Confidence signal and escalation dashboard.** Evidence-anchored confidence (retrieve K similar + past tasks with their *outcomes*, ask how confident we are this resolves cleanly), thresholds + driving autonomy, and a single cross-project human decision queue. Blocked on §15 Q12 + (web / TUI / CLI?). Retrieval quality is the crux — weak retrieval quietly poisons the model. +- **Memory and the coverage ledger.** Global per-agent memory plus per-project knowledge, and a + commit-pinned coverage ledger enabling delta-verification. Non-negotiable prerequisite: + invalidation when a covered region changes, or the ledger becomes false confidence. Keep the + trust boundary — the ledger is platform-verified machine fact; agent prose notes are opinion and + never the gate. +- **Script artifacts and the baseline scorecard.** Reusable executable `script` artifacts at task, + project, and agent scope, plus a provisioned baseline-rules scorecard. Blocked on §15 Q15 and + Q16. Watch the incentive integrity risk: reward *reused* tools, not tool proliferation. + +## Implementation Phases + +**Phase A — foundation (blocks everything)** +1. RBAC `tasks` namespace split, alias repoint, and the one-shot guarded binding backfill. +2. Prisma models Epic, EpicTask, EpicTaskEdge, Task, TaskVerdict, TaskEvent, CodeRepo, + AgentSession + enums + migration + `clearAllTables`. +3. `AgentProject` junction, with `Agent.projectId` kept in sync for one release. + +**Phase B — mcpd CRUD** +4. Epic resource: repository, service, zod validation, routes, `main.ts` wiring, revision opt-in. +5. EpicTask resource plus the edge graph and cycle rejection. +6. Task resource plus the transition table and compare-and-swap transitions. +7. CodeRepo resource with credential resolution. +8. RBAC wiring for all new resources: `mapUrlToPermission`, `nameResolvers`, non-CRUD verb cases. +9. Backup and YAML kinds for the new resources — `BACKUP_KINDS`, `APPLY_ORDER`, `kindMap`, the + export loop, `serializeResource`, and `main.ts` `kindFromSegment` / `importResource` / + `deleteResource`. Fix the pre-existing gap where `skill` is missing from the latter two. + +**Phase C — CLI** +10. `get` columns and `describe` formatters for all new resources. +11. `create`, `apply -f`, `edit`, `delete`, with the yaml round-trip test. +12. Shell completions and their guard test. +13. Roadmap view: `describe epic` rendering the epictask graph with per-node completeness. + +**Phase D — execution substrate** +14. Extend `ContainerSpec` and both orchestrator backends with restart policy, workdir, volumes, + and init containers. +15. `SessionService`: provisioning, repo-clone init container, credential resolution, TTL and GC. +16. `AgentRuntime` interface plus adapter #1 running a headless coding agent in-pod. +17. Exec surface: one-shot exec route, WebSocket attach, and `mcpctl exec`. + +**Phase E — lifecycle and PR** +18. Task lifecycle state machine, transition API, and `TaskEvent` log. +19. `GitProvider` interface plus the Gitea driver. +20. PR linkage and the human accept gate firing merge through the provider API. +21. QA / Security / SRE stage verdicts, risk-scaled, pinned to a commit. +22. Post-merge failure webhook reopening the task. + +**Phase F — cross-cutting** +23. Documentation: `docs/epics.md`, `docs/tasks.md`, `docs/repos.md`, `docs/sessions.md`, the + README resources table, and the CLAUDE.md resource cheatsheet. +24. Smoke tests for every new resource plus the session and lifecycle flows. + +**Phase G — deferred placeholders** +25. Tier-2 vision critic and Epic-comparison loop. +26. Collaboration modes: async standup and `exec` joint sessions. +27. Confidence signal and escalation dashboard. +28. Per-agent memory and the coverage ledger. +29. Script artifacts and the baseline scorecard. diff --git a/.taskmaster/tasks/tasks.json b/.taskmaster/tasks/tasks.json index d897f79..e597da7 100644 --- a/.taskmaster/tasks/tasks.json +++ b/.taskmaster/tasks/tasks.json @@ -2549,16 +2549,710 @@ "testStrategy": "1. Gate proxy model smoke test: identical behavior to current hardcoded gate (begin_session, tools/list filtering, ungating). 2. Composition test: model extending [gate, paginate] inherits both behaviors. 3. Conflict detection test: two parents intercepting same hook differently = compile-time error. 4. Discovery test: drop a .ts file in proxymodels dir, mcpctl get proxymodels shows it. 5. Existing smoke tests (proxy-pipeline.test.ts) pass unchanged after migration.", "subtasks": [], "updatedAt": "2026-03-07T01:26:57.383Z" + }, + { + "id": "115", + "title": "RBAC tasks namespace split and alias repoint", + "description": "Split the RBAC 'tasks' resource into 'tasks' (work items) and 'inference-tasks', update RBAC_RESOURCES and RESOURCE_ALIASES in rbac-definition.schema.ts", + "details": "In src/mcpd/src/validation/rbac-definition.schema.ts:\n1. Update RBAC_RESOURCES array to include: 'epics', 'epictasks', 'tasks', 'repos', 'sessions', 'inference-tasks'\n2. Update RESOURCE_ALIASES:\n - task → tasks (work items)\n - epic → epics\n - epictask → epictasks, et → epictasks\n - repo → repos, repository → repos, repositories → repos\n - session → sessions\n - inference-task → inference-tasks\n3. REMOVE the entry 'inference-tasks': 'tasks'\n4. Export normalizeResource unchanged (it uses the alias map)", + "testStrategy": "Unit tests in src/mcpd/tests/validation/: verify normalizeResource('task') returns 'tasks', normalizeResource('inference-task') returns 'inference-tasks', normalizeResource('et') returns 'epictasks'. Test that RBAC_RESOURCES includes all new entries.", + "priority": "high", + "dependencies": [], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-08T08:55:58.627Z" + }, + { + "id": "116", + "title": "One-shot RBAC binding backfill migration", + "description": "Implement guarded startup migration to rewrite existing 'tasks' bindings to 'inference-tasks' so pre-existing grants continue authorizing inference endpoints", + "details": "In src/mcpd/src/main.ts or a new bootstrap module:\n1. Read system settings from SYSTEM_SETTINGS_SECRET (see src/mcpd/src/bootstrap/system-settings.ts pattern)\n2. Check for boolean key 'rbacTasksBackfillComplete'\n3. If false/missing: iterate rbacRepo.findAll(), for each RbacDefinition row, rewrite roleBindings where resource === 'tasks' to resource: 'inference-tasks'\n4. Use rbacRepo.update() to persist changes\n5. Set 'rbacTasksBackfillComplete': true in system settings after completion\n6. Wildcard bindings (resource: '*') are untouched\n7. Model on migrateAdminRole pattern in main.ts", + "testStrategy": "Unit test: create RbacDefinition with resource:'tasks', run backfill, verify resource changed to 'inference-tasks'. Test idempotency: run twice, verify no duplicate rewrites. Test wildcard unchanged.", + "priority": "high", + "dependencies": [ + "115" + ], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-08T08:55:58.627Z" + }, + { + "id": "117", + "title": "Add Epic, EpicTask, EpicTaskEdge Prisma models", + "description": "Add Epic, EpicTask, EpicTaskEdge models with enums EpicStatus and EpicTaskStatus to schema.prisma following PRD specifications", + "details": "In src/db/prisma/schema.prisma add:\n1. enum EpicStatus { open, delivered, delivered_with_tradeoffs, cancelled }\n2. enum EpicTaskStatus { todo, in_progress, done, cancelled }\n3. model Epic with: id, name, description, content (@db.Text), doneCriteria (Json), acceptedTradeoffs (Json), projectId, status, semver, currentRevisionId, ownerId, version, createdAt, updatedAt. Relations: project, owner (User), epicTasks[]. @@unique([name, projectId]), @@index([projectId]), @@index([name])\n4. model EpicTask with: id, name, description, epicId, doneCriteria (Json), status, ownerId, version, createdAt, updatedAt. Relations: epic, owner, tasks[], dependsOn (EpicTaskEdge[]), blocks (EpicTaskEdge[]). @@unique([name, epicId]), @@index([epicId])\n5. model EpicTaskEdge with: id, fromId, toId, createdAt. Relations using @relation names. @@unique([fromId, toId]), @@index([fromId]), @@index([toId])\n6. Add reverse relations to User and Project models", + "testStrategy": "Schema tests in src/db/tests/: create Epic, verify unique constraint on (name, projectId), test cascade delete from Project, test EpicTask creation with Epic reference, test EpicTaskEdge uniqueness.", + "priority": "high", + "dependencies": [], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-08T08:55:58.627Z" + }, + { + "id": "118", + "title": "Add Task, TaskVerdict, TaskEvent Prisma models", + "description": "Add Task, TaskVerdict, TaskEvent models with enums TaskState and TaskRisk to schema.prisma for work item tracking", + "details": "In src/db/prisma/schema.prisma add:\n1. enum TaskState { todo, in_progress, building, self_testing, qa_review, security_review, sre_review, done, awaiting_review, merged, reopened, cancelled }\n2. enum TaskRisk { trivial, normal, high }\n3. model Task with: id, name, title, description (@db.Text), epicTaskId, repoId?, assigneeId?, state, risk, branch?, headCommit?, prUrl?, prNumber?, reviewNote? (@db.Text), ownerId, version, createdAt, updatedAt. Relations: epicTask, repo (CodeRepo?), assignee (Agent?), owner, verdicts[], sessions[], events[]. @@unique([name, epicTaskId]), indexes on epicTaskId, state, assigneeId\n4. model TaskVerdict with: id, taskId, stage (TaskState), agentId?, commit, passed, summary (@db.Text), findings (Json), createdAt. Relations: task, agent. @@index([taskId]), @@index([taskId, stage])\n5. model TaskEvent with: id, taskId, fromState?, toState, actorType, actorId?, note? (@db.Text), createdAt. @@index([taskId, createdAt])\n6. Add reverse relations to Agent model (assignedTasks[], verdicts[])", + "testStrategy": "Schema tests: create Task linked to EpicTask, verify cascade on EpicTask delete, test TaskVerdict immutability pattern, test TaskEvent append-only by creating multiple events for same task.", + "priority": "high", + "dependencies": [ + "117" + ], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-08T08:55:58.627Z" + }, + { + "id": "119", + "title": "Add CodeRepo Prisma model", + "description": "Add CodeRepo model to schema.prisma for git repository management within projects", + "details": "In src/db/prisma/schema.prisma add:\n1. model CodeRepo with: id, name, projectId, url, defaultBranch (@default(\"main\")), path (@default(\"\")), provider (@default(\"gitea\")), credSecretId?, credSecretKey?, ownerId, version, createdAt, updatedAt\n2. Relations: project (Project), cred (Secret?), owner (User), tasks (Task[])\n3. @@unique([name, projectId]), @@index([projectId])\n4. Add repos CodeRepo[] relation to Project model\n5. Add codeRepos CodeRepo[] relation to Secret model\n6. Add ownedRepos CodeRepo[] relation to User model", + "testStrategy": "Schema tests: create CodeRepo linked to Project, verify unique constraint on (name, projectId), test cascade delete from Project, test SetNull behavior when Secret is deleted.", + "priority": "high", + "dependencies": [], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-08T08:55:58.627Z" + }, + { + "id": "120", + "title": "Add AgentProject junction model", + "description": "Add AgentProject many-to-many junction table linking Agents to Projects with role and optional LLM override", + "details": "In src/db/prisma/schema.prisma add:\n1. model AgentProject with: id, agentId, projectId, role (@default(\"developer\")), llmId?, createdAt\n2. Relations: agent (Agent), project (Project), llm (Llm?)\n3. @@unique([agentId, projectId]), @@index([agentId]), @@index([projectId])\n4. Add projectMemberships AgentProject[] to Agent model\n5. Add agentMemberships AgentProject[] to Project model\n6. Add agentProjects AgentProject[] to Llm model\n7. Keep Agent.projectId for backward compatibility (one release)", + "testStrategy": "Schema tests: create AgentProject junction, verify unique constraint prevents duplicate membership, test cascade on both Agent and Project delete, test SetNull on Llm delete.", + "priority": "high", + "dependencies": [], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-08T08:55:58.627Z" + }, + { + "id": "121", + "title": "Add AgentSession Prisma model", + "description": "Add AgentSession model with SessionState enum for ephemeral per-session pod tracking", + "details": "In src/db/prisma/schema.prisma add:\n1. enum SessionState { pending, provisioning, running, ended, failed }\n2. model AgentSession with: id, agentId, projectId, taskId?, containerId?, state, image, expiresAt, lastError? (@db.Text), ownerId, createdAt, updatedAt, endedAt?\n3. Relations: agent (Agent), project (Project), task (Task?), owner (User)\n4. @@index([state]), @@index([taskId]), @@index([agentId])\n5. Add sessions AgentSession[] to Agent, Project, Task, User models", + "testStrategy": "Schema tests: create AgentSession with all relations, test cascade on Agent/Project delete, test SetNull on Task delete, verify state enum transitions.", + "priority": "high", + "dependencies": [ + "118", + "120" + ], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-08T08:55:58.627Z" + }, + { + "id": "122", + "title": "Update clearAllTables test helper", + "description": "Add deleteMany calls for all new models to clearAllTables in src/db/tests/helpers.ts", + "details": "In src/db/tests/helpers.ts clearAllTables function:\n1. Add deleteMany for TaskEvent (child first)\n2. Add deleteMany for TaskVerdict\n3. Add deleteMany for AgentSession\n4. Add deleteMany for Task\n5. Add deleteMany for EpicTaskEdge\n6. Add deleteMany for EpicTask\n7. Add deleteMany for Epic\n8. Add deleteMany for CodeRepo\n9. Add deleteMany for AgentProject\n10. Order matters: delete children before parents to avoid FK violations\n11. Follow existing pattern in the file", + "testStrategy": "Run existing test suite to verify clearAllTables works correctly with new models. Add explicit test that creates rows in all new tables then calls clearAllTables and verifies tables are empty.", + "priority": "high", + "dependencies": [ + "117", + "118", + "119", + "120", + "121" + ], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-08T08:55:58.627Z" + }, + { + "id": "123", + "title": "Epic repository layer", + "description": "Create EpicRepository in src/mcpd/src/repositories/ following existing repository patterns", + "details": "Create src/mcpd/src/repositories/epic.repository.ts:\n1. Export EpicRepository class following agent.repository.ts pattern\n2. Methods: create, findById, findByName, findByProjectId, findAll, update, delete\n3. update must increment version (optimistic concurrency)\n4. Include relations in queries where needed (epicTasks, project, owner)\n5. Add type exports for CreateEpicData, UpdateEpicData\n6. Register in repository index file", + "testStrategy": "Unit tests in src/mcpd/tests/repositories/: test CRUD operations, test optimistic concurrency version increment, test findByProjectId filtering, test cascade behavior.", + "priority": "medium", + "dependencies": [ + "117" + ], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-08T08:55:58.627Z" + }, + { + "id": "124", + "title": "Epic service layer with revision integration", + "description": "Create EpicService with CRUD operations, revision tracking via ResourceRevision, and done-criteria roll-up logic", + "details": "Create src/mcpd/src/services/epic.service.ts:\n1. Inject EpicRepository, ResourceRevisionRepository, ProjectRepository\n2. create(): validate project exists, create Epic, create initial ResourceRevision with resourceType:'epic'\n3. update(): bump semver, create ResourceRevision, update currentRevisionId\n4. getById(), getByName(), listByProject()\n5. addTradeoff(): append to acceptedTradeoffs JSON array\n6. computeStatus(): roll up EpicTask completion to determine if Epic is delivered/delivered_with_tradeoffs\n7. getRoadmap(): return epictask graph with per-node completeness percentages\n8. Follow PromptService pattern for revision integration", + "testStrategy": "Unit tests: test create writes revision, test update bumps semver and writes revision, test addTradeoff appends correctly, test computeStatus logic for all EpicStatus values.", + "priority": "medium", + "dependencies": [ + "123" + ], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-08T08:55:58.627Z" + }, + { + "id": "125", + "title": "Epic zod validation schemas", + "description": "Create Epic validation schemas in src/mcpd/src/validation/ for request/response validation", + "details": "Create src/mcpd/src/validation/epic.schema.ts:\n1. DoneCriterionSchema: z.object({ id: z.string(), text: z.string(), kind: z.enum(['outcome', 'quality']), met: z.boolean() })\n2. AcceptedTradeoffSchema: z.object({ what: z.string(), why: z.string(), who: z.string(), deScopedBy: z.string().optional(), at: z.string() })\n3. CreateEpicSchema: name, content, doneCriteria (array), projectId or project (name), description optional\n4. UpdateEpicSchema: partial of create fields plus status\n5. EpicResponseSchema for API responses\n6. Follow prompt.schema.ts patterns", + "testStrategy": "Unit tests: validate CreateEpicSchema accepts valid input, rejects missing required fields, validates doneCriteria array structure, validates status enum values.", + "priority": "medium", + "dependencies": [ + "117" + ], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-08T08:55:58.627Z" + }, + { + "id": "126", + "title": "Epic routes and API endpoints", + "description": "Create Epic routes in src/mcpd/src/routes/ with CRUD endpoints plus roadmap and tradeoffs", + "details": "Create src/mcpd/src/routes/epic.routes.ts:\n1. GET /api/v1/epics - list with ?project= filter\n2. POST /api/v1/epics - create, resolve project name to id\n3. GET /api/v1/epics/:id - get by id\n4. PUT /api/v1/epics/:id - update\n5. DELETE /api/v1/epics/:id - delete\n6. GET /api/v1/epics/:id/roadmap - return epictask graph with completeness\n7. POST /api/v1/epics/:id/tradeoffs - append accepted tradeoff\n8. Register routes in main.ts\n9. Follow agent-routes.ts pattern for auth and validation", + "testStrategy": "Route tests in src/mcpd/tests/routes/: test CRUD endpoints, test project name resolution, test roadmap returns graph structure, test tradeoffs append correctly.", + "priority": "medium", + "dependencies": [ + "124", + "125" + ], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-08T08:55:58.627Z" + }, + { + "id": "127", + "title": "EpicTask repository and service layers", + "description": "Create EpicTaskRepository and EpicTaskService with edge graph management and cycle detection", + "details": "Create src/mcpd/src/repositories/epic-task.repository.ts and src/mcpd/src/services/epic-task.service.ts:\n1. Repository: CRUD for EpicTask, edge CRUD for EpicTaskEdge, findByEpicId\n2. Service.addEdge(): before creating edge, walk toId's transitive dependencies (via dependsOn edges), reject if fromId is reachable (cycle detection)\n3. Service.removeEdge(): simple delete\n4. Service.getGraph(): return nodes with edges for visualization\n5. Service.computeCompleteness(): calculate percentage of doneCriteria met per node\n6. Follow existing repository/service patterns", + "testStrategy": "Unit tests: test edge creation, test cycle detection rejects A→B→C→A, test completeness calculation with mixed met/unmet criteria, test transitive dependency walk.", + "priority": "medium", + "dependencies": [ + "117", + "123" + ], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-08T08:55:58.627Z" + }, + { + "id": "128", + "title": "EpicTask routes and API endpoints", + "description": "Create EpicTask routes with CRUD and edge management endpoints", + "details": "Create src/mcpd/src/routes/epic-task.routes.ts:\n1. GET /api/v1/epictasks - list with ?epic=&project= filters\n2. POST /api/v1/epictasks - create\n3. GET /api/v1/epictasks/:id - get\n4. PUT /api/v1/epictasks/:id - update\n5. DELETE /api/v1/epictasks/:id - delete\n6. POST /api/v1/epictasks/:id/edges - add dependency edge { toId }\n7. DELETE /api/v1/epictasks/:id/edges - remove edge { toId }\n8. Register in main.ts", + "testStrategy": "Route tests: test CRUD, test edge creation returns error on cycle, test edge deletion, test filters work correctly.", + "priority": "medium", + "dependencies": [ + "127" + ], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-08T08:55:58.627Z" + }, + { + "id": "129", + "title": "Task repository with compare-and-swap transitions", + "description": "Create TaskRepository with optimistic locking state transitions using updateMany with where clause", + "details": "Create src/mcpd/src/repositories/task.repository.ts:\n1. Standard CRUD methods following existing patterns\n2. transition(id, fromState, toState): use prisma.task.updateMany({ where: { id, state: fromState }, data: { state: toState, version: { increment: 1 } } }), return affected count\n3. If affected count is 0, the transition failed (race or wrong state)\n4. findByFilters(): support epicTaskId, state, assigneeId filters\n5. Include verdict and event relations where appropriate", + "testStrategy": "Unit tests: test transition succeeds when state matches, test transition returns 0 when state doesn't match (simulating race), test concurrent transitions only one wins.", + "priority": "medium", + "dependencies": [ + "118" + ], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-08T08:55:58.627Z" + }, + { + "id": "130", + "title": "Task service with lifecycle state machine", + "description": "Create TaskService implementing the full task lifecycle with transition guards and event logging", + "details": "Create src/mcpd/src/services/task.service.ts:\n1. Define VALID_TRANSITIONS map: { [fromState]: [allowedToStates] } based on PRD lifecycle diagram\n2. transition(id, toState, actorType, actorId, note): check transition is valid, call repo.transition, if success create TaskEvent, return result\n3. assign(id, agentId): set assigneeId\n4. recordVerdict(id, stage, agentId, commit, passed, summary, findings): create TaskVerdict, if failed transition back to in_progress\n5. accept(id, userId, note): transition to merged, trigger merge via GitProvider\n6. reject(id, userId, note): transition to in_progress\n7. reopenOnPostMergeFailure(id): transition merged→reopened", + "testStrategy": "Unit tests: test all valid transitions succeed, test invalid transitions throw, test TaskEvent created on every transition, test verdict creation, test accept triggers merge.", + "priority": "medium", + "dependencies": [ + "129" + ], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-08T08:55:58.627Z" + }, + { + "id": "131", + "title": "Task routes and API endpoints", + "description": "Create Task routes with CRUD, transition, verdict, accept/reject, and events endpoints", + "details": "Create src/mcpd/src/routes/task.routes.ts:\n1. GET /api/v1/tasks - list with ?project=&epictask=&state=&assignee= filters\n2. POST /api/v1/tasks - create\n3. GET /api/v1/tasks/:id - get\n4. PUT /api/v1/tasks/:id - update\n5. DELETE /api/v1/tasks/:id - delete\n6. POST /api/v1/tasks/:id/transition - { toState, note? }\n7. POST /api/v1/tasks/:id/verdict - record QA/Security/SRE verdict\n8. POST /api/v1/tasks/:id/accept - { note? } human gate → merge\n9. POST /api/v1/tasks/:id/reject - { note } → back to in_progress\n10. GET /api/v1/tasks/:id/events - list TaskEvents\n11. Register in main.ts", + "testStrategy": "Route tests: test CRUD, test transition endpoint, test verdict recording, test accept triggers merge, test reject changes state, test events returns ordered list.", + "priority": "medium", + "dependencies": [ + "130" + ], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-08T08:55:58.627Z" + }, + { + "id": "132", + "title": "CodeRepo repository and service layers", + "description": "Create CodeRepoRepository and CodeRepoService with credential resolution through SecretService", + "details": "Create src/mcpd/src/repositories/code-repo.repository.ts and src/mcpd/src/services/code-repo.service.ts:\n1. Repository: standard CRUD, findByProjectId\n2. Service.create(): validate project exists, validate credSecretId/Key if provided\n3. Service.resolveCredentials(repoId): fetch Secret via SecretService, extract the key, return credential (never store plaintext)\n4. Service.update(): validate changes\n5. Service.delete(): check no active sessions reference this repo\n6. Follow existing service patterns for validation", + "testStrategy": "Unit tests: test create with valid project, test credential resolution returns correct value, test create fails with invalid secret reference, test findByProjectId returns filtered list.", + "priority": "medium", + "dependencies": [ + "119" + ], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-08T08:55:58.627Z" + }, + { + "id": "133", + "title": "CodeRepo routes and API endpoints", + "description": "Create CodeRepo routes with CRUD endpoints", + "details": "Create src/mcpd/src/routes/code-repo.routes.ts:\n1. GET /api/v1/repos - list with ?project= filter\n2. POST /api/v1/repos - create, resolve project name\n3. GET /api/v1/repos/:id - get (credential fields return references, not values)\n4. PUT /api/v1/repos/:id - update\n5. DELETE /api/v1/repos/:id - delete\n6. Register in main.ts\n7. Ensure credentials are never exposed in responses", + "testStrategy": "Route tests: test CRUD, test project filter, test response does not include credential values, test delete succeeds when no sessions reference repo.", + "priority": "medium", + "dependencies": [ + "132" + ], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-08T08:55:58.627Z" + }, + { + "id": "134", + "title": "RBAC wiring for new resources", + "description": "Update mapUrlToPermission and nameResolvers in main.ts for epics, epictasks, tasks, repos, sessions", + "details": "In src/mcpd/src/main.ts:\n1. Add to mapUrlToPermission cases: epics, epictasks, tasks, repos, sessions\n2. Map /api/v1/inference-tasks to 'inference-tasks' resource\n3. Add non-CRUD verb cases above the map:\n - POST /tasks/:id/accept|reject|transition → run:tasks:\n - POST /sessions/:id/exec, GET /sessions/:id/attach → run:sessions:\n4. Add nameResolvers for epics, epictasks, tasks, repos, sessions (fetch by id, return name for scoped grants)\n5. Follow existing patterns for permission mapping", + "testStrategy": "Unit tests: verify mapUrlToPermission returns correct permission strings for all new routes, test nameResolvers resolve id→name correctly, test non-CRUD verbs get run: prefix.", + "priority": "medium", + "dependencies": [ + "115", + "126", + "128", + "131", + "133" + ], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-08T08:55:58.627Z" + }, + { + "id": "135", + "title": "Backup and YAML kinds for new resources", + "description": "Add new resources to BACKUP_KINDS, APPLY_ORDER, kindMap, and export/import loops", + "details": "Update backup/apply infrastructure:\n1. Add 'Epic', 'EpicTask', 'Task', 'CodeRepo' to BACKUP_KINDS array\n2. Update APPLY_ORDER for correct dependency ordering (Epic before EpicTask before Task)\n3. Add to kindMap: { Epic: 'epics', EpicTask: 'epictasks', Task: 'tasks', CodeRepo: 'repos' }\n4. Update serializeResource for each new kind\n5. Update kindFromSegment in main.ts import handler\n6. Update importResource for create/update logic per kind\n7. Update deleteResource for each kind\n8. Fix existing gap: add 'skill' to importResource/deleteResource if missing", + "testStrategy": "Integration tests: test backup exports all new resource types, test apply imports Epic→EpicTask→Task in correct order, test round-trip export→import preserves data.", + "priority": "medium", + "dependencies": [ + "126", + "128", + "131", + "133" + ], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-08T08:55:58.627Z" + }, + { + "id": "136", + "title": "CLI get columns and describe formatters for new resources", + "description": "Add column definitions and describe formatters for epics, epictasks, tasks, repos, sessions in CLI", + "details": "In src/cli/src/commands/get.ts:\n1. Add row interfaces: EpicRow, EpicTaskRow, TaskRow, CodeRepoRow, AgentSessionRow\n2. Add to getColumnsForResource for each: define column names, widths, formatters\n3. Add to RESOURCE_KIND map: epics→Epic, epictasks→EpicTask, tasks→Task, repos→CodeRepo, sessions→AgentSession\n4. Add to toApplyDocs strip blocks\n\nIn src/cli/src/commands/describe.ts:\n5. Add describe handlers with clean sections and tables (not key-value dumps)\n6. For Epic: show roadmap section with epictask names and completeness\n7. For Task: show lifecycle state, assignee, PR link if present", + "testStrategy": "Unit tests: test getColumnsForResource returns correct columns for each resource, test describe output formatting matches expected structure.", + "priority": "medium", + "dependencies": [ + "126", + "128", + "131", + "133" + ], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-08T08:55:58.627Z" + }, + { + "id": "137", + "title": "CLI create subcommands for new resources", + "description": "Add create subcommands for epic, epictask, task, repo with all settable fields as flags", + "details": "In src/cli/src/commands/create.ts:\n1. Add 'epic' subcommand: --name, --project, --content, --done-criteria (JSON), --description\n2. Add 'epictask' subcommand: --name, --epic, --project, --done-criteria, --description, --depends-on (comma-separated names)\n3. Add 'task' subcommand: --name, --title, --epictask, --epic, --project, --description, --risk, --repo\n4. Add 'repo' subcommand: --name, --project, --url, --default-branch, --path, --provider, --cred-secret, --cred-key\n5. Every field settable in apply must have a corresponding flag\n6. Follow existing create subcommand patterns", + "testStrategy": "Unit tests: test each create subcommand parses all flags correctly, test required flags are enforced, test API calls are made with correct payload.", + "priority": "medium", + "dependencies": [ + "136" + ], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-08T08:55:58.627Z" + }, + { + "id": "138", + "title": "CLI apply and edit support for new resources", + "description": "Add new resources to apply -f spec schemas and edit validResources", + "details": "In src/cli/src/commands/apply.ts:\n1. Add spec schemas: EpicSpecSchema, EpicTaskSpecSchema, TaskSpecSchema, CodeRepoSpecSchema\n2. Add to ApplyConfigSchema discriminated union\n3. Add to KIND_TO_RESOURCE map\n4. Update dry-run counts for new resources\n5. Add apply loops handling create/update for each resource\n\nIn src/cli/src/commands/edit.ts:\n6. Add 'epic', 'epictask', 'task', 'repo' to validResources array", + "testStrategy": "Unit tests: test apply parses valid YAML for each new kind, test get -o yaml → apply -f round-trip for each resource type.", + "priority": "medium", + "dependencies": [ + "137" + ], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-08T08:55:58.627Z" + }, + { + "id": "139", + "title": "CLI shell completions for new resources", + "description": "Update completion generation for new resource names and aliases", + "details": "In scripts/generate-completions.ts:\n1. Add to CANONICAL_RESOURCES: 'epics', 'epictasks', 'tasks', 'repos', 'sessions'\n2. Add to ALIAS_ENTRIES: epic→epics, epictask→epictasks, et→epictasks, task→tasks, repo→repos, repository→repos, repositories→repos, session→sessions\n3. Run pnpm completions:generate to regenerate completion scripts\n4. Completions are generated, never hand-edited\n\nIn src/cli/tests/completions.test.ts:\n5. Test assertions already run generator with --check, will fail on drift automatically", + "testStrategy": "Run completions.test.ts which validates generated completions match source of truth. Manual test: source completions in shell, verify tab-completion works for new resources.", + "priority": "low", + "dependencies": [ + "138" + ], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-08T08:55:58.627Z" + }, + { + "id": "140", + "title": "Extend ContainerSpec for ephemeral pods", + "description": "Add restartPolicy, workdir, volumes, and initContainers to ContainerSpec interface", + "details": "In src/mcpd/src/services/orchestrator.ts:\n1. Add to ContainerSpec interface:\n - restartPolicy?: 'Always' | 'Never' | 'OnFailure'\n - workdir?: string\n - volumes?: Array<{ name: string; mountPath: string; kind: 'emptyDir' }>\n - initContainers?: Array<{ name: string; image: string; command: string[]; env?: Record; volumeMounts?: Array<{ name: string; mountPath: string }> }>\n2. These enable ephemeral session pods with workspace volumes and repo-clone init containers", + "testStrategy": "Type tests: verify ContainerSpec accepts all new optional fields, verify existing code still compiles without providing new fields.", + "priority": "high", + "dependencies": [], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-08T08:55:58.627Z" + }, + { + "id": "141", + "title": "Kubernetes orchestrator ephemeral pod support", + "description": "Update KubernetesOrchestrator to handle new ContainerSpec fields in generatePodSpec", + "details": "In src/mcpd/src/services/k8s/manifest-generator.ts and KubernetesOrchestrator:\n1. Update generatePodSpec to use spec.restartPolicy if provided (default 'Always' unchanged)\n2. Add workingDir to container spec if workdir provided\n3. Add volumes array for emptyDir volumes\n4. Add volumeMounts to main container\n5. Add initContainers array with their own volumeMounts\n6. Ensure init containers complete before main container starts (Kubernetes handles this)", + "testStrategy": "Unit tests: test generatePodSpec output includes restartPolicy, volumes, initContainers when provided. Integration test against test cluster: create pod with init container, verify init completes before main starts.", + "priority": "high", + "dependencies": [ + "140" + ], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-08T08:55:58.627Z" + }, + { + "id": "142", + "title": "Docker orchestrator ephemeral container support", + "description": "Update DockerContainerManager to handle new ContainerSpec fields", + "details": "In DockerContainerManager (src/mcpd/src/services/docker/):\n1. Map restartPolicy to Docker restart policy\n2. Set WorkingDir in container config\n3. Create volumes as Docker volumes, add Binds/Mounts\n4. For initContainers: run each sequentially before main container, fail if any init fails\n5. Docker doesn't have native init container support, so implement as sequential container runs that populate shared volume", + "testStrategy": "Unit tests: test container spec translation. Integration test: create container with init, verify volume populated before main container runs.", + "priority": "medium", + "dependencies": [ + "140" + ], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-08T08:55:58.627Z" + }, + { + "id": "143", + "title": "SessionService for pod provisioning and lifecycle", + "description": "Create SessionService handling AgentSession creation, repo-clone init container, credential resolution, and GC sweep", + "details": "Create src/mcpd/src/services/session.service.ts:\n1. create(agentId, projectId, taskId?, image, expiresAt): create AgentSession row in pending state\n2. provision(sessionId): resolve project's CodeRepo list, resolve credentials via SecretService (fail on resolution error like InstanceService.attemptStart), build ContainerSpec with init container that clones repos to workspace volume, create pod via orchestrator, transition to running\n3. end(sessionId): stop container, mark ended\n4. getById(), listByFilters()\n5. GC sweep: find sessions where now() > expiresAt, stop containers, mark ended. Run in same style as inference-task sweep in main.ts\n6. Note: assumes single mcpd replica (no leader election)", + "testStrategy": "Unit tests: test provision builds correct init container command, test credential resolution failure aborts provision, test GC sweep marks expired sessions ended.", + "priority": "high", + "dependencies": [ + "121", + "132", + "141", + "142" + ], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-08T08:55:58.627Z" + }, + { + "id": "144", + "title": "Session routes and exec/attach endpoints", + "description": "Create Session routes with CRUD, one-shot exec, and WebSocket attach endpoints", + "details": "Create src/mcpd/src/routes/session.routes.ts:\n1. GET /api/v1/sessions - list with ?project=&agent=&task= filters\n2. POST /api/v1/sessions - create and provision\n3. GET /api/v1/sessions/:id - get\n4. DELETE /api/v1/sessions/:id - end session\n5. POST /api/v1/sessions/:id/exec - one-shot exec, body: { command: string[] }, returns { exitCode, stdout, stderr }\n6. GET /api/v1/sessions/:id/attach - WebSocket upgrade, bidirectional stdio with terminal resize and exit code propagation\n7. Use existing execInContainer, attachInteractive from orchestrator\n8. Register in main.ts", + "testStrategy": "Route tests: test CRUD, test exec returns command output, test attach upgrades to WebSocket. Integration: exec 'echo hello' in session pod, verify output.", + "priority": "high", + "dependencies": [ + "143" + ], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-08T08:55:58.627Z" + }, + { + "id": "145", + "title": "CLI exec command", + "description": "Add mcpctl exec --project NAME AGENT -- command for interactive session access", + "details": "In src/cli/src/commands/ add exec.ts or extend existing:\n1. Parse: mcpctl exec --project NAME AGENT -- command args\n2. Find or create AgentSession for agent in project\n3. If command provided: call POST /sessions/:id/exec, print output\n4. If no command (or -- bash): attach via WebSocket to /sessions/:id/attach\n5. Handle terminal resize (SIGWINCH), pass to WebSocket\n6. Propagate exit code from session\n7. Per design doc: exec drops human into working environment (cloned repos), NOT the agent's reasoning loop", + "testStrategy": "Manual testing: mcpctl exec --project test myagent -- ls should list repo contents. mcpctl exec --project test myagent -- bash should give interactive shell.", + "priority": "medium", + "dependencies": [ + "144" + ], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-08T08:55:58.627Z" + }, + { + "id": "146", + "title": "AgentRuntime interface definition", + "description": "Define AgentRuntime adapter interface for swappable agent implementations", + "details": "Create src/mcpd/src/services/agent-runtime.ts:\n1. Define RuntimeSessionOpts: { agentId, projectId, taskId?, image, persona (prompt files) }\n2. Define RuntimeStatus: 'starting' | 'running' | 'completed' | 'failed'\n3. Define RuntimeResult: { branch: string, headCommit: string, summary: string, prUrl?: string }\n4. Define AgentRuntime interface:\n - startSession(opts: RuntimeSessionOpts): Promise<{ sessionId: string }>\n - send(sessionId: string, message: string): Promise\n - status(sessionId: string): Promise\n - result(sessionId: string): Promise\n - endSession(sessionId: string): Promise\n5. This is an interface, not an implementation - allows swapping runtimes later", + "testStrategy": "Type tests: verify interface can be implemented, verify all methods have correct signatures.", + "priority": "medium", + "dependencies": [], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-08T08:55:58.627Z" + }, + { + "id": "147", + "title": "Headless coding agent runtime adapter", + "description": "Implement AgentRuntime adapter that runs a headless coding agent inside the session pod", + "details": "Create src/mcpd/src/services/agent-runtime/headless-adapter.ts:\n1. Implement AgentRuntime interface\n2. startSession: create AgentSession, provision pod with coding agent image, inject persona as prompt files in container\n3. send: exec into container to send message to agent process (implementation depends on chosen agent's IPC)\n4. status: check agent process state via exec or container status\n5. result: read agent output (branch, commit, summary) from known output location\n6. endSession: stop container, update session state\n7. The coding agent handles git, branching, MCP tools, streaming internally\n8. Persona delivered via mounted files the agent reads", + "testStrategy": "Integration tests against test cluster: start session, send simple task, verify branch created and pushed.", + "priority": "medium", + "dependencies": [ + "143", + "146" + ], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-08T08:55:58.627Z" + }, + { + "id": "148", + "title": "GitProvider interface and Gitea driver", + "description": "Create GitProvider interface and Gitea implementation for PR creation, status check, and merge", + "details": "Create src/mcpd/src/services/git-provider.ts and src/mcpd/src/services/git-provider/gitea.ts:\n1. GitProvider interface: createPullRequest(repo, branch, title, body), getPullRequest(repo, prNumber), getCombinedStatus(repo, ref), mergePullRequest(repo, prNumber, method?)\n2. Gitea driver: implement using Gitea API v1\n - POST /api/v1/repos/:owner/:repo/pulls for createPullRequest\n - GET /api/v1/repos/:owner/:repo/pulls/:id for getPullRequest\n - GET /api/v1/repos/:owner/:repo/commits/:sha/status for getCombinedStatus\n - POST /api/v1/repos/:owner/:repo/pulls/:id/merge for mergePullRequest\n3. Credentials resolved from CodeRepo's Secret via SecretService\n4. Reference pr.sh script for endpoint shapes", + "testStrategy": "Unit tests with mocked HTTP: test each method sends correct request, handles success/error responses. Integration test against local Gitea instance if available.", + "priority": "high", + "dependencies": [ + "132" + ], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-08T08:55:58.627Z" + }, + { + "id": "149", + "title": "Task PR linkage and human accept gate", + "description": "Integrate GitProvider into TaskService for PR creation on push and merge on accept", + "details": "Update src/mcpd/src/services/task.service.ts:\n1. linkPR(taskId, branch, headCommit): create PR via GitProvider, update Task with prUrl, prNumber\n2. accept(taskId, userId, note):\n - Verify task is in awaiting_review state\n - Check PR combined status is success (optional gate based on project config)\n - Call gitProvider.mergePullRequest\n - Transition to merged state\n - Create TaskEvent\n3. If merge fails, keep in awaiting_review, surface error\n4. reject: transition to in_progress, clear PR linkage or keep for reference", + "testStrategy": "Unit tests: test linkPR updates task fields, test accept calls merge API, test accept fails if PR status is failing.", + "priority": "high", + "dependencies": [ + "130", + "148" + ], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-08T08:55:58.627Z" + }, + { + "id": "150", + "title": "QA/Security/SRE stage verdicts", + "description": "Implement risk-scaled verification stages that record commit-pinned verdicts", + "details": "Update TaskService:\n1. recordVerdict(taskId, stage, agentId, commit, passed, summary, findings): create TaskVerdict row\n2. If !passed: transition task back to in_progress, create TaskEvent explaining why\n3. Determine required stages based on Task.risk:\n - trivial: may pass on light self-check (no mandatory QA/Security/SRE)\n - normal: requires qa_review\n - high: requires qa_review + security_review + sre_review\n4. Verdicts are immutable - append only, pinned to commit SHA\n5. Each stage persona decides its own depth (not enforced in code, just recorded)\n6. Transition to done only when all required stages have passing verdicts for current headCommit", + "testStrategy": "Unit tests: test verdict creation, test failed verdict transitions back to in_progress, test risk-based stage requirements.", + "priority": "medium", + "dependencies": [ + "130" + ], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-08T08:55:58.627Z" + }, + { + "id": "151", + "title": "Git webhook receiver for post-merge failure", + "description": "Create webhook endpoint that reopens tasks when post-merge CI fails", + "details": "Create src/mcpd/src/routes/webhook.routes.ts:\n1. POST /api/v1/webhooks/git/:provider - receive webhook payloads\n2. Validate webhook signature using provider-specific method (Gitea uses HMAC-SHA256 with secret)\n3. Parse payload to identify: repo, commit SHA, status (success/failure/pending)\n4. If status is failure for a merged commit:\n - Find Task with that headCommit and state=merged\n - Call taskService.reopenOnPostMergeFailure(taskId)\n - This transitions merged→reopened and creates TaskEvent\n5. Register route in main.ts\n6. Webhook secret stored in CodeRepo's credential secret", + "testStrategy": "Unit tests: test signature validation, test payload parsing, test reopen triggered on failure status. Integration: simulate Gitea webhook payload.", + "priority": "medium", + "dependencies": [ + "130", + "148" + ], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-08T08:55:58.627Z" + }, + { + "id": "152", + "title": "Agent membership routes", + "description": "Create API endpoints for managing agent-project memberships", + "details": "Add to existing agent routes or create new file:\n1. GET /api/v1/agents/:name/projects - list projects agent belongs to\n2. POST /api/v1/agents/:name/projects/:project - add membership { role?, llm? }\n3. DELETE /api/v1/agents/:name/projects/:project - remove membership\n4. GET /api/v1/projects/:name/agents - list agents in project\n5. Keep Agent.projectId in sync when membership added/removed (backward compat for one release)\n6. Follow existing route patterns", + "testStrategy": "Route tests: test add membership creates junction row, test remove deletes it, test list returns correct memberships.", + "priority": "medium", + "dependencies": [ + "120" + ], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-08T08:55:58.627Z" + }, + { + "id": "153", + "title": "CLI tasks accept and reject commands", + "description": "Add mcpctl tasks accept and reject commands for human approval gate", + "details": "In src/cli/src/commands/ add or extend:\n1. mcpctl tasks accept NAME [--note TEXT] - call POST /tasks/:id/accept, triggers merge\n2. mcpctl tasks reject NAME --note TEXT - call POST /tasks/:id/reject, note required\n3. Resolve task name to id via API\n4. Print result: merge URL on success, error message on failure\n5. Accept should not require going to git - the CLI is the approval surface", + "testStrategy": "Unit tests: test accept sends correct API call, test reject requires note flag, test output formatting.", + "priority": "medium", + "dependencies": [ + "131", + "149" + ], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-08T08:55:58.627Z" + }, + { + "id": "154", + "title": "Documentation for new resources", + "description": "Create docs for epics, tasks, repos, sessions and update README and CLAUDE.md", + "details": "Create documentation files:\n1. docs/epics.md - Epic/EpicTask concepts, CLI usage, API reference\n2. docs/tasks.md - Task lifecycle, states, transitions, verdicts\n3. docs/repos.md - CodeRepo configuration, credential setup\n4. docs/sessions.md - AgentSession lifecycle, exec usage\n5. docs/inference-tasks.md - document the rename from 'tasks' RBAC resource\n6. Update README.md resources table with new resources\n7. Update CLAUDE.md resource cheatsheet with new resources", + "testStrategy": "Manual review: verify docs are accurate, CLI examples work, API endpoints match implementation.", + "priority": "low", + "dependencies": [ + "126", + "128", + "131", + "133", + "144", + "152" + ], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-08T08:55:58.627Z" + }, + { + "id": "155", + "title": "Smoke tests for Epic/EpicTask/Task CRUD", + "description": "Create smoke tests for vision layer resources against live mcpd", + "details": "Create src/mcplocal/tests/smoke/epic.smoke.test.ts:\n1. Follow personality.smoke.test.ts pattern: Date.now().toString(36) name suffix, healthz() preflight that skips when daemon unreachable, mcpctl --direct shelling, explicit afterAll cleanup\n2. Test Epic create/list/describe/delete\n3. Test EpicTask create with edge dependencies\n4. Test Task create linked to EpicTask\n5. Verify cascade: delete Epic removes EpicTasks and Tasks\n6. When test fails, fix environment not assertion", + "testStrategy": "Run against live mcpd instance. Tests self-document expected behavior.", + "priority": "medium", + "dependencies": [ + "126", + "128", + "131" + ], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-08T08:55:58.627Z" + }, + { + "id": "156", + "title": "Smoke tests for CodeRepo and Session", + "description": "Create smoke tests for repo and session resources with credential resolution", + "details": "Create src/mcplocal/tests/smoke/repo.smoke.test.ts and session.smoke.test.ts:\n1. repo tests: create CodeRepo with credential reference, list, describe, delete\n2. session tests: provision session pod, confirm repo cloned via exec, verify workspace mounted, verify pod reaped after expiry\n3. Follow smoke test patterns: preflight skip, cleanup in afterAll\n4. Session test requires real cluster access", + "testStrategy": "Run against live mcpd with cluster access. Verify repo credentials resolve, session pod starts and has cloned repos.", + "priority": "medium", + "dependencies": [ + "133", + "144" + ], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-08T08:55:58.627Z" + }, + { + "id": "157", + "title": "Smoke test for task lifecycle transitions", + "description": "Create smoke test driving a task through full lifecycle including rejected accept-gate bounce", + "details": "Create src/mcplocal/tests/smoke/task-lifecycle.smoke.test.ts:\n1. Create Epic → EpicTask → Task\n2. Transition task: todo → in_progress → building → self_testing → qa_review → done → awaiting_review\n3. Test reject: awaiting_review → in_progress\n4. Advance again to awaiting_review\n5. Test accept: verify merge called (may need mock git provider for smoke)\n6. Verify TaskEvents recorded for all transitions\n7. Follow smoke test patterns", + "testStrategy": "Run against live mcpd. Verify each transition succeeds, events logged, reject bounces state correctly.", + "priority": "medium", + "dependencies": [ + "131", + "149", + "153" + ], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-08T08:55:58.627Z" + }, + { + "id": "158", + "title": "Deferred: Tier-2 vision critic placeholder", + "description": "Placeholder task for Tier-2 vision critic and Epic-comparison loop - blocked on design questions", + "details": "DEFERRED - DO NOT IMPLEMENT YET\n\nBlocked on docs/agentic-teams.md §15 Q13: does this need render/screenshot infrastructure, or can a design-aware persona judge from a structured artifact?\n\nConcept: A review-only, non-executing design/product persona that judges the assembled result against the Epic in its native medium, producing reopen / new-task / exec outcomes.\n\nExpand this task when the blocking design question is answered.", + "testStrategy": "N/A - placeholder only", + "priority": "low", + "dependencies": [ + "131", + "149" + ], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-08T08:55:58.627Z" + }, + { + "id": "159", + "title": "Deferred: Collaboration modes placeholder", + "description": "Placeholder task for async standup and exec joint sessions - blocked on design questions", + "details": "DEFERRED - DO NOT IMPLEMENT YET\n\nBlocked on docs/agentic-teams.md §15 Q4 and Q5.\n\nConcepts:\n- mcpctl standup --project NAME: on-demand async standup\n- exec as a workflow rather than just a command\n\nExpand this task when the blocking design questions are answered.", + "testStrategy": "N/A - placeholder only", + "priority": "low", + "dependencies": [ + "144", + "145" + ], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-08T08:55:58.627Z" + }, + { + "id": "160", + "title": "Deferred: Confidence signal and escalation dashboard placeholder", + "description": "Placeholder task for evidence-anchored confidence and human decision queue - blocked on design questions", + "details": "DEFERRED - DO NOT IMPLEMENT YET\n\nBlocked on docs/agentic-teams.md §15 Q12: web / TUI / CLI?\n\nConcepts:\n- Evidence-anchored confidence: retrieve K similar past tasks with outcomes, assess confidence\n- Thresholds driving autonomy level\n- Single cross-project human decision queue\n- Retrieval quality is the crux - weak retrieval quietly poisons the model\n\nExpand this task when the blocking design question is answered.", + "testStrategy": "N/A - placeholder only", + "priority": "low", + "dependencies": [ + "131", + "150" + ], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-08T08:55:58.627Z" + }, + { + "id": "161", + "title": "Deferred: Per-agent memory and coverage ledger placeholder", + "description": "Placeholder task for agent memory and commit-pinned coverage ledger - blocked on design questions", + "details": "DEFERRED - DO NOT IMPLEMENT YET\n\nConcepts:\n- Global per-agent memory plus per-project knowledge\n- Commit-pinned coverage ledger enabling delta-verification\n- Non-negotiable: invalidation when covered region changes (or ledger becomes false confidence)\n- Trust boundary: ledger is platform-verified machine fact; agent prose notes are opinion, never the gate\n\nExpand this task when prerequisite infrastructure exists.", + "testStrategy": "N/A - placeholder only", + "priority": "low", + "dependencies": [ + "147", + "150" + ], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-08T08:55:58.627Z" + }, + { + "id": "162", + "title": "Deferred: Script artifacts and baseline scorecard placeholder", + "description": "Placeholder task for reusable script artifacts and baseline rules scorecard - blocked on design questions", + "details": "DEFERRED - DO NOT IMPLEMENT YET\n\nBlocked on docs/agentic-teams.md §15 Q15 and Q16.\n\nConcepts:\n- Reusable executable script artifacts at task, project, and agent scope\n- Provisioned baseline-rules scorecard\n- Watch incentive integrity risk: reward reused tools, not tool proliferation\n\nExpand this task when the blocking design questions are answered.", + "testStrategy": "N/A - placeholder only", + "priority": "low", + "dependencies": [ + "147" + ], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-08T08:55:58.627Z" } ], "metadata": { - "version": "1.0.0", - "lastModified": "2026-03-07T23:36:15.981Z", - "taskCount": 114, - "completedCount": 96, - "tags": [ - "master" - ] + "created": "2026-08-08T08:54:31.861Z", + "updated": "2026-08-08T08:54:31.861Z", + "description": "Tasks for master context" } } -} \ No newline at end of file +} -- 2.49.1 From a186d95a851f10762275adc5a550b4fdcc31870f Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 8 Aug 2026 10:10:53 +0100 Subject: [PATCH 04/32] feat(rbac): split the tasks namespace into tasks + inference-tasks v5 gave the durable inference queue the RBAC resource `tasks`, on the reasoning that inference was the only kind of task we had. Agentic teams introduces work-item tasks (Epic -> epictask -> task), which need that name. The queue moves to its own `inference-tasks` resource, matching its existing /api/v1/inference-tasks route prefix, and `task`/`tasks` now mean work items. Also lands the agentic-teams aliases: epic, epictask/et, repo/repository, session. The rename is not purely a code change. ResourceBindingSchema runs normalizeResource at parse time and stores the normalized string, so every RbacDefinition row written before the split literally contains "tasks" meaning inference. Shipping the alias change alone would silently stop those grants authorizing the queue and start them authorizing work items -- a privilege change in both directions. bootstrap/rbac-tasks-backfill.ts rewrites them once at startup, alongside migrateAdminRole. The run-once marker is claimed BEFORE the rewrite, not after. A failed marker write after a successful rewrite would let the next startup rewrite again, converting genuine post-split work-item grants into inference grants. Losing the rewrite is recoverable by re-granting; running it twice silently is not -- so if the marker cannot be persisted we skip the rewrite and warn loudly. mapUrlToPermission moves in lockstep: /api/v1/inference-tasks maps to `inference-tasks`, otherwise the split would break queue authorization on the first request. Adds generic getBooleanSetting/setSetting helpers to bootstrap/system-settings.ts, following the existing "global settings live in a Secret" pattern. setSetting refuses to write when the settings secret has been migrated onto a non-plaintext backend, where a direct `data` write would read back empty. Out of scope, tracked separately: the CLI still routes `mcpctl get tasks` to the inference queue (src/cli/src/commands/shared.ts). Repointing it needs the work-item resource to exist first, or the command breaks with no replacement. Tests: 14 unit tests cover alias resolution, the rewrite, wildcard and operation bindings being left alone, idempotency, and both marker-failure paths. Full workspace suite green (2291 tests). The new smoke test is RED until mcpd is deployed with this change -- it asserts post-split storage against a live daemon, and the currently deployed one predates the split. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p --- docs/inference-tasks.md | 19 +- src/mcpd/src/bootstrap/rbac-tasks-backfill.ts | 90 +++++++ src/mcpd/src/bootstrap/system-settings.ts | 94 ++++++- src/mcpd/src/main.ts | 12 +- .../src/validation/rbac-definition.schema.ts | 28 ++- src/mcpd/tests/rbac-tasks-backfill.test.ts | 233 ++++++++++++++++++ .../smoke/rbac-tasks-split.smoke.test.ts | 156 ++++++++++++ 7 files changed, 618 insertions(+), 14 deletions(-) create mode 100644 src/mcpd/src/bootstrap/rbac-tasks-backfill.ts create mode 100644 src/mcpd/tests/rbac-tasks-backfill.test.ts create mode 100644 src/mcplocal/tests/smoke/rbac-tasks-split.smoke.test.ts diff --git a/docs/inference-tasks.md b/docs/inference-tasks.md index d7845fc..6304d91 100644 --- a/docs/inference-tasks.md +++ b/docs/inference-tasks.md @@ -154,7 +154,7 @@ mcpctl get task $ID ## RBAC -A new `tasks` resource. Default action mapping: +The `inference-tasks` resource. Default action mapping: | Method | Action | |--------|--------| @@ -162,13 +162,22 @@ A new `tasks` resource. Default action mapping: | `POST` | `create` | | `DELETE` | `delete` | -Aliases: `task`, `inference-task`, `inference-tasks` all normalize to -`tasks` so `mcpctl create rbac-binding --resource task --role view ...` -works regardless of which form the operator types. +Aliases: `inference-task` and `inference-tasks` both normalize to +`inference-tasks`, so `mcpctl create rbac-binding --resource inference-task +--role view ...` works either way. + +> **Renamed.** v5 shipped this queue under the RBAC resource `tasks`, on the +> reasoning that inference was the only kind of task we had. Agentic teams +> introduces work-item tasks, which take `tasks` (and the `task` alias) over. +> Bindings written before the split literally stored `"tasks"`, so mcpd rewrites +> them to `inference-tasks` once at startup — see +> `src/mcpd/src/bootstrap/rbac-tasks-backfill.ts`. Nothing to do by hand; the +> rewrite logs what it changed. `view:tasks` now grants **work items**, not +> inference. Owner scoping is enforced at the route layer ON TOP of the RBAC hook: - A caller with only the implicit role sees and can cancel their own tasks. -- `view:tasks` resource-wide is required to see / list / cancel +- `view:inference-tasks` resource-wide is required to see / list / cancel another user's tasks. ## GC diff --git a/src/mcpd/src/bootstrap/rbac-tasks-backfill.ts b/src/mcpd/src/bootstrap/rbac-tasks-backfill.ts new file mode 100644 index 0000000..08932b0 --- /dev/null +++ b/src/mcpd/src/bootstrap/rbac-tasks-backfill.ts @@ -0,0 +1,90 @@ +/** + * One-shot rewrite of pre-split `tasks` RBAC bindings to `inference-tasks`. + * + * v5 gave the durable inference queue the RBAC resource `tasks`. Agentic teams + * introduces work-item tasks, which take that name over, and the queue moves to + * `inference-tasks` (see validation/rbac-definition.schema.ts). + * + * The rename is not purely a code change. `ResourceBindingSchema` runs + * `normalizeResource` at parse time and stores the *normalized* string, so every + * RbacDefinition row written before the split literally contains `"tasks"` + * meaning inference. Without this rewrite those grants would silently stop + * authorizing `/api/v1/inference-tasks` and start authorizing work items — + * a privilege change in both directions. + * + * Guarded to run exactly once. A blanket rewrite on every boot would clobber + * legitimate work-item grants created after the split, so the marker matters as + * much as the walk. If the marker cannot be persisted the rewrite is skipped + * entirely rather than risk running twice — see the warning path below. + * + * Wildcard bindings (`resource: '*'`) already cover both resources and are left + * alone. Operation bindings carry `action`, not `resource`, and are untouched. + */ +import type { PrismaClient } from '@prisma/client'; + +import type { IRbacDefinitionRepository } from '../repositories/rbac-definition.repository.js'; +import type { UpdateRbacDefinitionInput } from '../validation/rbac-definition.schema.js'; + +import { + SETTING_INFERENCE_TASK_BINDINGS_MIGRATED, + getBooleanSetting, + setSetting, +} from './system-settings.js'; + +/** The pre-split resource string stored on legacy inference grants. */ +const LEGACY_RESOURCE = 'tasks'; +/** Where those grants move to. */ +const NEW_RESOURCE = 'inference-tasks'; + +export async function backfillInferenceTaskBindings( + prisma: PrismaClient, + rbacRepo: IRbacDefinitionRepository, +): Promise { + const alreadyRun = await getBooleanSetting(prisma, SETTING_INFERENCE_TASK_BINDINGS_MIGRATED, false); + if (alreadyRun) return; + + // Claim the marker BEFORE rewriting. If we rewrote first and the marker write + // then failed, the next startup would rewrite again — turning work-item grants + // created in between into inference grants. Losing the rewrite is recoverable + // (an admin re-grants); running it twice silently is not. + const claimed = await setSetting(prisma, SETTING_INFERENCE_TASK_BINDINGS_MIGRATED, true); + if (!claimed) { + console.warn( + `mcpd: skipped the ${LEGACY_RESOURCE} → ${NEW_RESOURCE} RBAC rewrite — could not persist the ` + + `run-once marker. Existing '${LEGACY_RESOURCE}' grants now authorize work items, not the ` + + `inference queue. Re-grant them with 'mcpctl apply' or fix the system-settings secret and restart.`, + ); + return; + } + + const definitions = await rbacRepo.findAll(); + let rowsChanged = 0; + let bindingsChanged = 0; + + for (const def of definitions) { + const bindings = def.roleBindings as Array>; + let touched = 0; + + const rewritten = bindings.map((b) => { + if (b['resource'] !== LEGACY_RESOURCE) return b; + touched++; + return { ...b, resource: NEW_RESOURCE }; + }); + + if (touched === 0) continue; + + await rbacRepo.update(def.id, { + roleBindings: rewritten as UpdateRbacDefinitionInput['roleBindings'], + }); + rowsChanged++; + bindingsChanged += touched; + } + + if (rowsChanged > 0) { + // eslint-disable-next-line no-console + console.log( + `mcpd: migrated ${String(bindingsChanged)} RBAC binding(s) across ${String(rowsChanged)} ` + + `definition(s) from '${LEGACY_RESOURCE}' to '${NEW_RESOURCE}'`, + ); + } +} diff --git a/src/mcpd/src/bootstrap/system-settings.ts b/src/mcpd/src/bootstrap/system-settings.ts index 9fb3f3e..561de76 100644 --- a/src/mcpd/src/bootstrap/system-settings.ts +++ b/src/mcpd/src/bootstrap/system-settings.ts @@ -7,7 +7,9 @@ * See the `backup-ssh` secret for the original precedent. */ -import type { PrismaClient } from '@prisma/client'; +import type { Prisma, PrismaClient } from '@prisma/client'; + +import { DEFAULT_PLAINTEXT_BACKEND_NAME } from './secret-backends.js'; /** Well-known secret holding global mcpd settings as `data` JSON. */ export const SYSTEM_SETTINGS_SECRET = 'mcpctl-system-settings'; @@ -15,6 +17,12 @@ export const SYSTEM_SETTINGS_SECRET = 'mcpctl-system-settings'; /** Settings key: whether newly-created users get self password-change permission. */ export const SETTING_ALLOW_SELF_PASSWORD = 'allowSelfPasswordChange'; +/** + * Settings key: whether the one-shot `tasks` → `inference-tasks` RBAC binding + * rewrite has run. See bootstrap/rbac-tasks-backfill.ts. + */ +export const SETTING_INFERENCE_TASK_BINDINGS_MIGRATED = 'inferenceTaskBindingsMigrated'; + /** Coerce a stored JSON value (boolean, or "true"/"false"/"0" string) to boolean. */ function toBool(v: unknown, dflt: boolean): boolean { if (typeof v === 'boolean') return v; @@ -45,3 +53,87 @@ export async function getAllowSelfPasswordChange(prisma: PrismaClient): Promise< return true; } } + +/** + * Read one boolean setting. Missing secret / missing key / read error all + * return `dflt` — callers treat "can't tell" as "not set". + */ +export async function getBooleanSetting( + prisma: PrismaClient, + key: string, + dflt: boolean, +): Promise { + try { + const secret = await prisma.secret.findUnique({ where: { name: SYSTEM_SETTINGS_SECRET } }); + if (!secret) return dflt; + const data = (secret.data ?? {}) as Record; + if (!(key in data)) return dflt; + return toBool(data[key], dflt); + } catch { + return dflt; + } +} + +/** + * Persist one setting into the system-settings secret, creating the secret on + * the default plaintext backend if it doesn't exist yet. + * + * Returns whether the value was durably stored. Callers that use a setting as + * a run-once marker MUST treat `false` as "the marker did not stick" — the + * guarded work will be attempted again on the next startup. + * + * Writes `data` directly, which is only correct for the plaintext backend + * (non-plaintext backends keep their values behind `externalRef`). If the + * secret has been migrated onto another backend we refuse rather than write a + * value that reads back empty. + */ +export async function setSetting( + prisma: PrismaClient, + key: string, + value: unknown, +): Promise { + try { + const secret = await prisma.secret.findUnique({ + where: { name: SYSTEM_SETTINGS_SECRET }, + include: { backend: true }, + }); + + if (secret) { + if (secret.backend.type !== 'plaintext') { + console.warn( + `mcpd: cannot persist system setting '${key}' — secret '${SYSTEM_SETTINGS_SECRET}' lives on ` + + `the '${secret.backend.type}' backend; set it there manually.`, + ); + return false; + } + const data = { ...((secret.data ?? {}) as Record), [key]: value }; + await prisma.secret.update({ + where: { id: secret.id }, + data: { + data: data as Prisma.InputJsonValue, + keyNames: Object.keys(data).sort(), + version: { increment: 1 }, + }, + }); + return true; + } + + const backend = + (await prisma.secretBackend.findUnique({ where: { name: DEFAULT_PLAINTEXT_BACKEND_NAME } })) ?? + (await prisma.secretBackend.findFirst({ where: { isDefault: true } })); + if (!backend) return false; + + const data = { [key]: value }; + await prisma.secret.create({ + data: { + name: SYSTEM_SETTINGS_SECRET, + backendId: backend.id, + data: data as Prisma.InputJsonValue, + keyNames: Object.keys(data).sort(), + }, + }); + return true; + } catch { + return false; + } +} diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index 02a92a5..fa6fc33 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -23,6 +23,7 @@ import { import { SecretBackendRepository } from './repositories/secret-backend.repository.js'; import { SecretBackendService } from './services/secret-backend.service.js'; import { SecretMigrateService } from './services/secret-migrate.service.js'; +import { backfillInferenceTaskBindings } from './bootstrap/rbac-tasks-backfill.js'; import { bootstrapSecretBackends } from './bootstrap/secret-backends.js'; import { backfillSecretKeyNames } from './bootstrap/secret-key-names.js'; import { registerSecretBackendRoutes } from './routes/secret-backends.js'; @@ -211,7 +212,11 @@ function mapUrlToPermission(method: string, url: string): PermissionCheck { // any other resource (GET=view, POST=create, DELETE=delete). The // route layer enforces owner-scoping on top — a caller without // resource-wide grant only sees their own tasks. - 'inference-tasks': 'tasks', + // + // Maps to its own `inference-tasks` resource, NOT `tasks` — agentic-teams + // work items own `tasks` now. Pre-split grants are rewritten by + // backfillInferenceTaskBindings at startup. + 'inference-tasks': 'inference-tasks', 'agents': 'agents', // Personalities inherit the agent's RBAC: managing a personality // requires view/edit/create/delete on the `agents` resource. @@ -449,6 +454,11 @@ async function main(): Promise { // Migrate legacy 'admin' role → granular roles await migrateAdminRole(rbacDefinitionRepo); + // Repoint pre-split inference-queue grants at the `inference-tasks` resource + // now that `tasks` means agentic-teams work items. Runs once; needs the + // default secret backend, so it must follow bootstrapSecretBackends. + await backfillInferenceTaskBindings(prisma, rbacDefinitionRepo); + // Orchestrator — select backend via MCPD_ORCHESTRATOR env var const orchestrator = process.env['MCPD_ORCHESTRATOR'] === 'kubernetes' ? new KubernetesOrchestrator() diff --git a/src/mcpd/src/validation/rbac-definition.schema.ts b/src/mcpd/src/validation/rbac-definition.schema.ts index 61d6749..c5dca98 100644 --- a/src/mcpd/src/validation/rbac-definition.schema.ts +++ b/src/mcpd/src/validation/rbac-definition.schema.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; export const RBAC_ROLES = ['edit', 'view', 'create', 'delete', 'run', 'expose'] as const; -export const RBAC_RESOURCES = ['*', 'servers', 'instances', 'secrets', 'secretbackends', 'llms', 'projects', 'templates', 'users', 'groups', 'rbac', 'prompts', 'promptrequests', 'mcptokens', 'tasks'] as const; +export const RBAC_RESOURCES = ['*', 'servers', 'instances', 'secrets', 'secretbackends', 'llms', 'projects', 'templates', 'users', 'groups', 'rbac', 'prompts', 'promptrequests', 'mcptokens', 'inference-tasks', 'epics', 'epictasks', 'tasks', 'repos', 'sessions'] as const; /** Singular→plural map for resource names. */ const RESOURCE_ALIASES: Record = { @@ -17,13 +17,27 @@ const RESOURCE_ALIASES: Record = { mcptoken: 'mcptokens', secretbackend: 'secretbackends', llm: 'llms', - // v5: durable inference task queue. The route prefix is - // `/api/v1/inference-tasks` (multi-word for clarity in the URL); the - // RBAC resource is just `tasks` (shorter; the only "task" we have - // is inference for now). Singular alias `task` maps to the same. + // v5 shipped the durable inference task queue under the RBAC resource + // `tasks`, on the reasoning that inference was the only "task" we had. + // Agentic teams introduces work-item tasks, so the queue moves to its own + // `inference-tasks` resource — matching its `/api/v1/inference-tasks` route + // prefix — and `task`/`tasks` now mean work items. + // + // Pre-split RbacDefinition rows literally store `"tasks"` (ResourceBindingSchema + // normalizes at parse time), so they are rewritten to `inference-tasks` by the + // one-shot backfill in bootstrap/rbac-tasks-backfill.ts. Do not repoint these + // aliases without that migration — existing inference grants would silently + // become work-item grants. + 'inference-task': 'inference-tasks', + // ── Agentic teams (Epic → epictask → task) ── + epic: 'epics', + epictask: 'epictasks', + et: 'epictasks', task: 'tasks', - 'inference-task': 'tasks', - 'inference-tasks': 'tasks', + repo: 'repos', + repository: 'repos', + repositories: 'repos', + session: 'sessions', }; /** Normalize a resource name to its canonical plural form. */ diff --git a/src/mcpd/tests/rbac-tasks-backfill.test.ts b/src/mcpd/tests/rbac-tasks-backfill.test.ts new file mode 100644 index 0000000..f00e196 --- /dev/null +++ b/src/mcpd/tests/rbac-tasks-backfill.test.ts @@ -0,0 +1,233 @@ +import type { PrismaClient, RbacDefinition } from '@prisma/client'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { backfillInferenceTaskBindings } from '../src/bootstrap/rbac-tasks-backfill.js'; +import { SYSTEM_SETTINGS_SECRET } from '../src/bootstrap/system-settings.js'; +import type { IRbacDefinitionRepository } from '../src/repositories/rbac-definition.repository.js'; +import { RBAC_RESOURCES, normalizeResource } from '../src/validation/rbac-definition.schema.js'; + +function makeDef(overrides: Partial = {}): RbacDefinition { + return { + id: 'def-1', + name: 'test-rbac', + subjects: [{ kind: 'User', name: 'alice@example.com' }], + roleBindings: [], + version: 1, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +function mockRepo(definitions: RbacDefinition[]): IRbacDefinitionRepository & { + update: ReturnType; +} { + const update = vi.fn(async () => makeDef()); + return { + findAll: vi.fn(async () => definitions), + findById: vi.fn(async () => null), + findByName: vi.fn(async () => null), + create: vi.fn(async () => makeDef()), + update, + delete: vi.fn(async () => {}), + } as unknown as IRbacDefinitionRepository & { update: ReturnType }; +} + +/** + * In-memory stand-in for the system-settings secret plus its backend, enough to + * exercise the read/claim path in setSetting. + */ +function mockPrisma(opts: { + /** Existing settings data, or null when the secret does not exist yet. */ + settings?: Record | null; + /** Backend type the settings secret sits on. */ + backendType?: string; + /** When false, no default SecretBackend exists, so the marker cannot be created. */ + hasDefaultBackend?: boolean; +} = {}): { prisma: PrismaClient; written: () => Record | null } { + const backendType = opts.backendType ?? 'plaintext'; + const hasDefaultBackend = opts.hasDefaultBackend ?? true; + let stored: Record | null = opts.settings ?? null; + + const secretRow = (): unknown => + stored === null + ? null + : { + id: 'sec-1', + name: SYSTEM_SETTINGS_SECRET, + data: stored, + backend: { id: 'be-1', name: 'default', type: backendType }, + }; + + const prisma = { + secret: { + findUnique: vi.fn(async () => secretRow()), + update: vi.fn(async ({ data }: { data: { data: Record } }) => { + stored = data.data; + return secretRow(); + }), + create: vi.fn(async ({ data }: { data: { data: Record } }) => { + stored = data.data; + return secretRow(); + }), + }, + secretBackend: { + findUnique: vi.fn(async () => + hasDefaultBackend ? { id: 'be-1', name: 'default', type: backendType } : null, + ), + findFirst: vi.fn(async () => + hasDefaultBackend ? { id: 'be-1', name: 'default', type: backendType } : null, + ), + }, + } as unknown as PrismaClient; + + return { prisma, written: () => stored }; +} + +const MARKER = 'inferenceTaskBindingsMigrated'; + +describe('RBAC tasks namespace split', () => { + it('maps task aliases to the work-item resource', () => { + expect(normalizeResource('task')).toBe('tasks'); + expect(normalizeResource('tasks')).toBe('tasks'); + }); + + it('maps inference aliases to their own resource', () => { + expect(normalizeResource('inference-task')).toBe('inference-tasks'); + expect(normalizeResource('inference-tasks')).toBe('inference-tasks'); + }); + + it('no longer collapses inference-tasks onto tasks', () => { + expect(normalizeResource('inference-tasks')).not.toBe('tasks'); + expect(normalizeResource('inference-task')).not.toBe('tasks'); + }); + + it('maps the agentic-teams resource aliases', () => { + expect(normalizeResource('epic')).toBe('epics'); + expect(normalizeResource('epictask')).toBe('epictasks'); + expect(normalizeResource('et')).toBe('epictasks'); + expect(normalizeResource('repo')).toBe('repos'); + expect(normalizeResource('repository')).toBe('repos'); + expect(normalizeResource('repositories')).toBe('repos'); + expect(normalizeResource('session')).toBe('sessions'); + }); + + it('leaves unknown resources untouched', () => { + expect(normalizeResource('widgets')).toBe('widgets'); + expect(normalizeResource('*')).toBe('*'); + }); + + it('declares the new resources', () => { + for (const r of ['epics', 'epictasks', 'tasks', 'repos', 'sessions', 'inference-tasks']) { + expect(RBAC_RESOURCES).toContain(r); + } + }); +}); + +describe('backfillInferenceTaskBindings', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('rewrites legacy tasks bindings to inference-tasks', async () => { + const def = makeDef({ + roleBindings: [ + { role: 'view', resource: 'tasks' }, + { role: 'create', resource: 'tasks' }, + { role: 'view', resource: 'llms' }, + ], + }); + const repo = mockRepo([def]); + const { prisma } = mockPrisma(); + + await backfillInferenceTaskBindings(prisma, repo); + + expect(repo.update).toHaveBeenCalledTimes(1); + expect(repo.update.mock.calls[0]?.[1]).toEqual({ + roleBindings: [ + { role: 'view', resource: 'inference-tasks' }, + { role: 'create', resource: 'inference-tasks' }, + { role: 'view', resource: 'llms' }, + ], + }); + }); + + it('leaves wildcard bindings alone — they already cover both resources', async () => { + const repo = mockRepo([makeDef({ roleBindings: [{ role: 'edit', resource: '*' }] })]); + const { prisma } = mockPrisma(); + + await backfillInferenceTaskBindings(prisma, repo); + + expect(repo.update).not.toHaveBeenCalled(); + }); + + it('leaves operation bindings alone', async () => { + const repo = mockRepo([ + makeDef({ roleBindings: [{ role: 'run', action: 'backup' }] }), + ]); + const { prisma } = mockPrisma(); + + await backfillInferenceTaskBindings(prisma, repo); + + expect(repo.update).not.toHaveBeenCalled(); + }); + + it('sets the run-once marker', async () => { + const repo = mockRepo([makeDef({ roleBindings: [{ role: 'view', resource: 'tasks' }] })]); + const { prisma, written } = mockPrisma(); + + await backfillInferenceTaskBindings(prisma, repo); + + expect(written()).toMatchObject({ [MARKER]: true }); + }); + + it('is idempotent — a second run does not rewrite new work-item grants', async () => { + const { prisma, written } = mockPrisma(); + + const first = mockRepo([makeDef({ roleBindings: [{ role: 'view', resource: 'tasks' }] })]); + await backfillInferenceTaskBindings(prisma, first); + expect(first.update).toHaveBeenCalledTimes(1); + expect(written()).toMatchObject({ [MARKER]: true }); + + // A genuine post-split work-item grant must survive the next startup. + const second = mockRepo([ + makeDef({ id: 'def-2', roleBindings: [{ role: 'view', resource: 'tasks' }] }), + ]); + await backfillInferenceTaskBindings(prisma, second); + expect(second.update).not.toHaveBeenCalled(); + expect(second.findAll).not.toHaveBeenCalled(); + }); + + it('preserves other settings when claiming the marker', async () => { + const repo = mockRepo([]); + const { prisma, written } = mockPrisma({ settings: { allowSelfPasswordChange: false } }); + + await backfillInferenceTaskBindings(prisma, repo); + + expect(written()).toEqual({ allowSelfPasswordChange: false, [MARKER]: true }); + }); + + it('skips the rewrite entirely when the marker cannot be persisted', async () => { + const repo = mockRepo([makeDef({ roleBindings: [{ role: 'view', resource: 'tasks' }] })]); + const { prisma } = mockPrisma({ hasDefaultBackend: false }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await backfillInferenceTaskBindings(prisma, repo); + + // Running without a durable marker risks a second pass clobbering real + // work-item grants, so we would rather do nothing. + expect(repo.update).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalled(); + }); + + it('refuses to write settings onto a non-plaintext backend', async () => { + const repo = mockRepo([makeDef({ roleBindings: [{ role: 'view', resource: 'tasks' }] })]); + const { prisma } = mockPrisma({ settings: {}, backendType: 'openbao' }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await backfillInferenceTaskBindings(prisma, repo); + + expect(repo.update).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalled(); + }); +}); diff --git a/src/mcplocal/tests/smoke/rbac-tasks-split.smoke.test.ts b/src/mcplocal/tests/smoke/rbac-tasks-split.smoke.test.ts new file mode 100644 index 0000000..cac704f --- /dev/null +++ b/src/mcplocal/tests/smoke/rbac-tasks-split.smoke.test.ts @@ -0,0 +1,156 @@ +/** + * Smoke: the `tasks` → `inference-tasks` RBAC namespace split, against live mcpd. + * + * `ResourceBindingSchema` normalizes a binding's `resource` at parse time and + * stores the normalized string, so what a definition reads back as is the whole + * behaviour under test: + * + * 1. `resource: inference-task` → stored as `inference-tasks` (the queue). + * 2. `resource: task` → stored as `tasks` (agentic-teams work items). + * 3. The agentic-teams aliases (`epic`, `et`, `repository`, `session`) resolve. + * + * Pre-split, cases 1 and 2 both stored `tasks`. A failure here against a + * reachable daemon means mcpd is running code older than the split — deploy it, + * don't relax the assertion. + */ +import { execSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import http from 'node:http'; +import https from 'node:https'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; + +const MCPD_URL = process.env.MCPD_URL ?? 'https://mcpctl.ad.itaz.eu'; +const SUFFIX = Date.now().toString(36); +const RBAC_NAME = `smoke-tasksplit-${SUFFIX}`; + +interface CliResult { code: number; stdout: string; stderr: string } + +function run(args: string): CliResult { + try { + const stdout = execSync(`mcpctl --direct ${args}`, { + encoding: 'utf-8', + timeout: 30_000, + stdio: ['ignore', 'pipe', 'pipe'], + }); + return { code: 0, stdout: stdout.trim(), stderr: '' }; + } catch (err) { + const e = err as { status?: number; stdout?: Buffer | string; stderr?: Buffer | string }; + return { + code: e.status ?? 1, + stdout: e.stdout ? (typeof e.stdout === 'string' ? e.stdout : e.stdout.toString('utf-8')) : '', + stderr: e.stderr ? (typeof e.stderr === 'string' ? e.stderr : e.stderr.toString('utf-8')) : '', + }; + } +} + +function healthz(url: string, timeoutMs = 5000): Promise { + return new Promise((resolve) => { + const parsed = new URL(`${url.replace(/\/$/, '')}/healthz`); + const driver = parsed.protocol === 'https:' ? https : http; + const req = driver.get( + { + hostname: parsed.hostname, + port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80), + path: parsed.pathname, + timeout: timeoutMs, + }, + (res) => { resolve((res.statusCode ?? 500) < 500); res.resume(); }, + ); + req.on('error', () => resolve(false)); + req.on('timeout', () => { req.destroy(); resolve(false); }); + }); +} + +interface RbacDoc { name?: string; roleBindings?: Array<{ resource?: string }> } + +/** + * The resource strings mcpd stored, in binding order. + * + * `get -o json` emits an array of apply-shaped documents even for a single + * named resource, so unwrap before reading bindings. + */ +function storedResources(): string[] { + const res = run(`get rbac ${RBAC_NAME} -o json`); + expect(res.code, `get rbac failed: ${res.stderr || res.stdout}`).toBe(0); + const parsed = JSON.parse(res.stdout) as RbacDoc | RbacDoc[]; + const doc = Array.isArray(parsed) + ? parsed.find((d) => d.name === RBAC_NAME) ?? parsed[0] + : parsed; + const bindings = doc?.roleBindings ?? []; + expect(bindings.length, `no roleBindings on '${RBAC_NAME}'`).toBeGreaterThan(0); + return bindings.map((b) => b.resource ?? ''); +} + +let mcpdUp = false; +let workDir: string | null = null; + +describe('rbac tasks namespace split smoke', () => { + beforeAll(async () => { + mcpdUp = await healthz(MCPD_URL); + if (!mcpdUp) { + console.warn(`\n ○ rbac tasks split smoke: skipped — ${MCPD_URL}/healthz unreachable.\n`); + return; + } + + // RBAC definitions have no `create` subcommand — they come in via apply. + // Note `apply` maps a few singular aliases client-side, but none of the ones + // under test, so these strings reach mcpd verbatim and it is mcpd's + // normalizeResource that we are exercising. + workDir = mkdtempSync(join(tmpdir(), 'mcpctl-smoke-rbac-')); + const manifestPath = join(workDir, 'rbac.yaml'); + writeFileSync( + manifestPath, + [ + 'rbacBindings:', + ` - name: ${RBAC_NAME}`, + ' subjects:', + ' - kind: Group', + ` name: smoke-nonexistent-group-${SUFFIX}`, + ' roleBindings:', + ' - role: view', + ' resource: inference-task', + ' - role: view', + ' resource: task', + ' - role: view', + ' resource: epic', + ' - role: view', + ' resource: et', + ' - role: view', + ' resource: repository', + ' - role: view', + ' resource: session', + '', + ].join('\n'), + 'utf-8', + ); + + const res = run(`apply -f ${manifestPath}`); + expect(res.code, `apply failed: ${res.stderr || res.stdout}`).toBe(0); + }, 40_000); + + afterAll(() => { + if (mcpdUp) run(`delete rbac ${RBAC_NAME}`); + if (workDir) rmSync(workDir, { recursive: true, force: true }); + }); + + it('stores inference-task as inference-tasks, not tasks', () => { + if (!mcpdUp) return; + const resources = storedResources(); + expect(resources[0]).toBe('inference-tasks'); + expect(resources[0]).not.toBe('tasks'); + }); + + it('stores task as tasks — work items own the short name now', () => { + if (!mcpdUp) return; + const resources = storedResources(); + expect(resources[1]).toBe('tasks'); + }); + + it('resolves the agentic-teams aliases', () => { + if (!mcpdUp) return; + const resources = storedResources(); + expect(resources.slice(2)).toEqual(['epics', 'epictasks', 'repos', 'sessions']); + }); +}); -- 2.49.1 From fceb3cfb95c7fcc57ee2ce7c3bef977f0416bf16 Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 8 Aug 2026 10:30:02 +0100 Subject: [PATCH 05/32] =?UTF-8?q?feat(db):=20agentic-teams=20data=20model?= =?UTF-8?q?=20=E2=80=94=20epics,=20work=20items,=20repos,=20membership?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the foundation entities from docs/agentic-teams.md §5/§7: a versioned vision layer (Epic -> EpicTask -> Task), git repositories attached to projects, agents as many-to-many project members, and the ephemeral session that IS an agent's workspace. 9 tables + 5 enums, purely additive — no drops and no column changes on existing tables, since every new relation is held on the new side. Shape notes: - EpicTask chaining uses an explicit EpicTaskEdge table rather than a self-referencing array, so the roadmap graph walks in both directions and edges can carry a type later. - TaskVerdict is commit-pinned and immutable: "QA verified 3f2ab" stays reproducible, which is what lets later coverage claims be audited rather than believed. TaskEvent is the append-only transition log. - Task survives losing its repo and its assignee (both SetNull) — a work item outlives whoever happened to be working it — but cascades from its EpicTask. - CodeRepo, not Repository: `repository.repository.ts` next to 24 other *.repository.ts data-access files would be actively confusing. CLI resource is `repos`, with repo/repository/repositories aliased onto it. - AgentProject.llmId exists so the brain-ownership question (docs/agentic-teams.md §15 Q1) can be settled either way without a migration. - Agent.projectId is retained and coexists with AgentProject for one release so findByProjectId / prepareContext keep working. Note this partially reverses 20260223120000_remove_project_members — deliberately, and only for agents; human membership stays RBAC-driven. No CHECK constraints anywhere, on purpose: deploy/entrypoint.sh provisions with `prisma db push`, so hand-written migration SQL never runs in production. The migration file is kept for local `migrate dev` parity and as a readable record, and says so in its header. Invariants that cannot be expressed in the schema (EpicTaskEdge cycle rejection) belong to the service layer; a schema test documents that boundary rather than pretending the database enforces it. Tests: 23 schema tests covering defaults, per-project name scoping, cascade vs SetNull on every FK, all 12 lifecycle states, and the GC-sweep predicate. clearAllTables extended for all 9 models. Verified locally against postgres:16-alpine on :5433 — db suite 128 passed (baseline was 105; the same 4 pre-existing Secret_backendId_fkey failures remain, unrelated). Workspace 2291 passed, build clean, lint unchanged at the repo's pre-existing 869. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p --- .taskmaster/tasks/tasks.json | 44 +- .../migration.sql | 313 +++++++++++++ src/db/prisma/schema.prisma | 359 ++++++++++++++- src/db/tests/agentic-teams-schema.test.ts | 428 ++++++++++++++++++ src/db/tests/helpers.ts | 12 + 5 files changed, 1125 insertions(+), 31 deletions(-) create mode 100644 src/db/prisma/migrations/20260808103000_add_agentic_teams/migration.sql create mode 100644 src/db/tests/agentic-teams-schema.test.ts diff --git a/.taskmaster/tasks/tasks.json b/.taskmaster/tasks/tasks.json index e597da7..98e1ad2 100644 --- a/.taskmaster/tasks/tasks.json +++ b/.taskmaster/tasks/tasks.json @@ -2558,9 +2558,9 @@ "testStrategy": "Unit tests in src/mcpd/tests/validation/: verify normalizeResource('task') returns 'tasks', normalizeResource('inference-task') returns 'inference-tasks', normalizeResource('et') returns 'epictasks'. Test that RBAC_RESOURCES includes all new entries.", "priority": "high", "dependencies": [], - "status": "pending", + "status": "done", "subtasks": [], - "updatedAt": "2026-08-08T08:55:58.627Z" + "updatedAt": "2026-08-08T09:22:12.130Z" }, { "id": "116", @@ -2572,9 +2572,9 @@ "dependencies": [ "115" ], - "status": "pending", + "status": "done", "subtasks": [], - "updatedAt": "2026-08-08T08:55:58.627Z" + "updatedAt": "2026-08-08T09:22:12.140Z" }, { "id": "117", @@ -2584,9 +2584,9 @@ "testStrategy": "Schema tests in src/db/tests/: create Epic, verify unique constraint on (name, projectId), test cascade delete from Project, test EpicTask creation with Epic reference, test EpicTaskEdge uniqueness.", "priority": "high", "dependencies": [], - "status": "pending", + "status": "done", "subtasks": [], - "updatedAt": "2026-08-08T08:55:58.627Z" + "updatedAt": "2026-08-08T09:29:43.358Z" }, { "id": "118", @@ -2598,9 +2598,9 @@ "dependencies": [ "117" ], - "status": "pending", + "status": "done", "subtasks": [], - "updatedAt": "2026-08-08T08:55:58.627Z" + "updatedAt": "2026-08-08T09:29:43.369Z" }, { "id": "119", @@ -2610,9 +2610,9 @@ "testStrategy": "Schema tests: create CodeRepo linked to Project, verify unique constraint on (name, projectId), test cascade delete from Project, test SetNull behavior when Secret is deleted.", "priority": "high", "dependencies": [], - "status": "pending", + "status": "done", "subtasks": [], - "updatedAt": "2026-08-08T08:55:58.627Z" + "updatedAt": "2026-08-08T09:29:43.377Z" }, { "id": "120", @@ -2622,9 +2622,9 @@ "testStrategy": "Schema tests: create AgentProject junction, verify unique constraint prevents duplicate membership, test cascade on both Agent and Project delete, test SetNull on Llm delete.", "priority": "high", "dependencies": [], - "status": "pending", + "status": "done", "subtasks": [], - "updatedAt": "2026-08-08T08:55:58.627Z" + "updatedAt": "2026-08-08T09:29:43.384Z" }, { "id": "121", @@ -2637,9 +2637,9 @@ "118", "120" ], - "status": "pending", + "status": "done", "subtasks": [], - "updatedAt": "2026-08-08T08:55:58.627Z" + "updatedAt": "2026-08-08T09:29:43.392Z" }, { "id": "122", @@ -2655,9 +2655,9 @@ "120", "121" ], - "status": "pending", + "status": "done", "subtasks": [], - "updatedAt": "2026-08-08T08:55:58.627Z" + "updatedAt": "2026-08-08T09:29:43.398Z" }, { "id": "123", @@ -3250,9 +3250,13 @@ } ], "metadata": { - "created": "2026-08-08T08:54:31.861Z", - "updated": "2026-08-08T08:54:31.861Z", - "description": "Tasks for master context" + "version": "1.0.0", + "lastModified": "2026-08-08T09:29:43.398Z", + "taskCount": 162, + "completedCount": 104, + "tags": [ + "master" + ] } } -} +} \ No newline at end of file diff --git a/src/db/prisma/migrations/20260808103000_add_agentic_teams/migration.sql b/src/db/prisma/migrations/20260808103000_add_agentic_teams/migration.sql new file mode 100644 index 0000000..99609eb --- /dev/null +++ b/src/db/prisma/migrations/20260808103000_add_agentic_teams/migration.sql @@ -0,0 +1,313 @@ +-- Agentic Teams foundation: vision layer, work items, repos, membership, sessions. +-- +-- Purely additive: 9 tables + 5 enums, no drops, no column changes on existing +-- tables (the new relations are all held on the new side). +-- +-- HEADS UP: deploy/entrypoint.sh provisions with `prisma db push`, not +-- `prisma migrate deploy`, so this file does NOT run in production -- `db push` +-- derives the same DDL from schema.prisma. It is kept for local `migrate dev` +-- parity and as a readable record of the change. Any invariant that matters is +-- expressed in schema.prisma or enforced in the service layer, never here. + +-- CreateEnum +CREATE TYPE "EpicStatus" AS ENUM ('open', 'delivered', 'delivered_with_tradeoffs', 'cancelled'); + +-- CreateEnum +CREATE TYPE "EpicTaskStatus" AS ENUM ('todo', 'in_progress', 'done', 'cancelled'); + +-- CreateEnum +CREATE TYPE "TaskState" AS ENUM ('todo', 'in_progress', 'building', 'self_testing', 'qa_review', 'security_review', 'sre_review', 'done', 'awaiting_review', 'merged', 'reopened', 'cancelled'); + +-- CreateEnum +CREATE TYPE "TaskRisk" AS ENUM ('trivial', 'normal', 'high'); + +-- CreateEnum +CREATE TYPE "SessionState" AS ENUM ('pending', 'provisioning', 'running', 'ended', 'failed'); + +-- CreateTable +CREATE TABLE "Epic" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT NOT NULL DEFAULT '', + "content" TEXT NOT NULL, + "doneCriteria" JSONB NOT NULL DEFAULT '[]', + "acceptedTradeoffs" JSONB NOT NULL DEFAULT '[]', + "projectId" TEXT NOT NULL, + "status" "EpicStatus" NOT NULL DEFAULT 'open', + "semver" TEXT NOT NULL DEFAULT '0.1.0', + "currentRevisionId" TEXT, + "ownerId" TEXT NOT NULL, + "version" INTEGER NOT NULL DEFAULT 1, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Epic_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "EpicTask" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT NOT NULL DEFAULT '', + "epicId" TEXT NOT NULL, + "doneCriteria" JSONB NOT NULL DEFAULT '[]', + "status" "EpicTaskStatus" NOT NULL DEFAULT 'todo', + "ownerId" TEXT NOT NULL, + "version" INTEGER NOT NULL DEFAULT 1, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "EpicTask_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "EpicTaskEdge" ( + "id" TEXT NOT NULL, + "fromId" TEXT NOT NULL, + "toId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "EpicTaskEdge_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Task" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT NOT NULL DEFAULT '', + "epicTaskId" TEXT NOT NULL, + "repoId" TEXT, + "assigneeId" TEXT, + "state" "TaskState" NOT NULL DEFAULT 'todo', + "risk" "TaskRisk" NOT NULL DEFAULT 'normal', + "branch" TEXT, + "headCommit" TEXT, + "prUrl" TEXT, + "prNumber" INTEGER, + "reviewNote" TEXT, + "ownerId" TEXT NOT NULL, + "version" INTEGER NOT NULL DEFAULT 1, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Task_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TaskVerdict" ( + "id" TEXT NOT NULL, + "taskId" TEXT NOT NULL, + "stage" "TaskState" NOT NULL, + "agentId" TEXT, + "commit" TEXT NOT NULL, + "passed" BOOLEAN NOT NULL, + "summary" TEXT NOT NULL, + "findings" JSONB NOT NULL DEFAULT '[]', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "TaskVerdict_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TaskEvent" ( + "id" TEXT NOT NULL, + "taskId" TEXT NOT NULL, + "fromState" "TaskState", + "toState" "TaskState" NOT NULL, + "actorType" TEXT NOT NULL, + "actorId" TEXT, + "note" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "TaskEvent_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "CodeRepo" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "url" TEXT NOT NULL, + "defaultBranch" TEXT NOT NULL DEFAULT 'main', + "path" TEXT NOT NULL DEFAULT '', + "provider" TEXT NOT NULL DEFAULT 'gitea', + "credSecretId" TEXT, + "credSecretKey" TEXT, + "ownerId" TEXT NOT NULL, + "version" INTEGER NOT NULL DEFAULT 1, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "CodeRepo_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "AgentProject" ( + "id" TEXT NOT NULL, + "agentId" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "role" TEXT NOT NULL DEFAULT 'developer', + "llmId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "AgentProject_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "AgentSession" ( + "id" TEXT NOT NULL, + "agentId" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "taskId" TEXT, + "containerId" TEXT, + "state" "SessionState" NOT NULL DEFAULT 'pending', + "image" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "lastError" TEXT, + "ownerId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "endedAt" TIMESTAMP(3), + + CONSTRAINT "AgentSession_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Epic_projectId_idx" ON "Epic"("projectId"); + +-- CreateIndex +CREATE INDEX "Epic_name_idx" ON "Epic"("name"); + +-- CreateIndex +CREATE UNIQUE INDEX "Epic_name_projectId_key" ON "Epic"("name", "projectId"); + +-- CreateIndex +CREATE INDEX "EpicTask_epicId_idx" ON "EpicTask"("epicId"); + +-- CreateIndex +CREATE UNIQUE INDEX "EpicTask_name_epicId_key" ON "EpicTask"("name", "epicId"); + +-- CreateIndex +CREATE INDEX "EpicTaskEdge_fromId_idx" ON "EpicTaskEdge"("fromId"); + +-- CreateIndex +CREATE INDEX "EpicTaskEdge_toId_idx" ON "EpicTaskEdge"("toId"); + +-- CreateIndex +CREATE UNIQUE INDEX "EpicTaskEdge_fromId_toId_key" ON "EpicTaskEdge"("fromId", "toId"); + +-- CreateIndex +CREATE INDEX "Task_epicTaskId_idx" ON "Task"("epicTaskId"); + +-- CreateIndex +CREATE INDEX "Task_state_idx" ON "Task"("state"); + +-- CreateIndex +CREATE INDEX "Task_assigneeId_idx" ON "Task"("assigneeId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Task_name_epicTaskId_key" ON "Task"("name", "epicTaskId"); + +-- CreateIndex +CREATE INDEX "TaskVerdict_taskId_idx" ON "TaskVerdict"("taskId"); + +-- CreateIndex +CREATE INDEX "TaskVerdict_taskId_stage_idx" ON "TaskVerdict"("taskId", "stage"); + +-- CreateIndex +CREATE INDEX "TaskEvent_taskId_createdAt_idx" ON "TaskEvent"("taskId", "createdAt"); + +-- CreateIndex +CREATE INDEX "CodeRepo_projectId_idx" ON "CodeRepo"("projectId"); + +-- CreateIndex +CREATE UNIQUE INDEX "CodeRepo_name_projectId_key" ON "CodeRepo"("name", "projectId"); + +-- CreateIndex +CREATE INDEX "AgentProject_agentId_idx" ON "AgentProject"("agentId"); + +-- CreateIndex +CREATE INDEX "AgentProject_projectId_idx" ON "AgentProject"("projectId"); + +-- CreateIndex +CREATE UNIQUE INDEX "AgentProject_agentId_projectId_key" ON "AgentProject"("agentId", "projectId"); + +-- CreateIndex +CREATE INDEX "AgentSession_state_idx" ON "AgentSession"("state"); + +-- CreateIndex +CREATE INDEX "AgentSession_taskId_idx" ON "AgentSession"("taskId"); + +-- CreateIndex +CREATE INDEX "AgentSession_agentId_idx" ON "AgentSession"("agentId"); + +-- AddForeignKey +ALTER TABLE "Epic" ADD CONSTRAINT "Epic_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Epic" ADD CONSTRAINT "Epic_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "EpicTask" ADD CONSTRAINT "EpicTask_epicId_fkey" FOREIGN KEY ("epicId") REFERENCES "Epic"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "EpicTask" ADD CONSTRAINT "EpicTask_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "EpicTaskEdge" ADD CONSTRAINT "EpicTaskEdge_fromId_fkey" FOREIGN KEY ("fromId") REFERENCES "EpicTask"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "EpicTaskEdge" ADD CONSTRAINT "EpicTaskEdge_toId_fkey" FOREIGN KEY ("toId") REFERENCES "EpicTask"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Task" ADD CONSTRAINT "Task_epicTaskId_fkey" FOREIGN KEY ("epicTaskId") REFERENCES "EpicTask"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Task" ADD CONSTRAINT "Task_repoId_fkey" FOREIGN KEY ("repoId") REFERENCES "CodeRepo"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Task" ADD CONSTRAINT "Task_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "Agent"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Task" ADD CONSTRAINT "Task_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TaskVerdict" ADD CONSTRAINT "TaskVerdict_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "Task"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TaskVerdict" ADD CONSTRAINT "TaskVerdict_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TaskEvent" ADD CONSTRAINT "TaskEvent_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "Task"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CodeRepo" ADD CONSTRAINT "CodeRepo_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CodeRepo" ADD CONSTRAINT "CodeRepo_credSecretId_fkey" FOREIGN KEY ("credSecretId") REFERENCES "Secret"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CodeRepo" ADD CONSTRAINT "CodeRepo_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AgentProject" ADD CONSTRAINT "AgentProject_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AgentProject" ADD CONSTRAINT "AgentProject_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AgentProject" ADD CONSTRAINT "AgentProject_llmId_fkey" FOREIGN KEY ("llmId") REFERENCES "Llm"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AgentSession" ADD CONSTRAINT "AgentSession_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AgentSession" ADD CONSTRAINT "AgentSession_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AgentSession" ADD CONSTRAINT "AgentSession_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "Task"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AgentSession" ADD CONSTRAINT "AgentSession_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + diff --git a/src/db/prisma/schema.prisma b/src/db/prisma/schema.prisma index b8de194..bbe3c65 100644 --- a/src/db/prisma/schema.prisma +++ b/src/db/prisma/schema.prisma @@ -28,6 +28,12 @@ model User { mcpTokens McpToken[] ownedAgents Agent[] chatThreads ChatThread[] + // ── Agentic teams ── + ownedEpics Epic[] + ownedEpicTasks EpicTask[] + ownedTasks Task[] + ownedRepos CodeRepo[] + ownedSessions AgentSession[] @@index([email]) } @@ -168,8 +174,10 @@ model Secret { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - backend SecretBackend @relation(fields: [backendId], references: [id]) - llms Llm[] + backend SecretBackend @relation(fields: [backendId], references: [id]) + llms Llm[] + // Agentic teams: git-provider credentials for CodeRepo. + codeRepos CodeRepo[] @@index([name]) @@index([backendId]) @@ -256,6 +264,9 @@ model Llm { apiKeySecret Secret? @relation(fields: [apiKeySecretId], references: [id], onDelete: SetNull) agents Agent[] + // Agentic teams: per-membership brain override. + agentProjects AgentProject[] + @@index([name]) @@index([tier]) @@index([apiKeySecretId]) @@ -329,15 +340,22 @@ model Project { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) - servers ProjectServer[] - prompts Prompt[] - promptRequests PromptRequest[] - proposals ResourceProposal[] - skills Skill[] - mcpTokens McpToken[] - agents Agent[] - chatThreads ChatThread[] + owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) + servers ProjectServer[] + prompts Prompt[] + promptRequests PromptRequest[] + proposals ResourceProposal[] + skills Skill[] + mcpTokens McpToken[] + // Legacy single-project pin on Agent. Coexists with agentMemberships during + // the AgentProject transition release. + agents Agent[] + chatThreads ChatThread[] + // ── Agentic teams ── + epics Epic[] + codeRepos CodeRepo[] + agentMemberships AgentProject[] + sessions AgentSession[] @@index([name]) @@index([ownerId]) @@ -692,6 +710,11 @@ model Agent { skills Skill[] personalities Personality[] @relation("AgentPersonalities") defaultPersonality Personality? @relation("AgentDefaultPersonality", fields: [defaultPersonalityId], references: [id], onDelete: SetNull) + // ── Agentic teams ── + projectMemberships AgentProject[] + assignedTasks Task[] + verdicts TaskVerdict[] + sessions AgentSession[] @@index([name]) @@index([llmId]) @@ -888,3 +911,317 @@ model AuditLog { @@index([resource]) @@index([createdAt]) } + +// ═══════════════════════════════════════════════════════════════════════════ +// Agentic Teams — vision layer, work items, repos, membership, sessions. +// See docs/agentic-teams.md and .taskmaster/docs/prd-agentic-teams.md. +// +// NOTE: deploy/entrypoint.sh provisions with `prisma db push`, NOT +// `prisma migrate deploy`, so hand-written SQL in prisma/migrations/ never runs +// in production. Every invariant below is therefore either expressible in this +// schema (unique / non-null / enum / FK) or enforced in the service layer with +// a test. Do not add CHECK constraints and assume they hold. +// ═══════════════════════════════════════════════════════════════════════════ + +// ── Epic (the vision — project-scoped, revision-tracked) ── + +model Epic { + id String @id @default(cuid()) + name String + description String @default("") + // The agreed statement of the vision. Body of the Epic. + content String @db.Text + // Checkable outcomes + intent/quality attributes defining 100% delivery: + // [{ id, text, kind: 'outcome' | 'quality', met: boolean }] + doneCriteria Json @default("[]") + // Accepted-tradeoff records: [{ what, why, who, deScopedBy, at }]. An Epic is + // honestly "done" only at 100% delivery OR with these explicitly recorded. + acceptedTradeoffs Json @default("[]") + projectId String + status EpicStatus @default(open) + // Semantic version of the current content, auto-bumped on save like Prompt. + semver String @default("0.1.0") + // Soft pointer to the latest ResourceRevision row. NULL before first revision. + currentRevisionId String? + ownerId String + // Optimistic-concurrency counter. NOT semver — that's `semver` above. + version Int @default(1) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) + epicTasks EpicTask[] + + @@unique([name, projectId]) + @@index([projectId]) + @@index([name]) +} + +enum EpicStatus { + open + delivered + delivered_with_tradeoffs + cancelled +} + +// ── EpicTask (a roadmap node; the planning/navigation layer) ── + +model EpicTask { + id String @id @default(cuid()) + name String + description String @default("") + epicId String + // The decomposed subset of the Epic's doneCriteria this node owns, so the + // roadmap can show per-node completeness rather than one global percentage. + doneCriteria Json @default("[]") + status EpicTaskStatus @default(todo) + ownerId String + version Int @default(1) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + epic Epic @relation(fields: [epicId], references: [id], onDelete: Cascade) + owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) + tasks Task[] + dependsOn EpicTaskEdge[] @relation("EpicTaskEdgeFrom") + blocks EpicTaskEdge[] @relation("EpicTaskEdgeTo") + + @@unique([name, epicId]) + @@index([epicId]) +} + +enum EpicTaskStatus { + todo + in_progress + done + cancelled +} + +// ── EpicTaskEdge (roadmap chaining — the "spider web") ── +// +// An explicit edge table rather than a self-referencing array so the graph can +// be walked in both directions and edges can later carry a type. Cycles are +// rejected in the service layer (see the db-push note above). + +model EpicTaskEdge { + id String @id @default(cuid()) + fromId String // the dependent node + toId String // the prerequisite node + createdAt DateTime @default(now()) + + from EpicTask @relation("EpicTaskEdgeFrom", fields: [fromId], references: [id], onDelete: Cascade) + to EpicTask @relation("EpicTaskEdgeTo", fields: [toId], references: [id], onDelete: Cascade) + + @@unique([fromId, toId]) + @@index([fromId]) + @@index([toId]) +} + +// ── Task (the unit of work, owned by an EpicTask) ── + +model Task { + id String @id @default(cuid()) + name String + title String + description String @default("") @db.Text + epicTaskId String + repoId String? + // The agent currently responsible. NULL while unassigned. + assigneeId String? + state TaskState @default(todo) + // Drives how much Tier-1 verification is engaged: trivial work may pass on a + // light self-check; risky work engages QA / Security / SRE. + risk TaskRisk @default(normal) + // Git linkage, populated when the agent pushes. The human accepts inside + // mcpctl and never has to go to git. + branch String? + headCommit String? + prUrl String? + prNumber Int? + // Free-form reviewer comment attached at the accept gate. + reviewNote String? @db.Text + ownerId String + version Int @default(1) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + epicTask EpicTask @relation(fields: [epicTaskId], references: [id], onDelete: Cascade) + repo CodeRepo? @relation(fields: [repoId], references: [id], onDelete: SetNull) + assignee Agent? @relation(fields: [assigneeId], references: [id], onDelete: SetNull) + owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) + verdicts TaskVerdict[] + sessions AgentSession[] + events TaskEvent[] + + @@unique([name, epicTaskId]) + @@index([epicTaskId]) + @@index([state]) + @@index([assigneeId]) +} + +enum TaskState { + todo + in_progress + building // compile, run, smoke + self_testing // developer's own tests + qa_review + security_review + sre_review + done // leaves the developer's loop + awaiting_review // human accept gate + merged + reopened // post-merge failure + cancelled +} + +enum TaskRisk { + trivial // docs, formatting + normal + high // auth, secrets, migrations, cross-contract +} + +// ── TaskVerdict (a Tier-1 persona's judgement, pinned to a commit) ── +// +// Immutable provenance: "QA verified 3f2ab against current main" stays +// reproducible forever, which is what makes coverage claims auditable later. + +model TaskVerdict { + id String @id @default(cuid()) + taskId String + // qa_review | security_review | sre_review + stage TaskState + agentId String? + commit String + passed Boolean + summary String @db.Text + // [{ severity, message, path?, line? }] + findings Json @default("[]") + createdAt DateTime @default(now()) + + task Task @relation(fields: [taskId], references: [id], onDelete: Cascade) + agent Agent? @relation(fields: [agentId], references: [id], onDelete: SetNull) + + @@index([taskId]) + @@index([taskId, stage]) +} + +// ── TaskEvent (append-only transition log) ── + +model TaskEvent { + id String @id @default(cuid()) + taskId String + fromState TaskState? + toState TaskState + // 'user' | 'agent' | 'system' + actorType String + actorId String? + note String? @db.Text + createdAt DateTime @default(now()) + + task Task @relation(fields: [taskId], references: [id], onDelete: Cascade) + + @@index([taskId, createdAt]) +} + +// ── CodeRepo (a git repository attached to a project) ── +// +// Named CodeRepo, not Repository, to avoid colliding with the data-access +// "repository" layer in src/mcpd/src/repositories/. CLI resource is `repos`, +// with repo/repository/repositories all aliased to it. + +model CodeRepo { + id String @id @default(cuid()) + name String + projectId String + // Clone URL. https for token auth, ssh for key auth. + url String + defaultBranch String @default("main") + // Relative path under the session workspace root. Defaults to `name`. + path String @default("") + // Git provider, for PR/merge API calls. 'gitea' is the only driver in v1. + provider String @default("gitea") + // Secret holding the provider credential, resolved through SecretService at + // session-provision time — never stored in plaintext on the row. + credSecretId String? + credSecretKey String? + ownerId String + version Int @default(1) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + cred Secret? @relation(fields: [credSecretId], references: [id]) + owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) + tasks Task[] + + @@unique([name, projectId]) + @@index([projectId]) +} + +// ── AgentProject (agents are people who join many projects) ── +// +// Mirrors the ProjectServer junction shape. Agent.projectId is retained and +// kept in sync for one release so existing findByProjectId / prepareContext +// paths keep working; a follow-up removes it. + +model AgentProject { + id String @id @default(cuid()) + agentId String + projectId String + // Per-project persona role. TEXT, not an enum, so adding a role later is a + // non-migration change (same reasoning as ResourceProposal.resourceType). + // v1 values: 'developer' | 'qa' | 'security' | 'sre'. + role String @default("developer") + // Optional per-membership brain override. Present so the brain-ownership + // question (docs/agentic-teams.md §15 Q1) can be settled either way without + // a migration. + llmId String? + createdAt DateTime @default(now()) + + agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + llm Llm? @relation(fields: [llmId], references: [id], onDelete: SetNull) + + @@unique([agentId, projectId]) + @@index([agentId]) + @@index([projectId]) +} + +// ── AgentSession (the ephemeral pod that IS the agent's workspace) ── + +model AgentSession { + id String @id @default(cuid()) + agentId String + projectId String + taskId String? + // Orchestrator handle — pod name on k8s, container id on docker. + containerId String? + state SessionState @default(pending) + image String + // Reaped when now() > expiresAt, by a GC sweep in the same style as the + // inference-task sweep. Single-replica assumption: no leader election. + expiresAt DateTime + lastError String? @db.Text + ownerId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + endedAt DateTime? + + agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + task Task? @relation(fields: [taskId], references: [id], onDelete: SetNull) + owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) + + @@index([state]) + @@index([taskId]) + @@index([agentId]) +} + +enum SessionState { + pending + provisioning + running + ended + failed +} diff --git a/src/db/tests/agentic-teams-schema.test.ts b/src/db/tests/agentic-teams-schema.test.ts new file mode 100644 index 0000000..af16713 --- /dev/null +++ b/src/db/tests/agentic-teams-schema.test.ts @@ -0,0 +1,428 @@ +import type { PrismaClient } from '@prisma/client'; +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; + +import { setupTestDb, cleanupTestDb, clearAllTables } from './helpers.js'; + +/** + * Schema-level cover for the agentic-teams foundation: defaults, the uniqueness + * rules that make names addressable, and the cascade/SetNull behaviour that + * decides what survives a delete. + * + * Cascade rules are load-bearing here, not incidental — deleting a Project must + * take its Epics and work items with it, but deleting the *repo* or the + * *assignee* must leave the Task standing, because a task outlives the agent + * that happened to be working it. + */ +describe('Agentic teams schema', () => { + let prisma: PrismaClient; + let seq = 0; + + const uniq = (p: string): string => `${p}-${Date.now()}-${seq++}`; + + beforeAll(async () => { + prisma = await setupTestDb(); + }, 30_000); + + afterAll(async () => { + await cleanupTestDb(); + }); + + beforeEach(async () => { + await clearAllTables(prisma); + }); + + async function createUser() { + return prisma.user.create({ + data: { email: `${uniq('u')}@example.com`, name: 'Test', passwordHash: '!locked', role: 'USER' }, + }); + } + + async function createProject() { + const user = await createUser(); + const project = await prisma.project.create({ data: { name: uniq('proj'), ownerId: user.id } }); + return { project, user }; + } + + async function createLlm() { + return prisma.llm.create({ data: { name: uniq('llm'), type: 'openai', model: 'test' } }); + } + + async function createAgent() { + const user = await createUser(); + const llm = await createLlm(); + return prisma.agent.create({ data: { name: uniq('agent'), llmId: llm.id, ownerId: user.id } }); + } + + async function createEpic(projectId: string, ownerId: string) { + return prisma.epic.create({ + data: { name: uniq('epic'), content: 'the vision', projectId, ownerId }, + }); + } + + async function createEpicTask(epicId: string, ownerId: string) { + return prisma.epicTask.create({ data: { name: uniq('et'), epicId, ownerId } }); + } + + async function createTask(epicTaskId: string, ownerId: string, extra: Record = {}) { + return prisma.task.create({ + data: { name: uniq('task'), title: 'Do the thing', epicTaskId, ownerId, ...extra }, + }); + } + + describe('Epic', () => { + it('applies documented defaults', async () => { + const { project, user } = await createProject(); + const epic = await createEpic(project.id, user.id); + + expect(epic.status).toBe('open'); + expect(epic.semver).toBe('0.1.0'); + expect(epic.version).toBe(1); + expect(epic.currentRevisionId).toBeNull(); + expect(epic.description).toBe(''); + expect(epic.doneCriteria).toEqual([]); + expect(epic.acceptedTradeoffs).toEqual([]); + }); + + it('scopes name uniqueness per project, not globally', async () => { + const a = await createProject(); + const b = await createProject(); + const name = uniq('shared-epic'); + + await prisma.epic.create({ data: { name, content: 'x', projectId: a.project.id, ownerId: a.user.id } }); + // Same name in a different project is fine... + await expect( + prisma.epic.create({ data: { name, content: 'x', projectId: b.project.id, ownerId: b.user.id } }), + ).resolves.toBeDefined(); + // ...but a duplicate within one project is not. + await expect( + prisma.epic.create({ data: { name, content: 'y', projectId: a.project.id, ownerId: a.user.id } }), + ).rejects.toThrow(); + }); + + it('records accepted tradeoffs as structured JSON', async () => { + const { project, user } = await createProject(); + const epic = await createEpic(project.id, user.id); + const tradeoff = { what: 'no dark mode', why: 'out of budget', who: 'michal', deScopedBy: 'michal' }; + + const updated = await prisma.epic.update({ + where: { id: epic.id }, + data: { acceptedTradeoffs: [tradeoff], status: 'delivered_with_tradeoffs' }, + }); + + expect(updated.acceptedTradeoffs).toEqual([tradeoff]); + expect(updated.status).toBe('delivered_with_tradeoffs'); + }); + + it('cascades from Project', async () => { + const { project, user } = await createProject(); + await createEpic(project.id, user.id); + + await prisma.project.delete({ where: { id: project.id } }); + + expect(await prisma.epic.count({ where: { projectId: project.id } })).toBe(0); + }); + }); + + describe('EpicTask and the roadmap graph', () => { + it('chains nodes through an explicit edge table', async () => { + const { project, user } = await createProject(); + const epic = await createEpic(project.id, user.id); + const foundation = await createEpicTask(epic.id, user.id); + const dependent = await createEpicTask(epic.id, user.id); + + await prisma.epicTaskEdge.create({ data: { fromId: dependent.id, toId: foundation.id } }); + + const loaded = await prisma.epicTask.findUnique({ + where: { id: dependent.id }, + include: { dependsOn: true, blocks: true }, + }); + expect(loaded?.dependsOn).toHaveLength(1); + expect(loaded?.dependsOn[0]?.toId).toBe(foundation.id); + + // ...and the graph is walkable from the other side too. + const reverse = await prisma.epicTask.findUnique({ + where: { id: foundation.id }, + include: { blocks: true }, + }); + expect(reverse?.blocks).toHaveLength(1); + expect(reverse?.blocks[0]?.fromId).toBe(dependent.id); + }); + + it('rejects a duplicate edge between the same pair', async () => { + const { project, user } = await createProject(); + const epic = await createEpic(project.id, user.id); + const a = await createEpicTask(epic.id, user.id); + const b = await createEpicTask(epic.id, user.id); + + await prisma.epicTaskEdge.create({ data: { fromId: a.id, toId: b.id } }); + await expect(prisma.epicTaskEdge.create({ data: { fromId: a.id, toId: b.id } })).rejects.toThrow(); + }); + + it('does NOT reject a cycle — that is the service layer\'s job', async () => { + const { project, user } = await createProject(); + const epic = await createEpic(project.id, user.id); + const a = await createEpicTask(epic.id, user.id); + const b = await createEpicTask(epic.id, user.id); + + await prisma.epicTaskEdge.create({ data: { fromId: a.id, toId: b.id } }); + // Documents the boundary: production runs `prisma db push`, so a CHECK or + // trigger here would never exist in prod. Cycle rejection lives in the + // EpicTask service and is tested there. + await expect(prisma.epicTaskEdge.create({ data: { fromId: b.id, toId: a.id } })).resolves.toBeDefined(); + }); + + it('cascades edges when a node is deleted', async () => { + const { project, user } = await createProject(); + const epic = await createEpic(project.id, user.id); + const a = await createEpicTask(epic.id, user.id); + const b = await createEpicTask(epic.id, user.id); + await prisma.epicTaskEdge.create({ data: { fromId: a.id, toId: b.id } }); + + await prisma.epicTask.delete({ where: { id: a.id } }); + + expect(await prisma.epicTaskEdge.count()).toBe(0); + }); + }); + + describe('Task', () => { + it('applies documented defaults', async () => { + const { project, user } = await createProject(); + const epic = await createEpic(project.id, user.id); + const epicTask = await createEpicTask(epic.id, user.id); + const task = await createTask(epicTask.id, user.id); + + expect(task.state).toBe('todo'); + expect(task.risk).toBe('normal'); + expect(task.assigneeId).toBeNull(); + expect(task.repoId).toBeNull(); + expect(task.branch).toBeNull(); + expect(task.prUrl).toBeNull(); + expect(task.prNumber).toBeNull(); + expect(task.reviewNote).toBeNull(); + expect(task.version).toBe(1); + }); + + it('accepts every lifecycle state', async () => { + const { project, user } = await createProject(); + const epic = await createEpic(project.id, user.id); + const epicTask = await createEpicTask(epic.id, user.id); + const task = await createTask(epicTask.id, user.id); + + const states = [ + 'in_progress', 'building', 'self_testing', 'qa_review', 'security_review', + 'sre_review', 'done', 'awaiting_review', 'merged', 'reopened', 'cancelled', + ] as const; + + for (const state of states) { + const updated = await prisma.task.update({ where: { id: task.id }, data: { state } }); + expect(updated.state).toBe(state); + } + }); + + it('keeps the task when its assignee agent is deleted', async () => { + const { project, user } = await createProject(); + const epic = await createEpic(project.id, user.id); + const epicTask = await createEpicTask(epic.id, user.id); + const agent = await createAgent(); + const task = await createTask(epicTask.id, user.id, { assigneeId: agent.id }); + + await prisma.agent.delete({ where: { id: agent.id } }); + + // A task outlives whoever happened to be working it. + const after = await prisma.task.findUnique({ where: { id: task.id } }); + expect(after).not.toBeNull(); + expect(after?.assigneeId).toBeNull(); + }); + + it('cascades verdicts and events, and keeps them commit-pinned', async () => { + const { project, user } = await createProject(); + const epic = await createEpic(project.id, user.id); + const epicTask = await createEpicTask(epic.id, user.id); + const task = await createTask(epicTask.id, user.id); + + await prisma.taskVerdict.create({ + data: { + taskId: task.id, stage: 'qa_review', commit: '3f2ab19', passed: false, + summary: 'missing coverage on the error path', + findings: [{ severity: 'major', message: 'no test for 500', path: 'src/a.ts' }], + }, + }); + await prisma.taskEvent.create({ + data: { taskId: task.id, fromState: 'qa_review', toState: 'in_progress', actorType: 'agent' }, + }); + + const verdict = await prisma.taskVerdict.findFirst({ where: { taskId: task.id } }); + expect(verdict?.commit).toBe('3f2ab19'); + expect(verdict?.passed).toBe(false); + + await prisma.task.delete({ where: { id: task.id } }); + expect(await prisma.taskVerdict.count()).toBe(0); + expect(await prisma.taskEvent.count()).toBe(0); + }); + + it('allows a TaskEvent with no fromState — the first transition', async () => { + const { project, user } = await createProject(); + const epic = await createEpic(project.id, user.id); + const epicTask = await createEpicTask(epic.id, user.id); + const task = await createTask(epicTask.id, user.id); + + const event = await prisma.taskEvent.create({ + data: { taskId: task.id, toState: 'todo', actorType: 'system' }, + }); + expect(event.fromState).toBeNull(); + }); + }); + + describe('CodeRepo', () => { + it('applies documented defaults', async () => { + const { project, user } = await createProject(); + const repo = await prisma.codeRepo.create({ + data: { name: uniq('repo'), projectId: project.id, url: 'https://git.example/x.git', ownerId: user.id }, + }); + + expect(repo.defaultBranch).toBe('main'); + expect(repo.provider).toBe('gitea'); + expect(repo.path).toBe(''); + expect(repo.credSecretId).toBeNull(); + }); + + it('keeps the task when its repo is deleted', async () => { + const { project, user } = await createProject(); + const epic = await createEpic(project.id, user.id); + const epicTask = await createEpicTask(epic.id, user.id); + const repo = await prisma.codeRepo.create({ + data: { name: uniq('repo'), projectId: project.id, url: 'https://git.example/x.git', ownerId: user.id }, + }); + const task = await createTask(epicTask.id, user.id, { repoId: repo.id }); + + await prisma.codeRepo.delete({ where: { id: repo.id } }); + + const after = await prisma.task.findUnique({ where: { id: task.id } }); + expect(after?.repoId).toBeNull(); + }); + }); + + describe('AgentProject membership', () => { + it('lets one agent join many projects with different roles', async () => { + const agent = await createAgent(); + const a = await createProject(); + const b = await createProject(); + + await prisma.agentProject.create({ data: { agentId: agent.id, projectId: a.project.id, role: 'developer' } }); + await prisma.agentProject.create({ data: { agentId: agent.id, projectId: b.project.id, role: 'security' } }); + + const memberships = await prisma.agentProject.findMany({ where: { agentId: agent.id } }); + expect(memberships).toHaveLength(2); + expect(memberships.map((m) => m.role).sort()).toEqual(['developer', 'security']); + }); + + it('defaults the role to developer and leaves the brain override unset', async () => { + const agent = await createAgent(); + const { project } = await createProject(); + + const membership = await prisma.agentProject.create({ + data: { agentId: agent.id, projectId: project.id }, + }); + expect(membership.role).toBe('developer'); + expect(membership.llmId).toBeNull(); + }); + + it('rejects joining the same project twice', async () => { + const agent = await createAgent(); + const { project } = await createProject(); + + await prisma.agentProject.create({ data: { agentId: agent.id, projectId: project.id } }); + await expect( + prisma.agentProject.create({ data: { agentId: agent.id, projectId: project.id } }), + ).rejects.toThrow(); + }); + + it('drops the membership but keeps the agent when a project goes away', async () => { + const agent = await createAgent(); + const { project } = await createProject(); + await prisma.agentProject.create({ data: { agentId: agent.id, projectId: project.id } }); + + await prisma.project.delete({ where: { id: project.id } }); + + expect(await prisma.agentProject.count({ where: { agentId: agent.id } })).toBe(0); + expect(await prisma.agent.findUnique({ where: { id: agent.id } })).not.toBeNull(); + }); + + it('clears only the brain override when its Llm is deleted', async () => { + const agent = await createAgent(); + const { project } = await createProject(); + const llm = await createLlm(); + const membership = await prisma.agentProject.create({ + data: { agentId: agent.id, projectId: project.id, llmId: llm.id }, + }); + + await prisma.llm.delete({ where: { id: llm.id } }); + + const after = await prisma.agentProject.findUnique({ where: { id: membership.id } }); + expect(after).not.toBeNull(); + expect(after?.llmId).toBeNull(); + }); + }); + + describe('AgentSession', () => { + it('applies documented defaults', async () => { + const agent = await createAgent(); + const { project, user } = await createProject(); + + const session = await prisma.agentSession.create({ + data: { + agentId: agent.id, projectId: project.id, ownerId: user.id, + image: 'mcpctl-agent-runner:latest', expiresAt: new Date(Date.now() + 3600_000), + }, + }); + + expect(session.state).toBe('pending'); + expect(session.containerId).toBeNull(); + expect(session.taskId).toBeNull(); + expect(session.endedAt).toBeNull(); + expect(session.lastError).toBeNull(); + }); + + it('survives its task being deleted — the pod record outlives the work item', async () => { + const agent = await createAgent(); + const { project, user } = await createProject(); + const epic = await createEpic(project.id, user.id); + const epicTask = await createEpicTask(epic.id, user.id); + const task = await createTask(epicTask.id, user.id); + const session = await prisma.agentSession.create({ + data: { + agentId: agent.id, projectId: project.id, taskId: task.id, ownerId: user.id, + image: 'x:latest', expiresAt: new Date(Date.now() + 3600_000), + }, + }); + + await prisma.task.delete({ where: { id: task.id } }); + + const after = await prisma.agentSession.findUnique({ where: { id: session.id } }); + expect(after).not.toBeNull(); + expect(after?.taskId).toBeNull(); + }); + + it('finds expired sessions for the GC sweep', async () => { + const agent = await createAgent(); + const { project, user } = await createProject(); + await prisma.agentSession.create({ + data: { + agentId: agent.id, projectId: project.id, ownerId: user.id, image: 'x:latest', + state: 'running', expiresAt: new Date(Date.now() - 1000), + }, + }); + await prisma.agentSession.create({ + data: { + agentId: agent.id, projectId: project.id, ownerId: user.id, image: 'x:latest', + state: 'running', expiresAt: new Date(Date.now() + 3600_000), + }, + }); + + const expired = await prisma.agentSession.findMany({ + where: { state: { in: ['pending', 'provisioning', 'running'] }, expiresAt: { lt: new Date() } }, + }); + expect(expired).toHaveLength(1); + }); + }); +}); diff --git a/src/db/tests/helpers.ts b/src/db/tests/helpers.ts index 8926ba1..7d80678 100644 --- a/src/db/tests/helpers.ts +++ b/src/db/tests/helpers.ts @@ -42,6 +42,18 @@ export async function clearAllTables(client: PrismaClient): Promise { await client.resourceProposal.deleteMany(); // Revisions have no FK (soft FK); always orphans without explicit clear. await client.resourceRevision.deleteMany(); + // Agentic teams — leaves first, then up the chain. Most of these cascade from + // Project/Agent/User, but deleting them explicitly keeps the order obvious and + // stops a future nullable FK from silently leaking rows between tests. + await client.agentSession.deleteMany(); + await client.taskEvent.deleteMany(); + await client.taskVerdict.deleteMany(); + await client.task.deleteMany(); + await client.epicTaskEdge.deleteMany(); + await client.epicTask.deleteMany(); + await client.epic.deleteMany(); + await client.codeRepo.deleteMany(); + await client.agentProject.deleteMany(); await client.agent.deleteMany(); await client.llm.deleteMany(); await client.mcpInstance.deleteMany(); -- 2.49.1 From ffcd0f985b1fe872abfca057854e600e1d5ea298 Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 8 Aug 2026 10:36:21 +0100 Subject: [PATCH 06/32] =?UTF-8?q?feat(orchestrator):=20ephemeral=20session?= =?UTF-8?q?=20pods=20=E2=80=94=20restart=20policy,=20volumes,=20init=20con?= =?UTF-8?q?tainers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An MCP server container is long-lived and restarts forever; an agent session pod is the opposite — it does a unit of work and dies. ContainerSpec could not express that: restartPolicy was hardcoded 'Always' in generatePodSpec, and there were no volume, workdir, or init-container fields at all. Adds four optional fields to ContainerSpec (restartPolicy, workdir, volumes, initContainers) and implements them in both backends. All optional, so every existing caller is byte-identical — generatePodSpec still emits 'Always' unless asked otherwise, and omits volumes/initContainers keys entirely when unused. Kubernetes gets this nearly free: initContainers run to completion in declaration order before any app container starts, so "clone the repos, then work them" needs no sequencing logic from us. generateDeploymentSpec deliberately ignores spec.restartPolicy — k8s rejects anything but 'Always' on a Deployment pod template — and says so in a comment. Docker has no init containers, so they are emulated as sequential runs sharing the same volumes. The important property is the failure path: a non-zero init exit aborts BEFORE the main container is created, and the error carries the init container's last 50 log lines. Otherwise a failed git clone would leave an empty workspace that looks exactly like a successful one, and the agent would start work against nothing. Named Docker volumes are namespaced by container name to stand in for emptyDir. Tests: 10 k8s manifest tests (defaults unchanged, each restart policy, volume and mount emission, init ordering, name sanitization, security context still hardened) and 9 Docker tests including the abort-before-main case and log propagation. Local: build clean, workspace 2310 passed (+19), lint unchanged at the repo's pre-existing 869. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p --- .taskmaster/tasks/tasks.json | 16 +- .../src/services/docker/container-manager.ts | 81 +++++++++ .../src/services/k8s/manifest-generator.ts | 54 +++++- src/mcpd/src/services/orchestrator.ts | 35 ++++ src/mcpd/tests/docker-ephemeral.test.ts | 170 ++++++++++++++++++ src/mcpd/tests/k8s-manifest.test.ts | 105 +++++++++++ 6 files changed, 452 insertions(+), 9 deletions(-) create mode 100644 src/mcpd/tests/docker-ephemeral.test.ts diff --git a/.taskmaster/tasks/tasks.json b/.taskmaster/tasks/tasks.json index 98e1ad2..0ec808b 100644 --- a/.taskmaster/tasks/tasks.json +++ b/.taskmaster/tasks/tasks.json @@ -2917,9 +2917,9 @@ "testStrategy": "Type tests: verify ContainerSpec accepts all new optional fields, verify existing code still compiles without providing new fields.", "priority": "high", "dependencies": [], - "status": "pending", + "status": "done", "subtasks": [], - "updatedAt": "2026-08-08T08:55:58.627Z" + "updatedAt": "2026-08-08T09:36:21.123Z" }, { "id": "141", @@ -2931,9 +2931,9 @@ "dependencies": [ "140" ], - "status": "pending", + "status": "done", "subtasks": [], - "updatedAt": "2026-08-08T08:55:58.627Z" + "updatedAt": "2026-08-08T09:36:21.132Z" }, { "id": "142", @@ -2945,9 +2945,9 @@ "dependencies": [ "140" ], - "status": "pending", + "status": "done", "subtasks": [], - "updatedAt": "2026-08-08T08:55:58.627Z" + "updatedAt": "2026-08-08T09:36:21.139Z" }, { "id": "143", @@ -3251,9 +3251,9 @@ ], "metadata": { "version": "1.0.0", - "lastModified": "2026-08-08T09:29:43.398Z", + "lastModified": "2026-08-08T09:36:21.139Z", "taskCount": 162, - "completedCount": 104, + "completedCount": 107, "tags": [ "master" ] diff --git a/src/mcpd/src/services/docker/container-manager.ts b/src/mcpd/src/services/docker/container-manager.ts index 236cc71..2afe4bb 100644 --- a/src/mcpd/src/services/docker/container-manager.ts +++ b/src/mcpd/src/services/docker/container-manager.ts @@ -6,12 +6,28 @@ import type { ContainerInfo, ContainerLogs, ExecResult, + InitContainerSpec, InteractiveExec, } from '../orchestrator.js'; import { DEFAULT_MEMORY_LIMIT } from '../orchestrator.js'; const MCPCTL_LABEL = 'mcpctl.managed'; +/** + * Map the orchestrator's k8s-flavoured restart policy onto Docker's. + * 'Never' becomes 'no' — a session container that exits stays exited. + */ +function toDockerRestartPolicy(policy: NonNullable): { Name: string } { + switch (policy) { + case 'Never': + return { Name: 'no' }; + case 'OnFailure': + return { Name: 'on-failure' }; + default: + return { Name: 'always' }; + } +} + function mapState(state: string | undefined): ContainerInfo['state'] { switch (state?.toLowerCase()) { case 'running': @@ -77,6 +93,15 @@ export class DockerContainerManager implements McpOrchestrator { ? Object.entries(spec.env).map(([k, v]) => `${k}=${v}`) : undefined; + // Named Docker volumes stand in for k8s emptyDir: same lifetime intent + // (scratch space shared across the pod), removed by removeContainer's `v`. + const volumeName = (specName: string, vol: string): string => `${specName}-${vol}`; + const mounts: Docker.MountSettings[] = (spec.volumes ?? []).map((v) => ({ + Type: 'volume' as const, + Source: volumeName(spec.name, v.name), + Target: v.mountPath, + })); + const createOpts: Docker.ContainerCreateOptions = { Image: spec.image, name: spec.name, @@ -91,11 +116,24 @@ export class DockerContainerManager implements McpOrchestrator { Memory: memoryLimit, ...(nanoCpus ? { NanoCpus: nanoCpus } : {}), NetworkMode: spec.network ?? 'bridge', + ...(mounts.length > 0 ? { Mounts: mounts } : {}), + ...(spec.restartPolicy ? { RestartPolicy: toDockerRestartPolicy(spec.restartPolicy) } : {}), }, }; if (spec.command) { createOpts.Cmd = spec.command; } + if (spec.workdir !== undefined && spec.workdir !== '') { + createOpts.WorkingDir = spec.workdir; + } + + // Docker has no init containers. Run each to completion, in order, sharing + // the same volumes — the main container only starts once they all succeed. + // A non-zero exit aborts before the main container is ever created, so a + // failed clone can't be mistaken for an empty workspace. + for (const init of spec.initContainers ?? []) { + await this.runInitContainer(spec, init, mounts); + } const container = await this.docker.createContainer(createOpts); @@ -104,6 +142,49 @@ export class DockerContainerManager implements McpOrchestrator { return this.inspectContainer(container.id); } + /** Run one init container to completion; throw on non-zero exit. */ + private async runInitContainer( + spec: ContainerSpec, + init: InitContainerSpec, + podMounts: Docker.MountSettings[], + ): Promise { + // Honour the init container's own volumeMounts when given, otherwise give + // it the same view as the main container. + const mounts: Docker.MountSettings[] = init.volumeMounts + ? init.volumeMounts.map((m) => ({ + Type: 'volume' as const, + Source: `${spec.name}-${m.name}`, + Target: m.mountPath, + })) + : podMounts; + + const container = await this.docker.createContainer({ + Image: init.image, + name: `${spec.name}-init-${init.name}`, + Cmd: init.command, + ...(init.env ? { Env: Object.entries(init.env).map(([k, v]) => `${k}=${v}`) } : {}), + Labels: { [MCPCTL_LABEL]: 'true', ...spec.labels }, + HostConfig: { + ...(mounts.length > 0 ? { Mounts: mounts } : {}), + NetworkMode: spec.network ?? 'bridge', + }, + }); + + try { + await container.start(); + const result = (await container.wait()) as { StatusCode?: number }; + const code = result.StatusCode ?? 0; + if (code !== 0) { + const logs = await this.getContainerLogs(container.id, { tail: 50 }).catch(() => null); + const detail = logs ? `\n${logs.stdout}${logs.stderr}`.trimEnd() : ''; + throw new Error(`init container '${init.name}' exited with code ${String(code)}${detail}`); + } + } finally { + // Keep the volumes (`v: false`) — that is the whole point of the run. + await container.remove({ force: true, v: false }).catch(() => {}); + } + } + async stopContainer(containerId: string, timeoutSeconds = 10): Promise { const container = this.docker.getContainer(containerId); await container.stop({ t: timeoutSeconds }); diff --git a/src/mcpd/src/services/k8s/manifest-generator.ts b/src/mcpd/src/services/k8s/manifest-generator.ts index 670679d..4d7dce4 100644 --- a/src/mcpd/src/services/k8s/manifest-generator.ts +++ b/src/mcpd/src/services/k8s/manifest-generator.ts @@ -20,6 +20,8 @@ export interface K8sPodManifest { env?: Array<{ name: string; value: string }>; ports?: Array<{ containerPort: number }>; stdin?: boolean; + workingDir?: string; + volumeMounts?: Array<{ name: string; mountPath: string }>; resources: { limits: { memory: string; cpu: string }; requests: { memory: string; cpu: string }; @@ -32,6 +34,14 @@ export interface K8sPodManifest { seccompProfile: { type: string }; }; }>; + initContainers?: Array<{ + name: string; + image: string; + command: string[]; + env?: Array<{ name: string; value: string }>; + volumeMounts?: Array<{ name: string; mountPath: string }>; + }>; + volumes?: Array<{ name: string; emptyDir: Record }>; restartPolicy: 'Always' | 'Never' | 'OnFailure'; automountServiceAccountToken: boolean; nodeSelector?: Record; @@ -128,9 +138,45 @@ function buildContainerSpec(spec: ContainerSpec) { container.ports = [{ containerPort: spec.containerPort }]; } + if (spec.workdir !== undefined && spec.workdir !== '') { + container.workingDir = spec.workdir; + } + + // The main container sees every declared volume; init containers pick the + // subset they need via their own volumeMounts. + if (spec.volumes && spec.volumes.length > 0) { + container.volumeMounts = spec.volumes.map((v) => ({ name: v.name, mountPath: v.mountPath })); + } + return container; } +/** Translate ContainerSpec volumes/initContainers into pod-spec fragments. */ +function buildPodExtras(spec: ContainerSpec): Partial { + const extras: Partial = {}; + + if (spec.volumes && spec.volumes.length > 0) { + extras.volumes = spec.volumes.map((v) => ({ name: v.name, emptyDir: {} })); + } + + // Kubernetes runs initContainers to completion, in declaration order, before + // any app container starts — so "clone the repos, then work them" needs no + // sequencing logic on our side. + if (spec.initContainers && spec.initContainers.length > 0) { + extras.initContainers = spec.initContainers.map((c) => ({ + name: sanitizeName(c.name), + image: c.image, + command: c.command, + ...(c.env && Object.keys(c.env).length > 0 + ? { env: Object.entries(c.env).map(([name, value]) => ({ name, value })) } + : {}), + ...(c.volumeMounts && c.volumeMounts.length > 0 ? { volumeMounts: c.volumeMounts } : {}), + })); + } + + return extras; +} + export function generatePodSpec(spec: ContainerSpec, namespace: string): K8sPodManifest { const labels = buildLabels(spec); return { @@ -143,7 +189,10 @@ export function generatePodSpec(spec: ContainerSpec, namespace: string): K8sPodM }, spec: { containers: [buildContainerSpec(spec)], - restartPolicy: 'Always', + ...buildPodExtras(spec), + // Long-lived MCP servers restart forever; ephemeral session pods pass + // 'Never' so a finished or crashed run stays finished. + restartPolicy: spec.restartPolicy ?? 'Always', // MCP server pods don't need k8s API access automountServiceAccountToken: false, // On mixed-arch clusters, constrain to the same arch as mcpd @@ -177,6 +226,9 @@ export function generateDeploymentSpec(spec: ContainerSpec, namespace: string, r metadata: { labels }, spec: { containers: [buildContainerSpec(spec)], + ...buildPodExtras(spec), + // Deployments require 'Always' on their pod template — k8s rejects + // anything else — so spec.restartPolicy is deliberately ignored here. restartPolicy: 'Always', automountServiceAccountToken: false, }, diff --git a/src/mcpd/src/services/orchestrator.ts b/src/mcpd/src/services/orchestrator.ts index e6767c1..db55b9e 100644 --- a/src/mcpd/src/services/orchestrator.ts +++ b/src/mcpd/src/services/orchestrator.ts @@ -23,6 +23,41 @@ export interface ContainerSpec { labels?: Record; /** Network name to attach to */ network?: string; + + // ── Ephemeral session pods (agentic teams) ──────────────────────────────── + // MCP server containers are long-lived and restart forever. An agent session + // pod is the opposite: it does a unit of work and dies. These fields are all + // optional, so existing callers are unaffected. + + /** + * Restart behaviour. Defaults to 'Always' (long-lived MCP servers). + * Session pods use 'Never' so a finished or crashed run stays finished. + */ + restartPolicy?: 'Always' | 'Never' | 'OnFailure'; + /** Working directory for the main container. */ + workdir?: string; + /** Scratch volumes, e.g. the shared workspace an init container clones into. */ + volumes?: VolumeSpec[]; + /** + * Containers that must run to completion, in order, before the main container + * starts. Used to clone the project's repositories into the workspace volume. + */ + initContainers?: InitContainerSpec[]; +} + +export interface VolumeSpec { + name: string; + mountPath: string; + /** Only ephemeral scratch space for now; the pod's lifetime bounds it. */ + kind: 'emptyDir'; +} + +export interface InitContainerSpec { + name: string; + image: string; + command: string[]; + env?: Record; + volumeMounts?: Array<{ name: string; mountPath: string }>; } export interface ContainerInfo { diff --git a/src/mcpd/tests/docker-ephemeral.test.ts b/src/mcpd/tests/docker-ephemeral.test.ts new file mode 100644 index 0000000..ea6f7c4 --- /dev/null +++ b/src/mcpd/tests/docker-ephemeral.test.ts @@ -0,0 +1,170 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { DockerContainerManager } from '../src/services/docker/container-manager.js'; +import type { ContainerSpec } from '../src/services/orchestrator.js'; + +/** + * Docker has no init containers, so the manager emulates them as sequential + * runs against the same volumes. These tests pin the translation and — more + * importantly — that a failed init aborts before the main container is ever + * created, so a failed clone can never be mistaken for an empty workspace. + */ +const state = vi.hoisted(() => ({ + /** Every createContainer option object, in call order. */ + created: [] as Array>, + /** Exit codes handed out by container.wait(), in order. */ + exitCodes: [] as number[], + started: [] as string[], +})); + +vi.mock('dockerode', () => { + const makeContainer = (name: string) => ({ + id: `ctr-${name}`, + start: vi.fn(async () => { state.started.push(name); }), + wait: vi.fn(async () => ({ StatusCode: state.exitCodes.shift() ?? 0 })), + stop: vi.fn(async () => {}), + remove: vi.fn(async () => {}), + inspect: vi.fn(async () => ({ + Id: `ctr-${name}`, + Name: `/${name}`, + State: { Status: 'running' }, + Created: '2024-01-01T00:00:00Z', + NetworkSettings: { Ports: {} }, + })), + logs: vi.fn(async () => Buffer.from('clone failed: repository not found')), + }); + + class MockDocker { + modem = { + followProgress: vi.fn((_s: unknown, cb: (e: Error | null) => void) => { cb(null); }), + }; + ping = vi.fn(async () => 'OK'); + pull = vi.fn(async () => ({})); + createContainer = vi.fn(async (opts: Record) => { + state.created.push(opts); + return makeContainer(String(opts['name'] ?? 'anon')); + }); + getContainer = vi.fn((id: string) => makeContainer(id)); + } + + return { default: MockDocker }; +}); + +const WORKSPACE = { name: 'workspace', mountPath: '/workspace', kind: 'emptyDir' } as const; + +describe('DockerContainerManager — ephemeral session containers', () => { + let manager: DockerContainerManager; + + beforeEach(() => { + vi.clearAllMocks(); + state.created = []; + state.exitCodes = []; + state.started = []; + manager = new DockerContainerManager(); + }); + + const hostConfig = (i: number): Record => + state.created[i]?.['HostConfig'] as Record; + + it('leaves restart policy unset when the caller does not ask — MCP servers keep today\'s behaviour', async () => { + await manager.createContainer({ image: 'x:latest', name: 'srv' }); + expect(hostConfig(0)['RestartPolicy']).toBeUndefined(); + }); + + it('maps Never to Docker\'s "no" so a finished session stays finished', async () => { + await manager.createContainer({ image: 'x:latest', name: 'sess', restartPolicy: 'Never' }); + expect(hostConfig(0)['RestartPolicy']).toEqual({ Name: 'no' }); + }); + + it('maps OnFailure and Always', async () => { + await manager.createContainer({ image: 'x:latest', name: 'a', restartPolicy: 'OnFailure' }); + expect(hostConfig(0)['RestartPolicy']).toEqual({ Name: 'on-failure' }); + await manager.createContainer({ image: 'x:latest', name: 'b', restartPolicy: 'Always' }); + expect(hostConfig(1)['RestartPolicy']).toEqual({ Name: 'always' }); + }); + + it('sets the working directory', async () => { + await manager.createContainer({ image: 'x:latest', name: 'sess', workdir: '/workspace/api' }); + expect(state.created[0]?.['WorkingDir']).toBe('/workspace/api'); + }); + + it('translates volumes into namespaced Docker mounts', async () => { + await manager.createContainer({ image: 'x:latest', name: 'sess', volumes: [WORKSPACE] }); + expect(hostConfig(0)['Mounts']).toEqual([ + { Type: 'volume', Source: 'sess-workspace', Target: '/workspace' }, + ]); + }); + + it('runs init containers to completion, in order, before the main container', async () => { + const spec: ContainerSpec = { + image: 'agent:latest', + name: 'sess', + volumes: [WORKSPACE], + initContainers: [ + { + name: 'clone-api', + image: 'alpine/git', + command: ['git', 'clone', 'https://git.example/api.git'], + env: { GIT_TERMINAL_PROMPT: '0' }, + volumeMounts: [{ name: 'workspace', mountPath: '/workspace' }], + }, + { name: 'clone-web', image: 'alpine/git', command: ['git', 'clone', 'https://git.example/web.git'] }, + ], + }; + + await manager.createContainer(spec); + + expect(state.created.map((c) => c['name'])).toEqual([ + 'sess-init-clone-api', + 'sess-init-clone-web', + 'sess', + ]); + expect(state.created[0]?.['Cmd']).toEqual(['git', 'clone', 'https://git.example/api.git']); + expect(state.created[0]?.['Env']).toEqual(['GIT_TERMINAL_PROMPT=0']); + // Explicit volumeMounts are honoured, and namespaced the same way. + expect((state.created[0]?.['HostConfig'] as Record)['Mounts']).toEqual([ + { Type: 'volume', Source: 'sess-workspace', Target: '/workspace' }, + ]); + // No volumeMounts declared → inherits the main container's view. + expect((state.created[1]?.['HostConfig'] as Record)['Mounts']).toEqual([ + { Type: 'volume', Source: 'sess-workspace', Target: '/workspace' }, + ]); + }); + + it('aborts before creating the main container when an init container fails', async () => { + state.exitCodes = [128]; // git clone: repository not found + + await expect( + manager.createContainer({ + image: 'agent:latest', + name: 'sess', + volumes: [WORKSPACE], + initContainers: [ + { name: 'clone', image: 'alpine/git', command: ['git', 'clone', 'bad'] }, + { name: 'never-runs', image: 'alpine/git', command: ['true'] }, + ], + }), + ).rejects.toThrow(/init container 'clone' exited with code 128/); + + // The failure must stop the chain — no second init, and crucially no main + // container, so an empty workspace can't be mistaken for a successful clone. + expect(state.created.map((c) => c['name'])).toEqual(['sess-init-clone']); + }); + + it('includes init container logs in the failure so the cause is visible', async () => { + state.exitCodes = [1]; + await expect( + manager.createContainer({ + image: 'agent:latest', + name: 'sess', + initContainers: [{ name: 'clone', image: 'alpine/git', command: ['git', 'clone', 'bad'] }], + }), + ).rejects.toThrow(/repository not found/); + }); + + it('creates no init containers when none are declared', async () => { + await manager.createContainer({ image: 'x:latest', name: 'plain' }); + expect(state.created).toHaveLength(1); + expect(state.created[0]?.['name']).toBe('plain'); + }); +}); diff --git a/src/mcpd/tests/k8s-manifest.test.ts b/src/mcpd/tests/k8s-manifest.test.ts index 22d5429..3e84ddc 100644 --- a/src/mcpd/tests/k8s-manifest.test.ts +++ b/src/mcpd/tests/k8s-manifest.test.ts @@ -164,3 +164,108 @@ describe('generateNamespaceSpec', () => { expect(ns.metadata.name).toBe('mcpctl-prod'); }); }); + +describe('generatePodSpec — ephemeral session pods', () => { + it('defaults to restartPolicy Always so existing MCP server pods are unchanged', () => { + expect(generatePodSpec(baseSpec, 'ns').spec.restartPolicy).toBe('Always'); + }); + + it('honours an explicit restartPolicy', () => { + for (const policy of ['Never', 'OnFailure', 'Always'] as const) { + const pod = generatePodSpec({ ...baseSpec, restartPolicy: policy }, 'ns'); + expect(pod.spec.restartPolicy).toBe(policy); + } + }); + + it('omits volumes and initContainers entirely when not requested', () => { + const pod = generatePodSpec(baseSpec, 'ns'); + expect(pod.spec.volumes).toBeUndefined(); + expect(pod.spec.initContainers).toBeUndefined(); + expect(pod.spec.containers[0]?.volumeMounts).toBeUndefined(); + expect(pod.spec.containers[0]?.workingDir).toBeUndefined(); + }); + + it('declares emptyDir volumes and mounts them into the main container', () => { + const pod = generatePodSpec( + { ...baseSpec, volumes: [{ name: 'workspace', mountPath: '/workspace', kind: 'emptyDir' }] }, + 'ns', + ); + expect(pod.spec.volumes).toEqual([{ name: 'workspace', emptyDir: {} }]); + expect(pod.spec.containers[0]?.volumeMounts).toEqual([ + { name: 'workspace', mountPath: '/workspace' }, + ]); + }); + + it('sets the container working directory', () => { + const pod = generatePodSpec({ ...baseSpec, workdir: '/workspace/repo' }, 'ns'); + expect(pod.spec.containers[0]?.workingDir).toBe('/workspace/repo'); + }); + + it('emits init containers in declaration order — k8s runs them to completion first', () => { + const pod = generatePodSpec( + { + ...baseSpec, + volumes: [{ name: 'workspace', mountPath: '/workspace', kind: 'emptyDir' }], + initContainers: [ + { + name: 'clone-api', + image: 'alpine/git:latest', + command: ['git', 'clone', 'https://git.example/api.git', '/workspace/api'], + env: { GIT_TERMINAL_PROMPT: '0' }, + volumeMounts: [{ name: 'workspace', mountPath: '/workspace' }], + }, + { + name: 'clone-web', + image: 'alpine/git:latest', + command: ['git', 'clone', 'https://git.example/web.git', '/workspace/web'], + }, + ], + }, + 'ns', + ); + + expect(pod.spec.initContainers).toHaveLength(2); + expect(pod.spec.initContainers?.map((c) => c.name)).toEqual(['clone-api', 'clone-web']); + expect(pod.spec.initContainers?.[0]?.env).toEqual([{ name: 'GIT_TERMINAL_PROMPT', value: '0' }]); + expect(pod.spec.initContainers?.[0]?.volumeMounts).toEqual([ + { name: 'workspace', mountPath: '/workspace' }, + ]); + // No env / no mounts declared → the keys are absent rather than empty. + expect(pod.spec.initContainers?.[1]?.env).toBeUndefined(); + expect(pod.spec.initContainers?.[1]?.volumeMounts).toBeUndefined(); + }); + + it('sanitizes init container names like every other k8s name', () => { + const pod = generatePodSpec( + { + ...baseSpec, + initContainers: [{ name: 'Clone_My Repo', image: 'alpine/git', command: ['true'] }], + }, + 'ns', + ); + expect(pod.spec.initContainers?.[0]?.name).toBe(sanitizeName('Clone_My Repo')); + }); + + it('keeps a session pod hardened — security context is not relaxed', () => { + const pod = generatePodSpec({ ...baseSpec, restartPolicy: 'Never' }, 'ns'); + const sc = pod.spec.containers[0]?.securityContext; + expect(sc?.allowPrivilegeEscalation).toBe(false); + expect(sc?.capabilities.drop).toEqual(['ALL']); + expect(pod.spec.automountServiceAccountToken).toBe(false); + }); +}); + +describe('generateDeploymentSpec — restartPolicy', () => { + it('always emits Always, since k8s rejects anything else on a Deployment template', () => { + const dep = generateDeploymentSpec({ ...baseSpec, restartPolicy: 'Never' }, 'ns'); + expect(dep.spec.template.spec.restartPolicy).toBe('Always'); + }); + + it('still carries volumes through to the pod template', () => { + const dep = generateDeploymentSpec( + { ...baseSpec, volumes: [{ name: 'cache', mountPath: '/cache', kind: 'emptyDir' }] }, + 'ns', + ); + expect(dep.spec.template.spec.volumes).toEqual([{ name: 'cache', emptyDir: {} }]); + }); +}); -- 2.49.1 From 909506317ebae4025f0d0ae4cf264ac10aacecd6 Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 8 Aug 2026 11:05:28 +0100 Subject: [PATCH 07/32] =?UTF-8?q?feat(mcpd):=20Epic=20resource=20=E2=80=94?= =?UTF-8?q?=20the=20vision=20layer,=20with=20an=20honest-done=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full CRUD for Epic: repository, zod validation, service, routes, main.ts wiring, and revision tracking (ResourceType gains 'epic', which is a plain string union by design so this needed no migration). The substance is the done rule, not the CRUD. An Epic exists to stop "every task completed, still no car", so status is deliberately NOT settable through PUT — it goes through POST /epics/:id/status, which enforces: - `delivered` requires every done-criterion met. The 400 names the unmet ids (truncated past 5) so the caller knows what is actually missing. - `delivered` with recorded tradeoffs is refused — if you accepted a gap, the status has to say so. - `delivered_with_tradeoffs` requires at least one recorded tradeoff, otherwise it is `delivered` wearing a disguise. - `delivered` with zero criteria is refused: nothing was claimed, so nothing can have been delivered. - `cancelled` and `open` are always allowed — abandoning needs no ceremony, and reopening is how a gap gets fixed. Completeness counts `quality` criteria alongside `outcome` ones. Grading only the mechanically checkable half is exactly the failure the Epic exists to prevent, so both feed the percentage, and it is computed server-side and attached to every response rather than left for clients to re-derive. Tradeoffs are append-only and stamped with who and when — the record is the audit trail for why "done" was honest. Scope changes patch-bump semver, so creep is versioned as visibly as under-delivery. Tests: 26 service tests (the full done-rule matrix, tradeoff append/stamping, criterion flipping, semver bumping, and that a plain update cannot smuggle a status through) and 11 route tests (completeness attachment, owner stamping, 400 vs 404 vs 500 behaviour, the status gate over HTTP). Local: build clean, workspace 2347 passed (+37), lint unchanged at 869. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p --- .taskmaster/tasks/tasks.json | 20 +- src/mcpd/src/main.ts | 15 + src/mcpd/src/repositories/epic.repository.ts | 91 ++++++ .../resource-revision.repository.ts | 2 +- src/mcpd/src/routes/epics.ts | 107 +++++++ src/mcpd/src/services/epic.service.ts | 279 +++++++++++++++++ src/mcpd/src/validation/epic.schema.ts | 67 +++++ src/mcpd/tests/epic-routes.test.ts | 222 ++++++++++++++ src/mcpd/tests/services/epic-service.test.ts | 283 ++++++++++++++++++ 9 files changed, 1075 insertions(+), 11 deletions(-) create mode 100644 src/mcpd/src/repositories/epic.repository.ts create mode 100644 src/mcpd/src/routes/epics.ts create mode 100644 src/mcpd/src/services/epic.service.ts create mode 100644 src/mcpd/src/validation/epic.schema.ts create mode 100644 src/mcpd/tests/epic-routes.test.ts create mode 100644 src/mcpd/tests/services/epic-service.test.ts diff --git a/.taskmaster/tasks/tasks.json b/.taskmaster/tasks/tasks.json index 0ec808b..5307eda 100644 --- a/.taskmaster/tasks/tasks.json +++ b/.taskmaster/tasks/tasks.json @@ -2669,9 +2669,9 @@ "dependencies": [ "117" ], - "status": "pending", + "status": "done", "subtasks": [], - "updatedAt": "2026-08-08T08:55:58.627Z" + "updatedAt": "2026-08-08T10:05:28.155Z" }, { "id": "124", @@ -2683,9 +2683,9 @@ "dependencies": [ "123" ], - "status": "pending", + "status": "done", "subtasks": [], - "updatedAt": "2026-08-08T08:55:58.627Z" + "updatedAt": "2026-08-08T10:05:28.165Z" }, { "id": "125", @@ -2697,9 +2697,9 @@ "dependencies": [ "117" ], - "status": "pending", + "status": "done", "subtasks": [], - "updatedAt": "2026-08-08T08:55:58.627Z" + "updatedAt": "2026-08-08T10:05:28.172Z" }, { "id": "126", @@ -2712,9 +2712,9 @@ "124", "125" ], - "status": "pending", + "status": "done", "subtasks": [], - "updatedAt": "2026-08-08T08:55:58.627Z" + "updatedAt": "2026-08-08T10:05:28.178Z" }, { "id": "127", @@ -3251,9 +3251,9 @@ ], "metadata": { "version": "1.0.0", - "lastModified": "2026-08-08T09:36:21.139Z", + "lastModified": "2026-08-08T10:05:28.178Z", "taskCount": 162, - "completedCount": 107, + "completedCount": 111, "tags": [ "master" ] diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index fa6fc33..91e4955 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -106,6 +106,7 @@ import { import { registerPromptRoutes } from './routes/prompts.js'; import { registerRevisionRoutes } from './routes/revisions.js'; import { registerProposalRoutes } from './routes/proposals.js'; +import { registerEpicRoutes } from './routes/epics.js'; import { registerSkillRoutes } from './routes/skills.js'; import { registerGitBackupRoutes } from './routes/git-backup.js'; import { PromptService } from './services/prompt.service.js'; @@ -113,7 +114,9 @@ import { ResourceRevisionRepository } from './repositories/resource-revision.rep import { ResourceProposalRepository } from './repositories/resource-proposal.repository.js'; import { ResourceRevisionService } from './services/resource-revision.service.js'; import { ResourceProposalService } from './services/resource-proposal.service.js'; +import { EpicRepository } from './repositories/epic.repository.js'; import { SkillRepository } from './repositories/skill.repository.js'; +import { EpicService } from './services/epic.service.js'; import { SkillService } from './services/skill.service.js'; import { GitBackupService } from './services/backup/git-backup.service.js'; import type { BackupKind } from './services/backup/yaml-serializer.js'; @@ -221,6 +224,8 @@ function mapUrlToPermission(method: string, url: string): PermissionCheck { // Personalities inherit the agent's RBAC: managing a personality // requires view/edit/create/delete on the `agents` resource. 'personalities': 'agents', + // ── Agentic teams ── + 'epics': 'epics', }; const resource = resourceMap[segment]; @@ -427,6 +432,9 @@ async function main(): Promise { const userRepo = new UserRepository(prisma); const groupRepo = new GroupRepository(prisma); const mcpTokenRepo = new McpTokenRepository(prisma); + // Declared here (not with the other agentic-teams wiring below) because + // nameResolvers needs it for CUID→name RBAC resolution. + const epicRepo = new EpicRepository(prisma); // SecretBackend bootstrap: ensure a `plaintext` default row exists and any // pre-existing `Secret` rows are pointed at it. Idempotent per run. @@ -443,6 +451,7 @@ async function main(): Promise { mcptokens: mcpTokenRepo, llms: llmRepo, agents: agentRepo, + epics: epicRepo, // Resolve user CUID → email so name-scoped `edit:users:` bindings // match on PUT /api/v1/users/:id/password (admin reset). users: { findById: async (id: string) => { @@ -545,6 +554,11 @@ async function main(): Promise { const skillService = new SkillService(skillRepo, projectRepo, agentRepo); skillService.setRevisionService(resourceRevisionService); skillService.setProposalService(resourceProposalService); + + // Agentic teams: the Epic is the versioned vision every task walks up to. + // Revision-tracked so scope creep is as visible as under-delivery. + const epicService = new EpicService(epicRepo, projectRepo); + epicService.setRevisionService(resourceRevisionService); const personalityRepo = new PersonalityRepository(prisma); const personalityService = new PersonalityService(personalityRepo, agentRepo, promptRepo); const agentService = new AgentService(agentRepo, llmService, projectService, personalityRepo); @@ -753,6 +767,7 @@ async function main(): Promise { registerRevisionRoutes(app, { revisionService: resourceRevisionService, promptService }); registerProposalRoutes(app, { proposalService: resourceProposalService, projectRepo, agentRepo }); registerSkillRoutes(app, skillService, projectRepo, agentRepo); + registerEpicRoutes(app, epicService, projectRepo); // ── Git-based backup ── const gitBackup = new GitBackupService(prisma); diff --git a/src/mcpd/src/repositories/epic.repository.ts b/src/mcpd/src/repositories/epic.repository.ts new file mode 100644 index 0000000..b756550 --- /dev/null +++ b/src/mcpd/src/repositories/epic.repository.ts @@ -0,0 +1,91 @@ +import type { PrismaClient, Prisma, Epic, EpicStatus } from '@prisma/client'; + +/** + * Epic repository. Unlike Prompt/Skill, an Epic is always project-scoped — + * there is no global epic — so the nullable-FK compound-key workaround does not + * apply here and `name_projectId` can be used directly. + */ + +export interface EpicCreateInput { + name: string; + content: string; + description?: string; + doneCriteria?: Prisma.InputJsonValue; + acceptedTradeoffs?: Prisma.InputJsonValue; + projectId: string; + ownerId: string; + semver?: string; +} + +export interface EpicUpdateInput { + content?: string; + description?: string; + doneCriteria?: Prisma.InputJsonValue; + acceptedTradeoffs?: Prisma.InputJsonValue; + status?: EpicStatus; + semver?: string; + currentRevisionId?: string | null; +} + +export interface IEpicRepository { + findAll(projectId?: string): Promise; + findById(id: string): Promise; + findByName(name: string): Promise; + findByNameAndProject(name: string, projectId: string): Promise; + create(data: EpicCreateInput): Promise; + update(id: string, data: EpicUpdateInput): Promise; + delete(id: string): Promise; +} + +const EPIC_INCLUDE = { project: { select: { name: true } } } as const; + +export class EpicRepository implements IEpicRepository { + constructor(private readonly prisma: PrismaClient) {} + + async findAll(projectId?: string): Promise { + return this.prisma.epic.findMany({ + ...(projectId !== undefined ? { where: { projectId } } : {}), + include: EPIC_INCLUDE, + orderBy: { name: 'asc' }, + }); + } + + async findById(id: string): Promise { + return this.prisma.epic.findUnique({ where: { id }, include: EPIC_INCLUDE }); + } + + /** + * Resolve by bare name. Epic names are unique per project, not globally, so + * this returns the first match by name — used by the RBAC CUID→name resolver, + * which only needs *a* name for the binding comparison. Callers that care + * about a specific project must use findByNameAndProject. + */ + async findByName(name: string): Promise { + return this.prisma.epic.findFirst({ where: { name }, include: EPIC_INCLUDE }); + } + + async findByNameAndProject(name: string, projectId: string): Promise { + return this.prisma.epic.findUnique({ + where: { name_projectId: { name, projectId } }, + include: EPIC_INCLUDE, + }); + } + + async create(data: EpicCreateInput): Promise { + return this.prisma.epic.create({ data, include: EPIC_INCLUDE }); + } + + async update(id: string, data: EpicUpdateInput): Promise { + // Optimistic-concurrency counter is bumped in the repository, unconditionally + // and on every write — same convention as AgentRepository. + return this.prisma.epic.update({ + where: { id }, + data: { ...data, version: { increment: 1 } }, + include: EPIC_INCLUDE, + }); + } + + async delete(id: string): Promise { + await this.prisma.epic.delete({ where: { id } }); + } +} diff --git a/src/mcpd/src/repositories/resource-revision.repository.ts b/src/mcpd/src/repositories/resource-revision.repository.ts index 2731423..9e0b994 100644 --- a/src/mcpd/src/repositories/resource-revision.repository.ts +++ b/src/mcpd/src/repositories/resource-revision.repository.ts @@ -12,7 +12,7 @@ import type { PrismaClient, Prisma, ResourceRevision } from '@prisma/client'; * service layer to keep them in sync with real IDs. */ -export type ResourceType = 'prompt' | 'skill'; +export type ResourceType = 'prompt' | 'skill' | 'epic'; export interface CreateRevisionInput { resourceType: ResourceType; diff --git a/src/mcpd/src/routes/epics.ts b/src/mcpd/src/routes/epics.ts new file mode 100644 index 0000000..0ecf7b4 --- /dev/null +++ b/src/mcpd/src/routes/epics.ts @@ -0,0 +1,107 @@ +import type { Epic } from '@prisma/client'; +import type { FastifyInstance } from 'fastify'; + +import type { IProjectRepository } from '../repositories/project.repository.js'; +import type { EpicService } from '../services/epic.service.js'; +import { EpicService as EpicSvc } from '../services/epic.service.js'; + +/** Attach computed completeness so clients never re-derive the rule. */ +function withCompleteness(epic: Epic): Epic & { completeness: ReturnType } { + return { ...epic, completeness: EpicSvc.completeness(epic) }; +} + +export function registerEpicRoutes( + app: FastifyInstance, + service: EpicService, + projectRepo: IProjectRepository, +): void { + // ── List ── + + app.get<{ Querystring: { project?: string; projectId?: string } }>( + '/api/v1/epics', + async (request) => { + const { project, projectId } = request.query; + let epics: Epic[]; + if (project !== undefined) { + const proj = await projectRepo.findByName(project); + if (proj === null) { + throw Object.assign(new Error(`Project not found: ${project}`), { statusCode: 404 }); + } + epics = await service.listEpics(proj.id); + } else if (projectId !== undefined) { + epics = await service.listEpics(projectId); + } else { + epics = await service.listEpics(); + } + return epics.map(withCompleteness); + }, + ); + + app.get<{ Params: { id: string } }>('/api/v1/epics/:id', async (request) => { + return withCompleteness(await service.getEpic(request.params.id)); + }); + + // ── Create / Update / Delete ── + + app.post('/api/v1/epics', async (request, reply) => { + const body = request.body as Record; + const resolved: Record = { ...body }; + + // Accept `project` by name, same as skills/prompts do. + if (typeof body['project'] === 'string') { + const proj = await projectRepo.findByName(body['project']); + if (proj === null) { + throw Object.assign(new Error(`Project not found: ${body['project']}`), { statusCode: 404 }); + } + resolved['projectId'] = proj.id; + delete resolved['project']; + } + + const ownerId = request.userId ?? 'system'; + const epic = await service.createEpic(resolved, ownerId); + reply.code(201); + return withCompleteness(epic); + }); + + app.put<{ Params: { id: string } }>('/api/v1/epics/:id', async (request) => { + return withCompleteness(await service.updateEpic(request.params.id, request.body)); + }); + + app.delete<{ Params: { id: string } }>('/api/v1/epics/:id', async (request, reply) => { + await service.deleteEpic(request.params.id); + reply.code(204); + }); + + // ── The honest-done gate ── + // Separate from PUT so the rule cannot be bypassed by a plain field write. + + app.post<{ Params: { id: string } }>('/api/v1/epics/:id/status', async (request) => { + return withCompleteness(await service.setStatus(request.params.id, request.body)); + }); + + app.post<{ Params: { id: string } }>('/api/v1/epics/:id/tradeoffs', async (request, reply) => { + const epic = await service.addTradeoff(request.params.id, request.body, request.userId); + reply.code(201); + return withCompleteness(epic); + }); + + app.post<{ Params: { id: string; criterionId: string }; Body: { met?: boolean } }>( + '/api/v1/epics/:id/criteria/:criterionId', + async (request) => { + const met = request.body?.met ?? true; + return withCompleteness( + await service.setCriterionMet(request.params.id, request.params.criterionId, met), + ); + }, + ); + + // ── Project-scoped view ── + + app.get<{ Params: { name: string } }>('/api/v1/projects/:name/epics', async (request) => { + const proj = await projectRepo.findByName(request.params.name); + if (proj === null) { + throw Object.assign(new Error(`Project not found: ${request.params.name}`), { statusCode: 404 }); + } + return (await service.listEpics(proj.id)).map(withCompleteness); + }); +} diff --git a/src/mcpd/src/services/epic.service.ts b/src/mcpd/src/services/epic.service.ts new file mode 100644 index 0000000..d948bb2 --- /dev/null +++ b/src/mcpd/src/services/epic.service.ts @@ -0,0 +1,279 @@ +import type { Epic, EpicStatus, Prisma } from '@prisma/client'; + +import type { IEpicRepository } from '../repositories/epic.repository.js'; +import type { IProjectRepository } from '../repositories/project.repository.js'; +import { + CreateEpicSchema, + UpdateEpicSchema, + SetEpicStatusSchema, + AddTradeoffSchema, + type AcceptedTradeoff, + type DoneCriterion, +} from '../validation/epic.schema.js'; +import { bumpSemver, type BumpKind } from '../utils/semver.js'; + +import { NotFoundError } from './mcp-server.service.js'; +import type { ResourceRevisionService } from './resource-revision.service.js'; + +export interface EpicCompleteness { + total: number; + met: number; + /** 0-100, rounded. 100 with zero criteria means "nothing claimed yet". */ + percent: number; + unmet: DoneCriterion[]; +} + +function badRequest(message: string): Error { + return Object.assign(new Error(message), { statusCode: 400 }); +} + +function readCriteria(epic: Epic): DoneCriterion[] { + return (epic.doneCriteria ?? []) as unknown as DoneCriterion[]; +} + +function readTradeoffs(epic: Epic): AcceptedTradeoff[] { + return (epic.acceptedTradeoffs ?? []) as unknown as AcceptedTradeoff[]; +} + +export class EpicService { + private revisionService: ResourceRevisionService | null = null; + + constructor( + private readonly epicRepo: IEpicRepository, + private readonly projectRepo: IProjectRepository, + ) {} + + setRevisionService(service: ResourceRevisionService): void { + this.revisionService = service; + } + + // ── Completeness ── + + /** + * How much of the vision is actually delivered. Both `outcome` and `quality` + * criteria count — grading only the mechanical half is precisely how you end + * up with every task done and no car. + */ + static completeness(epic: Epic): EpicCompleteness { + const criteria = readCriteria(epic); + const met = criteria.filter((c) => c.met).length; + const total = criteria.length; + return { + total, + met, + percent: total === 0 ? 0 : Math.round((met / total) * 100), + unmet: criteria.filter((c) => !c.met), + }; + } + + // ── CRUD ── + + async listEpics(projectId?: string): Promise { + return this.epicRepo.findAll(projectId); + } + + async getEpic(id: string): Promise { + const epic = await this.epicRepo.findById(id); + if (epic === null) throw new NotFoundError(`Epic not found: ${id}`); + return epic; + } + + async createEpic(input: unknown, ownerId: string): Promise { + const data = CreateEpicSchema.parse(input); + + const project = await this.projectRepo.findById(data.projectId); + if (project === null) throw new NotFoundError(`Project not found: ${data.projectId}`); + + const duplicate = await this.epicRepo.findByNameAndProject(data.name, data.projectId); + if (duplicate !== null) { + throw Object.assign( + new Error(`Epic '${data.name}' already exists in project '${project.name}'`), + { statusCode: 409 }, + ); + } + + this.assertCriteriaIdsUnique(data.doneCriteria); + + const epic = await this.epicRepo.create({ + name: data.name, + content: data.content, + projectId: data.projectId, + ownerId, + ...(data.description !== undefined ? { description: data.description } : {}), + ...(data.doneCriteria !== undefined + ? { doneCriteria: data.doneCriteria as unknown as Prisma.InputJsonValue } + : {}), + ...(data.acceptedTradeoffs !== undefined + ? { acceptedTradeoffs: data.acceptedTradeoffs as unknown as Prisma.InputJsonValue } + : {}), + ...(data.semver !== undefined ? { semver: data.semver } : {}), + }); + + if (this.revisionService) { + this.recordRevision(epic, epic.semver, 'created').catch(() => {}); + } + return epic; + } + + async updateEpic(id: string, input: unknown): Promise { + const data = UpdateEpicSchema.parse(input); + if (data.semver !== undefined && data.bump !== undefined) { + throw badRequest('Pass semver or bump, not both'); + } + const existing = await this.getEpic(id); + this.assertCriteriaIdsUnique(data.doneCriteria); + + const contentChanged = + data.content !== undefined || data.description !== undefined || data.doneCriteria !== undefined; + + let newSemver = existing.semver; + if (data.semver !== undefined) { + newSemver = data.semver; + } else if (data.bump !== undefined) { + newSemver = bumpSemver(existing.semver, data.bump as BumpKind); + } else if (contentChanged) { + // Scope changes are versioned so creep is as visible as under-delivery. + newSemver = bumpSemver(existing.semver, 'patch'); + } + + const epic = await this.epicRepo.update(id, { + ...(data.content !== undefined ? { content: data.content } : {}), + ...(data.description !== undefined ? { description: data.description } : {}), + ...(data.doneCriteria !== undefined + ? { doneCriteria: data.doneCriteria as unknown as Prisma.InputJsonValue } + : {}), + ...(newSemver !== existing.semver ? { semver: newSemver } : {}), + }); + + if (this.revisionService && (contentChanged || data.bump !== undefined || data.semver !== undefined)) { + this.recordRevision(epic, newSemver, data.note ?? null).catch(() => {}); + } + return epic; + } + + async deleteEpic(id: string): Promise { + await this.getEpic(id); // 404 if missing + await this.epicRepo.delete(id); + } + + // ── The honest-done rule ── + + /** + * Move an Epic to a terminal status. + * + * An Epic is done ONLY when 100% of its criteria are met, OR the user + * explicitly accepted the gap. Anything else is a stick with wheels being + * called a car, so: + * + * - `delivered` requires every criterion met, and no recorded tradeoffs + * (if you accepted tradeoffs, the honest status names them). + * - `delivered_with_tradeoffs` requires at least one recorded tradeoff — + * otherwise it is just `delivered` wearing a disguise. + * - `cancelled` and `open` are always allowed; abandoning work needs no + * ceremony, and reopening is how a gap gets fixed. + */ + async setStatus(id: string, input: unknown): Promise { + const { status, note } = SetEpicStatusSchema.parse(input); + const epic = await this.getEpic(id); + + if (status === 'delivered') { + const { unmet, total, met } = EpicService.completeness(epic); + if (total === 0) { + throw badRequest( + 'Cannot mark an Epic delivered with no done-criteria — there is nothing to have delivered', + ); + } + if (unmet.length > 0) { + const names = unmet.slice(0, 5).map((c) => c.id).join(', '); + const more = unmet.length > 5 ? ` (+${String(unmet.length - 5)} more)` : ''; + throw badRequest( + `Cannot mark delivered: ${String(met)}/${String(total)} criteria met. ` + + `Unmet: ${names}${more}. Meet them, or record the accepted tradeoffs and use delivered_with_tradeoffs.`, + ); + } + if (readTradeoffs(epic).length > 0) { + throw badRequest( + 'This Epic has accepted tradeoffs recorded — use delivered_with_tradeoffs so "done" stays honest', + ); + } + } + + if (status === 'delivered_with_tradeoffs' && readTradeoffs(epic).length === 0) { + throw badRequest( + 'Cannot mark delivered_with_tradeoffs without recording at least one accepted tradeoff', + ); + } + + const updated = await this.epicRepo.update(id, { status: status as EpicStatus }); + + if (this.revisionService) { + this.recordRevision(updated, updated.semver, note ?? `status → ${status}`).catch(() => {}); + } + return updated; + } + + /** Append an accepted tradeoff. Append-only — the record is the audit trail. */ + async addTradeoff(id: string, input: unknown, who?: string): Promise { + const parsed = AddTradeoffSchema.parse(input); + const epic = await this.getEpic(id); + + const entry: AcceptedTradeoff = { + ...parsed, + ...(parsed.who === undefined && who !== undefined ? { who } : {}), + at: parsed.at ?? new Date().toISOString(), + }; + const next = [...readTradeoffs(epic), entry]; + + const updated = await this.epicRepo.update(id, { + acceptedTradeoffs: next as unknown as Prisma.InputJsonValue, + }); + + if (this.revisionService) { + this.recordRevision(updated, updated.semver, `accepted tradeoff: ${entry.what}`).catch(() => {}); + } + return updated; + } + + /** Flip one criterion's met flag without rewriting the whole array. */ + async setCriterionMet(id: string, criterionId: string, met: boolean): Promise { + const epic = await this.getEpic(id); + const criteria = readCriteria(epic); + const idx = criteria.findIndex((c) => c.id === criterionId); + if (idx === -1) throw new NotFoundError(`Done-criterion not found on epic: ${criterionId}`); + + const next = criteria.map((c, i) => (i === idx ? { ...c, met } : c)); + return this.epicRepo.update(id, { doneCriteria: next as unknown as Prisma.InputJsonValue }); + } + + // ── Internals ── + + private assertCriteriaIdsUnique(criteria?: DoneCriterion[]): void { + if (criteria === undefined) return; + const seen = new Set(); + for (const c of criteria) { + if (seen.has(c.id)) { + throw badRequest(`Duplicate done-criterion id: ${c.id}`); + } + seen.add(c.id); + } + } + + /** Best-effort revision write — same shape as PromptService / SkillService. */ + private async recordRevision(epic: Epic, semver: string, note: string | null): Promise { + if (this.revisionService === null) return; + const { revision } = await this.revisionService.record({ + resourceType: 'epic', + resourceId: epic.id, + semver, + body: { + content: epic.content, + description: epic.description, + doneCriteria: epic.doneCriteria, + acceptedTradeoffs: epic.acceptedTradeoffs, + status: epic.status, + }, + ...(note !== null ? { note } : {}), + }); + await this.epicRepo.update(epic.id, { currentRevisionId: revision.id }); + } +} diff --git a/src/mcpd/src/validation/epic.schema.ts b/src/mcpd/src/validation/epic.schema.ts new file mode 100644 index 0000000..7f6030b --- /dev/null +++ b/src/mcpd/src/validation/epic.schema.ts @@ -0,0 +1,67 @@ +import { z } from 'zod'; + +const SEMVER_RE = /^\d+\.\d+\.\d+$/; +const NAME_RE = /^[a-z0-9-]+$/; + +/** + * A single checkable outcome on the Epic. `kind` separates the two halves of + * the two-tier Definition of Done: + * + * - `outcome` — mechanically checkable ("the CLI can create a task"). + * - `quality` — the intent/feel attributes that decide whether the delivered + * whole is a car or a stick with wheels ("the roadmap reads at a glance"). + * + * Both count toward completeness. Dropping `quality` from the count is exactly + * the failure the Epic exists to prevent. + */ +export const DoneCriterionSchema = z.object({ + id: z.string().min(1).max(100), + text: z.string().min(1).max(2000), + kind: z.enum(['outcome', 'quality']).default('outcome'), + met: z.boolean().default(false), +}); + +/** + * A tradeoff the user explicitly accepted. Recorded rather than inferred so + * "done" stays honest and auditable: who de-scoped what, and why. + */ +export const AcceptedTradeoffSchema = z.object({ + what: z.string().min(1).max(2000), + why: z.string().min(1).max(2000), + who: z.string().min(1).max(200).optional(), + deScopedBy: z.string().min(1).max(200).optional(), + at: z.string().datetime().optional(), +}); + +export const CreateEpicSchema = z.object({ + name: z.string().min(1).max(100).regex(NAME_RE, 'Name must be lowercase alphanumeric with hyphens'), + content: z.string().min(1).max(200_000), + description: z.string().max(500).optional(), + doneCriteria: z.array(DoneCriterionSchema).max(200).optional(), + acceptedTradeoffs: z.array(AcceptedTradeoffSchema).max(200).optional(), + projectId: z.string().min(1), + semver: z.string().regex(SEMVER_RE, 'Semver must be MAJOR.MINOR.PATCH').optional(), +}); + +export const UpdateEpicSchema = z.object({ + content: z.string().min(1).max(200_000).optional(), + description: z.string().max(500).optional(), + doneCriteria: z.array(DoneCriterionSchema).max(200).optional(), + // Status is deliberately NOT settable here — reaching a terminal status goes + // through EpicService.setStatus, which enforces the honest-done rule. + semver: z.string().regex(SEMVER_RE, 'Semver must be MAJOR.MINOR.PATCH').optional(), + bump: z.enum(['major', 'minor', 'patch']).optional(), + note: z.string().max(500).optional(), +}); + +export const SetEpicStatusSchema = z.object({ + status: z.enum(['open', 'delivered', 'delivered_with_tradeoffs', 'cancelled']), + note: z.string().max(500).optional(), +}); + +export const AddTradeoffSchema = AcceptedTradeoffSchema; + +export type CreateEpicInput = z.infer; +export type UpdateEpicInput = z.infer; +export type DoneCriterion = z.infer; +export type AcceptedTradeoff = z.infer; diff --git a/src/mcpd/tests/epic-routes.test.ts b/src/mcpd/tests/epic-routes.test.ts new file mode 100644 index 0000000..1cc0c27 --- /dev/null +++ b/src/mcpd/tests/epic-routes.test.ts @@ -0,0 +1,222 @@ +import type { Epic, Project } from '@prisma/client'; +import Fastify, { type FastifyInstance } from 'fastify'; +import { describe, it, expect, vi, afterEach } from 'vitest'; + +import { errorHandler } from '../src/middleware/error-handler.js'; +import type { IEpicRepository } from '../src/repositories/epic.repository.js'; +import type { IProjectRepository } from '../src/repositories/project.repository.js'; +import { registerEpicRoutes } from '../src/routes/epics.js'; +import { EpicService } from '../src/services/epic.service.js'; + +let app: FastifyInstance; + +function makeEpic(overrides: Partial = {}): Epic { + return { + id: 'epic-1', + name: 'the-car', + description: '', + content: 'build a car', + doneCriteria: [], + acceptedTradeoffs: [], + projectId: 'proj-1', + status: 'open', + semver: '0.1.0', + currentRevisionId: null, + ownerId: 'user-1', + version: 1, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + } as Epic; +} + +const makeProject = (o: Partial = {}): Project => + ({ id: 'proj-1', name: 'mcpctl', ...o } as Project); + +const criterion = (id: string, met: boolean) => + ({ id, text: `criterion ${id}`, kind: 'outcome' as const, met }); + +async function buildApp(epic: Epic | null = makeEpic(), userId?: string): Promise<{ + app: FastifyInstance; + epicRepo: IEpicRepository & { update: ReturnType }; +}> { + let current = epic; + const update = vi.fn(async (_id: string, data: Record) => { + current = { ...(current as Epic), ...data } as Epic; + return current; + }); + const epicRepo = { + findAll: vi.fn(async () => (current ? [current] : [])), + findById: vi.fn(async () => current), + findByName: vi.fn(async () => current), + findByNameAndProject: vi.fn(async () => null), + create: vi.fn(async (data: Record) => makeEpic(data as Partial)), + update, + delete: vi.fn(async () => {}), + } as unknown as IEpicRepository & { update: ReturnType }; + + const projectRepo = { + findById: vi.fn(async () => makeProject()), + findByName: vi.fn(async (n: string) => (n === 'mcpctl' ? makeProject() : null)), + } as unknown as IProjectRepository; + + const instance = Fastify(); + instance.setErrorHandler(errorHandler); + // Stand in for the auth middleware, which normally sets request.userId. + if (userId !== undefined) { + instance.addHook('onRequest', async (req) => { req.userId = userId; }); + } + registerEpicRoutes(instance, new EpicService(epicRepo, projectRepo), projectRepo); + await instance.ready(); + return { app: instance, epicRepo }; +} + +afterEach(async () => { + if (app) await app.close(); +}); + +describe('epic routes', () => { + it('attaches computed completeness to list and get', async () => { + const built = await buildApp( + makeEpic({ doneCriteria: [criterion('a', true), criterion('b', false)] as never }), + ); + app = built.app; + + const list = await app.inject({ method: 'GET', url: '/api/v1/epics' }); + expect(list.statusCode).toBe(200); + expect(list.json()[0].completeness).toMatchObject({ total: 2, met: 1, percent: 50 }); + + const one = await app.inject({ method: 'GET', url: '/api/v1/epics/epic-1' }); + expect(one.json().completeness.unmet).toHaveLength(1); + }); + + it('creates from a project name and stamps the caller as owner', async () => { + const built = await buildApp(null, 'user-42'); + app = built.app; + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/epics', + payload: { name: 'the-car', content: 'build a car', project: 'mcpctl' }, + }); + + expect(res.statusCode).toBe(201); + expect(built.epicRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ projectId: 'proj-1', ownerId: 'user-42' }), + ); + }); + + it('404s on an unknown project name', async () => { + const built = await buildApp(null); + app = built.app; + const res = await app.inject({ + method: 'POST', + url: '/api/v1/epics', + payload: { name: 'x', content: 'c', project: 'ghost' }, + }); + expect(res.statusCode).toBe(404); + }); + + it('400s a bad payload rather than 500ing', async () => { + const built = await buildApp(null); + app = built.app; + const res = await app.inject({ + method: 'POST', + url: '/api/v1/epics', + payload: { name: 'Not Kebab', content: 'c', project: 'mcpctl' }, + }); + expect(res.statusCode).toBe(400); + }); + + it('400s an unhonest delivered, and explains what is missing', async () => { + const built = await buildApp( + makeEpic({ doneCriteria: [criterion('a', true), criterion('b', false)] as never }), + ); + app = built.app; + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/epics/epic-1/status', + payload: { status: 'delivered' }, + }); + + expect(res.statusCode).toBe(400); + expect(res.json().error ?? res.json().message).toMatch(/1\/2 criteria met/); + }); + + it('accepts delivered once everything is met', async () => { + const built = await buildApp(makeEpic({ doneCriteria: [criterion('a', true)] as never })); + app = built.app; + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/epics/epic-1/status', + payload: { status: 'delivered' }, + }); + expect(res.statusCode).toBe(200); + expect(res.json().status).toBe('delivered'); + expect(res.json().completeness.percent).toBe(100); + }); + + it('records a tradeoff and returns 201', async () => { + const built = await buildApp(makeEpic(), 'user-42'); + app = built.app; + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/epics/epic-1/tradeoffs', + payload: { what: 'no dark mode', why: 'out of budget' }, + }); + + expect(res.statusCode).toBe(201); + const written = built.epicRepo.update.mock.calls[0]?.[1] as { + acceptedTradeoffs: Array<{ what: string; who?: string }>; + }; + expect(written.acceptedTradeoffs[0]).toMatchObject({ what: 'no dark mode', who: 'user-42' }); + }); + + it('flips a single criterion, defaulting met to true', async () => { + const built = await buildApp( + makeEpic({ doneCriteria: [criterion('a', false), criterion('b', false)] as never }), + ); + app = built.app; + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/epics/epic-1/criteria/b', + payload: {}, + }); + + expect(res.statusCode).toBe(200); + expect(res.json().completeness).toMatchObject({ met: 1, total: 2 }); + }); + + it('404s an unknown criterion', async () => { + const built = await buildApp(makeEpic({ doneCriteria: [criterion('a', false)] as never })); + app = built.app; + const res = await app.inject({ + method: 'POST', + url: '/api/v1/epics/epic-1/criteria/ghost', + payload: { met: true }, + }); + expect(res.statusCode).toBe(404); + }); + + it('deletes with 204', async () => { + const built = await buildApp(); + app = built.app; + const res = await app.inject({ method: 'DELETE', url: '/api/v1/epics/epic-1' }); + expect(res.statusCode).toBe(204); + }); + + it('serves the project-scoped view', async () => { + const built = await buildApp(); + app = built.app; + const res = await app.inject({ method: 'GET', url: '/api/v1/projects/mcpctl/epics' }); + expect(res.statusCode).toBe(200); + expect(res.json()).toHaveLength(1); + + const missing = await app.inject({ method: 'GET', url: '/api/v1/projects/ghost/epics' }); + expect(missing.statusCode).toBe(404); + }); +}); diff --git a/src/mcpd/tests/services/epic-service.test.ts b/src/mcpd/tests/services/epic-service.test.ts new file mode 100644 index 0000000..f13a7bb --- /dev/null +++ b/src/mcpd/tests/services/epic-service.test.ts @@ -0,0 +1,283 @@ +import type { Epic, Project } from '@prisma/client'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import type { IEpicRepository } from '../../src/repositories/epic.repository.js'; +import type { IProjectRepository } from '../../src/repositories/project.repository.js'; +import { EpicService } from '../../src/services/epic.service.js'; + +function makeEpic(overrides: Partial = {}): Epic { + return { + id: 'epic-1', + name: 'the-car', + description: '', + content: 'build a car', + doneCriteria: [], + acceptedTradeoffs: [], + projectId: 'proj-1', + status: 'open', + semver: '0.1.0', + currentRevisionId: null, + ownerId: 'user-1', + version: 1, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + } as Epic; +} + +const criterion = (id: string, met: boolean, kind: 'outcome' | 'quality' = 'outcome') => + ({ id, text: `criterion ${id}`, kind, met }); + +function mockRepos(epic: Epic | null = makeEpic()) { + let current = epic; + const update = vi.fn(async (_id: string, data: Record) => { + current = { ...(current as Epic), ...data } as Epic; + return current; + }); + const epicRepo = { + findAll: vi.fn(async () => (current ? [current] : [])), + findById: vi.fn(async () => current), + findByName: vi.fn(async () => current), + findByNameAndProject: vi.fn(async () => null), + create: vi.fn(async (data: Record) => makeEpic(data as Partial)), + update, + delete: vi.fn(async () => {}), + } as unknown as IEpicRepository & { update: typeof update }; + + const projectRepo = { + findById: vi.fn(async () => ({ id: 'proj-1', name: 'mcpctl' } as Project)), + findByName: vi.fn(async () => ({ id: 'proj-1', name: 'mcpctl' } as Project)), + } as unknown as IProjectRepository; + + return { epicRepo, projectRepo, service: new EpicService(epicRepo, projectRepo) }; +} + +describe('EpicService.completeness', () => { + it('is 0% when nothing is claimed yet', () => { + expect(EpicService.completeness(makeEpic()).percent).toBe(0); + }); + + it('counts quality criteria, not just mechanical outcomes', () => { + // The whole point of the Epic: grading only the checkable half is how you + // end up with every task done and no car. + const epic = makeEpic({ + doneCriteria: [criterion('a', true), criterion('feels-finished', false, 'quality')] as never, + }); + const c = EpicService.completeness(epic); + expect(c.total).toBe(2); + expect(c.met).toBe(1); + expect(c.percent).toBe(50); + expect(c.unmet.map((u) => u.id)).toEqual(['feels-finished']); + }); + + it('rounds to whole percent', () => { + const epic = makeEpic({ + doneCriteria: [criterion('a', true), criterion('b', false), criterion('c', false)] as never, + }); + expect(EpicService.completeness(epic).percent).toBe(33); + }); +}); + +describe('EpicService.setStatus — the honest-done rule', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + it('refuses delivered while criteria are unmet, and names them', async () => { + const { service } = mockRepos( + makeEpic({ doneCriteria: [criterion('a', true), criterion('b', false)] as never }), + ); + await expect(service.setStatus('epic-1', { status: 'delivered' })).rejects.toThrow( + /1\/2 criteria met.*Unmet: b/s, + ); + }); + + it('truncates a long unmet list rather than dumping all of it', async () => { + const many = Array.from({ length: 9 }, (_, i) => criterion(`c${String(i)}`, false)); + const { service } = mockRepos(makeEpic({ doneCriteria: many as never })); + await expect(service.setStatus('epic-1', { status: 'delivered' })).rejects.toThrow(/\+4 more/); + }); + + it('refuses delivered when no criteria exist at all', async () => { + const { service } = mockRepos(makeEpic({ doneCriteria: [] as never })); + await expect(service.setStatus('epic-1', { status: 'delivered' })).rejects.toThrow( + /nothing to have delivered/, + ); + }); + + it('allows delivered when every criterion is met', async () => { + const { service, epicRepo } = mockRepos( + makeEpic({ doneCriteria: [criterion('a', true), criterion('b', true)] as never }), + ); + const result = await service.setStatus('epic-1', { status: 'delivered' }); + expect(result.status).toBe('delivered'); + expect(epicRepo.update).toHaveBeenCalledWith('epic-1', { status: 'delivered' }); + }); + + it('refuses plain delivered when tradeoffs were accepted — the status must name them', async () => { + const { service } = mockRepos( + makeEpic({ + doneCriteria: [criterion('a', true)] as never, + acceptedTradeoffs: [{ what: 'no dark mode', why: 'budget' }] as never, + }), + ); + await expect(service.setStatus('epic-1', { status: 'delivered' })).rejects.toThrow( + /use delivered_with_tradeoffs/, + ); + }); + + it('refuses delivered_with_tradeoffs when none were recorded', async () => { + const { service } = mockRepos(makeEpic({ doneCriteria: [criterion('a', false)] as never })); + await expect( + service.setStatus('epic-1', { status: 'delivered_with_tradeoffs' }), + ).rejects.toThrow(/without recording at least one accepted tradeoff/); + }); + + it('allows delivered_with_tradeoffs even with unmet criteria, once the gap is recorded', async () => { + const { service } = mockRepos( + makeEpic({ + doneCriteria: [criterion('a', true), criterion('b', false)] as never, + acceptedTradeoffs: [{ what: 'b', why: 'deferred to next quarter' }] as never, + }), + ); + const result = await service.setStatus('epic-1', { status: 'delivered_with_tradeoffs' }); + expect(result.status).toBe('delivered_with_tradeoffs'); + }); + + it('always allows cancelled and reopening', async () => { + const { service } = mockRepos(makeEpic({ doneCriteria: [criterion('a', false)] as never })); + await expect(service.setStatus('epic-1', { status: 'cancelled' })).resolves.toBeDefined(); + await expect(service.setStatus('epic-1', { status: 'open' })).resolves.toBeDefined(); + }); + + it('rejects an unknown status', async () => { + const { service } = mockRepos(); + await expect(service.setStatus('epic-1', { status: 'shipped' })).rejects.toThrow(); + }); +}); + +describe('EpicService.addTradeoff', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + it('appends rather than replacing — the record is the audit trail', async () => { + const { service, epicRepo } = mockRepos( + makeEpic({ acceptedTradeoffs: [{ what: 'first', why: 'x', at: '2026-01-01T00:00:00.000Z' }] as never }), + ); + await service.addTradeoff('epic-1', { what: 'second', why: 'y' }); + + const written = epicRepo.update.mock.calls[0]?.[1] as { acceptedTradeoffs: Array<{ what: string }> }; + expect(written.acceptedTradeoffs.map((t) => t.what)).toEqual(['first', 'second']); + }); + + it('stamps who from the caller and when from the clock', async () => { + const { service, epicRepo } = mockRepos(); + await service.addTradeoff('epic-1', { what: 'no dark mode', why: 'budget' }, 'user-42'); + + const written = epicRepo.update.mock.calls[0]?.[1] as { + acceptedTradeoffs: Array<{ who?: string; at?: string }>; + }; + expect(written.acceptedTradeoffs[0]?.who).toBe('user-42'); + expect(written.acceptedTradeoffs[0]?.at).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + it('does not overwrite an explicitly supplied who', async () => { + const { service, epicRepo } = mockRepos(); + await service.addTradeoff('epic-1', { what: 'x', why: 'y', who: 'the-cto' }, 'user-42'); + const written = epicRepo.update.mock.calls[0]?.[1] as { acceptedTradeoffs: Array<{ who?: string }> }; + expect(written.acceptedTradeoffs[0]?.who).toBe('the-cto'); + }); + + it('requires both what and why', async () => { + const { service } = mockRepos(); + await expect(service.addTradeoff('epic-1', { what: 'x' })).rejects.toThrow(); + await expect(service.addTradeoff('epic-1', { why: 'y' })).rejects.toThrow(); + }); +}); + +describe('EpicService.setCriterionMet', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + it('flips one flag and leaves the rest alone', async () => { + const { service, epicRepo } = mockRepos( + makeEpic({ doneCriteria: [criterion('a', false), criterion('b', false)] as never }), + ); + await service.setCriterionMet('epic-1', 'b', true); + + const written = epicRepo.update.mock.calls[0]?.[1] as { + doneCriteria: Array<{ id: string; met: boolean }>; + }; + expect(written.doneCriteria).toEqual([ + expect.objectContaining({ id: 'a', met: false }), + expect.objectContaining({ id: 'b', met: true }), + ]); + }); + + it('404s on an unknown criterion', async () => { + const { service } = mockRepos(makeEpic({ doneCriteria: [criterion('a', false)] as never })); + await expect(service.setCriterionMet('epic-1', 'nope', true)).rejects.toThrow(/not found/i); + }); +}); + +describe('EpicService create/update', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + it('rejects duplicate criterion ids', async () => { + const { service } = mockRepos(null); + await expect( + service.createEpic( + { + name: 'x', content: 'c', projectId: 'proj-1', + doneCriteria: [criterion('dup', false), criterion('dup', true)], + }, + 'user-1', + ), + ).rejects.toThrow(/Duplicate done-criterion id: dup/); + }); + + it('409s on a duplicate epic name within the project', async () => { + const { service, epicRepo } = mockRepos(null); + (epicRepo.findByNameAndProject as ReturnType).mockResolvedValueOnce(makeEpic()); + await expect( + service.createEpic({ name: 'the-car', content: 'c', projectId: 'proj-1' }, 'user-1'), + ).rejects.toThrow(/already exists in project 'mcpctl'/); + }); + + it('404s when the project does not exist', async () => { + const { service, projectRepo } = mockRepos(null); + (projectRepo.findById as ReturnType).mockResolvedValueOnce(null); + await expect( + service.createEpic({ name: 'x', content: 'c', projectId: 'ghost' }, 'user-1'), + ).rejects.toThrow(/Project not found/); + }); + + it('rejects a name that is not lowercase-kebab', async () => { + const { service } = mockRepos(null); + await expect( + service.createEpic({ name: 'The Car', content: 'c', projectId: 'proj-1' }, 'user-1'), + ).rejects.toThrow(); + }); + + it('patch-bumps semver when scope changes, so creep is versioned', async () => { + const { service, epicRepo } = mockRepos(makeEpic({ semver: '1.2.3' })); + await service.updateEpic('epic-1', { doneCriteria: [criterion('new', false)] }); + expect(epicRepo.update.mock.calls[0]?.[1]).toMatchObject({ semver: '1.2.4' }); + }); + + it('honours an explicit bump', async () => { + const { service, epicRepo } = mockRepos(makeEpic({ semver: '1.2.3' })); + await service.updateEpic('epic-1', { content: 'rewritten', bump: 'minor' }); + expect(epicRepo.update.mock.calls[0]?.[1]).toMatchObject({ semver: '1.3.0' }); + }); + + it('refuses semver and bump together', async () => { + const { service } = mockRepos(); + await expect( + service.updateEpic('epic-1', { content: 'x', semver: '2.0.0', bump: 'major' }), + ).rejects.toThrow(/not both/); + }); + + it('does not let a plain update set status — that gate is setStatus only', async () => { + const { service, epicRepo } = mockRepos(makeEpic({ doneCriteria: [criterion('a', false)] as never })); + await service.updateEpic('epic-1', { content: 'x', status: 'delivered' } as never); + const written = epicRepo.update.mock.calls[0]?.[1] as Record; + expect(written['status']).toBeUndefined(); + }); +}); -- 2.49.1 From 7f64316d59a18af73e00effb649ae1ca8f9d3e3a Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 8 Aug 2026 11:10:39 +0100 Subject: [PATCH 08/32] =?UTF-8?q?feat(mcpd):=20EpicTask=20resource=20?= =?UTF-8?q?=E2=80=94=20roadmap=20nodes,=20edges,=20and=20the=20roadmap=20v?= =?UTF-8?q?iew?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Roadmap nodes plus the edges that chain them into the dependency graph the design doc calls the spider web. Edge direction is "from depends on to". The database cannot enforce graph integrity here — production provisions with `prisma db push`, so a trigger or CHECK would never exist there, and the schema test added earlier documents that boundary explicitly. So the service rejects the three things that would otherwise corrupt the roadmap: - self-edges, - edges crossing Epics, since a roadmap is one Epic's graph, - cycles, which make the graph unschedulable and hang any topological walk. Cycle detection is a BFS over the prerequisite direction: adding from→to closes a loop exactly when `to` already depends transitively on `from`. The error names the offending path, not just "cycle", so a failed apply says which nodes to look at. The traversal carries a seen-set that also guarantees termination if the stored graph ALREADY contains a cycle — from a hand-edited database or an earlier bug — rather than spinning forever. There is a test for that case. Adding an existing edge is a no-op rather than an error, so applying a manifest twice is safe. GET /api/v1/epics/:id/roadmap returns nodes with per-node completeness, both edge directions, and `ready`: the nodes whose prerequisites are all satisfied, which is the question anyone actually asks of a roadmap. A cancelled prerequisite counts as satisfied — it is not coming. Completeness reuses EpicService.completeness verbatim so a node and its Epic can never disagree about what "met" means. Tests: 19 service tests — the full cycle matrix (self, 2-cycle, long transitive, diamond-is-fine, pre-existing-cycle termination), roadmap edge directions, readiness including the cancelled-prerequisite case, and CRUD guards. Local: build clean, workspace 2366 passed (+19), db 128 passed (same 4 pre-existing Secret failures), lint unchanged at 869. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p --- .taskmaster/tasks/tasks.json | 12 +- src/mcpd/src/main.ts | 8 + .../src/repositories/epic-task.repository.ts | 107 ++++++++ src/mcpd/src/routes/epictasks.ts | 127 +++++++++ src/mcpd/src/services/epic-task.service.ts | 235 ++++++++++++++++ src/mcpd/src/validation/epic-task.schema.ts | 35 +++ .../tests/services/epic-task-service.test.ts | 250 ++++++++++++++++++ 7 files changed, 768 insertions(+), 6 deletions(-) create mode 100644 src/mcpd/src/repositories/epic-task.repository.ts create mode 100644 src/mcpd/src/routes/epictasks.ts create mode 100644 src/mcpd/src/services/epic-task.service.ts create mode 100644 src/mcpd/src/validation/epic-task.schema.ts create mode 100644 src/mcpd/tests/services/epic-task-service.test.ts diff --git a/.taskmaster/tasks/tasks.json b/.taskmaster/tasks/tasks.json index 5307eda..6978ece 100644 --- a/.taskmaster/tasks/tasks.json +++ b/.taskmaster/tasks/tasks.json @@ -2727,9 +2727,9 @@ "117", "123" ], - "status": "pending", + "status": "done", "subtasks": [], - "updatedAt": "2026-08-08T08:55:58.627Z" + "updatedAt": "2026-08-08T10:10:39.751Z" }, { "id": "128", @@ -2741,9 +2741,9 @@ "dependencies": [ "127" ], - "status": "pending", + "status": "done", "subtasks": [], - "updatedAt": "2026-08-08T08:55:58.627Z" + "updatedAt": "2026-08-08T10:10:39.761Z" }, { "id": "129", @@ -3251,9 +3251,9 @@ ], "metadata": { "version": "1.0.0", - "lastModified": "2026-08-08T10:05:28.178Z", + "lastModified": "2026-08-08T10:10:39.761Z", "taskCount": 162, - "completedCount": 111, + "completedCount": 113, "tags": [ "master" ] diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index 91e4955..89e0f96 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -107,6 +107,7 @@ import { registerPromptRoutes } from './routes/prompts.js'; import { registerRevisionRoutes } from './routes/revisions.js'; import { registerProposalRoutes } from './routes/proposals.js'; import { registerEpicRoutes } from './routes/epics.js'; +import { registerEpicTaskRoutes } from './routes/epictasks.js'; import { registerSkillRoutes } from './routes/skills.js'; import { registerGitBackupRoutes } from './routes/git-backup.js'; import { PromptService } from './services/prompt.service.js'; @@ -115,8 +116,10 @@ import { ResourceProposalRepository } from './repositories/resource-proposal.rep import { ResourceRevisionService } from './services/resource-revision.service.js'; import { ResourceProposalService } from './services/resource-proposal.service.js'; import { EpicRepository } from './repositories/epic.repository.js'; +import { EpicTaskRepository } from './repositories/epic-task.repository.js'; import { SkillRepository } from './repositories/skill.repository.js'; import { EpicService } from './services/epic.service.js'; +import { EpicTaskService } from './services/epic-task.service.js'; import { SkillService } from './services/skill.service.js'; import { GitBackupService } from './services/backup/git-backup.service.js'; import type { BackupKind } from './services/backup/yaml-serializer.js'; @@ -226,6 +229,7 @@ function mapUrlToPermission(method: string, url: string): PermissionCheck { 'personalities': 'agents', // ── Agentic teams ── 'epics': 'epics', + 'epictasks': 'epictasks', }; const resource = resourceMap[segment]; @@ -435,6 +439,7 @@ async function main(): Promise { // Declared here (not with the other agentic-teams wiring below) because // nameResolvers needs it for CUID→name RBAC resolution. const epicRepo = new EpicRepository(prisma); + const epicTaskRepo = new EpicTaskRepository(prisma); // SecretBackend bootstrap: ensure a `plaintext` default row exists and any // pre-existing `Secret` rows are pointed at it. Idempotent per run. @@ -452,6 +457,7 @@ async function main(): Promise { llms: llmRepo, agents: agentRepo, epics: epicRepo, + epictasks: epicTaskRepo, // Resolve user CUID → email so name-scoped `edit:users:` bindings // match on PUT /api/v1/users/:id/password (admin reset). users: { findById: async (id: string) => { @@ -559,6 +565,7 @@ async function main(): Promise { // Revision-tracked so scope creep is as visible as under-delivery. const epicService = new EpicService(epicRepo, projectRepo); epicService.setRevisionService(resourceRevisionService); + const epicTaskService = new EpicTaskService(epicTaskRepo, epicRepo); const personalityRepo = new PersonalityRepository(prisma); const personalityService = new PersonalityService(personalityRepo, agentRepo, promptRepo); const agentService = new AgentService(agentRepo, llmService, projectService, personalityRepo); @@ -768,6 +775,7 @@ async function main(): Promise { registerProposalRoutes(app, { proposalService: resourceProposalService, projectRepo, agentRepo }); registerSkillRoutes(app, skillService, projectRepo, agentRepo); registerEpicRoutes(app, epicService, projectRepo); + registerEpicTaskRoutes(app, epicTaskService, epicRepo, epicTaskRepo, projectRepo); // ── Git-based backup ── const gitBackup = new GitBackupService(prisma); diff --git a/src/mcpd/src/repositories/epic-task.repository.ts b/src/mcpd/src/repositories/epic-task.repository.ts new file mode 100644 index 0000000..aefc2f9 --- /dev/null +++ b/src/mcpd/src/repositories/epic-task.repository.ts @@ -0,0 +1,107 @@ +import type { PrismaClient, Prisma, EpicTask, EpicTaskEdge, EpicTaskStatus } from '@prisma/client'; + +/** + * EpicTask repository — roadmap nodes plus the edges that chain them. + * + * Edge direction is `from depends on to`: `fromId` is the dependent node, + * `toId` the prerequisite. Both directions are indexed so the graph walks + * either way (`dependsOn` / `blocks`). + */ + +export interface EpicTaskCreateInput { + name: string; + epicId: string; + ownerId: string; + description?: string; + doneCriteria?: Prisma.InputJsonValue; +} + +export interface EpicTaskUpdateInput { + description?: string; + doneCriteria?: Prisma.InputJsonValue; + status?: EpicTaskStatus; +} + +export interface IEpicTaskRepository { + findAll(epicId?: string): Promise; + findById(id: string): Promise; + findByName(name: string): Promise; + findByNameAndEpic(name: string, epicId: string): Promise; + create(data: EpicTaskCreateInput): Promise; + update(id: string, data: EpicTaskUpdateInput): Promise; + delete(id: string): Promise; + + // ── Edges ── + findEdges(epicId: string): Promise; + findDependencies(fromId: string): Promise; + createEdge(fromId: string, toId: string): Promise; + deleteEdge(fromId: string, toId: string): Promise; +} + +const NODE_INCLUDE = { epic: { select: { name: true, projectId: true } } } as const; + +export class EpicTaskRepository implements IEpicTaskRepository { + constructor(private readonly prisma: PrismaClient) {} + + async findAll(epicId?: string): Promise { + return this.prisma.epicTask.findMany({ + ...(epicId !== undefined ? { where: { epicId } } : {}), + include: NODE_INCLUDE, + orderBy: { name: 'asc' }, + }); + } + + async findById(id: string): Promise { + return this.prisma.epicTask.findUnique({ where: { id }, include: NODE_INCLUDE }); + } + + /** + * Resolve by bare name. Names are unique per Epic, not globally — this is for + * the RBAC CUID→name resolver, which only needs *a* name to compare bindings. + */ + async findByName(name: string): Promise { + return this.prisma.epicTask.findFirst({ where: { name }, include: NODE_INCLUDE }); + } + + async findByNameAndEpic(name: string, epicId: string): Promise { + return this.prisma.epicTask.findUnique({ + where: { name_epicId: { name, epicId } }, + include: NODE_INCLUDE, + }); + } + + async create(data: EpicTaskCreateInput): Promise { + return this.prisma.epicTask.create({ data, include: NODE_INCLUDE }); + } + + async update(id: string, data: EpicTaskUpdateInput): Promise { + return this.prisma.epicTask.update({ + where: { id }, + data: { ...data, version: { increment: 1 } }, + include: NODE_INCLUDE, + }); + } + + async delete(id: string): Promise { + await this.prisma.epicTask.delete({ where: { id } }); + } + + // ── Edges ── + + /** Every edge whose dependent node belongs to this Epic. */ + async findEdges(epicId: string): Promise { + return this.prisma.epicTaskEdge.findMany({ where: { from: { epicId } } }); + } + + async findDependencies(fromId: string): Promise { + return this.prisma.epicTaskEdge.findMany({ where: { fromId } }); + } + + async createEdge(fromId: string, toId: string): Promise { + return this.prisma.epicTaskEdge.create({ data: { fromId, toId } }); + } + + async deleteEdge(fromId: string, toId: string): Promise { + await this.prisma.epicTaskEdge.deleteMany({ where: { fromId, toId } }); + } +} diff --git a/src/mcpd/src/routes/epictasks.ts b/src/mcpd/src/routes/epictasks.ts new file mode 100644 index 0000000..de96f00 --- /dev/null +++ b/src/mcpd/src/routes/epictasks.ts @@ -0,0 +1,127 @@ +import type { FastifyInstance } from 'fastify'; + +import type { IEpicRepository } from '../repositories/epic.repository.js'; +import type { IEpicTaskRepository } from '../repositories/epic-task.repository.js'; +import type { IProjectRepository } from '../repositories/project.repository.js'; +import type { EpicTaskService } from '../services/epic-task.service.js'; +import { CreateEdgeSchema } from '../validation/epic-task.schema.js'; + +export function registerEpicTaskRoutes( + app: FastifyInstance, + service: EpicTaskService, + epicRepo: IEpicRepository, + epicTaskRepo: IEpicTaskRepository, + projectRepo: IProjectRepository, +): void { + /** Resolve `?epic=` (+ optional `?project=`) to an epic id. */ + async function resolveEpicId(epicName: string, projectName?: string): Promise { + if (projectName !== undefined) { + const proj = await projectRepo.findByName(projectName); + if (proj === null) { + throw Object.assign(new Error(`Project not found: ${projectName}`), { statusCode: 404 }); + } + const epic = await epicRepo.findByNameAndProject(epicName, proj.id); + if (epic === null) { + throw Object.assign( + new Error(`Epic not found: ${epicName} in project ${projectName}`), + { statusCode: 404 }, + ); + } + return epic.id; + } + const epic = await epicRepo.findByName(epicName); + if (epic === null) { + throw Object.assign(new Error(`Epic not found: ${epicName}`), { statusCode: 404 }); + } + return epic.id; + } + + // ── List / get ── + + app.get<{ Querystring: { epic?: string; epicId?: string; project?: string } }>( + '/api/v1/epictasks', + async (request) => { + const { epic, epicId, project } = request.query; + if (epicId !== undefined) return service.list(epicId); + if (epic !== undefined) return service.list(await resolveEpicId(epic, project)); + return service.list(); + }, + ); + + app.get<{ Params: { id: string } }>('/api/v1/epictasks/:id', async (request) => { + return service.get(request.params.id); + }); + + // ── Create / update / delete ── + + app.post('/api/v1/epictasks', async (request, reply) => { + const body = request.body as Record; + const resolved: Record = { ...body }; + + if (typeof body['epic'] === 'string') { + resolved['epicId'] = await resolveEpicId( + body['epic'], + typeof body['project'] === 'string' ? body['project'] : undefined, + ); + delete resolved['epic']; + delete resolved['project']; + } + + const node = await service.create(resolved, request.userId ?? 'system'); + reply.code(201); + return node; + }); + + app.put<{ Params: { id: string } }>('/api/v1/epictasks/:id', async (request) => { + return service.update(request.params.id, request.body); + }); + + app.delete<{ Params: { id: string } }>('/api/v1/epictasks/:id', async (request, reply) => { + await service.delete(request.params.id); + reply.code(204); + }); + + // ── Edges ── + // POST /api/v1/epictasks/:id/edges { toId } | { to: "" } + // means "this node depends on `to`". + + app.post<{ Params: { id: string } }>('/api/v1/epictasks/:id/edges', async (request, reply) => { + const parsed = CreateEdgeSchema.parse(request.body); + + let toId = parsed.toId; + if (toId === undefined) { + const from = await service.get(request.params.id); + const target = await epicTaskRepo.findByNameAndEpic(parsed.to as string, from.epicId); + if (target === null) { + throw Object.assign( + new Error(`EpicTask not found in this epic: ${String(parsed.to)}`), + { statusCode: 404 }, + ); + } + toId = target.id; + } + + const edge = await service.addEdge(request.params.id, toId); + reply.code(201); + return edge; + }); + + app.delete<{ Params: { id: string }; Querystring: { toId?: string } }>( + '/api/v1/epictasks/:id/edges', + async (request, reply) => { + const { toId } = request.query; + if (toId === undefined) { + throw Object.assign(new Error('toId query parameter is required'), { statusCode: 400 }); + } + await service.removeEdge(request.params.id, toId); + reply.code(204); + }, + ); + + // ── Roadmap ── + // The spider-web view: nodes + per-node completeness + what is startable. + + app.get<{ Params: { id: string } }>('/api/v1/epics/:id/roadmap', async (request) => { + return service.roadmap(request.params.id); + }); +} diff --git a/src/mcpd/src/services/epic-task.service.ts b/src/mcpd/src/services/epic-task.service.ts new file mode 100644 index 0000000..eba9a45 --- /dev/null +++ b/src/mcpd/src/services/epic-task.service.ts @@ -0,0 +1,235 @@ +import type { EpicTask, EpicTaskEdge, EpicTaskStatus, Prisma } from '@prisma/client'; + +import type { IEpicRepository } from '../repositories/epic.repository.js'; +import type { IEpicTaskRepository } from '../repositories/epic-task.repository.js'; +import type { DoneCriterion } from '../validation/epic.schema.js'; +import { + CreateEpicTaskSchema, + UpdateEpicTaskSchema, +} from '../validation/epic-task.schema.js'; + +import { EpicService, type EpicCompleteness } from './epic.service.js'; +import { NotFoundError } from './mcp-server.service.js'; + +/** One roadmap node plus its edges and completeness — the shape the web renders. */ +export interface RoadmapNode { + id: string; + name: string; + description: string; + status: EpicTaskStatus; + completeness: EpicCompleteness; + /** Prerequisite node ids: this node depends on these. */ + dependsOn: string[]; + /** Dependent node ids: these wait on this node. */ + blocks: string[]; +} + +export interface Roadmap { + epicId: string; + nodes: RoadmapNode[]; + edges: Array<{ from: string; to: string }>; + /** Nodes with no unfinished prerequisites — where work can actually start. */ + ready: string[]; +} + +function badRequest(message: string): Error { + return Object.assign(new Error(message), { statusCode: 400 }); +} + +function readCriteria(node: { doneCriteria: unknown }): DoneCriterion[] { + return (node.doneCriteria ?? []) as DoneCriterion[]; +} + +export class EpicTaskService { + constructor( + private readonly repo: IEpicTaskRepository, + private readonly epicRepo: IEpicRepository, + ) {} + + // ── CRUD ── + + async list(epicId?: string): Promise { + return this.repo.findAll(epicId); + } + + async get(id: string): Promise { + const node = await this.repo.findById(id); + if (node === null) throw new NotFoundError(`EpicTask not found: ${id}`); + return node; + } + + async create(input: unknown, ownerId: string): Promise { + const data = CreateEpicTaskSchema.parse(input); + + const epic = await this.epicRepo.findById(data.epicId); + if (epic === null) throw new NotFoundError(`Epic not found: ${data.epicId}`); + + const duplicate = await this.repo.findByNameAndEpic(data.name, data.epicId); + if (duplicate !== null) { + throw Object.assign( + new Error(`EpicTask '${data.name}' already exists in epic '${epic.name}'`), + { statusCode: 409 }, + ); + } + + return this.repo.create({ + name: data.name, + epicId: data.epicId, + ownerId, + ...(data.description !== undefined ? { description: data.description } : {}), + ...(data.doneCriteria !== undefined + ? { doneCriteria: data.doneCriteria as unknown as Prisma.InputJsonValue } + : {}), + }); + } + + async update(id: string, input: unknown): Promise { + const data = UpdateEpicTaskSchema.parse(input); + await this.get(id); // 404 if missing + + return this.repo.update(id, { + ...(data.description !== undefined ? { description: data.description } : {}), + ...(data.doneCriteria !== undefined + ? { doneCriteria: data.doneCriteria as unknown as Prisma.InputJsonValue } + : {}), + ...(data.status !== undefined ? { status: data.status as EpicTaskStatus } : {}), + }); + } + + async delete(id: string): Promise { + await this.get(id); + await this.repo.delete(id); + } + + // ── Edges ── + + /** + * Add "from depends on to". + * + * Rejects three things the database cannot (production runs `prisma db push`, + * so a CHECK or trigger here would never exist in prod — see the schema test + * that documents this boundary): + * + * 1. self-edges, + * 2. edges crossing Epics — a roadmap is one Epic's graph, + * 3. cycles, which would make the roadmap unschedulable and hang any + * topological walk over it. + */ + async addEdge(fromId: string, toId: string): Promise { + if (fromId === toId) { + throw badRequest('An epictask cannot depend on itself'); + } + + const from = await this.get(fromId); + const to = await this.repo.findById(toId); + if (to === null) throw new NotFoundError(`EpicTask not found: ${toId}`); + + if (from.epicId !== to.epicId) { + throw badRequest( + 'Cannot chain epictasks across different Epics — a roadmap is one Epic\'s graph', + ); + } + + // Adding from→to means "from depends on to". That closes a cycle exactly + // when `to` already depends, transitively, on `from`. + const path = await this.findDependencyPath(toId, fromId); + if (path !== null) { + const names = [from.name, ...(await this.namesFor(path))].join(' → '); + throw badRequest(`Cycle detected: ${names} → ${from.name}`); + } + + const existing = await this.repo.findDependencies(fromId); + if (existing.some((e) => e.toId === toId)) { + // Already recorded — treat as success so applying a manifest twice is safe. + return existing.find((e) => e.toId === toId) as EpicTaskEdge; + } + + return this.repo.createEdge(fromId, toId); + } + + async removeEdge(fromId: string, toId: string): Promise { + await this.get(fromId); + await this.repo.deleteEdge(fromId, toId); + } + + /** + * Breadth-first walk of `start`'s transitive dependencies, looking for + * `target`. Returns the path (excluding `start`) or null. + * + * The `seen` set does double duty: it keeps this terminating even if the + * stored graph already contains a cycle from an earlier bug or a hand-edited + * database, rather than spinning forever. + */ + private async findDependencyPath(start: string, target: string): Promise { + const seen = new Set([start]); + const queue: Array<{ id: string; path: string[] }> = [{ id: start, path: [start] }]; + + while (queue.length > 0) { + const current = queue.shift() as { id: string; path: string[] }; + const edges = await this.repo.findDependencies(current.id); + for (const edge of edges) { + if (edge.toId === target) return [...current.path, edge.toId]; + if (seen.has(edge.toId)) continue; + seen.add(edge.toId); + queue.push({ id: edge.toId, path: [...current.path, edge.toId] }); + } + } + return null; + } + + private async namesFor(ids: string[]): Promise { + const out: string[] = []; + for (const id of ids) { + const node = await this.repo.findById(id); + out.push(node?.name ?? id); + } + return out; + } + + // ── Roadmap ── + + /** + * The whole graph for one Epic: nodes with per-node completeness, the edges + * between them, and which nodes are actually startable. + */ + async roadmap(epicId: string): Promise { + const epic = await this.epicRepo.findById(epicId); + if (epic === null) throw new NotFoundError(`Epic not found: ${epicId}`); + + const nodes = await this.repo.findAll(epicId); + const edges = await this.repo.findEdges(epicId); + + const dependsOn = new Map(); + const blocks = new Map(); + for (const e of edges) { + dependsOn.set(e.fromId, [...(dependsOn.get(e.fromId) ?? []), e.toId]); + blocks.set(e.toId, [...(blocks.get(e.toId) ?? []), e.fromId]); + } + + const statusById = new Map(nodes.map((n) => [n.id, n.status])); + + const roadmapNodes: RoadmapNode[] = nodes.map((n) => ({ + id: n.id, + name: n.name, + description: n.description, + status: n.status, + // Reuse the Epic's rule verbatim so a node and its Epic never disagree + // about what "met" means. + completeness: EpicService.completeness({ doneCriteria: readCriteria(n) } as never), + dependsOn: dependsOn.get(n.id) ?? [], + blocks: blocks.get(n.id) ?? [], + })); + + const ready = roadmapNodes + .filter((n) => n.status === 'todo' || n.status === 'in_progress') + .filter((n) => n.dependsOn.every((d) => statusById.get(d) === 'done' || statusById.get(d) === 'cancelled')) + .map((n) => n.id); + + return { + epicId, + nodes: roadmapNodes, + edges: edges.map((e) => ({ from: e.fromId, to: e.toId })), + ready, + }; + } +} diff --git a/src/mcpd/src/validation/epic-task.schema.ts b/src/mcpd/src/validation/epic-task.schema.ts new file mode 100644 index 0000000..0c46f12 --- /dev/null +++ b/src/mcpd/src/validation/epic-task.schema.ts @@ -0,0 +1,35 @@ +import { z } from 'zod'; + +import { DoneCriterionSchema } from './epic.schema.js'; + +const NAME_RE = /^[a-z0-9-]+$/; + +export const CreateEpicTaskSchema = z.object({ + name: z.string().min(1).max(100).regex(NAME_RE, 'Name must be lowercase alphanumeric with hyphens'), + epicId: z.string().min(1), + description: z.string().max(2000).optional(), + /** + * The decomposed subset of the Epic's criteria this node owns. Same shape as + * the Epic's, so the roadmap can show per-node completeness with one rule. + */ + doneCriteria: z.array(DoneCriterionSchema).max(200).optional(), +}); + +export const UpdateEpicTaskSchema = z.object({ + description: z.string().max(2000).optional(), + doneCriteria: z.array(DoneCriterionSchema).max(200).optional(), + status: z.enum(['todo', 'in_progress', 'done', 'cancelled']).optional(), +}); + +/** `to` is the prerequisite; the node named in the URL depends on it. */ +export const CreateEdgeSchema = z.object({ + toId: z.string().min(1).optional(), + /** Convenience: name within the same Epic, resolved server-side. */ + to: z.string().min(1).optional(), +}).refine( + (d) => (d.toId !== undefined) !== (d.to !== undefined), + { message: 'Pass exactly one of toId or to' }, +); + +export type CreateEpicTaskInput = z.infer; +export type UpdateEpicTaskInput = z.infer; diff --git a/src/mcpd/tests/services/epic-task-service.test.ts b/src/mcpd/tests/services/epic-task-service.test.ts new file mode 100644 index 0000000..585e926 --- /dev/null +++ b/src/mcpd/tests/services/epic-task-service.test.ts @@ -0,0 +1,250 @@ +import type { Epic, EpicTask, EpicTaskEdge } from '@prisma/client'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import type { IEpicRepository } from '../../src/repositories/epic.repository.js'; +import type { IEpicTaskRepository } from '../../src/repositories/epic-task.repository.js'; +import { EpicTaskService } from '../../src/services/epic-task.service.js'; + +/** + * An in-memory roadmap graph. The schema deliberately does NOT reject cycles + * (production runs `prisma db push`, so a trigger would never exist there), so + * these tests are the only thing standing between the roadmap and an + * unschedulable graph. + */ +function makeGraph( + nodes: Array<{ id: string; name: string; epicId?: string; status?: EpicTask['status']; doneCriteria?: unknown }>, + edges: Array<[string, string]> = [], +) { + const rows: EpicTask[] = nodes.map((n) => ({ + id: n.id, + name: n.name, + description: '', + epicId: n.epicId ?? 'epic-1', + doneCriteria: n.doneCriteria ?? [], + status: n.status ?? 'todo', + ownerId: 'user-1', + version: 1, + createdAt: new Date(), + updatedAt: new Date(), + }) as EpicTask); + + let edgeRows: EpicTaskEdge[] = edges.map(([fromId, toId], i) => ({ + id: `edge-${String(i)}`, + fromId, + toId, + createdAt: new Date(), + }) as EpicTaskEdge); + + const repo = { + findAll: vi.fn(async (epicId?: string) => + epicId === undefined ? rows : rows.filter((r) => r.epicId === epicId), + ), + findById: vi.fn(async (id: string) => rows.find((r) => r.id === id) ?? null), + findByName: vi.fn(async (name: string) => rows.find((r) => r.name === name) ?? null), + findByNameAndEpic: vi.fn(async (name: string, epicId: string) => + rows.find((r) => r.name === name && r.epicId === epicId) ?? null, + ), + create: vi.fn(async () => rows[0] as EpicTask), + update: vi.fn(async () => rows[0] as EpicTask), + delete: vi.fn(async () => {}), + findEdges: vi.fn(async (epicId: string) => + edgeRows.filter((e) => rows.find((r) => r.id === e.fromId)?.epicId === epicId), + ), + findDependencies: vi.fn(async (fromId: string) => edgeRows.filter((e) => e.fromId === fromId)), + createEdge: vi.fn(async (fromId: string, toId: string) => { + const row = { id: `edge-new`, fromId, toId, createdAt: new Date() } as EpicTaskEdge; + edgeRows = [...edgeRows, row]; + return row; + }), + deleteEdge: vi.fn(async (fromId: string, toId: string) => { + edgeRows = edgeRows.filter((e) => !(e.fromId === fromId && e.toId === toId)); + }), + } as unknown as IEpicTaskRepository & Record>; + + const epicRepo = { + findById: vi.fn(async (id: string) => ({ id, name: 'the-car', projectId: 'proj-1' } as Epic)), + findByName: vi.fn(async () => ({ id: 'epic-1', name: 'the-car' } as Epic)), + findByNameAndProject: vi.fn(async () => ({ id: 'epic-1', name: 'the-car' } as Epic)), + } as unknown as IEpicRepository; + + return { repo, epicRepo, service: new EpicTaskService(repo, epicRepo) }; +} + +const crit = (id: string, met: boolean) => ({ id, text: id, kind: 'outcome' as const, met }); + +describe('EpicTaskService.addEdge — graph integrity', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + it('records a plain dependency', async () => { + const { service, repo } = makeGraph([ + { id: 'a', name: 'wheels' }, + { id: 'b', name: 'chassis' }, + ]); + await service.addEdge('a', 'b'); + expect(repo.createEdge).toHaveBeenCalledWith('a', 'b'); + }); + + it('rejects a self-edge', async () => { + const { service } = makeGraph([{ id: 'a', name: 'wheels' }]); + await expect(service.addEdge('a', 'a')).rejects.toThrow(/cannot depend on itself/); + }); + + it('rejects an edge crossing Epics — a roadmap is one Epic\'s graph', async () => { + const { service } = makeGraph([ + { id: 'a', name: 'wheels', epicId: 'epic-1' }, + { id: 'b', name: 'other', epicId: 'epic-2' }, + ]); + await expect(service.addEdge('a', 'b')).rejects.toThrow(/across different Epics/); + }); + + it('rejects a direct 2-cycle', async () => { + const { service } = makeGraph( + [{ id: 'a', name: 'wheels' }, { id: 'b', name: 'chassis' }], + [['a', 'b']], + ); + // a already depends on b; making b depend on a closes the loop. + await expect(service.addEdge('b', 'a')).rejects.toThrow(/Cycle detected/); + }); + + it('rejects a long transitive cycle and names the path', async () => { + const { service } = makeGraph( + [ + { id: 'a', name: 'aa' }, { id: 'b', name: 'bb' }, + { id: 'c', name: 'cc' }, { id: 'd', name: 'dd' }, + ], + [['a', 'b'], ['b', 'c'], ['c', 'd']], + ); + // a→b→c→d already; adding d→a would close it. + await expect(service.addEdge('d', 'a')).rejects.toThrow(/Cycle detected: dd/); + }); + + it('allows a diamond — shared prerequisites are not cycles', async () => { + const { service, repo } = makeGraph( + [ + { id: 'top', name: 'top' }, { id: 'l', name: 'left' }, + { id: 'r', name: 'right' }, { id: 'base', name: 'base' }, + ], + [['top', 'l'], ['top', 'r'], ['l', 'base']], + ); + await service.addEdge('r', 'base'); + expect(repo.createEdge).toHaveBeenCalledWith('r', 'base'); + }); + + it('is idempotent — re-adding an existing edge does not duplicate it', async () => { + const { service, repo } = makeGraph( + [{ id: 'a', name: 'aa' }, { id: 'b', name: 'bb' }], + [['a', 'b']], + ); + const edge = await service.addEdge('a', 'b'); + expect(edge.toId).toBe('b'); + expect(repo.createEdge).not.toHaveBeenCalled(); + }); + + it('terminates even if the stored graph already contains a cycle', async () => { + // A hand-edited database or an earlier bug could leave one behind; the + // traversal must not spin forever on it. + const { service } = makeGraph( + [{ id: 'a', name: 'aa' }, { id: 'b', name: 'bb' }, { id: 'c', name: 'cc' }], + [['a', 'b'], ['b', 'a']], + ); + await expect(service.addEdge('c', 'a')).resolves.toBeDefined(); + }); + + it('404s on a missing prerequisite node', async () => { + const { service } = makeGraph([{ id: 'a', name: 'aa' }]); + await expect(service.addEdge('a', 'ghost')).rejects.toThrow(/not found/i); + }); +}); + +describe('EpicTaskService.roadmap', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + it('reports both edge directions per node', async () => { + const { service } = makeGraph( + [{ id: 'a', name: 'aa' }, { id: 'b', name: 'bb' }], + [['a', 'b']], + ); + const map = await service.roadmap('epic-1'); + const a = map.nodes.find((n) => n.id === 'a'); + const b = map.nodes.find((n) => n.id === 'b'); + expect(a?.dependsOn).toEqual(['b']); + expect(b?.blocks).toEqual(['a']); + expect(map.edges).toEqual([{ from: 'a', to: 'b' }]); + }); + + it('computes per-node completeness with the Epic\'s own rule', async () => { + const { service } = makeGraph([ + { id: 'a', name: 'aa', doneCriteria: [crit('x', true), crit('y', false)] }, + ]); + const map = await service.roadmap('epic-1'); + expect(map.nodes[0]?.completeness).toMatchObject({ total: 2, met: 1, percent: 50 }); + }); + + it('marks only nodes with satisfied prerequisites as ready', async () => { + const { service } = makeGraph( + [ + { id: 'base', name: 'base', status: 'todo' }, + { id: 'mid', name: 'mid', status: 'todo' }, + ], + [['mid', 'base']], + ); + const map = await service.roadmap('epic-1'); + expect(map.ready).toEqual(['base']); + }); + + it('treats a cancelled prerequisite as satisfied — it is not coming', async () => { + const { service } = makeGraph( + [ + { id: 'base', name: 'base', status: 'cancelled' }, + { id: 'mid', name: 'mid', status: 'todo' }, + ], + [['mid', 'base']], + ); + const map = await service.roadmap('epic-1'); + expect(map.ready).toContain('mid'); + }); + + it('excludes finished nodes from ready', async () => { + const { service } = makeGraph([{ id: 'a', name: 'aa', status: 'done' }]); + const map = await service.roadmap('epic-1'); + expect(map.ready).toEqual([]); + }); + + it('404s on an unknown epic', async () => { + const { service, epicRepo } = makeGraph([]); + (epicRepo.findById as ReturnType).mockResolvedValueOnce(null); + await expect(service.roadmap('ghost')).rejects.toThrow(/Epic not found/); + }); +}); + +describe('EpicTaskService CRUD', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + it('409s on a duplicate name within the epic', async () => { + const { service } = makeGraph([{ id: 'a', name: 'wheels' }]); + await expect( + service.create({ name: 'wheels', epicId: 'epic-1' }, 'user-1'), + ).rejects.toThrow(/already exists in epic/); + }); + + it('404s when the epic does not exist', async () => { + const { service, epicRepo } = makeGraph([]); + (epicRepo.findById as ReturnType).mockResolvedValueOnce(null); + await expect(service.create({ name: 'x', epicId: 'ghost' }, 'user-1')).rejects.toThrow( + /Epic not found/, + ); + }); + + it('rejects a non-kebab name', async () => { + const { service } = makeGraph([]); + await expect(service.create({ name: 'Wheels!', epicId: 'epic-1' }, 'user-1')).rejects.toThrow(); + }); + + it('accepts every node status', async () => { + const { service, repo } = makeGraph([{ id: 'a', name: 'aa' }]); + for (const status of ['todo', 'in_progress', 'done', 'cancelled'] as const) { + await service.update('a', { status }); + expect(repo.update).toHaveBeenCalledWith('a', expect.objectContaining({ status })); + } + }); +}); -- 2.49.1 From da4752b90fba90897b27e9d12daccf1baa0cd93c Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 8 Aug 2026 11:35:16 +0100 Subject: [PATCH 09/32] fix(mcplocal): stop the 30s proxy timeout killing agent turns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mcpctl chat ` failed with HTTP 503 {"error":"service_unavailable","message":"Cannot reach mcpd daemon. Is it running?"} while mcpd was answering /healthz in 32ms. The message was wrong in a way that cost real debugging time: mcplocal was reaching mcpd fine and giving up after 30s. journalctl shows the signature plainly — statusCode 503 with responseTime 30003.87 on POST /api/v1/agents/reviewer/chat. This blocks the agentic-teams epic outright. An agent turn is a multi-turn tool-use loop that runs for minutes by design, so a 30s ceiling on the chat path is not a safety net, it is a guaranteed failure for every non-trivial turn. Three defects, all in the same path: 1. One blanket budget for every forwarded route. DEFAULT_TIMEOUT_MS = 30_000 is right for CRUD and wrong for chat. Chat, project chat, llm infer and inference-task streams now get LONG_RUNNING_TIMEOUT_MS (600_000, override with MCPLOCAL_LONG_TIMEOUT_MS) — matching STREAM_TIMEOUT_MS, which the CLI already allowed. mcplocal in the middle was the binding constraint. 2. Timeouts were reported as connection failures. Split UpstreamTimeoutError out of ConnectionError and map it to 504 with an accurate message that says the daemon IS reachable. ConnectionError still means unreachable and still returns 503. Verified nothing else branches on ConnectionError. 3. SSE was buffered. `forward()` reads the whole body through res.text(), so even turns that finished in time arrived as one blob and the CLI's live token output never appeared. Streaming routes now use forwardStream() and pipe the body straight through, preserving content-type and x-accel-buffering (dropping the latter lets intermediaries re-buffer and reintroduces the stall). Also closes the escape that produced the sibling `500 code:23` failure: the body read in forward() was outside the try, so when mcpd had already written SSE headers the raw DOMException reached Fastify unhandled. Tests: 9 new proxy tests. The two that matter — "does not abort an agent chat that outlives the CRUD budget" and "streams SSE through instead of buffering" — were confirmed to FAIL against the pre-fix behaviour and pass after. Three existing mcpd-client tests asserted the old taxonomy and were updated to assert the new one deliberately. Local: build clean, workspace 2375 passed, lint unchanged at 869. NOT YET LIVE: mcplocal runs from the installed RPM, so this needs a package rebuild + `systemctl --user restart mcplocal` to take effect. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p --- src/mcplocal/src/http/index.ts | 2 +- src/mcplocal/src/http/mcpd-client.ts | 94 ++++++++- src/mcplocal/src/http/routes/proxy.ts | 131 ++++++++++-- src/mcplocal/src/index.ts | 2 +- src/mcplocal/tests/mcpd-client.test.ts | 18 +- src/mcplocal/tests/proxy-long-running.test.ts | 196 ++++++++++++++++++ 6 files changed, 416 insertions(+), 27 deletions(-) create mode 100644 src/mcplocal/tests/proxy-long-running.test.ts diff --git a/src/mcplocal/src/http/index.ts b/src/mcplocal/src/http/index.ts index 274a655..ffdb7d4 100644 --- a/src/mcplocal/src/http/index.ts +++ b/src/mcplocal/src/http/index.ts @@ -2,7 +2,7 @@ export { createHttpServer } from './server.js'; export type { HttpServerDeps } from './server.js'; export { loadHttpConfig } from './config.js'; export type { HttpConfig } from './config.js'; -export { McpdClient, AuthenticationError, ConnectionError } from './mcpd-client.js'; +export { McpdClient, AuthenticationError, ConnectionError, UpstreamTimeoutError } from './mcpd-client.js'; export { registerProxyRoutes } from './routes/proxy.js'; export { registerMcpEndpoint } from './mcp-endpoint.js'; export { registerProjectMcpEndpoint } from './project-mcp-endpoint.js'; diff --git a/src/mcplocal/src/http/mcpd-client.ts b/src/mcplocal/src/http/mcpd-client.ts index 53511e5..8e755e4 100644 --- a/src/mcplocal/src/http/mcpd-client.ts +++ b/src/mcplocal/src/http/mcpd-client.ts @@ -20,9 +20,41 @@ export class ConnectionError extends Error { } } +/** + * Thrown when mcpd was reachable but did not finish in time. + * + * Deliberately NOT a ConnectionError. Folding timeouts into "cannot connect" + * is what made this class of failure so expensive to diagnose: mcpd answered + * /healthz in 32ms while the proxy insisted the daemon was down. A timeout and + * an unreachable daemon need different messages and different status codes. + */ +export class UpstreamTimeoutError extends Error { + constructor(readonly url: string, readonly timeoutMs: number) { + super(`mcpd did not respond within ${String(timeoutMs)}ms: ${url}`); + this.name = 'UpstreamTimeoutError'; + } +} + +/** True when `err` is an AbortSignal.timeout() firing. */ +function isTimeout(err: unknown): boolean { + return err instanceof DOMException && err.name === 'TimeoutError'; +} + /** Default timeout for mcpd requests (ms). Prevents indefinite hangs on slow upstream tool calls. */ export const DEFAULT_TIMEOUT_MS = 30_000; +/** + * Budget for routes that are *expected* to run long: agent/project chat and + * raw inference. An agent turn is a multi-turn tool-use loop and legitimately + * runs for minutes, so the 30s default is not a safety net there — it is a + * guaranteed failure. Matches `STREAM_TIMEOUT_MS` in the CLI's chat command + * (src/cli/src/commands/chat.ts), which already allowed 10 minutes; mcplocal + * sitting in the middle with 30s was the binding constraint. + * + * Override with `MCPLOCAL_LONG_TIMEOUT_MS`. + */ +export const LONG_RUNNING_TIMEOUT_MS = Number(process.env['MCPLOCAL_LONG_TIMEOUT_MS']) || 600_000; + /** * Discovery-class operations (tools/list, resources/list, prompts/list) should not share * the full tool-call timeout budget — a single dead upstream would stall session init for @@ -121,9 +153,7 @@ export class McpdClient { try { res = await fetch(url, init); } catch (err: unknown) { - if (err instanceof DOMException && err.name === 'TimeoutError') { - throw new ConnectionError(this.baseUrl, new Error(`Request timed out after ${this.timeoutMs}ms`)); - } + if (isTimeout(err)) throw new UpstreamTimeoutError(this.baseUrl, this.timeoutMs); throw new ConnectionError(this.baseUrl, err); } @@ -131,7 +161,18 @@ export class McpdClient { throw new AuthenticationError(); } - const text = await res.text(); + // The body read MUST be inside a try. mcpd writes SSE headers immediately + // on chat routes, so fetch() resolves long before the turn finishes and the + // abort lands here instead — previously escaping as a raw DOMException and + // surfacing to the user as an opaque `500 code:23`. + let text: string; + try { + text = await res.text(); + } catch (err: unknown) { + if (isTimeout(err)) throw new UpstreamTimeoutError(this.baseUrl, this.timeoutMs); + throw new ConnectionError(this.baseUrl, err); + } + let parsed: unknown; try { parsed = JSON.parse(text); @@ -142,6 +183,51 @@ export class McpdClient { return { status: res.status, body: parsed }; } + /** + * Forward a request and hand back the raw Response, body unread. + * + * `forward()` buffers through `res.text()`, which is fine for CRUD but + * defeats streaming entirely: an SSE chat arrives at the client as one blob + * after the turn ends, so the token-by-token output the CLI draws never + * appears. Streaming routes use this instead and pipe the body straight + * through. + */ + async forwardStream( + method: string, + path: string, + query: string, + body: unknown | undefined, + authOverride?: string, + ): Promise { + const url = `${this.baseUrl}${path}${query ? `?${query}` : ''}`; + const headers: Record = { + ...this.extraHeaders, + 'Authorization': `Bearer ${authOverride ?? this.token}`, + // Accept both: mcpd picks SSE or JSON based on the request's `stream` flag. + 'Accept': 'text/event-stream, application/json', + }; + + const init: RequestInit = { + method, + headers, + signal: AbortSignal.timeout(this.timeoutMs), + }; + if (body !== undefined && body !== null && method !== 'GET' && method !== 'HEAD') { + headers['Content-Type'] = 'application/json'; + init.body = JSON.stringify(body); + } + + try { + const res = await fetch(url, init); + if (res.status === 401) throw new AuthenticationError(); + return res; + } catch (err: unknown) { + if (err instanceof AuthenticationError) throw err; + if (isTimeout(err)) throw new UpstreamTimeoutError(this.baseUrl, this.timeoutMs); + throw new ConnectionError(this.baseUrl, err); + } + } + private async request(method: string, path: string, body?: unknown): Promise { const result = await this.forward(method, path, '', body); diff --git a/src/mcplocal/src/http/routes/proxy.ts b/src/mcplocal/src/http/routes/proxy.ts index 985f6e6..d6ff369 100644 --- a/src/mcplocal/src/http/routes/proxy.ts +++ b/src/mcplocal/src/http/routes/proxy.ts @@ -1,10 +1,62 @@ /** * Catch-all proxy route that forwards /api/v1/* requests to mcpd. */ -import type { FastifyInstance } from 'fastify'; -import { AuthenticationError, ConnectionError } from '../mcpd-client.js'; +import { Readable } from 'node:stream'; + +import type { FastifyInstance, FastifyReply } from 'fastify'; + +import { AuthenticationError, ConnectionError, UpstreamTimeoutError, LONG_RUNNING_TIMEOUT_MS } from '../mcpd-client.js'; import type { McpdClient } from '../mcpd-client.js'; +/** + * Routes that are expected to run long and/or stream. + * + * An agent turn is a multi-turn tool-use loop — minutes, not seconds — so the + * 30s default budget guarantees failure rather than guarding against it. These + * also stream SSE, which must be piped rather than buffered or the client sees + * one blob at the end instead of live output. + */ +const LONG_RUNNING = [ + /^\/api\/v1\/agents\/[^/]+\/chat\b/, + /^\/api\/v1\/projects\/[^/]+\/chat\b/, + /^\/api\/v1\/llms\/[^/]+\/infer\b/, + /^\/api\/v1\/inference-tasks\/[^/]+\/stream\b/, +]; + +function isLongRunning(path: string): boolean { + return LONG_RUNNING.some((re) => re.test(path)); +} + +/** Headers worth preserving from mcpd; everything else is re-derived by Fastify. */ +const PASSTHROUGH_HEADERS = ['content-type', 'cache-control', 'x-accel-buffering']; + +function sendUpstreamError(reply: FastifyReply, err: unknown): FastifyReply | undefined { + if (err instanceof AuthenticationError) { + return reply.code(401).send({ + error: 'unauthorized', + message: 'Authentication with mcpd failed. Run `mcpctl login` to refresh your token.', + }); + } + if (err instanceof UpstreamTimeoutError) { + // 504, not 503 — mcpd was reachable, it just did not finish. Reporting this + // as "cannot reach mcpd" sent a previous debugging session chasing a + // network fault while /healthz answered in 32ms. + return reply.code(504).send({ + error: 'upstream_timeout', + message: + `mcpd did not respond within ${String(err.timeoutMs)}ms. The daemon is reachable — the ` + + 'request itself ran long. Raise MCPLOCAL_LONG_TIMEOUT_MS if this is a legitimately slow turn.', + }); + } + if (err instanceof ConnectionError) { + return reply.code(503).send({ + error: 'service_unavailable', + message: 'Cannot reach mcpd daemon. Is it running?', + }); + } + return undefined; +} + export function registerProxyRoutes(app: FastifyInstance, client: McpdClient): void { app.all('/api/v1/*', async (request, reply) => { const path = (request.url.split('?')[0]) ?? '/'; @@ -21,23 +73,74 @@ export function registerProxyRoutes(app: FastifyInstance, client: McpdClient): v const authHeader = request.headers['authorization'] as string | undefined; const userToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : undefined; + if (isLongRunning(path)) { + return proxyStreaming(reply, client, request.method, path, querystring, body, userToken); + } + try { const result = await client.forward(request.method, path, querystring, body, userToken); return reply.code(result.status).send(result.body); } catch (err: unknown) { - if (err instanceof AuthenticationError) { - return reply.code(401).send({ - error: 'unauthorized', - message: 'Authentication with mcpd failed. Run `mcpctl login` to refresh your token.', - }); - } - if (err instanceof ConnectionError) { - return reply.code(503).send({ - error: 'service_unavailable', - message: 'Cannot reach mcpd daemon. Is it running?', - }); - } + const handled = sendUpstreamError(reply, err); + if (handled) return handled; throw err; } }); } + +/** + * Pipe a long-running response straight through, headers and all. + * + * Hijacks the reply so Fastify does not try to serialize a stream, then copies + * mcpd's status and content-type before piping. `x-accel-buffering` matters: + * mcpd sets it to `no` so intermediaries don't buffer SSE, and dropping it here + * would reintroduce the exact stall we are fixing. + */ +async function proxyStreaming( + reply: FastifyReply, + client: McpdClient, + method: string, + path: string, + querystring: string, + body: unknown, + userToken: string | undefined, +): Promise { + const longClient = client.withTimeout(LONG_RUNNING_TIMEOUT_MS); + + let res: Response; + try { + res = await longClient.forwardStream(method, path, querystring, body, userToken); + } catch (err: unknown) { + const handled = sendUpstreamError(reply, err); + if (handled) return; + throw err; + } + + const headers: Record = {}; + for (const name of PASSTHROUGH_HEADERS) { + const value = res.headers.get(name); + if (value !== null) headers[name] = value; + } + + reply.hijack(); + reply.raw.writeHead(res.status, headers); + + if (res.body === null) { + reply.raw.end(); + return; + } + + try { + // Node's Readable.fromWeb bridges the fetch ReadableStream onto the socket. + await new Promise((resolve, reject) => { + const upstream = Readable.fromWeb(res.body as Parameters[0]); + upstream.on('error', reject); + reply.raw.on('close', () => { upstream.destroy(); resolve(); }); + upstream.pipe(reply.raw).on('finish', resolve).on('error', reject); + }); + } catch { + // Headers are already on the wire, so there is no status left to change. + // Close the socket; the client surfaces the truncated stream. + if (!reply.raw.writableEnded) reply.raw.end(); + } +} diff --git a/src/mcplocal/src/index.ts b/src/mcplocal/src/index.ts index 489e0ba..dbc01d4 100644 --- a/src/mcplocal/src/index.ts +++ b/src/mcplocal/src/index.ts @@ -11,7 +11,7 @@ export type { MainResult } from './main.js'; export { ProviderRegistry } from './providers/index.js'; export type { LlmProvider, CompletionOptions, CompletionResult, ChatMessage } from './providers/index.js'; export { OpenAiProvider, AnthropicProvider, OllamaProvider, GeminiCliProvider, DeepSeekProvider } from './providers/index.js'; -export { createHttpServer, loadHttpConfig, McpdClient, AuthenticationError, ConnectionError, registerProxyRoutes } from './http/index.js'; +export { createHttpServer, loadHttpConfig, McpdClient, AuthenticationError, ConnectionError, UpstreamTimeoutError, registerProxyRoutes } from './http/index.js'; export type { HttpConfig, HttpServerDeps } from './http/index.js'; export type { JsonRpcRequest, diff --git a/src/mcplocal/tests/mcpd-client.test.ts b/src/mcplocal/tests/mcpd-client.test.ts index c9a50d4..7dac059 100644 --- a/src/mcplocal/tests/mcpd-client.test.ts +++ b/src/mcplocal/tests/mcpd-client.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, afterAll, afterEach } from 'vitest'; import http from 'node:http'; -import { McpdClient, ConnectionError } from '../src/http/mcpd-client.js'; +import { McpdClient, ConnectionError, UpstreamTimeoutError } from '../src/http/mcpd-client.js'; /** * Create a local HTTP server for testing McpdClient behavior. @@ -85,7 +85,7 @@ describe('McpdClient', () => { // ── Timeout behavior ── - it('times out on slow responses and throws ConnectionError', async () => { + it('times out on slow responses and throws UpstreamTimeoutError', async () => { const { server, url } = await createTestServer((_req, _res) => { // Never respond — simulates a hanging upstream tool call }); @@ -96,7 +96,7 @@ describe('McpdClient', () => { const start = Date.now(); await expect(client.post('/api/v1/mcp/proxy', { serverId: 's1' })).rejects.toThrow( - /timed out/, + /did not respond within/, ); const elapsed = Date.now() - start; @@ -105,7 +105,7 @@ describe('McpdClient', () => { expect(elapsed).toBeLessThan(3000); }); - it('timeout error is a ConnectionError with descriptive message', async () => { + it('timeout is NOT a ConnectionError — a slow daemon is not an absent one', async () => { const { server, url } = await createTestServer((_req, _res) => { // Never respond }); @@ -117,8 +117,12 @@ describe('McpdClient', () => { await client.get('/test'); expect.unreachable('Should have thrown'); } catch (err) { - expect(err).toBeInstanceOf(ConnectionError); - expect((err as Error).message).toContain('Request timed out after 200ms'); + // Reporting a timeout as "cannot connect" is what sent a previous + // debugging session chasing a network fault that did not exist. + expect(err).toBeInstanceOf(UpstreamTimeoutError); + expect(err).not.toBeInstanceOf(ConnectionError); + expect((err as UpstreamTimeoutError).timeoutMs).toBe(200); + expect((err as Error).message).toContain('did not respond within 200ms'); } }); @@ -146,7 +150,7 @@ describe('McpdClient', () => { const derived = client.withHeaders({ 'X-Custom': 'val' }); const start = Date.now(); - await expect(derived.get('/test')).rejects.toThrow(/timed out/); + await expect(derived.get('/test')).rejects.toThrow(/did not respond within/); const elapsed = Date.now() - start; expect(elapsed).toBeLessThan(2000); }); diff --git a/src/mcplocal/tests/proxy-long-running.test.ts b/src/mcplocal/tests/proxy-long-running.test.ts new file mode 100644 index 0000000..0c5fd04 --- /dev/null +++ b/src/mcplocal/tests/proxy-long-running.test.ts @@ -0,0 +1,196 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { describe, it, expect, afterEach } from 'vitest'; + +import { + McpdClient, + UpstreamTimeoutError, + ConnectionError, + LONG_RUNNING_TIMEOUT_MS, + DEFAULT_TIMEOUT_MS, +} from '../src/http/mcpd-client.js'; +import { registerProxyRoutes } from '../src/http/routes/proxy.js'; + +/** + * Regression cover for the 30s proxy timeout that made `mcpctl chat` fail with + * a misleading "Cannot reach mcpd daemon" 503 while mcpd was answering + * /healthz in 32ms. + * + * Three separate defects are pinned here: + * 1. chat routes inherited the 30s CRUD budget, so any turn longer than 30s + * failed — and an agent turn is a tool-use loop that routinely exceeds it; + * 2. a timeout was reported as a connection failure, sending diagnosis after + * a network fault that did not exist; + * 3. SSE was buffered through res.text(), so streaming never reached the + * client even when the turn finished in time. + */ +let app: FastifyInstance | null = null; +let upstream: FastifyInstance | null = null; + +afterEach(async () => { + if (app) { await app.close(); app = null; } + if (upstream) { await upstream.close(); upstream = null; } +}); + +/** A stand-in mcpd. Returns its base URL. */ +async function startUpstream(register: (a: FastifyInstance) => void): Promise { + upstream = Fastify(); + register(upstream); + await upstream.listen({ port: 0, host: '127.0.0.1' }); + const addr = upstream.server.address(); + if (addr === null || typeof addr === 'string') throw new Error('no address'); + return `http://127.0.0.1:${String(addr.port)}`; +} + +async function startProxy(baseUrl: string, timeoutMs?: number): Promise { + app = Fastify(); + registerProxyRoutes(app, new McpdClient(baseUrl, 'test-token', {}, timeoutMs)); + await app.ready(); + return app; +} + +describe('proxy — long-running route budget', () => { + it('gives chat routes the long budget, not the 30s CRUD default', () => { + // The constants themselves are the contract: a 30s cap on an agent turn is + // a guaranteed failure, not a safety net. + expect(DEFAULT_TIMEOUT_MS).toBe(30_000); + expect(LONG_RUNNING_TIMEOUT_MS).toBeGreaterThanOrEqual(600_000); + }); + + it('does not abort an agent chat that outlives the CRUD budget', async () => { + const base = await startUpstream((a) => { + a.post('/api/v1/agents/:name/chat', async () => { + // Longer than the (deliberately tiny) CRUD budget below. Before the + // fix this inherited that budget and 503'd. + await new Promise((r) => setTimeout(r, 250)); + return { answer: 'pong' }; + }); + }); + // CRUD budget of 50ms — a chat route must NOT inherit it. + const proxy = await startProxy(base, 50); + + const res = await proxy.inject({ + method: 'POST', + url: '/api/v1/agents/reviewer/chat', + payload: { message: 'hi' }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ answer: 'pong' }); + }); + + it('still applies the short budget to ordinary CRUD routes', async () => { + const base = await startUpstream((a) => { + a.get('/api/v1/servers', async () => { + await new Promise((r) => setTimeout(r, 300)); + return []; + }); + }); + const proxy = await startProxy(base, 50); + + const res = await proxy.inject({ method: 'GET', url: '/api/v1/servers' }); + // Times out — and is now reported honestly as a timeout, not a connection fault. + expect(res.statusCode).toBe(504); + expect(res.json().error).toBe('upstream_timeout'); + }); + + it('reports a timeout as 504, never as "cannot reach mcpd"', async () => { + const base = await startUpstream((a) => { + a.get('/api/v1/servers', async () => { + await new Promise((r) => setTimeout(r, 300)); + return []; + }); + }); + const proxy = await startProxy(base, 50); + + const res = await proxy.inject({ method: 'GET', url: '/api/v1/servers' }); + const body = res.json(); + expect(body.message).toMatch(/did not respond within/); + expect(body.message).not.toMatch(/Cannot reach mcpd/); + expect(body.message).toMatch(/reachable/); + }); + + it('streams SSE through instead of buffering it', async () => { + const base = await startUpstream((a) => { + a.post('/api/v1/agents/:name/chat', async (_req, reply) => { + reply.raw.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'X-Accel-Buffering': 'no', + }); + reply.raw.write('data: {"type":"text","delta":"po"}\n\n'); + reply.raw.write('data: {"type":"text","delta":"ng"}\n\n'); + reply.raw.write('data: [DONE]\n\n'); + reply.raw.end(); + return reply; + }); + }); + const proxy = await startProxy(base, 50); + + const res = await proxy.inject({ + method: 'POST', + url: '/api/v1/agents/reviewer/chat', + payload: { message: 'hi', stream: true }, + }); + + expect(res.statusCode).toBe(200); + // Content-type must survive — a client that gets application/json will not + // parse the event stream. + expect(res.headers['content-type']).toMatch(/text\/event-stream/); + // x-accel-buffering=no must survive too, or intermediaries re-buffer the + // stream and reintroduce the stall. + expect(res.headers['x-accel-buffering']).toBe('no'); + expect(res.body).toContain('"delta":"po"'); + expect(res.body).toContain('"delta":"ng"'); + expect(res.body).toContain('[DONE]'); + }); + + it('relays a non-200 status from a streaming route', async () => { + const base = await startUpstream((a) => { + a.post('/api/v1/agents/:name/chat', async (_req, reply) => { + return reply.code(404).send({ error: 'Agent not found' }); + }); + }); + const proxy = await startProxy(base, 50); + + const res = await proxy.inject({ + method: 'POST', + url: '/api/v1/agents/ghost/chat', + payload: { message: 'hi' }, + }); + expect(res.statusCode).toBe(404); + expect(res.body).toContain('Agent not found'); + }); + + it('still reports a genuinely unreachable daemon as 503', async () => { + // Port 1 is reserved and refuses instantly. + const proxy = await startProxy('http://127.0.0.1:1', 500); + const res = await proxy.inject({ method: 'GET', url: '/api/v1/servers' }); + + expect(res.statusCode).toBe(503); + expect(res.json().error).toBe('service_unavailable'); + }); + + it('propagates 401 from a streaming route so login guidance still fires', async () => { + const base = await startUpstream((a) => { + a.post('/api/v1/agents/:name/chat', async (_req, reply) => reply.code(401).send({})); + }); + const proxy = await startProxy(base, 50); + + const res = await proxy.inject({ + method: 'POST', + url: '/api/v1/agents/reviewer/chat', + payload: { message: 'hi' }, + }); + expect(res.statusCode).toBe(401); + expect(res.json().message).toMatch(/mcpctl login/); + }); +}); + +describe('error taxonomy', () => { + it('keeps timeout and unreachable as distinct types', () => { + const timeout = new UpstreamTimeoutError('http://mcpd', 30_000); + expect(timeout).not.toBeInstanceOf(ConnectionError); + expect(timeout.timeoutMs).toBe(30_000); + expect(timeout.message).toMatch(/did not respond within 30000ms/); + }); +}); -- 2.49.1 From 05abe3522c4d54c88231e3ca5e0b1d71301043fc Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 8 Aug 2026 16:28:26 +0100 Subject: [PATCH 10/32] =?UTF-8?q?feat(mcpd):=20Task=20lifecycle=20?= =?UTF-8?q?=E2=80=94=20CAS=20transitions,=20risk-scaled=20done=20gate,=20a?= =?UTF-8?q?ccept=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The work-item spine: repository, validation, service and routes for Task, plus its verdicts and append-only transition log. Transitions are compare-and-swap. The expected current state is part of the WHERE clause and the affected-row count is the answer, so two agents racing to advance the same task cannot both win — a read-then-write would let both "succeed" and silently lose one. Losing the race is a 409 naming the state the task is actually in, not a 500, because on a shared board that is a normal outcome the caller retries. Same idiom as InferenceTaskRepository's claim. QA / Security / SRE are stages inside the task's own loop, not post-submit reviewers, so each can bounce straight back to in_progress — that is what "feedback" means here. A failed build likewise goes backward, not forward. `merged` allows only `reopened`, so a merged task cannot be quietly edited; post-merge failure is the one way back in. The gate on `done` scales with risk instead of running a fixed pipeline: trivial needs nothing, normal needs a passing QA verdict, high needs QA + Security + SRE. Forcing a one-line docs fix through security review is how a process gets routed around, so the cost has to match the risk. A FAILING verdict does not clear a gate — a recorded failure is evidence the gate was tried and did not hold. The 400 names which gates are still missing. The human accept gate is a task-level yes/no on a resolved outcome, not per-tool approval: accept fires the merge and stores the comment, reject sends it back to in_progress and REQUIRES a comment, because the comment is the direction the agent works from. GET /tasks/:id returns `legalTransitions` so no client has to hardcode the table. Tests: 25 service tests — full happy path, every stage's bounce-back, illegal and no-op transitions, the CAS race (including that no event is written when the race is lost), the complete risk matrix including failing-verdict-does-not- count, and the accept/reject gate. Local: build clean, workspace 2400 passed (+25), db 128 passed (same 4 pre-existing Secret failures), lint unchanged at 869. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p --- .taskmaster/tasks/tasks.json | 16 +- src/mcpd/src/main.ts | 9 + src/mcpd/src/repositories/task.repository.ts | 163 ++++++++++ src/mcpd/src/routes/tasks.ts | 108 +++++++ src/mcpd/src/services/task.service.ts | 294 +++++++++++++++++++ src/mcpd/src/validation/task.schema.ts | 60 ++++ src/mcpd/tests/services/task-service.test.ts | 290 ++++++++++++++++++ 7 files changed, 932 insertions(+), 8 deletions(-) create mode 100644 src/mcpd/src/repositories/task.repository.ts create mode 100644 src/mcpd/src/routes/tasks.ts create mode 100644 src/mcpd/src/services/task.service.ts create mode 100644 src/mcpd/src/validation/task.schema.ts create mode 100644 src/mcpd/tests/services/task-service.test.ts diff --git a/.taskmaster/tasks/tasks.json b/.taskmaster/tasks/tasks.json index 6978ece..a0058f9 100644 --- a/.taskmaster/tasks/tasks.json +++ b/.taskmaster/tasks/tasks.json @@ -2755,9 +2755,9 @@ "dependencies": [ "118" ], - "status": "pending", + "status": "done", "subtasks": [], - "updatedAt": "2026-08-08T08:55:58.627Z" + "updatedAt": "2026-08-08T15:28:26.687Z" }, { "id": "130", @@ -2769,9 +2769,9 @@ "dependencies": [ "129" ], - "status": "pending", + "status": "done", "subtasks": [], - "updatedAt": "2026-08-08T08:55:58.627Z" + "updatedAt": "2026-08-08T15:28:26.698Z" }, { "id": "131", @@ -2783,9 +2783,9 @@ "dependencies": [ "130" ], - "status": "pending", + "status": "done", "subtasks": [], - "updatedAt": "2026-08-08T08:55:58.627Z" + "updatedAt": "2026-08-08T15:28:26.705Z" }, { "id": "132", @@ -3251,9 +3251,9 @@ ], "metadata": { "version": "1.0.0", - "lastModified": "2026-08-08T10:10:39.761Z", + "lastModified": "2026-08-08T15:28:26.705Z", "taskCount": 162, - "completedCount": 113, + "completedCount": 116, "tags": [ "master" ] diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index 89e0f96..3d8a3c5 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -108,6 +108,7 @@ import { registerRevisionRoutes } from './routes/revisions.js'; import { registerProposalRoutes } from './routes/proposals.js'; import { registerEpicRoutes } from './routes/epics.js'; import { registerEpicTaskRoutes } from './routes/epictasks.js'; +import { registerTaskRoutes } from './routes/tasks.js'; import { registerSkillRoutes } from './routes/skills.js'; import { registerGitBackupRoutes } from './routes/git-backup.js'; import { PromptService } from './services/prompt.service.js'; @@ -117,9 +118,11 @@ import { ResourceRevisionService } from './services/resource-revision.service.js import { ResourceProposalService } from './services/resource-proposal.service.js'; import { EpicRepository } from './repositories/epic.repository.js'; import { EpicTaskRepository } from './repositories/epic-task.repository.js'; +import { TaskRepository } from './repositories/task.repository.js'; import { SkillRepository } from './repositories/skill.repository.js'; import { EpicService } from './services/epic.service.js'; import { EpicTaskService } from './services/epic-task.service.js'; +import { TaskService } from './services/task.service.js'; import { SkillService } from './services/skill.service.js'; import { GitBackupService } from './services/backup/git-backup.service.js'; import type { BackupKind } from './services/backup/yaml-serializer.js'; @@ -230,6 +233,8 @@ function mapUrlToPermission(method: string, url: string): PermissionCheck { // ── Agentic teams ── 'epics': 'epics', 'epictasks': 'epictasks', + // Work items. NOT inference-tasks — see the rbac-tasks-backfill bootstrap. + 'tasks': 'tasks', }; const resource = resourceMap[segment]; @@ -440,6 +445,7 @@ async function main(): Promise { // nameResolvers needs it for CUID→name RBAC resolution. const epicRepo = new EpicRepository(prisma); const epicTaskRepo = new EpicTaskRepository(prisma); + const workTaskRepo = new TaskRepository(prisma); // SecretBackend bootstrap: ensure a `plaintext` default row exists and any // pre-existing `Secret` rows are pointed at it. Idempotent per run. @@ -458,6 +464,7 @@ async function main(): Promise { agents: agentRepo, epics: epicRepo, epictasks: epicTaskRepo, + tasks: workTaskRepo, // Resolve user CUID → email so name-scoped `edit:users:` bindings // match on PUT /api/v1/users/:id/password (admin reset). users: { findById: async (id: string) => { @@ -566,6 +573,7 @@ async function main(): Promise { const epicService = new EpicService(epicRepo, projectRepo); epicService.setRevisionService(resourceRevisionService); const epicTaskService = new EpicTaskService(epicTaskRepo, epicRepo); + const workTaskService = new TaskService(workTaskRepo, epicTaskRepo); const personalityRepo = new PersonalityRepository(prisma); const personalityService = new PersonalityService(personalityRepo, agentRepo, promptRepo); const agentService = new AgentService(agentRepo, llmService, projectService, personalityRepo); @@ -776,6 +784,7 @@ async function main(): Promise { registerSkillRoutes(app, skillService, projectRepo, agentRepo); registerEpicRoutes(app, epicService, projectRepo); registerEpicTaskRoutes(app, epicTaskService, epicRepo, epicTaskRepo, projectRepo); + registerTaskRoutes(app, workTaskService, epicTaskRepo); // ── Git-based backup ── const gitBackup = new GitBackupService(prisma); diff --git a/src/mcpd/src/repositories/task.repository.ts b/src/mcpd/src/repositories/task.repository.ts new file mode 100644 index 0000000..14dd9ea --- /dev/null +++ b/src/mcpd/src/repositories/task.repository.ts @@ -0,0 +1,163 @@ +import type { PrismaClient, Prisma, Task, TaskEvent, TaskState, TaskVerdict, TaskRisk } from '@prisma/client'; + +/** + * Task repository — work items plus their verdicts and transition log. + * + * State changes go through `transition()`, which is a compare-and-swap: the + * expected current state is part of the WHERE clause and the affected-row count + * is the result. Two agents racing to advance the same task both issue a valid + * UPDATE; exactly one matches. A read-then-write would let both "succeed" and + * silently lose one transition. Same idiom as InferenceTaskRepository's claim. + */ + +export interface TaskCreateInput { + name: string; + title: string; + epicTaskId: string; + ownerId: string; + description?: string; + repoId?: string; + assigneeId?: string; + risk?: TaskRisk; +} + +export interface TaskUpdateInput { + title?: string; + description?: string; + repoId?: string | null; + assigneeId?: string | null; + risk?: TaskRisk; + branch?: string; + headCommit?: string; + prUrl?: string; + prNumber?: number; + reviewNote?: string; +} + +export interface TaskFilters { + epicTaskId?: string; + state?: TaskState; + assigneeId?: string; +} + +export interface VerdictCreateInput { + taskId: string; + stage: TaskState; + commit: string; + passed: boolean; + summary: string; + agentId?: string; + findings?: Prisma.InputJsonValue; +} + +export interface EventCreateInput { + taskId: string; + toState: TaskState; + actorType: string; + fromState?: TaskState; + actorId?: string; + note?: string; +} + +export interface ITaskRepository { + findAll(filters?: TaskFilters): Promise; + findById(id: string): Promise; + findByName(name: string): Promise; + findByNameAndEpicTask(name: string, epicTaskId: string): Promise; + create(data: TaskCreateInput): Promise; + update(id: string, data: TaskUpdateInput): Promise; + delete(id: string): Promise; + + /** Compare-and-swap. Returns true only if this caller won the transition. */ + transition(id: string, fromState: TaskState, toState: TaskState): Promise; + + appendEvent(data: EventCreateInput): Promise; + findEvents(taskId: string): Promise; + addVerdict(data: VerdictCreateInput): Promise; + findVerdicts(taskId: string): Promise; +} + +const TASK_INCLUDE = { + epicTask: { select: { name: true, epicId: true } }, + repo: { select: { name: true } }, + assignee: { select: { name: true } }, +} as const; + +export class TaskRepository implements ITaskRepository { + constructor(private readonly prisma: PrismaClient) {} + + async findAll(filters: TaskFilters = {}): Promise { + return this.prisma.task.findMany({ + where: { + ...(filters.epicTaskId !== undefined ? { epicTaskId: filters.epicTaskId } : {}), + ...(filters.state !== undefined ? { state: filters.state } : {}), + ...(filters.assigneeId !== undefined ? { assigneeId: filters.assigneeId } : {}), + }, + include: TASK_INCLUDE, + orderBy: { createdAt: 'asc' }, + }); + } + + async findById(id: string): Promise { + return this.prisma.task.findUnique({ where: { id }, include: TASK_INCLUDE }); + } + + /** Names are unique per EpicTask; this is for the RBAC CUID→name resolver. */ + async findByName(name: string): Promise { + return this.prisma.task.findFirst({ where: { name }, include: TASK_INCLUDE }); + } + + async findByNameAndEpicTask(name: string, epicTaskId: string): Promise { + return this.prisma.task.findUnique({ + where: { name_epicTaskId: { name, epicTaskId } }, + include: TASK_INCLUDE, + }); + } + + async create(data: TaskCreateInput): Promise { + return this.prisma.task.create({ data, include: TASK_INCLUDE }); + } + + async update(id: string, data: TaskUpdateInput): Promise { + return this.prisma.task.update({ + where: { id }, + data: { ...data, version: { increment: 1 } }, + include: TASK_INCLUDE, + }); + } + + async delete(id: string): Promise { + await this.prisma.task.delete({ where: { id } }); + } + + /** + * Advance the state only if it is still `fromState`. + * + * `updateMany` (not `update`) because we need the affected-row count rather + * than a throw: zero means someone else moved the task first, which is a + * normal race outcome the caller reports as a conflict, not an error. + */ + async transition(id: string, fromState: TaskState, toState: TaskState): Promise { + const result = await this.prisma.task.updateMany({ + where: { id, state: fromState }, + data: { state: toState, version: { increment: 1 } }, + }); + return result.count === 1; + } + + async appendEvent(data: EventCreateInput): Promise { + return this.prisma.taskEvent.create({ data }); + } + + async findEvents(taskId: string): Promise { + return this.prisma.taskEvent.findMany({ where: { taskId }, orderBy: { createdAt: 'asc' } }); + } + + async addVerdict(data: VerdictCreateInput): Promise { + return this.prisma.taskVerdict.create({ data }); + } + + async findVerdicts(taskId: string): Promise { + return this.prisma.taskVerdict.findMany({ where: { taskId }, orderBy: { createdAt: 'asc' } }); + } +} diff --git a/src/mcpd/src/routes/tasks.ts b/src/mcpd/src/routes/tasks.ts new file mode 100644 index 0000000..798c2bb --- /dev/null +++ b/src/mcpd/src/routes/tasks.ts @@ -0,0 +1,108 @@ +import type { TaskState } from '@prisma/client'; +import type { FastifyInstance, FastifyRequest } from 'fastify'; + +import type { IEpicTaskRepository } from '../repositories/epic-task.repository.js'; +import type { TaskService } from '../services/task.service.js'; +import { TaskService as TaskSvc } from '../services/task.service.js'; + +/** Who is driving this transition — used for the append-only event log. */ +function actorOf(request: FastifyRequest): { type: string; id?: string } { + const userId = request.userId; + return userId === undefined ? { type: 'system' } : { type: 'user', id: userId }; +} + +export function registerTaskRoutes( + app: FastifyInstance, + service: TaskService, + epicTaskRepo: IEpicTaskRepository, +): void { + // ── List / get ── + + app.get<{ Querystring: { epictask?: string; epicTaskId?: string; state?: TaskState; assignee?: string } }>( + '/api/v1/tasks', + async (request) => { + const { epictask, epicTaskId, state, assignee } = request.query; + let resolvedEpicTaskId = epicTaskId; + if (resolvedEpicTaskId === undefined && epictask !== undefined) { + const node = await epicTaskRepo.findByName(epictask); + if (node === null) { + throw Object.assign(new Error(`EpicTask not found: ${epictask}`), { statusCode: 404 }); + } + resolvedEpicTaskId = node.id; + } + return service.list({ + ...(resolvedEpicTaskId !== undefined ? { epicTaskId: resolvedEpicTaskId } : {}), + ...(state !== undefined ? { state } : {}), + ...(assignee !== undefined ? { assigneeId: assignee } : {}), + }); + }, + ); + + app.get<{ Params: { id: string } }>('/api/v1/tasks/:id', async (request) => { + const task = await service.get(request.params.id); + // Surface what can happen next so a client never has to hardcode the table. + return { ...task, legalTransitions: TaskSvc.legalFrom(task.state) }; + }); + + // ── Create / update / delete ── + + app.post('/api/v1/tasks', async (request, reply) => { + const body = request.body as Record; + const resolved: Record = { ...body }; + + if (typeof body['epictask'] === 'string') { + const node = await epicTaskRepo.findByName(body['epictask']); + if (node === null) { + throw Object.assign(new Error(`EpicTask not found: ${body['epictask']}`), { statusCode: 404 }); + } + resolved['epicTaskId'] = node.id; + delete resolved['epictask']; + } + + const task = await service.create(resolved, request.userId ?? 'system'); + reply.code(201); + return task; + }); + + app.put<{ Params: { id: string } }>('/api/v1/tasks/:id', async (request) => { + return service.update(request.params.id, request.body); + }); + + app.delete<{ Params: { id: string } }>('/api/v1/tasks/:id', async (request, reply) => { + await service.delete(request.params.id); + reply.code(204); + }); + + // ── Lifecycle ── + + app.post<{ Params: { id: string } }>('/api/v1/tasks/:id/transition', async (request) => { + const { task, event } = await service.transition(request.params.id, request.body, actorOf(request)); + return { ...task, legalTransitions: TaskSvc.legalFrom(task.state), event }; + }); + + app.post<{ Params: { id: string } }>('/api/v1/tasks/:id/accept', async (request) => { + const { task } = await service.accept(request.params.id, request.body ?? {}, actorOf(request)); + return task; + }); + + app.post<{ Params: { id: string } }>('/api/v1/tasks/:id/reject', async (request) => { + const { task } = await service.reject(request.params.id, request.body, actorOf(request)); + return task; + }); + + // ── Verdicts + history ── + + app.post<{ Params: { id: string } }>('/api/v1/tasks/:id/verdicts', async (request, reply) => { + const verdict = await service.addVerdict(request.params.id, request.body); + reply.code(201); + return verdict; + }); + + app.get<{ Params: { id: string } }>('/api/v1/tasks/:id/verdicts', async (request) => { + return service.listVerdicts(request.params.id); + }); + + app.get<{ Params: { id: string } }>('/api/v1/tasks/:id/events', async (request) => { + return service.listEvents(request.params.id); + }); +} diff --git a/src/mcpd/src/services/task.service.ts b/src/mcpd/src/services/task.service.ts new file mode 100644 index 0000000..8fa35f9 --- /dev/null +++ b/src/mcpd/src/services/task.service.ts @@ -0,0 +1,294 @@ +import type { Prisma, Task, TaskEvent, TaskRisk, TaskState, TaskVerdict } from '@prisma/client'; + +import type { IEpicTaskRepository } from '../repositories/epic-task.repository.js'; +import type { ITaskRepository } from '../repositories/task.repository.js'; +import { + AcceptTaskSchema, + AddVerdictSchema, + CreateTaskSchema, + RejectTaskSchema, + TransitionTaskSchema, +} from '../validation/task.schema.js'; +import { UpdateTaskSchema } from '../validation/task.schema.js'; + +import { NotFoundError } from './mcp-server.service.js'; + +/** + * Legal state transitions. + * + * QA / Security / SRE are stages INSIDE the task's own loop, not post-submit + * reviewers, which is why each of them can bounce straight back to + * `in_progress` — that is what "feedback" means here. `done` leaves the + * developer's loop; `awaiting_review` is the single human accept gate. + */ +const TRANSITIONS: Record = { + todo: ['in_progress', 'cancelled'], + in_progress: ['building', 'cancelled'], + // A failed build goes back to work rather than forward. + building: ['self_testing', 'in_progress', 'cancelled'], + self_testing: ['qa_review', 'done', 'in_progress', 'cancelled'], + qa_review: ['security_review', 'done', 'in_progress', 'cancelled'], + security_review: ['sre_review', 'done', 'in_progress', 'cancelled'], + sre_review: ['done', 'in_progress', 'cancelled'], + done: ['awaiting_review', 'in_progress', 'cancelled'], + // The human gate: accept fires the merge, reject sends it back with a comment. + awaiting_review: ['merged', 'in_progress', 'cancelled'], + // Post-merge failure reopens; the same agent picks it back up. + merged: ['reopened'], + reopened: ['in_progress', 'cancelled'], + cancelled: ['todo'], +}; + +/** + * Which review stages must have PASSED before a task may reach `done`. + * + * Verification effort scales with risk rather than running a fixed pipeline — + * forcing a one-line docs fix through security and SRE review is how a process + * gets routed around. Trivial work needs only the developer's own check. + */ +const REQUIRED_VERDICTS: Record = { + trivial: [], + normal: ['qa_review'], + high: ['qa_review', 'security_review', 'sre_review'], +}; + +const STAGE_LABEL: Record = { + qa_review: 'QA', + security_review: 'Security', + sre_review: 'SRE', +}; + +function badRequest(message: string): Error { + return Object.assign(new Error(message), { statusCode: 400 }); +} + +function conflict(message: string): Error { + return Object.assign(new Error(message), { statusCode: 409 }); +} + +export interface TransitionResult { + task: Task; + event: TaskEvent; +} + +export class TaskService { + constructor( + private readonly repo: ITaskRepository, + private readonly epicTaskRepo: IEpicTaskRepository, + ) {} + + static isLegal(from: TaskState, to: TaskState): boolean { + return TRANSITIONS[from].includes(to); + } + + static legalFrom(from: TaskState): readonly TaskState[] { + return TRANSITIONS[from]; + } + + // ── CRUD ── + + async list(filters: { epicTaskId?: string; state?: TaskState; assigneeId?: string } = {}): Promise { + return this.repo.findAll(filters); + } + + async get(id: string): Promise { + const task = await this.repo.findById(id); + if (task === null) throw new NotFoundError(`Task not found: ${id}`); + return task; + } + + async create(input: unknown, ownerId: string): Promise { + const data = CreateTaskSchema.parse(input); + + const epicTask = await this.epicTaskRepo.findById(data.epicTaskId); + if (epicTask === null) throw new NotFoundError(`EpicTask not found: ${data.epicTaskId}`); + + const duplicate = await this.repo.findByNameAndEpicTask(data.name, data.epicTaskId); + if (duplicate !== null) { + throw conflict(`Task '${data.name}' already exists in epictask '${epicTask.name}'`); + } + + const task = await this.repo.create({ + name: data.name, + title: data.title, + epicTaskId: data.epicTaskId, + ownerId, + ...(data.description !== undefined ? { description: data.description } : {}), + ...(data.repoId !== undefined ? { repoId: data.repoId } : {}), + ...(data.assigneeId !== undefined ? { assigneeId: data.assigneeId } : {}), + ...(data.risk !== undefined ? { risk: data.risk as TaskRisk } : {}), + }); + + // Seed the log so the history has an origin, not a first entry from nowhere. + await this.repo.appendEvent({ + taskId: task.id, + toState: task.state, + actorType: 'user', + actorId: ownerId, + note: 'created', + }); + return task; + } + + async update(id: string, input: unknown): Promise { + const data = UpdateTaskSchema.parse(input); + await this.get(id); + // Spread-guard rather than passing the parsed object straight through: + // exactOptionalPropertyTypes distinguishes "absent" from "explicitly + // undefined", and repoId/assigneeId use null (not undefined) to unset. + return this.repo.update(id, { + ...(data.title !== undefined ? { title: data.title } : {}), + ...(data.description !== undefined ? { description: data.description } : {}), + ...(data.repoId !== undefined ? { repoId: data.repoId } : {}), + ...(data.assigneeId !== undefined ? { assigneeId: data.assigneeId } : {}), + ...(data.risk !== undefined ? { risk: data.risk as TaskRisk } : {}), + ...(data.branch !== undefined ? { branch: data.branch } : {}), + ...(data.headCommit !== undefined ? { headCommit: data.headCommit } : {}), + ...(data.prUrl !== undefined ? { prUrl: data.prUrl } : {}), + ...(data.prNumber !== undefined ? { prNumber: data.prNumber } : {}), + }); + } + + async delete(id: string): Promise { + await this.get(id); + await this.repo.delete(id); + } + + // ── Lifecycle ── + + /** + * Move a task to `toState`. + * + * Guarded twice: the transition must be legal for the CURRENT state, and the + * write is a compare-and-swap so a concurrent mover cannot be silently + * clobbered. Losing the CAS is a 409, not a 500 — it means the task moved + * under us, which is a normal outcome when several agents share a board. + */ + async transition( + id: string, + input: unknown, + actor: { type: string; id?: string }, + ): Promise { + const { toState, note } = TransitionTaskSchema.parse(input); + const task = await this.get(id); + const from = task.state; + + if (from === toState) { + throw badRequest(`Task is already in state '${toState}'`); + } + if (!TaskService.isLegal(from, toState)) { + throw badRequest( + `Illegal transition ${from} → ${toState}. Legal from '${from}': ` + + `${TRANSITIONS[from].join(', ') || '(terminal)'}`, + ); + } + + if (toState === 'done') { + await this.assertVerificationSatisfied(task); + } + + const won = await this.repo.transition(id, from, toState); + if (!won) { + const current = await this.repo.findById(id); + throw conflict( + `Task moved concurrently: expected '${from}', now '${current?.state ?? 'unknown'}'. Re-read and retry.`, + ); + } + + const event = await this.repo.appendEvent({ + taskId: id, + fromState: from, + toState, + actorType: actor.type, + ...(actor.id !== undefined ? { actorId: actor.id } : {}), + ...(note !== undefined ? { note } : {}), + }); + + return { task: await this.get(id), event }; + } + + /** + * Enforce the risk-scaled gate on `done`. A stage counts as satisfied only by + * a PASSING verdict — a recorded failure is evidence the gate was tried and + * did not hold, not evidence it was cleared. + */ + private async assertVerificationSatisfied(task: Task): Promise { + const required = REQUIRED_VERDICTS[task.risk]; + if (required.length === 0) return; + + const verdicts = await this.repo.findVerdicts(task.id); + const passed = new Set(verdicts.filter((v) => v.passed).map((v) => v.stage)); + const missing = required.filter((stage) => !passed.has(stage)); + + if (missing.length > 0) { + const labels = missing.map((s) => STAGE_LABEL[s] ?? s).join(', '); + throw badRequest( + `Cannot mark done: risk '${task.risk}' requires a passing verdict from ${labels}. ` + + 'Record the verdict, or lower the task risk if the gate genuinely does not apply.', + ); + } + } + + /** Record a stage verdict, pinned to the commit it judged. */ + async addVerdict(id: string, input: unknown): Promise { + const data = AddVerdictSchema.parse(input); + await this.get(id); + + return this.repo.addVerdict({ + taskId: id, + stage: data.stage as TaskState, + commit: data.commit, + passed: data.passed, + summary: data.summary, + ...(data.agentId !== undefined ? { agentId: data.agentId } : {}), + ...(data.findings !== undefined + ? { findings: data.findings as unknown as Prisma.InputJsonValue } + : {}), + }); + } + + async listVerdicts(id: string): Promise { + await this.get(id); + return this.repo.findVerdicts(id); + } + + async listEvents(id: string): Promise { + await this.get(id); + return this.repo.findEvents(id); + } + + // ── The human accept gate ── + + /** + * Accept: awaiting_review → merged. The caller's comment is stored on the + * task, so the decision and its reason live together. + * + * This is a task-level yes/no on a resolved outcome, NOT a per-tool approval + * — the agent has already acted autonomously up to this point. + */ + async accept(id: string, input: unknown, actor: { type: string; id?: string }): Promise { + const { note } = AcceptTaskSchema.parse(input ?? {}); + const task = await this.get(id); + if (task.state !== 'awaiting_review') { + throw badRequest(`Only a task in 'awaiting_review' can be accepted (this one is '${task.state}')`); + } + if (note !== undefined) await this.repo.update(id, { reviewNote: note }); + return this.transition(id, { toState: 'merged', note: note ?? 'accepted' }, actor); + } + + /** Reject: back to in_progress. The comment is mandatory — that is the direction. */ + async reject(id: string, input: unknown, actor: { type: string; id?: string }): Promise { + const { note } = RejectTaskSchema.parse(input); + const task = await this.get(id); + if (task.state !== 'awaiting_review') { + throw badRequest(`Only a task in 'awaiting_review' can be rejected (this one is '${task.state}')`); + } + await this.repo.update(id, { reviewNote: note }); + return this.transition(id, { toState: 'in_progress', note }, actor); + } + + /** Post-merge failure: merged → reopened, driven by the git provider webhook. */ + async reopen(id: string, note: string, actor: { type: string; id?: string }): Promise { + return this.transition(id, { toState: 'reopened', note }, actor); + } +} diff --git a/src/mcpd/src/validation/task.schema.ts b/src/mcpd/src/validation/task.schema.ts new file mode 100644 index 0000000..8969c3c --- /dev/null +++ b/src/mcpd/src/validation/task.schema.ts @@ -0,0 +1,60 @@ +import { z } from 'zod'; + +const NAME_RE = /^[a-z0-9-]+$/; + +export const TASK_STATES = [ + 'todo', 'in_progress', 'building', 'self_testing', 'qa_review', + 'security_review', 'sre_review', 'done', 'awaiting_review', 'merged', + 'reopened', 'cancelled', +] as const; + +/** The three stages that produce a verdict. */ +export const REVIEW_STAGES = ['qa_review', 'security_review', 'sre_review'] as const; + +export const CreateTaskSchema = z.object({ + name: z.string().min(1).max(100).regex(NAME_RE, 'Name must be lowercase alphanumeric with hyphens'), + title: z.string().min(1).max(300), + epicTaskId: z.string().min(1), + description: z.string().max(20_000).optional(), + repoId: z.string().min(1).optional(), + assigneeId: z.string().min(1).optional(), + risk: z.enum(['trivial', 'normal', 'high']).optional(), +}); + +export const UpdateTaskSchema = z.object({ + title: z.string().min(1).max(300).optional(), + description: z.string().max(20_000).optional(), + repoId: z.string().min(1).nullable().optional(), + assigneeId: z.string().min(1).nullable().optional(), + risk: z.enum(['trivial', 'normal', 'high']).optional(), + // Git linkage, written by the agent when it pushes. + branch: z.string().min(1).max(300).optional(), + headCommit: z.string().min(1).max(100).optional(), + prUrl: z.string().url().optional(), + prNumber: z.number().int().positive().optional(), +}); + +export const TransitionTaskSchema = z.object({ + toState: z.enum(TASK_STATES), + note: z.string().max(2000).optional(), +}); + +export const AddVerdictSchema = z.object({ + stage: z.enum(REVIEW_STAGES), + commit: z.string().min(1).max(100), + passed: z.boolean(), + summary: z.string().min(1).max(20_000), + agentId: z.string().min(1).optional(), + findings: z.array(z.object({ + severity: z.enum(['info', 'minor', 'major', 'critical']), + message: z.string().min(1).max(2000), + path: z.string().max(500).optional(), + line: z.number().int().nonnegative().optional(), + })).max(500).optional(), +}); + +export const AcceptTaskSchema = z.object({ note: z.string().max(2000).optional() }); +export const RejectTaskSchema = z.object({ note: z.string().min(1).max(2000) }); + +export type CreateTaskInput = z.infer; +export type UpdateTaskInput = z.infer; diff --git a/src/mcpd/tests/services/task-service.test.ts b/src/mcpd/tests/services/task-service.test.ts new file mode 100644 index 0000000..419c0b8 --- /dev/null +++ b/src/mcpd/tests/services/task-service.test.ts @@ -0,0 +1,290 @@ +import type { EpicTask, Task, TaskEvent, TaskState, TaskVerdict } from '@prisma/client'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import type { IEpicTaskRepository } from '../../src/repositories/epic-task.repository.js'; +import type { ITaskRepository } from '../../src/repositories/task.repository.js'; +import { TaskService } from '../../src/services/task.service.js'; + +function makeTask(overrides: Partial = {}): Task { + return { + id: 'task-1', + name: 'wire-the-thing', + title: 'Wire the thing', + description: '', + epicTaskId: 'et-1', + repoId: null, + assigneeId: null, + state: 'todo', + risk: 'normal', + branch: null, + headCommit: null, + prUrl: null, + prNumber: null, + reviewNote: null, + ownerId: 'user-1', + version: 1, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + } as Task; +} + +const verdict = (stage: TaskState, passed: boolean): TaskVerdict => + ({ id: `v-${stage}`, taskId: 'task-1', stage, agentId: null, commit: '3f2ab', passed, + summary: '', findings: [], createdAt: new Date() } as TaskVerdict); + +function mocks(task: Task | null = makeTask(), verdicts: TaskVerdict[] = []) { + let current = task; + /** When true, the CAS loses — simulating a concurrent mover. */ + let loseRace = false; + + const repo = { + findAll: vi.fn(async () => (current ? [current] : [])), + findById: vi.fn(async () => current), + findByName: vi.fn(async () => current), + findByNameAndEpicTask: vi.fn(async () => null), + create: vi.fn(async (d: Record) => makeTask(d as Partial)), + update: vi.fn(async (_id: string, d: Record) => { + current = { ...(current as Task), ...d } as Task; + return current; + }), + delete: vi.fn(async () => {}), + transition: vi.fn(async (_id: string, from: TaskState, to: TaskState) => { + if (loseRace) { current = { ...(current as Task), state: 'cancelled' } as Task; return false; } + if ((current as Task).state !== from) return false; + current = { ...(current as Task), state: to } as Task; + return true; + }), + appendEvent: vi.fn(async (d: Record) => ({ id: 'ev-1', ...d } as unknown as TaskEvent)), + findEvents: vi.fn(async () => []), + addVerdict: vi.fn(async (d: Record) => ({ id: 'v-new', ...d } as unknown as TaskVerdict)), + findVerdicts: vi.fn(async () => verdicts), + } as unknown as ITaskRepository & Record>; + + const epicTaskRepo = { + findById: vi.fn(async () => ({ id: 'et-1', name: 'chassis' } as EpicTask)), + findByName: vi.fn(async () => ({ id: 'et-1', name: 'chassis' } as EpicTask)), + } as unknown as IEpicTaskRepository; + + return { + repo, epicTaskRepo, + service: new TaskService(repo, epicTaskRepo), + forceRace: () => { loseRace = true; }, + }; +} + +const ACTOR = { type: 'user', id: 'user-1' }; + +describe('TaskService transition table', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + it('walks the whole happy path for a trivial task', async () => { + const { service } = mocks(makeTask({ risk: 'trivial' })); + for (const to of ['in_progress', 'building', 'self_testing', 'done', 'awaiting_review', 'merged'] as const) { + const { task } = await service.transition('task-1', { toState: to }, ACTOR); + expect(task.state).toBe(to); + } + }); + + it('lets QA, Security and SRE bounce a task back to in_progress', async () => { + // They are stages inside the loop, not post-submit reviewers — feedback + // means the task goes back to work. + for (const stage of ['qa_review', 'security_review', 'sre_review'] as const) { + const { service } = mocks(makeTask({ state: stage })); + const { task } = await service.transition('task-1', { toState: 'in_progress' }, ACTOR); + expect(task.state).toBe('in_progress'); + } + }); + + it('sends a failed build back to work rather than forward', async () => { + const { service } = mocks(makeTask({ state: 'building' })); + const { task } = await service.transition('task-1', { toState: 'in_progress' }, ACTOR); + expect(task.state).toBe('in_progress'); + }); + + it('rejects an illegal jump and lists what IS legal', async () => { + const { service } = mocks(makeTask({ state: 'todo' })); + await expect(service.transition('task-1', { toState: 'merged' }, ACTOR)).rejects.toThrow( + /Illegal transition todo → merged.*in_progress, cancelled/s, + ); + }); + + it('rejects a no-op transition', async () => { + const { service } = mocks(makeTask({ state: 'todo' })); + await expect(service.transition('task-1', { toState: 'todo' }, ACTOR)).rejects.toThrow( + /already in state 'todo'/, + ); + }); + + it('only allows reopened out of merged — a merged task is not editable', async () => { + expect(TaskService.legalFrom('merged')).toEqual(['reopened']); + const { service } = mocks(makeTask({ state: 'merged' })); + await expect(service.transition('task-1', { toState: 'in_progress' }, ACTOR)).rejects.toThrow( + /Illegal transition/, + ); + const { task } = await service.transition('task-1', { toState: 'reopened' }, ACTOR); + expect(task.state).toBe('reopened'); + }); + + it('appends an event for every transition', async () => { + const { service, repo } = mocks(makeTask()); + await service.transition('task-1', { toState: 'in_progress', note: 'picked up' }, ACTOR); + expect(repo.appendEvent).toHaveBeenCalledWith( + expect.objectContaining({ fromState: 'todo', toState: 'in_progress', actorType: 'user', note: 'picked up' }), + ); + }); +}); + +describe('TaskService compare-and-swap', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + it('409s when another mover won the race, and reports the current state', async () => { + const { service, forceRace } = mocks(makeTask({ state: 'todo' })); + forceRace(); + await expect(service.transition('task-1', { toState: 'in_progress' }, ACTOR)).rejects.toThrow( + /moved concurrently: expected 'todo', now 'cancelled'/, + ); + }); + + it('passes the expected state to the CAS so the DB does the arbitration', async () => { + const { service, repo } = mocks(makeTask({ state: 'todo' })); + await service.transition('task-1', { toState: 'in_progress' }, ACTOR); + expect(repo.transition).toHaveBeenCalledWith('task-1', 'todo', 'in_progress'); + }); + + it('writes no event when the CAS is lost', async () => { + const { service, repo, forceRace } = mocks(makeTask({ state: 'todo' })); + forceRace(); + await service.transition('task-1', { toState: 'in_progress' }, ACTOR).catch(() => {}); + expect(repo.appendEvent).not.toHaveBeenCalled(); + }); +}); + +describe('TaskService risk-scaled done gate', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + it('lets trivial work reach done with no verdicts at all', async () => { + const { service } = mocks(makeTask({ state: 'self_testing', risk: 'trivial' }), []); + const { task } = await service.transition('task-1', { toState: 'done' }, ACTOR); + expect(task.state).toBe('done'); + }); + + it('blocks normal work without a passing QA verdict', async () => { + const { service } = mocks(makeTask({ state: 'self_testing', risk: 'normal' }), []); + await expect(service.transition('task-1', { toState: 'done' }, ACTOR)).rejects.toThrow( + /requires a passing verdict from QA/, + ); + }); + + it('does not accept a FAILING verdict as clearing the gate', async () => { + // A recorded failure is evidence the gate was tried and did not hold. + const { service } = mocks( + makeTask({ state: 'qa_review', risk: 'normal' }), + [verdict('qa_review', false)], + ); + await expect(service.transition('task-1', { toState: 'done' }, ACTOR)).rejects.toThrow( + /requires a passing verdict from QA/, + ); + }); + + it('allows normal work through once QA passes', async () => { + const { service } = mocks( + makeTask({ state: 'qa_review', risk: 'normal' }), + [verdict('qa_review', true)], + ); + const { task } = await service.transition('task-1', { toState: 'done' }, ACTOR); + expect(task.state).toBe('done'); + }); + + it('requires all three gates for high-risk work, and names the missing ones', async () => { + const { service } = mocks( + makeTask({ state: 'sre_review', risk: 'high' }), + [verdict('qa_review', true)], + ); + await expect(service.transition('task-1', { toState: 'done' }, ACTOR)).rejects.toThrow( + /Security, SRE/, + ); + }); + + it('allows high-risk work once every gate passes', async () => { + const { service } = mocks( + makeTask({ state: 'sre_review', risk: 'high' }), + [verdict('qa_review', true), verdict('security_review', true), verdict('sre_review', true)], + ); + const { task } = await service.transition('task-1', { toState: 'done' }, ACTOR); + expect(task.state).toBe('done'); + }); +}); + +describe('TaskService accept / reject gate', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + it('accepts from awaiting_review and stores the comment', async () => { + const { service, repo } = mocks(makeTask({ state: 'awaiting_review' })); + const { task } = await service.accept('task-1', { note: 'ship it' }, ACTOR); + expect(task.state).toBe('merged'); + expect(repo.update).toHaveBeenCalledWith('task-1', { reviewNote: 'ship it' }); + }); + + it('accepts with no comment', async () => { + const { service } = mocks(makeTask({ state: 'awaiting_review' })); + const { task } = await service.accept('task-1', {}, ACTOR); + expect(task.state).toBe('merged'); + }); + + it('refuses to accept a task that is not at the gate', async () => { + const { service } = mocks(makeTask({ state: 'in_progress' })); + await expect(service.accept('task-1', {}, ACTOR)).rejects.toThrow(/Only a task in 'awaiting_review'/); + }); + + it('rejects back to in_progress and requires a comment', async () => { + const { service, repo } = mocks(makeTask({ state: 'awaiting_review' })); + const { task } = await service.reject('task-1', { note: 'missing tests on the error path' }, ACTOR); + expect(task.state).toBe('in_progress'); + expect(repo.update).toHaveBeenCalledWith('task-1', { reviewNote: 'missing tests on the error path' }); + }); + + it('will not reject without a comment — the comment IS the direction', async () => { + const { service } = mocks(makeTask({ state: 'awaiting_review' })); + await expect(service.reject('task-1', {}, ACTOR)).rejects.toThrow(); + await expect(service.reject('task-1', { note: '' }, ACTOR)).rejects.toThrow(); + }); +}); + +describe('TaskService verdicts and creation', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + it('pins a verdict to the commit it judged', async () => { + const { service, repo } = mocks(); + await service.addVerdict('task-1', { + stage: 'qa_review', commit: '3f2ab19', passed: false, summary: 'no test for 500', + findings: [{ severity: 'major', message: 'error path uncovered', path: 'src/a.ts', line: 12 }], + }); + expect(repo.addVerdict).toHaveBeenCalledWith( + expect.objectContaining({ taskId: 'task-1', stage: 'qa_review', commit: '3f2ab19', passed: false }), + ); + }); + + it('rejects a verdict for a non-review stage', async () => { + const { service } = mocks(); + await expect( + service.addVerdict('task-1', { stage: 'merged', commit: 'a', passed: true, summary: 's' }), + ).rejects.toThrow(); + }); + + it('seeds the event log on create so history has an origin', async () => { + const { service, repo } = mocks(null); + await service.create({ name: 'x', title: 'X', epicTaskId: 'et-1' }, 'user-1'); + expect(repo.appendEvent).toHaveBeenCalledWith( + expect.objectContaining({ toState: 'todo', actorType: 'user', note: 'created' }), + ); + }); + + it('409s on a duplicate name within the epictask', async () => { + const { service, repo } = mocks(null); + (repo.findByNameAndEpicTask as ReturnType).mockResolvedValueOnce(makeTask()); + await expect( + service.create({ name: 'wire-the-thing', title: 'X', epicTaskId: 'et-1' }, 'user-1'), + ).rejects.toThrow(/already exists in epictask 'chassis'/); + }); +}); -- 2.49.1 From b70300d389544b852f36d2fdf06c7d6f6aecc36a Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 8 Aug 2026 16:42:40 +0100 Subject: [PATCH 11/32] =?UTF-8?q?feat(mcpd):=20CodeRepo=20+=20GitProvider?= =?UTF-8?q?=20=E2=80=94=20register=20a=20forge,=20discover=20and=20import?= =?UTF-8?q?=20repos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two resources landing together, because a repo you have to hand-type is a repo nobody adds. CodeRepo is a git repository attached to a project. GitProvider is a configured forge instance you register once — URL and credential in one place — and then list and import from. Nothing above the driver layer knows what a Gitea is. src/mcpd/src/services/git/ holds the GitProviderDriver interface, the Gitea implementation and a registry that resolves by type, mirroring LlmAdapterRegistry.build. Adding GitHub or GitLab is one file plus one case. The instance URL always comes from the GitProvider row; there is no hardcoded host anywhere. Follows the mcpservertemplates convention: a declarative resource applied via apply -f, with instances keeping denormalized provenance that outlives the source. CodeRepo stores sourceProvider + sourceFullName alongside the FK, so "where did this repo come from" stays answerable after the forge is disconnected — the same reasoning as McpServer.templateName/templateVersion. The FK is SetNull, so disconnecting a forge never deletes the repos, or the tasks hanging off them, that came from it. Discovery uses /repos/search, deliberately NOT /user/repos. The latter needs the read:user scope, which repo-scoped automation tokens do not carry — the token in this repo's .env has write:repository and 403s on /user/repos. The driver also passes Gitea's 403 scope message through verbatim, so an under-scoped token says which scope it is missing instead of looking like an empty forge. Import is partial-success by contract: one bad name must not lose the other nineteen. Everything importable is imported; everything skipped comes back with a reason, and a re-import is distinguished from a name collision with an unrelated repo. Imported repos store no credential of their own and inherit the forge's, so rotating one token covers every repo from that forge. Credentials never leave as plaintext: reads return the reference only, and resolveCheckouts() — the single path that produces a token — exists for the session provisioner and is called by no route. References are verified at definition time, so a wrong key fails with "secret has no key 'X'" rather than as a clone that mysteriously cannot authenticate. Clone URLs must be https/ssh/scp-style and checkout paths must be relative with no ".." segment: both are inputs to the clone step, and neither should be able to point it at mcpd's own filesystem or escape the workspace. Tests: 18 CodeRepo + 22 GitProvider unit tests. Gitea driver additionally verified live against https://mysources.co.uk (Gitea 1.25.4, 10 repos discovered, query filter working). Local: build clean, workspace 2440 passed (+40), db 128 passed (same 4 pre-existing Secret failures), lint unchanged at 869. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p --- .taskmaster/tasks/tasks.json | 28 +- .../migration.sql | 40 ++ src/db/prisma/schema.prisma | 64 +++- src/mcpd/src/main.ts | 21 ++ .../src/repositories/code-repo.repository.ts | 92 +++++ .../repositories/git-provider.repository.ts | 61 ++++ src/mcpd/src/routes/gitproviders.ts | 77 ++++ src/mcpd/src/routes/repos.ts | 67 ++++ src/mcpd/src/services/code-repo.service.ts | 189 ++++++++++ src/mcpd/src/services/git-provider.service.ts | 283 +++++++++++++++ src/mcpd/src/services/git/gitea.driver.ts | 134 +++++++ src/mcpd/src/services/git/registry.ts | 41 +++ src/mcpd/src/services/git/types.ts | 64 ++++ src/mcpd/src/validation/code-repo.schema.ts | 57 +++ .../src/validation/git-provider.schema.ts | 45 +++ .../src/validation/rbac-definition.schema.ts | 4 +- .../tests/services/code-repo-service.test.ts | 201 ++++++++++ src/mcpd/tests/services/git-provider.test.ts | 342 ++++++++++++++++++ 18 files changed, 1794 insertions(+), 16 deletions(-) create mode 100644 src/db/prisma/migrations/20260808120000_add_git_provider/migration.sql create mode 100644 src/mcpd/src/repositories/code-repo.repository.ts create mode 100644 src/mcpd/src/repositories/git-provider.repository.ts create mode 100644 src/mcpd/src/routes/gitproviders.ts create mode 100644 src/mcpd/src/routes/repos.ts create mode 100644 src/mcpd/src/services/code-repo.service.ts create mode 100644 src/mcpd/src/services/git-provider.service.ts create mode 100644 src/mcpd/src/services/git/gitea.driver.ts create mode 100644 src/mcpd/src/services/git/registry.ts create mode 100644 src/mcpd/src/services/git/types.ts create mode 100644 src/mcpd/src/validation/code-repo.schema.ts create mode 100644 src/mcpd/src/validation/git-provider.schema.ts create mode 100644 src/mcpd/tests/services/code-repo-service.test.ts create mode 100644 src/mcpd/tests/services/git-provider.test.ts diff --git a/.taskmaster/tasks/tasks.json b/.taskmaster/tasks/tasks.json index a0058f9..e1ad59f 100644 --- a/.taskmaster/tasks/tasks.json +++ b/.taskmaster/tasks/tasks.json @@ -2797,9 +2797,9 @@ "dependencies": [ "119" ], - "status": "pending", + "status": "done", "subtasks": [], - "updatedAt": "2026-08-08T08:55:58.627Z" + "updatedAt": "2026-08-08T15:42:40.442Z" }, { "id": "133", @@ -2811,9 +2811,9 @@ "dependencies": [ "132" ], - "status": "pending", + "status": "done", "subtasks": [], - "updatedAt": "2026-08-08T08:55:58.627Z" + "updatedAt": "2026-08-08T15:42:40.452Z" }, { "id": "134", @@ -3247,13 +3247,27 @@ "status": "pending", "subtasks": [], "updatedAt": "2026-08-08T08:55:58.627Z" + }, + { + "id": "163", + "title": "GitProvider resource: register a forge, discover and import repos", + "description": "Register a git forge instance (Gitea today) as a first-class resource, list its repositories live, and import selected ones into a project as CodeRepo rows.", + "details": "Requested mid-epic: registering the instance once means repos are discovered and imported rather than hand-typed, and credentials live in one place.\n\n- Prisma `GitProvider` {name, type, url, credSecretId/Key, ownerId}; `CodeRepo` gains gitProviderId (SetNull) + denormalized sourceProvider/sourceFullName.\n- Driver abstraction in src/mcpd/src/services/git/ (types + gitea.driver + registry), mirroring LlmAdapterRegistry.build(type). Nothing above that layer knows what a Gitea is; the instance URL always comes from the row.\n- GET /api/v1/gitproviders/:id/repos discovers live (nothing stored) and marks rows the target project already has.\n- POST /api/v1/gitproviders/:id/import imports selected repos with partial success + per-repo skip reasons.\n- Discovery uses /repos/search, NOT /user/repos: the latter needs read:user scope which repo-scoped automation tokens do not carry.\n\nFollows the mcpservertemplates convention: declarative resource applied via apply -f, with instances keeping denormalized provenance that outlives the source (cf. McpServer.templateName).", + "testStrategy": "22 unit tests in src/mcpd/tests/services/git-provider.test.ts covering driver field mapping, the token auth scheme, 403 scope-message passthrough, pagination, registry dispatch, discovery marking, and import partial-success. Driver verified live against https://mysources.co.uk (Gitea 1.25.4).", + "priority": "high", + "dependencies": [ + "132" + ], + "status": "done", + "subtasks": [], + "updatedAt": "2026-08-08T15:42:14.594Z" } ], "metadata": { "version": "1.0.0", - "lastModified": "2026-08-08T15:28:26.705Z", - "taskCount": 162, - "completedCount": 116, + "lastModified": "2026-08-08T15:42:40.452Z", + "taskCount": 163, + "completedCount": 119, "tags": [ "master" ] diff --git a/src/db/prisma/migrations/20260808120000_add_git_provider/migration.sql b/src/db/prisma/migrations/20260808120000_add_git_provider/migration.sql new file mode 100644 index 0000000..2e74165 --- /dev/null +++ b/src/db/prisma/migrations/20260808120000_add_git_provider/migration.sql @@ -0,0 +1,40 @@ +-- AlterTable +ALTER TABLE "CodeRepo" ADD COLUMN "gitProviderId" TEXT, +ADD COLUMN "sourceFullName" TEXT, +ADD COLUMN "sourceProvider" TEXT; + +-- CreateTable +CREATE TABLE "GitProvider" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT NOT NULL DEFAULT '', + "type" TEXT NOT NULL DEFAULT 'gitea', + "url" TEXT NOT NULL, + "credSecretId" TEXT, + "credSecretKey" TEXT, + "ownerId" TEXT NOT NULL, + "version" INTEGER NOT NULL DEFAULT 1, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "GitProvider_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "GitProvider_name_key" ON "GitProvider"("name"); + +-- CreateIndex +CREATE INDEX "GitProvider_name_idx" ON "GitProvider"("name"); + +-- CreateIndex +CREATE INDEX "GitProvider_ownerId_idx" ON "GitProvider"("ownerId"); + +-- AddForeignKey +ALTER TABLE "CodeRepo" ADD CONSTRAINT "CodeRepo_gitProviderId_fkey" FOREIGN KEY ("gitProviderId") REFERENCES "GitProvider"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "GitProvider" ADD CONSTRAINT "GitProvider_credSecretId_fkey" FOREIGN KEY ("credSecretId") REFERENCES "Secret"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "GitProvider" ADD CONSTRAINT "GitProvider_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + diff --git a/src/db/prisma/schema.prisma b/src/db/prisma/schema.prisma index bbe3c65..5222643 100644 --- a/src/db/prisma/schema.prisma +++ b/src/db/prisma/schema.prisma @@ -34,6 +34,7 @@ model User { ownedTasks Task[] ownedRepos CodeRepo[] ownedSessions AgentSession[] + gitProviders GitProvider[] @@index([email]) } @@ -174,10 +175,11 @@ model Secret { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - backend SecretBackend @relation(fields: [backendId], references: [id]) - llms Llm[] + backend SecretBackend @relation(fields: [backendId], references: [id]) + llms Llm[] // Agentic teams: git-provider credentials for CodeRepo. - codeRepos CodeRepo[] + codeRepos CodeRepo[] + gitProviders GitProvider[] @@index([name]) @@index([backendId]) @@ -1139,8 +1141,19 @@ model CodeRepo { defaultBranch String @default("main") // Relative path under the session workspace root. Defaults to `name`. path String @default("") - // Git provider, for PR/merge API calls. 'gitea' is the only driver in v1. + // Driver key for PR/merge API calls. Inherited from the GitProvider when the + // repo was imported from one. provider String @default("gitea") + // The GitProvider this repo came from, when it was imported rather than + // hand-added. Supplies the credential, so an imported repo needs none of its + // own; a hand-added repo leaves this null and carries its own credSecretId. + gitProviderId String? + // Denormalized provenance, mirroring McpServer.templateName/templateVersion: + // survives the provider being deleted, so "where did this repo come from" + // stays answerable after the forge is disconnected. + sourceProvider String? + // Owner/name on the forge, e.g. "michal/mcpctl". Null for hand-added repos. + sourceFullName String? // Secret holding the provider credential, resolved through SecretService at // session-provision time — never stored in plaintext on the row. credSecretId String? @@ -1150,10 +1163,13 @@ model CodeRepo { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - cred Secret? @relation(fields: [credSecretId], references: [id]) - owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) - tasks Task[] + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + cred Secret? @relation(fields: [credSecretId], references: [id]) + // SetNull, not Cascade: disconnecting a forge must not delete the repos — + // and the tasks hanging off them — that were imported from it. + gitProvider GitProvider? @relation(fields: [gitProviderId], references: [id], onDelete: SetNull) + owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) + tasks Task[] @@unique([name, projectId]) @@index([projectId]) @@ -1225,3 +1241,35 @@ enum SessionState { ended failed } + +// ── GitProvider (a configured git forge: Gitea today, others later) ── +// +// Registering the instance once means repos are discovered and imported rather +// than hand-typed, and credentials live in one place instead of on every repo. +// `type` selects the driver and is TEXT, not an enum, so adding github/gitlab +// is a non-migration change (same reasoning as ResourceProposal.resourceType). + +model GitProvider { + id String @id @default(cuid()) + name String @unique + description String @default("") + // Driver key: gitea | (future) github | gitlab + type String @default("gitea") + // Instance base URL, e.g. https://mysources.co.uk — never hardcoded anywhere. + url String + // API credential, by reference. Same shape as Llm.apiKeySecret*; the value is + // resolved through SecretService and never stored or returned here. + credSecretId String? + credSecretKey String? + ownerId String + version Int @default(1) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + cred Secret? @relation(fields: [credSecretId], references: [id]) + owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) + repos CodeRepo[] + + @@index([name]) + @@index([ownerId]) +} diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index 3d8a3c5..59ac06e 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -109,6 +109,8 @@ import { registerProposalRoutes } from './routes/proposals.js'; import { registerEpicRoutes } from './routes/epics.js'; import { registerEpicTaskRoutes } from './routes/epictasks.js'; import { registerTaskRoutes } from './routes/tasks.js'; +import { registerCodeRepoRoutes } from './routes/repos.js'; +import { registerGitProviderRoutes } from './routes/gitproviders.js'; import { registerSkillRoutes } from './routes/skills.js'; import { registerGitBackupRoutes } from './routes/git-backup.js'; import { PromptService } from './services/prompt.service.js'; @@ -119,10 +121,15 @@ import { ResourceProposalService } from './services/resource-proposal.service.js import { EpicRepository } from './repositories/epic.repository.js'; import { EpicTaskRepository } from './repositories/epic-task.repository.js'; import { TaskRepository } from './repositories/task.repository.js'; +import { CodeRepoRepository } from './repositories/code-repo.repository.js'; +import { GitProviderRepository } from './repositories/git-provider.repository.js'; import { SkillRepository } from './repositories/skill.repository.js'; import { EpicService } from './services/epic.service.js'; import { EpicTaskService } from './services/epic-task.service.js'; import { TaskService } from './services/task.service.js'; +import { CodeRepoService } from './services/code-repo.service.js'; +import { GitProviderService } from './services/git-provider.service.js'; +import { GitProviderRegistry } from './services/git/registry.js'; import { SkillService } from './services/skill.service.js'; import { GitBackupService } from './services/backup/git-backup.service.js'; import type { BackupKind } from './services/backup/yaml-serializer.js'; @@ -235,6 +242,8 @@ function mapUrlToPermission(method: string, url: string): PermissionCheck { 'epictasks': 'epictasks', // Work items. NOT inference-tasks — see the rbac-tasks-backfill bootstrap. 'tasks': 'tasks', + 'repos': 'repos', + 'gitproviders': 'gitproviders', }; const resource = resourceMap[segment]; @@ -446,6 +455,8 @@ async function main(): Promise { const epicRepo = new EpicRepository(prisma); const epicTaskRepo = new EpicTaskRepository(prisma); const workTaskRepo = new TaskRepository(prisma); + const codeRepoRepo = new CodeRepoRepository(prisma); + const gitProviderRepo = new GitProviderRepository(prisma); // SecretBackend bootstrap: ensure a `plaintext` default row exists and any // pre-existing `Secret` rows are pointed at it. Idempotent per run. @@ -465,6 +476,8 @@ async function main(): Promise { epics: epicRepo, epictasks: epicTaskRepo, tasks: workTaskRepo, + repos: codeRepoRepo, + gitproviders: gitProviderRepo, // Resolve user CUID → email so name-scoped `edit:users:` bindings // match on PUT /api/v1/users/:id/password (admin reset). users: { findById: async (id: string) => { @@ -574,6 +587,12 @@ async function main(): Promise { epicService.setRevisionService(resourceRevisionService); const epicTaskService = new EpicTaskService(epicTaskRepo, epicRepo); const workTaskService = new TaskService(workTaskRepo, epicTaskRepo); + const codeRepoService = new CodeRepoService(codeRepoRepo, projectRepo, secretRepo, secretService, gitProviderRepo); + // Forge drivers are resolved by type — nothing above this layer knows what a + // Gitea is, and the instance URL always comes from the GitProvider row. + const gitProviderService = new GitProviderService( + gitProviderRepo, codeRepoRepo, projectRepo, new GitProviderRegistry(), secretRepo, secretService, + ); const personalityRepo = new PersonalityRepository(prisma); const personalityService = new PersonalityService(personalityRepo, agentRepo, promptRepo); const agentService = new AgentService(agentRepo, llmService, projectService, personalityRepo); @@ -785,6 +804,8 @@ async function main(): Promise { registerEpicRoutes(app, epicService, projectRepo); registerEpicTaskRoutes(app, epicTaskService, epicRepo, epicTaskRepo, projectRepo); registerTaskRoutes(app, workTaskService, epicTaskRepo); + registerCodeRepoRoutes(app, codeRepoService, projectRepo); + registerGitProviderRoutes(app, gitProviderService, projectRepo); // ── Git-based backup ── const gitBackup = new GitBackupService(prisma); diff --git a/src/mcpd/src/repositories/code-repo.repository.ts b/src/mcpd/src/repositories/code-repo.repository.ts new file mode 100644 index 0000000..f704ef6 --- /dev/null +++ b/src/mcpd/src/repositories/code-repo.repository.ts @@ -0,0 +1,92 @@ +import type { PrismaClient, CodeRepo } from '@prisma/client'; + +/** + * CodeRepo repository — git repositories attached to a project. + * + * Named CodeRepo rather than Repository so this file is not + * `repository.repository.ts` and so "repository" keeps meaning the + * data-access layer everywhere else in src/mcpd/src/repositories/. + */ + +export interface CodeRepoCreateInput { + name: string; + projectId: string; + url: string; + ownerId: string; + defaultBranch?: string; + path?: string; + provider?: string; + credSecretId?: string; + credSecretKey?: string; + /** Set when the repo was imported from a GitProvider rather than hand-added. */ + gitProviderId?: string; + /** Denormalized provenance; survives the provider being deleted. */ + sourceProvider?: string; + /** Owner-qualified name on the forge, e.g. "michal/mcpctl". */ + sourceFullName?: string; +} + +export interface CodeRepoUpdateInput { + url?: string; + defaultBranch?: string; + path?: string; + provider?: string; + credSecretId?: string | null; + credSecretKey?: string | null; +} + +export interface ICodeRepoRepository { + findAll(projectId?: string): Promise; + findById(id: string): Promise; + findByName(name: string): Promise; + findByNameAndProject(name: string, projectId: string): Promise; + create(data: CodeRepoCreateInput): Promise; + update(id: string, data: CodeRepoUpdateInput): Promise; + delete(id: string): Promise; +} + +const REPO_INCLUDE = { project: { select: { name: true } } } as const; + +export class CodeRepoRepository implements ICodeRepoRepository { + constructor(private readonly prisma: PrismaClient) {} + + async findAll(projectId?: string): Promise { + return this.prisma.codeRepo.findMany({ + ...(projectId !== undefined ? { where: { projectId } } : {}), + include: REPO_INCLUDE, + orderBy: { name: 'asc' }, + }); + } + + async findById(id: string): Promise { + return this.prisma.codeRepo.findUnique({ where: { id }, include: REPO_INCLUDE }); + } + + /** Names are unique per project; this is for the RBAC CUID→name resolver. */ + async findByName(name: string): Promise { + return this.prisma.codeRepo.findFirst({ where: { name }, include: REPO_INCLUDE }); + } + + async findByNameAndProject(name: string, projectId: string): Promise { + return this.prisma.codeRepo.findUnique({ + where: { name_projectId: { name, projectId } }, + include: REPO_INCLUDE, + }); + } + + async create(data: CodeRepoCreateInput): Promise { + return this.prisma.codeRepo.create({ data, include: REPO_INCLUDE }); + } + + async update(id: string, data: CodeRepoUpdateInput): Promise { + return this.prisma.codeRepo.update({ + where: { id }, + data: { ...data, version: { increment: 1 } }, + include: REPO_INCLUDE, + }); + } + + async delete(id: string): Promise { + await this.prisma.codeRepo.delete({ where: { id } }); + } +} diff --git a/src/mcpd/src/repositories/git-provider.repository.ts b/src/mcpd/src/repositories/git-provider.repository.ts new file mode 100644 index 0000000..0f0d13b --- /dev/null +++ b/src/mcpd/src/repositories/git-provider.repository.ts @@ -0,0 +1,61 @@ +import type { PrismaClient, GitProvider } from '@prisma/client'; + +/** GitProvider repository — one row per configured forge instance. */ + +export interface GitProviderCreateInput { + name: string; + url: string; + ownerId: string; + type?: string; + description?: string; + credSecretId?: string; + credSecretKey?: string; +} + +export interface GitProviderUpdateInput { + url?: string; + type?: string; + description?: string; + credSecretId?: string | null; + credSecretKey?: string | null; +} + +export interface IGitProviderRepository { + findAll(): Promise; + findById(id: string): Promise; + findByName(name: string): Promise; + create(data: GitProviderCreateInput): Promise; + update(id: string, data: GitProviderUpdateInput): Promise; + delete(id: string): Promise; +} + +export class GitProviderRepository implements IGitProviderRepository { + constructor(private readonly prisma: PrismaClient) {} + + async findAll(): Promise { + return this.prisma.gitProvider.findMany({ orderBy: { name: 'asc' } }); + } + + async findById(id: string): Promise { + return this.prisma.gitProvider.findUnique({ where: { id } }); + } + + async findByName(name: string): Promise { + return this.prisma.gitProvider.findUnique({ where: { name } }); + } + + async create(data: GitProviderCreateInput): Promise { + return this.prisma.gitProvider.create({ data }); + } + + async update(id: string, data: GitProviderUpdateInput): Promise { + return this.prisma.gitProvider.update({ + where: { id }, + data: { ...data, version: { increment: 1 } }, + }); + } + + async delete(id: string): Promise { + await this.prisma.gitProvider.delete({ where: { id } }); + } +} diff --git a/src/mcpd/src/routes/gitproviders.ts b/src/mcpd/src/routes/gitproviders.ts new file mode 100644 index 0000000..a8e5172 --- /dev/null +++ b/src/mcpd/src/routes/gitproviders.ts @@ -0,0 +1,77 @@ +import type { FastifyInstance } from 'fastify'; + +import type { GitProviderService } from '../services/git-provider.service.js'; +import type { IProjectRepository } from '../repositories/project.repository.js'; + +export function registerGitProviderRoutes( + app: FastifyInstance, + service: GitProviderService, + projectRepo: IProjectRepository, +): void { + // ── CRUD ── + // Reads return the credential REFERENCE only, never the token. + + app.get('/api/v1/gitproviders', async () => service.list()); + + app.get<{ Params: { id: string } }>('/api/v1/gitproviders/:id', async (request) => { + return service.get(request.params.id); + }); + + app.post('/api/v1/gitproviders', async (request, reply) => { + const provider = await service.create(request.body, request.userId ?? 'system'); + reply.code(201); + return provider; + }); + + app.put<{ Params: { id: string } }>('/api/v1/gitproviders/:id', async (request) => { + return service.update(request.params.id, request.body); + }); + + app.delete<{ Params: { id: string } }>('/api/v1/gitproviders/:id', async (request, reply) => { + await service.delete(request.params.id); + reply.code(204); + }); + + // ── Discovery ── + // Live view of what is on the forge. Nothing is stored; `imported` marks the + // rows the target project already has so re-importing is obvious. + + app.get<{ Params: { id: string }; Querystring: { q?: string; limit?: string; project?: string; projectId?: string } }>( + '/api/v1/gitproviders/:id/repos', + async (request) => { + const { q, limit, project, projectId } = request.query; + + let scopedProjectId = projectId; + if (scopedProjectId === undefined && project !== undefined) { + const proj = await projectRepo.findByName(project); + if (proj === null) { + throw Object.assign(new Error(`Project not found: ${project}`), { statusCode: 404 }); + } + scopedProjectId = proj.id; + } + + const parsedLimit = limit === undefined ? undefined : Number(limit); + if (parsedLimit !== undefined && (!Number.isFinite(parsedLimit) || parsedLimit < 1)) { + throw Object.assign(new Error('limit must be a positive number'), { statusCode: 400 }); + } + + return service.discover(request.params.id, { + ...(q !== undefined ? { query: q } : {}), + ...(parsedLimit !== undefined ? { limit: parsedLimit } : {}), + ...(scopedProjectId !== undefined ? { projectId: scopedProjectId } : {}), + }); + }, + ); + + // Import selected repos. Partial success: everything importable is imported, + // everything skipped comes back with a reason. + app.post<{ Params: { id: string } }>('/api/v1/gitproviders/:id/import', async (request, reply) => { + const result = await service.importRepos(request.params.id, request.body, request.userId ?? 'system'); + reply.code(result.imported.length > 0 ? 201 : 200); + return result; + }); + + app.post<{ Params: { id: string } }>('/api/v1/gitproviders/:id/probe', async (request) => { + return service.probe(request.params.id); + }); +} diff --git a/src/mcpd/src/routes/repos.ts b/src/mcpd/src/routes/repos.ts new file mode 100644 index 0000000..b3f1488 --- /dev/null +++ b/src/mcpd/src/routes/repos.ts @@ -0,0 +1,67 @@ +import type { FastifyInstance } from 'fastify'; + +import type { IProjectRepository } from '../repositories/project.repository.js'; +import type { CodeRepoService } from '../services/code-repo.service.js'; + +export function registerCodeRepoRoutes( + app: FastifyInstance, + service: CodeRepoService, + projectRepo: IProjectRepository, +): void { + async function projectIdFor(name: string): Promise { + const proj = await projectRepo.findByName(name); + if (proj === null) { + throw Object.assign(new Error(`Project not found: ${name}`), { statusCode: 404 }); + } + return proj.id; + } + + // ── List / get ── + // Reads return the credential REFERENCE (credSecretId/credSecretKey) and + // never the value. Only the session provisioner resolves the secret. + + app.get<{ Querystring: { project?: string; projectId?: string } }>( + '/api/v1/repos', + async (request) => { + const { project, projectId } = request.query; + if (project !== undefined) return service.list(await projectIdFor(project)); + if (projectId !== undefined) return service.list(projectId); + return service.list(); + }, + ); + + app.get<{ Params: { id: string } }>('/api/v1/repos/:id', async (request) => { + return service.get(request.params.id); + }); + + // ── Create / update / delete ── + + app.post('/api/v1/repos', async (request, reply) => { + const body = request.body as Record; + const resolved: Record = { ...body }; + + if (typeof body['project'] === 'string') { + resolved['projectId'] = await projectIdFor(body['project']); + delete resolved['project']; + } + + const repo = await service.create(resolved, request.userId ?? 'system'); + reply.code(201); + return repo; + }); + + app.put<{ Params: { id: string } }>('/api/v1/repos/:id', async (request) => { + return service.update(request.params.id, request.body); + }); + + app.delete<{ Params: { id: string } }>('/api/v1/repos/:id', async (request, reply) => { + await service.delete(request.params.id); + reply.code(204); + }); + + // ── Project-scoped view ── + + app.get<{ Params: { name: string } }>('/api/v1/projects/:name/repos', async (request) => { + return service.list(await projectIdFor(request.params.name)); + }); +} diff --git a/src/mcpd/src/services/code-repo.service.ts b/src/mcpd/src/services/code-repo.service.ts new file mode 100644 index 0000000..6535418 --- /dev/null +++ b/src/mcpd/src/services/code-repo.service.ts @@ -0,0 +1,189 @@ +import type { CodeRepo } from '@prisma/client'; + +import type { ICodeRepoRepository } from '../repositories/code-repo.repository.js'; +import type { IGitProviderRepository } from '../repositories/git-provider.repository.js'; +import type { IProjectRepository } from '../repositories/project.repository.js'; +import type { ISecretRepository } from '../repositories/interfaces.js'; +import { + CreateCodeRepoSchema, + UpdateCodeRepoSchema, + type CredRef, +} from '../validation/code-repo.schema.js'; + +import { NotFoundError } from './mcp-server.service.js'; +import type { SecretService } from './secret.service.js'; + +/** What the session provisioner needs to clone one repo. */ +export interface ResolvedCheckout { + name: string; + url: string; + branch: string; + /** Workspace-relative directory. Defaults to the repo name. */ + path: string; + /** Resolved credential, or null when the repo is public / uses ambient auth. */ + credential: string | null; +} + +function badRequest(message: string): Error { + return Object.assign(new Error(message), { statusCode: 400 }); +} + +export class CodeRepoService { + constructor( + private readonly repo: ICodeRepoRepository, + private readonly projectRepo: IProjectRepository, + private readonly secretRepo?: ISecretRepository, + private readonly secrets?: SecretService, + /** Supplies the inherited credential for repos imported from a forge. */ + private readonly gitProviders?: IGitProviderRepository, + ) {} + + // ── CRUD ── + + async list(projectId?: string): Promise { + return this.repo.findAll(projectId); + } + + async get(id: string): Promise { + const row = await this.repo.findById(id); + if (row === null) throw new NotFoundError(`Repo not found: ${id}`); + return row; + } + + async create(input: unknown, ownerId: string): Promise { + const data = CreateCodeRepoSchema.parse(input); + + const project = await this.projectRepo.findById(data.projectId); + if (project === null) throw new NotFoundError(`Project not found: ${data.projectId}`); + + const duplicate = await this.repo.findByNameAndProject(data.name, data.projectId); + if (duplicate !== null) { + throw Object.assign( + new Error(`Repo '${data.name}' already exists in project '${project.name}'`), + { statusCode: 409 }, + ); + } + + const cred = await this.resolveCredRefToIds(data.credRef); + + return this.repo.create({ + name: data.name, + projectId: data.projectId, + url: data.url, + ownerId, + ...(data.defaultBranch !== undefined ? { defaultBranch: data.defaultBranch } : {}), + ...(data.path !== undefined ? { path: data.path } : {}), + ...(data.provider !== undefined ? { provider: data.provider } : {}), + ...cred, + }); + } + + async update(id: string, input: unknown): Promise { + const data = UpdateCodeRepoSchema.parse(input); + await this.get(id); + + let cred: { credSecretId?: string | null; credSecretKey?: string | null } = {}; + if (data.credRef === null) { + cred = { credSecretId: null, credSecretKey: null }; + } else if (data.credRef !== undefined) { + cred = await this.resolveCredRefToIds(data.credRef); + } + + return this.repo.update(id, { + ...(data.url !== undefined ? { url: data.url } : {}), + ...(data.defaultBranch !== undefined ? { defaultBranch: data.defaultBranch } : {}), + ...(data.path !== undefined ? { path: data.path } : {}), + ...(data.provider !== undefined ? { provider: data.provider } : {}), + ...cred, + }); + } + + async delete(id: string): Promise { + await this.get(id); + await this.repo.delete(id); + } + + // ── Credentials ── + + /** + * Translate `{name, key}` to the FK pair, verifying up front that the secret + * and the key both exist. + * + * Failing here — at definition time, with a clear message — beats failing at + * session-provision time, where the symptom is a clone that mysteriously + * cannot authenticate. Same reasoning as InstanceService treating secret + * resolution failure as fatal to a start attempt. + */ + private async resolveCredRefToIds( + ref?: CredRef, + ): Promise<{ credSecretId?: string; credSecretKey?: string }> { + if (ref === undefined) return {}; + if (this.secretRepo === undefined) { + throw badRequest('Credential references require SecretRepository to be wired into CodeRepoService'); + } + + const secret = await this.secretRepo.findByName(ref.name); + if (secret === null) throw new NotFoundError(`Secret not found: ${ref.name}`); + + if (this.secrets !== undefined) { + // Verifies the key exists on whichever backend holds it; throws if not. + await this.secrets.resolve(ref.name, ref.key); + } + + return { credSecretId: secret.id, credSecretKey: ref.key }; + } + + /** + * Resolve the checkout instructions for every repo in a project. + * + * This is the ONLY path that produces a plaintext credential, and it exists + * for the session provisioner. No route returns a ResolvedCheckout — repo + * reads expose the reference (credSecretId/credSecretKey) and never the + * value. + */ + async resolveCheckouts(projectId: string): Promise { + const repos = await this.repo.findAll(projectId); + const out: ResolvedCheckout[] = []; + + for (const r of repos) { + // Precedence: the repo's own credential, else the credential of the + // GitProvider it was imported from. Imported repos deliberately store no + // reference of their own, so the forge's token is the only one they have. + let credSecretId = r.credSecretId; + let credSecretKey = r.credSecretKey; + if ((credSecretId === null || credSecretKey === null) && r.gitProviderId !== null) { + const provider = this.gitProviders === undefined + ? null + : await this.gitProviders.findById(r.gitProviderId); + if (provider !== null) { + credSecretId = provider.credSecretId; + credSecretKey = provider.credSecretKey; + } + } + + let credential: string | null = null; + if (credSecretId !== null && credSecretKey !== null) { + if (this.secretRepo === undefined || this.secrets === undefined) { + throw new Error(`Repo '${r.name}' has a credential reference but SecretService is not wired`); + } + const secret = await this.secretRepo.findById(credSecretId); + if (secret === null) { + throw new NotFoundError(`Repo '${r.name}' references a secret that no longer exists`); + } + credential = await this.secrets.resolve(secret.name, credSecretKey); + } + + out.push({ + name: r.name, + url: r.url, + branch: r.defaultBranch, + // `path` defaults to the repo name so two repos never collide in the + // workspace root just because nobody set it. + path: r.path === '' ? r.name : r.path, + credential, + }); + } + + return out; + } +} diff --git a/src/mcpd/src/services/git-provider.service.ts b/src/mcpd/src/services/git-provider.service.ts new file mode 100644 index 0000000..d80736c --- /dev/null +++ b/src/mcpd/src/services/git-provider.service.ts @@ -0,0 +1,283 @@ +import type { CodeRepo, GitProvider } from '@prisma/client'; + +import type { ICodeRepoRepository } from '../repositories/code-repo.repository.js'; +import type { IGitProviderRepository } from '../repositories/git-provider.repository.js'; +import type { ISecretRepository } from '../repositories/interfaces.js'; +import type { IProjectRepository } from '../repositories/project.repository.js'; +import type { CredRef } from '../validation/code-repo.schema.js'; +import { + CreateGitProviderSchema, + ImportReposSchema, + UpdateGitProviderSchema, +} from '../validation/git-provider.schema.js'; + +import type { GitProviderRegistry } from './git/registry.js'; +import type { DiscoveredRepo, GitProviderContext } from './git/types.js'; +import { NotFoundError } from './mcp-server.service.js'; +import type { SecretService } from './secret.service.js'; + +/** A discovered repo plus whether this project already has it. */ +export interface DiscoveredRepoView extends DiscoveredRepo { + /** True when a CodeRepo in the target project already points at it. */ + imported: boolean; + /** Why it cannot be imported, when it cannot. */ + unavailableReason?: string; +} + +export interface ImportResult { + imported: CodeRepo[]; + skipped: Array<{ fullName: string; reason: string }>; +} + +function badRequest(message: string): Error { + return Object.assign(new Error(message), { statusCode: 400 }); +} + +/** Derive a CodeRepo name from an owner-qualified forge name. */ +export function repoNameFrom(fullName: string): string { + const bare = fullName.includes('/') ? (fullName.split('/').pop() ?? fullName) : fullName; + const slug = bare.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, ''); + return slug === '' ? 'repo' : slug; +} + +export class GitProviderService { + constructor( + private readonly repo: IGitProviderRepository, + private readonly codeRepos: ICodeRepoRepository, + private readonly projectRepo: IProjectRepository, + private readonly drivers: GitProviderRegistry, + private readonly secretRepo?: ISecretRepository, + private readonly secrets?: SecretService, + ) {} + + // ── CRUD ── + + async list(): Promise { + return this.repo.findAll(); + } + + async get(id: string): Promise { + const row = await this.repo.findById(id); + if (row === null) throw new NotFoundError(`GitProvider not found: ${id}`); + return row; + } + + async create(input: unknown, ownerId: string): Promise { + const data = CreateGitProviderSchema.parse(input); + + const existing = await this.repo.findByName(data.name); + if (existing !== null) { + throw Object.assign(new Error(`GitProvider already exists: ${data.name}`), { statusCode: 409 }); + } + + const cred = await this.resolveCredRefToIds(data.credRef); + const type = data.type ?? 'gitea'; + + // Probe at registration, like LlmService does: a wrong URL or an + // under-scoped token should fail here with a clear message, not later at + // discovery where it looks like "the forge has no repos". + if (data.skipProbe !== true) { + await this.probeOrThrow(type, data.url, data.credRef); + } + + return this.repo.create({ + name: data.name, + url: data.url, + ownerId, + type, + ...(data.description !== undefined ? { description: data.description } : {}), + ...cred, + }); + } + + async update(id: string, input: unknown): Promise { + const data = UpdateGitProviderSchema.parse(input); + const current = await this.get(id); + + let cred: { credSecretId?: string | null; credSecretKey?: string | null } = {}; + if (data.credRef === null) { + cred = { credSecretId: null, credSecretKey: null }; + } else if (data.credRef !== undefined) { + cred = await this.resolveCredRefToIds(data.credRef); + } + + if (data.skipProbe !== true && (data.url !== undefined || data.credRef !== undefined || data.type !== undefined)) { + await this.probeOrThrow( + data.type ?? current.type, + data.url ?? current.url, + data.credRef ?? undefined, + data.credRef === undefined ? current : undefined, + ); + } + + return this.repo.update(id, { + ...(data.url !== undefined ? { url: data.url } : {}), + ...(data.type !== undefined ? { type: data.type } : {}), + ...(data.description !== undefined ? { description: data.description } : {}), + ...cred, + }); + } + + async delete(id: string): Promise { + await this.get(id); + // Repos imported from it survive (FK is SetNull) and keep their + // sourceProvider name, so provenance outlives the connection. + await this.repo.delete(id); + } + + // ── Discovery ── + + /** + * List what is on the forge right now. Nothing is stored — this is a live + * view so the caller can pick. When `projectId` is given, each row is marked + * with whether that project already has it, so re-importing is obvious. + */ + async discover( + id: string, + opts: { query?: string; limit?: number; projectId?: string } = {}, + ): Promise { + const provider = await this.get(id); + const driver = this.drivers.build(provider.type); + const ctx = await this.contextFor(provider); + + const found = await driver.listRepositories(ctx, { + ...(opts.query !== undefined ? { query: opts.query } : {}), + ...(opts.limit !== undefined ? { limit: opts.limit } : {}), + }); + + const existing = opts.projectId === undefined + ? [] + : await this.codeRepos.findAll(opts.projectId); + const already = new Set(existing.map((r) => r.sourceFullName ?? '').filter((s) => s !== '')); + + return found.map((r) => ({ + ...r, + imported: already.has(r.fullName), + ...(r.empty ? { unavailableReason: 'repository is empty — nothing to clone' } : {}), + })); + } + + /** + * Import selected repos into a project. + * + * Partial success is the contract: one bad name should not lose the other + * nineteen. Everything importable is imported, and everything skipped is + * reported with a reason. + */ + async importRepos(id: string, input: unknown, ownerId: string): Promise { + const data = ImportReposSchema.parse(input); + const provider = await this.get(id); + + const projectId = data.projectId ?? (await this.projectIdForName(data.project as string)); + + const driver = this.drivers.build(provider.type); + const ctx = await this.contextFor(provider); + const available = await driver.listRepositories(ctx, { limit: 500 }); + const byFullName = new Map(available.map((r) => [r.fullName, r])); + + const imported: CodeRepo[] = []; + const skipped: Array<{ fullName: string; reason: string }> = []; + + for (const fullName of data.repos) { + const found = byFullName.get(fullName); + if (found === undefined) { + skipped.push({ fullName, reason: 'not found on this provider' }); + continue; + } + if (found.empty) { + skipped.push({ fullName, reason: 'repository is empty — nothing to clone' }); + continue; + } + + const name = repoNameFrom(fullName); + const clash = await this.codeRepos.findByNameAndProject(name, projectId); + if (clash !== null) { + skipped.push({ + fullName, + reason: clash.sourceFullName === fullName + ? 'already imported' + : `name '${name}' is taken by another repo in this project`, + }); + continue; + } + + imported.push(await this.codeRepos.create({ + name, + projectId, + url: data.ssh === true ? found.sshUrl : found.cloneUrl, + ownerId, + defaultBranch: found.defaultBranch, + provider: provider.type, + gitProviderId: provider.id, + // Denormalized so provenance survives the provider being deleted — + // same reasoning as McpServer.templateName. + sourceProvider: provider.name, + sourceFullName: fullName, + // Credentials are inherited from the provider; the repo stores no + // reference of its own. + })); + } + + return { imported, skipped }; + } + + /** Live credential check for an already-registered provider. */ + async probe(id: string): Promise<{ ok: boolean; detail: string }> { + const provider = await this.get(id); + const driver = this.drivers.build(provider.type); + return driver.probe(await this.contextFor(provider)); + } + + // ── Internals ── + + private async projectIdForName(name: string): Promise { + const project = await this.projectRepo.findByName(name); + if (project === null) throw new NotFoundError(`Project not found: ${name}`); + return project.id; + } + + /** Resolve a provider row into the driver context, including its token. */ + async contextFor(provider: GitProvider): Promise { + let token: string | null = null; + if (provider.credSecretId !== null && provider.credSecretKey !== null) { + if (this.secretRepo === undefined || this.secrets === undefined) { + throw new Error(`GitProvider '${provider.name}' has a credential but SecretService is not wired`); + } + const secret = await this.secretRepo.findById(provider.credSecretId); + if (secret === null) { + throw new NotFoundError(`GitProvider '${provider.name}' references a secret that no longer exists`); + } + token = await this.secrets.resolve(secret.name, provider.credSecretKey); + } + return { url: provider.url, token }; + } + + private async probeOrThrow( + type: string, + url: string, + credRef?: CredRef, + fallback?: GitProvider, + ): Promise { + let token: string | null = null; + if (credRef !== undefined && this.secrets !== undefined) { + token = await this.secrets.resolve(credRef.name, credRef.key); + } else if (fallback !== undefined) { + token = (await this.contextFor(fallback)).token; + } + const driver = this.drivers.build(type); + await driver.probe({ url, token }); + } + + private async resolveCredRefToIds( + ref?: CredRef, + ): Promise<{ credSecretId?: string; credSecretKey?: string }> { + if (ref === undefined) return {}; + if (this.secretRepo === undefined) { + throw badRequest('Credential references require SecretRepository to be wired into GitProviderService'); + } + const secret = await this.secretRepo.findByName(ref.name); + if (secret === null) throw new NotFoundError(`Secret not found: ${ref.name}`); + if (this.secrets !== undefined) await this.secrets.resolve(ref.name, ref.key); + return { credSecretId: secret.id, credSecretKey: ref.key }; + } +} diff --git a/src/mcpd/src/services/git/gitea.driver.ts b/src/mcpd/src/services/git/gitea.driver.ts new file mode 100644 index 0000000..6cd5557 --- /dev/null +++ b/src/mcpd/src/services/git/gitea.driver.ts @@ -0,0 +1,134 @@ +import { + GitProviderAuthError, + GitProviderError, + type DiscoverOptions, + type DiscoveredRepo, + type GitProviderContext, + type GitProviderDriver, +} from './types.js'; + +/** Gitea's search endpoint wraps results; /version does not. */ +interface GiteaSearchResponse { + ok?: boolean; + data?: GiteaRepo[]; + message?: string; +} + +interface GiteaRepo { + name?: string; + full_name?: string; + description?: string; + clone_url?: string; + ssh_url?: string; + default_branch?: string; + private?: boolean; + empty?: boolean; + archived?: boolean; + owner?: { login?: string }; +} + +const PAGE_SIZE = 50; +const DEFAULT_LIMIT = 200; +const TIMEOUT_MS = 15_000; + +export class GiteaDriver implements GitProviderDriver { + readonly type = 'gitea'; + + private async call(ctx: GitProviderContext, path: string): Promise { + const base = ctx.url.replace(/\/+$/, ''); + const headers: Record = { Accept: 'application/json' }; + // Gitea's scheme is `token `, not `Bearer`. + if (ctx.token !== null) headers['Authorization'] = `token ${ctx.token}`; + + let res: Response; + try { + res = await fetch(`${base}${path}`, { headers, signal: AbortSignal.timeout(TIMEOUT_MS) }); + } catch (err: unknown) { + const detail = err instanceof Error ? err.message : String(err); + throw new GitProviderError(`Cannot reach Gitea at ${base}: ${detail}`); + } + + const text = await res.text(); + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + parsed = text; + } + + if (res.status === 401 || res.status === 403) { + // Gitea reports insufficient token scopes as 403 with the required scope + // named. Surfacing that verbatim turns an opaque failure into an + // actionable one — the first token tried here had write:repository but + // not read:user, which silently broke /user/repos. + const message = + typeof parsed === 'object' && parsed !== null && 'message' in parsed + ? String((parsed as { message: unknown }).message) + : `HTTP ${String(res.status)}`; + throw new GitProviderAuthError(`Gitea rejected the credential: ${message}`); + } + if (res.status >= 400) { + throw new GitProviderError(`Gitea returned HTTP ${String(res.status)} for ${path}`); + } + + return parsed as T; + } + + async probe(ctx: GitProviderContext): Promise<{ ok: boolean; detail: string }> { + const version = await this.call<{ version?: string }>(ctx, '/api/v1/version'); + // /version answers without auth, so confirm the credential separately — + // otherwise a bad token registers cleanly and fails later at discovery. + const repos = await this.listRepositories(ctx, { limit: 1 }); + return { + ok: true, + detail: `Gitea ${version.version ?? 'unknown'}, credential OK (${String(repos.length)} repo visible)`, + }; + } + + /** + * List repositories via `/repos/search`. + * + * Deliberately NOT `/user/repos`: that needs the `read:user` scope, which a + * repo-scoped token does not carry, so it 403s on exactly the tokens people + * create for repository automation. `/repos/search` needs only repository + * scope and returns the same rows. + */ + async listRepositories(ctx: GitProviderContext, opts: DiscoverOptions = {}): Promise { + const limit = opts.limit ?? DEFAULT_LIMIT; + const out: DiscoveredRepo[] = []; + + for (let page = 1; out.length < limit; page++) { + const params = new URLSearchParams({ + limit: String(Math.min(PAGE_SIZE, limit - out.length)), + page: String(page), + }); + if (opts.query !== undefined && opts.query !== '') params.set('q', opts.query); + + const body = await this.call(ctx, `/api/v1/repos/search?${params.toString()}`); + const rows = body.data ?? []; + if (rows.length === 0) break; + + for (const r of rows) out.push(toDiscovered(r)); + // Short page means the last page. + if (rows.length < PAGE_SIZE) break; + } + + return out.slice(0, limit); + } +} + +function toDiscovered(r: GiteaRepo): DiscoveredRepo { + const fullName = r.full_name ?? r.name ?? ''; + return { + fullName, + name: r.name ?? fullName.split('/').pop() ?? '', + owner: r.owner?.login ?? fullName.split('/')[0] ?? '', + description: r.description ?? '', + cloneUrl: r.clone_url ?? '', + sshUrl: r.ssh_url ?? '', + defaultBranch: r.default_branch ?? 'main', + private: r.private ?? false, + empty: r.empty ?? false, + archived: r.archived ?? false, + }; +} diff --git a/src/mcpd/src/services/git/registry.ts b/src/mcpd/src/services/git/registry.ts new file mode 100644 index 0000000..2ee7f3f --- /dev/null +++ b/src/mcpd/src/services/git/registry.ts @@ -0,0 +1,41 @@ +import { GiteaDriver } from './gitea.driver.js'; +import type { GitProviderDriver } from './types.js'; + +/** Driver keys with an implementation. Validation rejects anything else. */ +export const GIT_PROVIDER_TYPES = ['gitea'] as const; +export type GitProviderType = (typeof GIT_PROVIDER_TYPES)[number]; + +export class UnsupportedGitProviderError extends Error { + constructor(type: string) { + super( + `Unsupported git provider type '${type}'. Supported: ${GIT_PROVIDER_TYPES.join(', ')}`, + ); + this.name = 'UnsupportedGitProviderError'; + } +} + +/** + * Resolve a driver by type. Mirrors LlmAdapterRegistry.build — adding GitHub + * or GitLab is one file plus one case here, and nothing above this layer + * changes. + */ +export class GitProviderRegistry { + private readonly cache = new Map(); + + build(type: string): GitProviderDriver { + const cached = this.cache.get(type); + if (cached !== undefined) return cached; + + let driver: GitProviderDriver; + switch (type) { + case 'gitea': + driver = new GiteaDriver(); + break; + default: + throw new UnsupportedGitProviderError(type); + } + + this.cache.set(type, driver); + return driver; + } +} diff --git a/src/mcpd/src/services/git/types.ts b/src/mcpd/src/services/git/types.ts new file mode 100644 index 0000000..2a02bb3 --- /dev/null +++ b/src/mcpd/src/services/git/types.ts @@ -0,0 +1,64 @@ +/** + * Git forge driver abstraction. + * + * Nothing above this layer knows what a Gitea is. Adding GitHub or GitLab means + * one new file plus a case in the registry — the same shape as the LLM adapter + * dispatcher. The instance URL always comes from the GitProvider row, never + * from a constant. + */ + +/** A repository as it exists on the forge, before we import it. */ +export interface DiscoveredRepo { + /** Owner-qualified name, e.g. "michal/mcpctl". */ + fullName: string; + /** Bare name, e.g. "mcpctl". */ + name: string; + owner: string; + description: string; + cloneUrl: string; + sshUrl: string; + defaultBranch: string; + private: boolean; + /** No commits yet — nothing to clone into a session workspace. */ + empty: boolean; + archived: boolean; +} + +export interface DiscoverOptions { + /** Substring match on the repo name, passed to the forge. */ + query?: string; + /** Max repos to return. Drivers paginate up to this. */ + limit?: number; +} + +/** Everything a driver needs to talk to one configured forge. */ +export interface GitProviderContext { + /** Instance base URL, e.g. https://mysources.co.uk. */ + url: string; + /** Resolved API credential, or null when the forge is anonymous-readable. */ + token: string | null; +} + +export interface GitProviderDriver { + readonly type: string; + /** List repositories visible to the configured credential. */ + listRepositories(ctx: GitProviderContext, opts?: DiscoverOptions): Promise; + /** Cheap credential/URL check used at registration time. */ + probe(ctx: GitProviderContext): Promise<{ ok: boolean; detail: string }>; +} + +/** Raised when a forge rejects the credential or its scopes are insufficient. */ +export class GitProviderAuthError extends Error { + constructor(message: string) { + super(message); + this.name = 'GitProviderAuthError'; + } +} + +/** Raised when the forge is unreachable or answers unusably. */ +export class GitProviderError extends Error { + constructor(message: string) { + super(message); + this.name = 'GitProviderError'; + } +} diff --git a/src/mcpd/src/validation/code-repo.schema.ts b/src/mcpd/src/validation/code-repo.schema.ts new file mode 100644 index 0000000..c917830 --- /dev/null +++ b/src/mcpd/src/validation/code-repo.schema.ts @@ -0,0 +1,57 @@ +import { z } from 'zod'; + +const NAME_RE = /^[a-z0-9-]+$/; + +/** Git providers with a driver. `gitea` is the only one in v1. */ +export const GIT_PROVIDERS = ['gitea'] as const; + +/** + * Credential reference, mirroring `Llm.apiKeyRef`: the API takes a + * {name, key} pair and the service translates it to the FK columns. The value + * itself never appears in a request or a response — only the reference. + */ +export const CredRefSchema = z.object({ + name: z.string().min(1).max(100), + key: z.string().min(1).max(200), +}); + +/** + * Clone URL. https or ssh only — a file:// or relative URL would let a repo + * definition point the clone step at the mcpd container's own filesystem. + */ +const CloneUrlSchema = z.string().min(1).max(2000).refine( + (u) => /^https:\/\//.test(u) || /^ssh:\/\//.test(u) || /^[\w.-]+@[\w.-]+:/.test(u), + { message: 'url must be https://, ssh://, or scp-style user@host:path' }, +); + +/** + * Workspace-relative checkout path. Must stay inside the workspace: a `..` + * segment or a leading `/` would let a repo escape it during the clone. + */ +const WorkspacePathSchema = z.string().max(300).refine( + (p) => p === '' || (!p.startsWith('/') && !p.split('/').includes('..')), + { message: 'path must be relative and must not contain ".." segments' }, +); + +export const CreateCodeRepoSchema = z.object({ + name: z.string().min(1).max(100).regex(NAME_RE, 'Name must be lowercase alphanumeric with hyphens'), + projectId: z.string().min(1), + url: CloneUrlSchema, + defaultBranch: z.string().min(1).max(200).optional(), + path: WorkspacePathSchema.optional(), + provider: z.enum(GIT_PROVIDERS).optional(), + credRef: CredRefSchema.optional(), +}); + +export const UpdateCodeRepoSchema = z.object({ + url: CloneUrlSchema.optional(), + defaultBranch: z.string().min(1).max(200).optional(), + path: WorkspacePathSchema.optional(), + provider: z.enum(GIT_PROVIDERS).optional(), + // null clears the credential reference. + credRef: CredRefSchema.nullable().optional(), +}); + +export type CreateCodeRepoInput = z.infer; +export type UpdateCodeRepoInput = z.infer; +export type CredRef = z.infer; diff --git a/src/mcpd/src/validation/git-provider.schema.ts b/src/mcpd/src/validation/git-provider.schema.ts new file mode 100644 index 0000000..38c3df1 --- /dev/null +++ b/src/mcpd/src/validation/git-provider.schema.ts @@ -0,0 +1,45 @@ +import { z } from 'zod'; + +import { GIT_PROVIDER_TYPES } from '../services/git/registry.js'; +import { CredRefSchema } from './code-repo.schema.js'; + +const NAME_RE = /^[a-z0-9-]+$/; + +/** Instance base URL. http/https only — this is an API endpoint, not a clone URL. */ +const InstanceUrlSchema = z.string().url().refine( + (u) => u.startsWith('http://') || u.startsWith('https://'), + { message: 'url must be http:// or https://' }, +); + +export const CreateGitProviderSchema = z.object({ + name: z.string().min(1).max(100).regex(NAME_RE, 'Name must be lowercase alphanumeric with hyphens'), + url: InstanceUrlSchema, + type: z.enum(GIT_PROVIDER_TYPES).optional(), + description: z.string().max(500).optional(), + credRef: CredRefSchema.optional(), + /** Skip the registration-time credential probe (wiring config before infra exists). */ + skipProbe: z.boolean().optional(), +}); + +export const UpdateGitProviderSchema = z.object({ + url: InstanceUrlSchema.optional(), + type: z.enum(GIT_PROVIDER_TYPES).optional(), + description: z.string().max(500).optional(), + credRef: CredRefSchema.nullable().optional(), + skipProbe: z.boolean().optional(), +}); + +/** Import selected discovered repos into a project as CodeRepo rows. */ +export const ImportReposSchema = z.object({ + projectId: z.string().min(1).optional(), + project: z.string().min(1).optional(), + /** Owner-qualified names exactly as discovery returned them. */ + repos: z.array(z.string().min(1).max(300)).min(1).max(200), + /** Use ssh_url instead of clone_url. */ + ssh: z.boolean().optional(), +}).refine( + (d) => (d.projectId !== undefined) !== (d.project !== undefined), + { message: 'Pass exactly one of project or projectId' }, +); + +export type CreateGitProviderInput = z.infer; diff --git a/src/mcpd/src/validation/rbac-definition.schema.ts b/src/mcpd/src/validation/rbac-definition.schema.ts index c5dca98..f7fa46f 100644 --- a/src/mcpd/src/validation/rbac-definition.schema.ts +++ b/src/mcpd/src/validation/rbac-definition.schema.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; export const RBAC_ROLES = ['edit', 'view', 'create', 'delete', 'run', 'expose'] as const; -export const RBAC_RESOURCES = ['*', 'servers', 'instances', 'secrets', 'secretbackends', 'llms', 'projects', 'templates', 'users', 'groups', 'rbac', 'prompts', 'promptrequests', 'mcptokens', 'inference-tasks', 'epics', 'epictasks', 'tasks', 'repos', 'sessions'] as const; +export const RBAC_RESOURCES = ['*', 'servers', 'instances', 'secrets', 'secretbackends', 'llms', 'projects', 'templates', 'users', 'groups', 'rbac', 'prompts', 'promptrequests', 'mcptokens', 'inference-tasks', 'epics', 'epictasks', 'tasks', 'repos', 'sessions', 'gitproviders'] as const; /** Singular→plural map for resource names. */ const RESOURCE_ALIASES: Record = { @@ -38,6 +38,8 @@ const RESOURCE_ALIASES: Record = { repository: 'repos', repositories: 'repos', session: 'sessions', + gitprovider: 'gitproviders', + forge: 'gitproviders', }; /** Normalize a resource name to its canonical plural form. */ diff --git a/src/mcpd/tests/services/code-repo-service.test.ts b/src/mcpd/tests/services/code-repo-service.test.ts new file mode 100644 index 0000000..08d5820 --- /dev/null +++ b/src/mcpd/tests/services/code-repo-service.test.ts @@ -0,0 +1,201 @@ +import type { CodeRepo, Project, Secret } from '@prisma/client'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import type { ICodeRepoRepository } from '../../src/repositories/code-repo.repository.js'; +import type { ISecretRepository } from '../../src/repositories/interfaces.js'; +import type { IProjectRepository } from '../../src/repositories/project.repository.js'; +import { CodeRepoService } from '../../src/services/code-repo.service.js'; +import type { SecretService } from '../../src/services/secret.service.js'; + +function makeRepo(overrides: Partial = {}): CodeRepo { + return { + id: 'repo-1', + name: 'api', + projectId: 'proj-1', + url: 'https://git.example/api.git', + defaultBranch: 'main', + path: '', + provider: 'gitea', + credSecretId: null, + credSecretKey: null, + ownerId: 'user-1', + version: 1, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + } as CodeRepo; +} + +function mocks(rows: CodeRepo[] = []) { + const repo = { + findAll: vi.fn(async () => rows), + findById: vi.fn(async (id: string) => rows.find((r) => r.id === id) ?? null), + findByName: vi.fn(async (n: string) => rows.find((r) => r.name === n) ?? null), + findByNameAndProject: vi.fn(async () => null), + create: vi.fn(async (d: Record) => makeRepo(d as Partial)), + update: vi.fn(async (_id: string, d: Record) => makeRepo(d as Partial)), + delete: vi.fn(async () => {}), + } as unknown as ICodeRepoRepository & Record>; + + const projectRepo = { + findById: vi.fn(async () => ({ id: 'proj-1', name: 'mcpctl' } as Project)), + findByName: vi.fn(async () => ({ id: 'proj-1', name: 'mcpctl' } as Project)), + } as unknown as IProjectRepository; + + const secretRepo = { + findByName: vi.fn(async (n: string) => + n === 'gitea-token' ? ({ id: 'sec-1', name: 'gitea-token' } as Secret) : null, + ), + findById: vi.fn(async () => ({ id: 'sec-1', name: 'gitea-token' } as Secret)), + } as unknown as ISecretRepository & Record>; + + const secrets = { + resolve: vi.fn(async (_name: string, key: string) => { + if (key !== 'TOKEN') throw new Error(`Secret 'gitea-token' has no key '${key}'`); + return 'ghp_supersecret'; + }), + } as unknown as SecretService & { resolve: ReturnType }; + + return { + repo, projectRepo, secretRepo, secrets, + service: new CodeRepoService(repo, projectRepo, secretRepo, secrets), + }; +} + +const base = { name: 'api', projectId: 'proj-1', url: 'https://git.example/api.git' }; + +describe('CodeRepoService validation', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + it('accepts https, ssh:// and scp-style clone URLs', async () => { + const { service } = mocks(); + for (const url of [ + 'https://git.example/api.git', + 'ssh://git@git.example/api.git', + 'git@git.example:michal/api.git', + ]) { + await expect(service.create({ ...base, url }, 'user-1')).resolves.toBeDefined(); + } + }); + + it('rejects a file:// URL — a repo must not point the clone at mcpd\'s own disk', async () => { + const { service } = mocks(); + await expect(service.create({ ...base, url: 'file:///etc' }, 'user-1')).rejects.toThrow(); + await expect(service.create({ ...base, url: '/etc/passwd' }, 'user-1')).rejects.toThrow(); + }); + + it('rejects a checkout path that escapes the workspace', async () => { + const { service } = mocks(); + for (const path of ['../outside', 'a/../../b', '/absolute']) { + await expect(service.create({ ...base, path }, 'user-1')).rejects.toThrow(); + } + }); + + it('accepts an ordinary nested path', async () => { + const { service } = mocks(); + await expect(service.create({ ...base, path: 'services/api' }, 'user-1')).resolves.toBeDefined(); + }); + + it('rejects an unknown provider', async () => { + const { service } = mocks(); + await expect(service.create({ ...base, provider: 'bitbucket' }, 'user-1')).rejects.toThrow(); + }); + + it('409s on a duplicate name within the project', async () => { + const { service, repo } = mocks(); + (repo.findByNameAndProject as ReturnType).mockResolvedValueOnce(makeRepo()); + await expect(service.create(base, 'user-1')).rejects.toThrow(/already exists in project 'mcpctl'/); + }); +}); + +describe('CodeRepoService credentials', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + it('translates credRef {name,key} to the FK pair', async () => { + const { service, repo } = mocks(); + await service.create({ ...base, credRef: { name: 'gitea-token', key: 'TOKEN' } }, 'user-1'); + expect(repo.create).toHaveBeenCalledWith( + expect.objectContaining({ credSecretId: 'sec-1', credSecretKey: 'TOKEN' }), + ); + }); + + it('never stores the credential VALUE on the row', async () => { + const { service, repo } = mocks(); + await service.create({ ...base, credRef: { name: 'gitea-token', key: 'TOKEN' } }, 'user-1'); + expect(JSON.stringify(repo.create.mock.calls[0]?.[0])).not.toContain('ghp_supersecret'); + }); + + it('fails at definition time when the secret does not exist', async () => { + const { service } = mocks(); + await expect( + service.create({ ...base, credRef: { name: 'nope', key: 'TOKEN' } }, 'user-1'), + ).rejects.toThrow(/Secret not found: nope/); + }); + + it('fails at definition time when the KEY does not exist', async () => { + // Better here, with a clear message, than at provision time where the + // symptom is a clone that mysteriously cannot authenticate. + const { service } = mocks(); + await expect( + service.create({ ...base, credRef: { name: 'gitea-token', key: 'WRONG' } }, 'user-1'), + ).rejects.toThrow(/no key 'WRONG'/); + }); + + it('clears the reference when credRef is null', async () => { + const { service, repo } = mocks([makeRepo({ credSecretId: 'sec-1', credSecretKey: 'TOKEN' })]); + await service.update('repo-1', { credRef: null }); + expect(repo.update).toHaveBeenCalledWith('repo-1', { credSecretId: null, credSecretKey: null }); + }); + + it('leaves the reference untouched when credRef is absent', async () => { + const { service, repo } = mocks([makeRepo({ credSecretId: 'sec-1', credSecretKey: 'TOKEN' })]); + await service.update('repo-1', { defaultBranch: 'develop' }); + const written = repo.update.mock.calls[0]?.[1] as Record; + expect(written).not.toHaveProperty('credSecretId'); + expect(written).toEqual({ defaultBranch: 'develop' }); + }); +}); + +describe('CodeRepoService.resolveCheckouts', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + it('resolves the credential only on this path', async () => { + const { service, secrets } = mocks([ + makeRepo({ credSecretId: 'sec-1', credSecretKey: 'TOKEN' }), + ]); + const checkouts = await service.resolveCheckouts('proj-1'); + expect(checkouts[0]?.credential).toBe('ghp_supersecret'); + expect(secrets.resolve).toHaveBeenCalledWith('gitea-token', 'TOKEN'); + }); + + it('returns a null credential for a repo with no reference', async () => { + const { service, secrets } = mocks([makeRepo()]); + const checkouts = await service.resolveCheckouts('proj-1'); + expect(checkouts[0]?.credential).toBeNull(); + expect(secrets.resolve).not.toHaveBeenCalled(); + }); + + it('defaults the checkout path to the repo name so repos cannot collide', async () => { + const { service } = mocks([makeRepo({ name: 'api', path: '' }), makeRepo({ id: 'r2', name: 'web', path: '' })]); + const checkouts = await service.resolveCheckouts('proj-1'); + expect(checkouts.map((c) => c.path)).toEqual(['api', 'web']); + }); + + it('honours an explicit path', async () => { + const { service } = mocks([makeRepo({ path: 'services/api' })]); + expect((await service.resolveCheckouts('proj-1'))[0]?.path).toBe('services/api'); + }); + + it('fails loudly when a referenced secret has been deleted', async () => { + const { service, secretRepo } = mocks([makeRepo({ credSecretId: 'sec-1', credSecretKey: 'TOKEN' })]); + (secretRepo.findById as ReturnType).mockResolvedValueOnce(null); + // Silently cloning without credentials would look like a public-repo + // success and fail confusingly much later. + await expect(service.resolveCheckouts('proj-1')).rejects.toThrow(/no longer exists/); + }); + + it('carries the branch through for the checkout', async () => { + const { service } = mocks([makeRepo({ defaultBranch: 'develop' })]); + expect((await service.resolveCheckouts('proj-1'))[0]?.branch).toBe('develop'); + }); +}); diff --git a/src/mcpd/tests/services/git-provider.test.ts b/src/mcpd/tests/services/git-provider.test.ts new file mode 100644 index 0000000..60efa38 --- /dev/null +++ b/src/mcpd/tests/services/git-provider.test.ts @@ -0,0 +1,342 @@ +import type { CodeRepo, GitProvider, Project, Secret } from '@prisma/client'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +import type { ICodeRepoRepository } from '../../src/repositories/code-repo.repository.js'; +import type { IGitProviderRepository } from '../../src/repositories/git-provider.repository.js'; +import type { ISecretRepository } from '../../src/repositories/interfaces.js'; +import type { IProjectRepository } from '../../src/repositories/project.repository.js'; +import { GiteaDriver } from '../../src/services/git/gitea.driver.js'; +import { GitProviderRegistry, UnsupportedGitProviderError } from '../../src/services/git/registry.js'; +import { GitProviderAuthError, GitProviderError } from '../../src/services/git/types.js'; +import { GitProviderService, repoNameFrom } from '../../src/services/git-provider.service.js'; +import type { SecretService } from '../../src/services/secret.service.js'; + +// ── Gitea driver ────────────────────────────────────────────────────────── + +const giteaRepo = (full: string, extra: Record = {}) => ({ + name: full.split('/')[1], + full_name: full, + owner: { login: full.split('/')[0] }, + description: 'a repo', + clone_url: `https://mysources.co.uk/${full}.git`, + ssh_url: `git@mysources.co.uk:${full}.git`, + default_branch: 'main', + private: false, + empty: false, + archived: false, + ...extra, +}); + +function mockFetch(handler: (url: string, init: RequestInit) => { status: number; body: unknown }) { + return vi.fn(async (url: string | URL, init?: RequestInit) => { + const { status, body } = handler(String(url), init ?? {}); + return { + status, + text: async () => (typeof body === 'string' ? body : JSON.stringify(body)), + } as unknown as Response; + }); +} + +describe('GiteaDriver', () => { + const original = globalThis.fetch; + afterEach(() => { globalThis.fetch = original; }); + + const CTX = { url: 'https://mysources.co.uk', token: 'tok123' }; + + it('discovers via /repos/search, not /user/repos', async () => { + // /user/repos requires the read:user scope, which repo-scoped automation + // tokens do not carry — it 403s on exactly the tokens people create for + // this. /repos/search needs only repository scope. + const seen: string[] = []; + globalThis.fetch = mockFetch((url) => { + seen.push(url); + return { status: 200, body: { ok: true, data: [giteaRepo('michal/mcpctl')] } }; + }) as unknown as typeof fetch; + + await new GiteaDriver().listRepositories(CTX, { limit: 10 }); + + expect(seen[0]).toContain('/api/v1/repos/search'); + expect(seen.join()).not.toContain('/user/repos'); + }); + + it('uses Gitea\'s "token" auth scheme, not Bearer', async () => { + let auth: string | undefined; + globalThis.fetch = mockFetch((_url, init) => { + auth = (init.headers as Record)['Authorization']; + return { status: 200, body: { ok: true, data: [] } }; + }) as unknown as typeof fetch; + + await new GiteaDriver().listRepositories(CTX); + expect(auth).toBe('token tok123'); + }); + + it('omits the header entirely when there is no token', async () => { + let headers: Record = {}; + globalThis.fetch = mockFetch((_url, init) => { + headers = init.headers as Record; + return { status: 200, body: { ok: true, data: [] } }; + }) as unknown as typeof fetch; + + await new GiteaDriver().listRepositories({ url: CTX.url, token: null }); + expect(headers['Authorization']).toBeUndefined(); + }); + + it('maps the forge payload onto DiscoveredRepo', async () => { + globalThis.fetch = mockFetch(() => ({ + status: 200, + body: { ok: true, data: [giteaRepo('michal/mcpctl', { private: true, default_branch: 'develop' })] }, + })) as unknown as typeof fetch; + + const [repo] = await new GiteaDriver().listRepositories(CTX); + expect(repo).toMatchObject({ + fullName: 'michal/mcpctl', + name: 'mcpctl', + owner: 'michal', + defaultBranch: 'develop', + private: true, + cloneUrl: 'https://mysources.co.uk/michal/mcpctl.git', + sshUrl: 'git@mysources.co.uk:michal/mcpctl.git', + }); + }); + + it('surfaces the forge\'s scope message verbatim on 403', async () => { + // The real failure that cost time: a token with write:repository but not + // read:user. The required scope must reach the operator. + globalThis.fetch = mockFetch(() => ({ + status: 403, + body: { message: 'token does not have at least one of required scope(s), required=[read:user]' }, + })) as unknown as typeof fetch; + + await expect(new GiteaDriver().listRepositories(CTX)).rejects.toThrow(GitProviderAuthError); + await expect(new GiteaDriver().listRepositories(CTX)).rejects.toThrow(/required=\[read:user\]/); + }); + + it('reports an unreachable forge as GitProviderError, not an auth failure', async () => { + globalThis.fetch = vi.fn(async () => { throw new Error('ECONNREFUSED'); }) as unknown as typeof fetch; + await expect(new GiteaDriver().listRepositories(CTX)).rejects.toThrow(GitProviderError); + }); + + it('pages until the forge returns a short page', async () => { + let page = 0; + globalThis.fetch = mockFetch(() => { + page++; + const rows = page === 1 + ? Array.from({ length: 50 }, (_, i) => giteaRepo(`michal/r${String(i)}`)) + : [giteaRepo('michal/last')]; + return { status: 200, body: { ok: true, data: rows } }; + }) as unknown as typeof fetch; + + const repos = await new GiteaDriver().listRepositories(CTX, { limit: 200 }); + expect(repos).toHaveLength(51); + expect(page).toBe(2); + }); + + it('honours the limit rather than draining the forge', async () => { + globalThis.fetch = mockFetch(() => ({ + status: 200, + body: { ok: true, data: Array.from({ length: 50 }, (_, i) => giteaRepo(`michal/r${String(i)}`)) }, + })) as unknown as typeof fetch; + + expect(await new GiteaDriver().listRepositories(CTX, { limit: 5 })).toHaveLength(5); + }); +}); + +describe('GitProviderRegistry', () => { + it('builds the gitea driver and caches it', () => { + const reg = new GitProviderRegistry(); + const a = reg.build('gitea'); + expect(a.type).toBe('gitea'); + expect(reg.build('gitea')).toBe(a); + }); + + it('rejects an unknown type with the supported list', () => { + expect(() => new GitProviderRegistry().build('bitbucket')).toThrow(UnsupportedGitProviderError); + expect(() => new GitProviderRegistry().build('bitbucket')).toThrow(/Supported: gitea/); + }); +}); + +describe('repoNameFrom', () => { + it('takes the bare name and slugs it', () => { + expect(repoNameFrom('michal/mcpctl')).toBe('mcpctl'); + expect(repoNameFrom('michal/Home_Assistant.Alchemy')).toBe('home-assistant-alchemy'); + expect(repoNameFrom('plain')).toBe('plain'); + }); +}); + +// ── Service: discovery + import ─────────────────────────────────────────── + +const PROVIDER = { + id: 'gp-1', name: 'mysources', description: '', type: 'gitea', + url: 'https://mysources.co.uk', credSecretId: 'sec-1', credSecretKey: 'TOKEN', + ownerId: 'user-1', version: 1, createdAt: new Date(), updatedAt: new Date(), +} as GitProvider; + +function svc(opts: { existing?: CodeRepo[]; discovered?: string[]; empty?: string[] } = {}) { + const existing = opts.existing ?? []; + const created: CodeRepo[] = []; + + const providerRepo = { + findAll: vi.fn(async () => [PROVIDER]), + findById: vi.fn(async () => PROVIDER), + findByName: vi.fn(async () => null), + create: vi.fn(async () => PROVIDER), + update: vi.fn(async () => PROVIDER), + delete: vi.fn(async () => {}), + } as unknown as IGitProviderRepository & Record>; + + const codeRepos = { + findAll: vi.fn(async () => existing), + findById: vi.fn(async () => null), + findByName: vi.fn(async () => null), + findByNameAndProject: vi.fn(async (name: string) => + existing.find((r) => r.name === name) ?? null), + create: vi.fn(async (d: Record) => { + const row = d as unknown as CodeRepo; + created.push(row); + return row; + }), + update: vi.fn(async () => ({} as CodeRepo)), + delete: vi.fn(async () => {}), + } as unknown as ICodeRepoRepository & Record>; + + const projectRepo = { + findById: vi.fn(async () => ({ id: 'proj-1', name: 'mcpctl' } as Project)), + findByName: vi.fn(async (n: string) => (n === 'mcpctl' ? ({ id: 'proj-1', name: 'mcpctl' } as Project) : null)), + } as unknown as IProjectRepository; + + const secretRepo = { + findById: vi.fn(async () => ({ id: 'sec-1', name: 'gitea-token' } as Secret)), + findByName: vi.fn(async () => ({ id: 'sec-1', name: 'gitea-token' } as Secret)), + } as unknown as ISecretRepository; + + const secrets = { resolve: vi.fn(async () => 'tok123') } as unknown as SecretService; + + const names = opts.discovered ?? ['michal/mcpctl', 'michal/lab']; + const emptySet = new Set(opts.empty ?? []); + const registry = { + build: vi.fn(() => ({ + type: 'gitea', + probe: vi.fn(async () => ({ ok: true, detail: 'ok' })), + listRepositories: vi.fn(async () => names.map((n) => ({ + fullName: n, name: n.split('/')[1] as string, owner: n.split('/')[0] as string, + description: '', cloneUrl: `https://mysources.co.uk/${n}.git`, + sshUrl: `git@mysources.co.uk:${n}.git`, defaultBranch: 'main', + private: false, empty: emptySet.has(n), archived: false, + }))), + })), + } as unknown as GitProviderRegistry; + + return { + created, codeRepos, providerRepo, + service: new GitProviderService(providerRepo, codeRepos, projectRepo, registry, secretRepo, secrets), + }; +} + +describe('GitProviderService.discover', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + it('returns the live list without storing anything', async () => { + const { service, codeRepos } = svc(); + const rows = await service.discover('gp-1'); + expect(rows.map((r) => r.fullName)).toEqual(['michal/mcpctl', 'michal/lab']); + expect(codeRepos.create).not.toHaveBeenCalled(); + }); + + it('marks repos the target project already imported', async () => { + const { service } = svc({ + existing: [{ name: 'mcpctl', sourceFullName: 'michal/mcpctl' } as CodeRepo], + }); + const rows = await service.discover('gp-1', { projectId: 'proj-1' }); + expect(rows.find((r) => r.fullName === 'michal/mcpctl')?.imported).toBe(true); + expect(rows.find((r) => r.fullName === 'michal/lab')?.imported).toBe(false); + }); + + it('flags an empty repo as unavailable — there is nothing to clone', async () => { + const { service } = svc({ empty: ['michal/lab'] }); + const rows = await service.discover('gp-1'); + expect(rows.find((r) => r.fullName === 'michal/lab')?.unavailableReason).toMatch(/empty/); + }); +}); + +describe('GitProviderService.importRepos', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + it('imports with denormalized provenance that outlives the provider', async () => { + const { service, created } = svc(); + const result = await service.importRepos( + 'gp-1', { project: 'mcpctl', repos: ['michal/mcpctl'] }, 'user-1', + ); + + expect(result.imported).toHaveLength(1); + expect(created[0]).toMatchObject({ + name: 'mcpctl', + projectId: 'proj-1', + url: 'https://mysources.co.uk/michal/mcpctl.git', + gitProviderId: 'gp-1', + sourceProvider: 'mysources', + sourceFullName: 'michal/mcpctl', + provider: 'gitea', + }); + }); + + it('stores no credential on an imported repo — it inherits the forge\'s', async () => { + const { service, created } = svc(); + await service.importRepos('gp-1', { project: 'mcpctl', repos: ['michal/mcpctl'] }, 'user-1'); + expect(created[0]).not.toHaveProperty('credSecretId'); + }); + + it('uses ssh urls when asked', async () => { + const { service, created } = svc(); + await service.importRepos('gp-1', { project: 'mcpctl', repos: ['michal/mcpctl'], ssh: true }, 'user-1'); + expect(created[0]?.url).toBe('git@mysources.co.uk:michal/mcpctl.git'); + }); + + it('imports what it can and reports what it skipped, with reasons', async () => { + // One bad name must not lose the other nineteen. + const { service } = svc({ empty: ['michal/lab'] }); + const result = await service.importRepos( + 'gp-1', { project: 'mcpctl', repos: ['michal/mcpctl', 'michal/lab', 'michal/ghost'] }, 'user-1', + ); + + expect(result.imported.map((r) => r.name)).toEqual(['mcpctl']); + expect(result.skipped).toEqual([ + { fullName: 'michal/lab', reason: 'repository is empty — nothing to clone' }, + { fullName: 'michal/ghost', reason: 'not found on this provider' }, + ]); + }); + + it('skips a repo already imported rather than duplicating it', async () => { + const { service } = svc({ + existing: [{ name: 'mcpctl', sourceFullName: 'michal/mcpctl' } as CodeRepo], + }); + const result = await service.importRepos( + 'gp-1', { project: 'mcpctl', repos: ['michal/mcpctl'] }, 'user-1', + ); + expect(result.skipped[0]).toEqual({ fullName: 'michal/mcpctl', reason: 'already imported' }); + }); + + it('distinguishes a name collision from a re-import', async () => { + const { service } = svc({ + existing: [{ name: 'mcpctl', sourceFullName: 'someone-else/mcpctl' } as CodeRepo], + }); + const result = await service.importRepos( + 'gp-1', { project: 'mcpctl', repos: ['michal/mcpctl'] }, 'user-1', + ); + expect(result.skipped[0]?.reason).toMatch(/name 'mcpctl' is taken by another repo/); + }); + + it('404s on an unknown project', async () => { + const { service } = svc(); + await expect( + service.importRepos('gp-1', { project: 'ghost', repos: ['michal/mcpctl'] }, 'user-1'), + ).rejects.toThrow(/Project not found/); + }); + + it('requires exactly one of project or projectId', async () => { + const { service } = svc(); + await expect(service.importRepos('gp-1', { repos: ['x'] }, 'u')).rejects.toThrow(); + await expect( + service.importRepos('gp-1', { project: 'mcpctl', projectId: 'proj-1', repos: ['x'] }, 'u'), + ).rejects.toThrow(); + }); +}); -- 2.49.1 From 772c25f605c005d75c3d7fb64ecb6307bd67b58e Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 8 Aug 2026 17:00:25 +0100 Subject: [PATCH 12/32] feat(cli): make the agentic-teams stack usable from the terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everything so far was API-only. This adds the CLI surface and proves the whole vertical works end to end against a real mcpd and a real Gitea. mcpctl create gitprovider mysources --url https://mysources.co.uk \ --secret gitea-token --key TOKEN mcpctl repos discover mysources --query mcp --project demo mcpctl repos import mysources --project demo michal/mcpctl mcpctl create epic ship-agents --project demo --content "..." \ --criterion "cli=CLI can drive a task" --criterion "q:feel=reads at a glance" mcpctl create epictask foundation --epic ship-agents mcpctl create task wire-cli --epictask foundation --title "Wire the CLI" mcpctl tasks advance wire-cli in_progress mcpctl tasks verdict wire-cli --stage qa_review --commit 3f2ab19 --pass --summary "..." mcpctl tasks accept wire-cli --note "ship it" mcpctl tasks history wire-cli `repos` and `tasks` are their own verb groups rather than more create/edit flags. Discovery is inherently two-step — look at what is there, then pick — and accept/reject are the human gate, not field writes. `get` gained columns for all five resources. The Epic row shows completeness as "40% (2/5)" and CodeRepo shows SOURCE/FROM, so imported repos say where they came from at a glance. BREAKING: `mcpctl get tasks` now lists WORK ITEMS. The inference queue keeps `inference-task(s)` and gains `itask(s)`; the short `task`/`tasks` aliases move to work items. This completes the split already made in RBAC (a186d95) — having the CLI and RBAC disagree about what "tasks" means would be worse than the rename. Verified end to end, not just unit tested: mcpd run locally against the test database, admin bootstrapped, then the full flow driven through the built CLI. Discovery returned the real repositories from https://mysources.co.uk, import created the CodeRepo with provenance, re-discovery marked it `imported`, and the task walked todo → in_progress → building → self_testing → qa_review with a FAILING QA verdict correctly refusing `done`, then bounced back, passed on a second commit, and merged through the accept gate. `tasks history` shows both verdicts and all twelve transitions. Two bugs the walkthrough caught that unit tests had not: `get repos` and `get epics` crashed on `padEnd` because Prisma includes hand back `{ name }` rather than a bare string, and the completions guard test failed because the resource lists had changed without regenerating. Local: build clean, workspace 2440 passed, db 128 passed (same 4 pre-existing Secret failures), lint errors unchanged at the repo's pre-existing 794 (+2 no-console warnings, the same shape every other command registration uses). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p --- .taskmaster/tasks/tasks.json | 16 ++-- completions/mcpctl.bash | 68 +++++++++++++++- completions/mcpctl.fish | 103 ++++++++++++++++++++++-- scripts/generate-completions.ts | 18 ++++- src/cli/src/commands/create.ts | 110 ++++++++++++++++++++++++- src/cli/src/commands/get.ts | 76 ++++++++++++++++++ src/cli/src/commands/repos.ts | 115 +++++++++++++++++++++++++++ src/cli/src/commands/shared.ts | 30 +++++-- src/cli/src/commands/tasks.ts | 137 ++++++++++++++++++++++++++++++++ src/cli/src/index.ts | 16 ++++ 10 files changed, 658 insertions(+), 31 deletions(-) create mode 100644 src/cli/src/commands/repos.ts create mode 100644 src/cli/src/commands/tasks.ts diff --git a/.taskmaster/tasks/tasks.json b/.taskmaster/tasks/tasks.json index e1ad59f..287d7e1 100644 --- a/.taskmaster/tasks/tasks.json +++ b/.taskmaster/tasks/tasks.json @@ -2863,9 +2863,9 @@ "131", "133" ], - "status": "pending", + "status": "done", "subtasks": [], - "updatedAt": "2026-08-08T08:55:58.627Z" + "updatedAt": "2026-08-08T16:00:25.459Z" }, { "id": "137", @@ -2877,9 +2877,9 @@ "dependencies": [ "136" ], - "status": "pending", + "status": "done", "subtasks": [], - "updatedAt": "2026-08-08T08:55:58.627Z" + "updatedAt": "2026-08-08T16:00:25.469Z" }, { "id": "138", @@ -2905,9 +2905,9 @@ "dependencies": [ "138" ], - "status": "pending", + "status": "done", "subtasks": [], - "updatedAt": "2026-08-08T08:55:58.627Z" + "updatedAt": "2026-08-08T16:00:25.475Z" }, { "id": "140", @@ -3265,9 +3265,9 @@ ], "metadata": { "version": "1.0.0", - "lastModified": "2026-08-08T15:42:40.452Z", + "lastModified": "2026-08-08T16:00:25.475Z", "taskCount": 163, - "completedCount": 119, + "completedCount": 122, "tags": [ "master" ] diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index b544435..8b4f96d 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -5,11 +5,11 @@ _mcpctl() { local cur prev words cword _init_completion || return - local commands="status login logout config get describe delete logs create edit apply chat chat-llm patch passwd errors backup approve review skills console cache provider test migrate rotate" + local commands="status login logout config repos tasks get describe delete logs create edit apply chat chat-llm patch passwd errors backup approve review skills console cache provider test migrate rotate" local project_commands="get describe delete logs create edit attach-server detach-server favourites" local global_opts="-v --version --daemon-url --direct -p --project -h --help" - local resources="servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks all" - local resource_aliases="servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks all server srv instance inst secret sec secretbackend sb llm agent personality template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm task tasks inference-task" + local resources="servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks epics epictasks tasks repos gitproviders all" + local resource_aliases="servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks epics epictasks tasks repos gitproviders all server srv instance inst secret sec secretbackend sb llm agent personality template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm inference-task itask itasks epic epictask et task repo repository repositories gitprovider forge gp" # Check if --project/-p was given local has_project=false @@ -136,6 +136,51 @@ _mcpctl() { esac fi return ;; + repos) + local repos_sub=$(_mcpctl_get_subcmd $subcmd_pos) + if [[ -z "$repos_sub" ]]; then + COMPREPLY=($(compgen -W "discover import help" -- "$cur")) + else + case "$repos_sub" in + discover) + COMPREPLY=($(compgen -W "-q --query --project --limit -o --output -h --help" -- "$cur")) + ;; + import) + COMPREPLY=($(compgen -W "--project --ssh -h --help" -- "$cur")) + ;; + *) + COMPREPLY=($(compgen -W "-h --help" -- "$cur")) + ;; + esac + fi + return ;; + tasks) + local tasks_sub=$(_mcpctl_get_subcmd $subcmd_pos) + if [[ -z "$tasks_sub" ]]; then + COMPREPLY=($(compgen -W "advance verdict accept reject history help" -- "$cur")) + else + case "$tasks_sub" in + advance) + COMPREPLY=($(compgen -W "--note -h --help" -- "$cur")) + ;; + verdict) + COMPREPLY=($(compgen -W "--stage --commit --summary --pass --fail -h --help" -- "$cur")) + ;; + accept) + COMPREPLY=($(compgen -W "--note -h --help" -- "$cur")) + ;; + reject) + COMPREPLY=($(compgen -W "--note -h --help" -- "$cur")) + ;; + history) + COMPREPLY=($(compgen -W "-h --help" -- "$cur")) + ;; + *) + COMPREPLY=($(compgen -W "-h --help" -- "$cur")) + ;; + esac + fi + return ;; get) if [[ -z "$resource_type" ]]; then COMPREPLY=($(compgen -W "$resources -o --output -p --project -A --all -h --help" -- "$cur")) @@ -175,9 +220,24 @@ _mcpctl() { create) local create_sub=$(_mcpctl_get_subcmd $subcmd_pos) if [[ -z "$create_sub" ]]; then - COMPREPLY=($(compgen -W "server secret llm agent secretbackend project user group rbac mcptoken prompt skill personality serverattachment promptrequest help" -- "$cur")) + COMPREPLY=($(compgen -W "gitprovider epic epictask task repo server secret llm agent secretbackend project user group rbac mcptoken prompt skill personality serverattachment promptrequest help" -- "$cur")) else case "$create_sub" in + gitprovider) + COMPREPLY=($(compgen -W "--url --type --description --secret --key --skip-probe -h --help" -- "$cur")) + ;; + epic) + COMPREPLY=($(compgen -W "--project --content --content-file --description --criterion -h --help" -- "$cur")) + ;; + epictask) + COMPREPLY=($(compgen -W "--epic --project --description --depends-on -h --help" -- "$cur")) + ;; + task) + COMPREPLY=($(compgen -W "--epictask --title --description --risk --repo -h --help" -- "$cur")) + ;; + repo) + COMPREPLY=($(compgen -W "--project --url --branch --path --secret --key -h --help" -- "$cur")) + ;; server) COMPREPLY=($(compgen -W "-d --description --package-name --runtime --docker-image --transport --repository-url --external-url --command --container-port --replicas --env --from-template --env-from-secret --force -h --help" -- "$cur")) ;; diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index ff85934..ec047eb 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -4,7 +4,7 @@ # Erase any stale completions from previous versions complete -c mcpctl -e -set -l commands status login logout config get describe delete logs create edit apply chat chat-llm patch passwd errors backup approve review skills console cache provider test migrate rotate +set -l commands status login logout config repos tasks get describe delete logs create edit apply chat chat-llm patch passwd errors backup approve review skills console cache provider test migrate rotate set -l project_commands get describe delete logs create edit attach-server detach-server favourites # Disable file completions by default @@ -31,10 +31,10 @@ function __mcpctl_has_project end # Resource type detection -set -l resources servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks all +set -l resources servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks epics epictasks tasks repos gitproviders all function __mcpctl_needs_resource_type - set -l resource_aliases servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks all server srv instance inst secret sec secretbackend sb llm agent personality template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm task tasks inference-task + set -l resource_aliases servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks epics epictasks tasks repos gitproviders all server srv instance inst secret sec secretbackend sb llm agent personality template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm inference-task itask itasks epic epictask et task repo repository repositories gitprovider forge gp set -l tokens (commandline -opc) set -l found_cmd false for tok in $tokens @@ -72,14 +72,19 @@ function __mcpctl_resolve_resource case promptrequest promptrequests pr; echo promptrequests case serverattachment serverattachments sa; echo serverattachments case proxymodel proxymodels pm; echo proxymodels - case task tasks inference-task inference-tasks; echo inference-tasks + case inference-task inference-tasks itask itasks; echo inference-tasks + case epic epics; echo epics + case epictask epictasks et; echo epictasks + case task tasks; echo tasks + case repo repos repository repositories; echo repos + case gitprovider gitproviders forge gp; echo gitproviders case all; echo all case '*'; echo $argv[1] end end function __mcpctl_get_resource_type - set -l resource_aliases servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks all server srv instance inst secret sec secretbackend sb llm agent personality template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm task tasks inference-task + set -l resource_aliases servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks epics epictasks tasks repos gitproviders all server srv instance inst secret sec secretbackend sb llm agent personality template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm inference-task itask itasks epic epictask et task repo repository repositories gitprovider forge gp set -l tokens (commandline -opc) set -l found_cmd false for tok in $tokens @@ -224,11 +229,13 @@ complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_ complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a login -d 'Authenticate with mcpd' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a logout -d 'Log out and remove stored credentials' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a config -d 'Manage mcpctl configuration' +complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a repos -d 'Discover and import git repositories from a registered gitprovider' +complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a tasks -d 'Drive a work item: advance state, record verdicts, accept or reject' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a get -d 'List resources (servers, projects, instances, all)' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a describe -d 'Show detailed information about a resource' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a delete -d 'Delete a resource (server, instance, secret, project, user, group, rbac, personality)' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a logs -d 'Get logs from an MCP server instance' -complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a create -d 'Create a resource (server, secret, secretbackend, llm, agent, project, user, group, rbac, serverattachment, prompt)' +complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a create -d 'Create a resource (server, secret, secretbackend, llm, agent, project, user, group, rbac, serverattachment, prompt, gitprovider, epic, epictask, task, repo)' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a edit -d 'Edit a resource in your default editor (server, project)' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a apply -d 'Apply declarative configuration from a YAML or JSON file' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a chat -d 'Open an interactive chat session with an agent or a project (REPL or one-shot).' @@ -252,7 +259,7 @@ complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a describe -d 'Show detailed information about a resource' complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a delete -d 'Delete a resource (server, instance, secret, project, user, group, rbac, personality)' complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a logs -d 'Get logs from an MCP server instance' -complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a create -d 'Create a resource (server, secret, secretbackend, llm, agent, project, user, group, rbac, serverattachment, prompt)' +complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a create -d 'Create a resource (server, secret, secretbackend, llm, agent, project, user, group, rbac, serverattachment, prompt, gitprovider, epic, epictask, task, repo)' complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a edit -d 'Edit a resource in your default editor (server, project)' complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a attach-server -d 'Attach a server to a project (requires --project)' complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a detach-server -d 'Detach a server from a project (requires --project)' @@ -297,8 +304,52 @@ complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l skip-sk # config impersonate options complete -c mcpctl -n "__mcpctl_subcmd_active config impersonate" -l quit -d 'Stop impersonating and return to original identity' +# repos subcommands +set -l repos_cmds discover import +complete -c mcpctl -n "__fish_seen_subcommand_from repos; and not __fish_seen_subcommand_from $repos_cmds" -a discover -d 'List repositories available on a gitprovider' +complete -c mcpctl -n "__fish_seen_subcommand_from repos; and not __fish_seen_subcommand_from $repos_cmds" -a import -d 'Import selected repositories into a project' + +# repos discover options +complete -c mcpctl -n "__mcpctl_subcmd_active repos discover" -s q -l query -d 'Filter by name' -x +complete -c mcpctl -n "__mcpctl_subcmd_active repos discover" -l project -d 'Mark which repos this project already has' -xa '(__mcpctl_project_names)' +complete -c mcpctl -n "__mcpctl_subcmd_active repos discover" -l limit -d 'Max results' -x +complete -c mcpctl -n "__mcpctl_subcmd_active repos discover" -s o -l output -d 'table | json' -x + +# repos import options +complete -c mcpctl -n "__mcpctl_subcmd_active repos import" -l project -d 'Project to import into' -xa '(__mcpctl_project_names)' +complete -c mcpctl -n "__mcpctl_subcmd_active repos import" -l ssh -d 'Use ssh clone URLs instead of https' + +# tasks subcommands +set -l tasks_cmds advance verdict accept reject history +complete -c mcpctl -n "__fish_seen_subcommand_from tasks; and not __fish_seen_subcommand_from $tasks_cmds" -a advance -d 'Move a task to the next state' +complete -c mcpctl -n "__fish_seen_subcommand_from tasks; and not __fish_seen_subcommand_from $tasks_cmds" -a verdict -d 'Record a QA / Security / SRE verdict, pinned to the commit it judged' +complete -c mcpctl -n "__fish_seen_subcommand_from tasks; and not __fish_seen_subcommand_from $tasks_cmds" -a accept -d 'Accept a task awaiting review — fires the merge' +complete -c mcpctl -n "__fish_seen_subcommand_from tasks; and not __fish_seen_subcommand_from $tasks_cmds" -a reject -d 'Send a task back with direction (the comment is required)' +complete -c mcpctl -n "__fish_seen_subcommand_from tasks; and not __fish_seen_subcommand_from $tasks_cmds" -a history -d 'Show the transition log and recorded verdicts' + +# tasks advance options +complete -c mcpctl -n "__mcpctl_subcmd_active tasks advance" -l note -d 'Note recorded on the transition' -x + +# tasks verdict options +complete -c mcpctl -n "__mcpctl_subcmd_active tasks verdict" -l stage -d 'qa_review | security_review | sre_review' -x +complete -c mcpctl -n "__mcpctl_subcmd_active tasks verdict" -l commit -d 'Commit the verdict applies to' -x +complete -c mcpctl -n "__mcpctl_subcmd_active tasks verdict" -l summary -d 'What was found' -x +complete -c mcpctl -n "__mcpctl_subcmd_active tasks verdict" -l pass -d 'Mark the verdict as passing' +complete -c mcpctl -n "__mcpctl_subcmd_active tasks verdict" -l fail -d 'Mark the verdict as failing' + +# tasks accept options +complete -c mcpctl -n "__mcpctl_subcmd_active tasks accept" -l note -d 'Comment stored with the decision' -x + +# tasks reject options +complete -c mcpctl -n "__mcpctl_subcmd_active tasks reject" -l note -d 'What needs to change — this is what the agent works from' -x + # create subcommands -set -l create_cmds server secret llm agent secretbackend project user group rbac mcptoken prompt skill personality serverattachment promptrequest +set -l create_cmds gitprovider epic epictask task repo server secret llm agent secretbackend project user group rbac mcptoken prompt skill personality serverattachment promptrequest +complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a gitprovider -d 'Register a git forge (Gitea) so its repositories can be discovered and imported' +complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a epic -d 'Create an Epic — the versioned vision tasks are measured against' +complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a epictask -d 'Create an EpicTask — a roadmap node under an Epic' +complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a task -d 'Create a work item under an EpicTask' +complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a repo -d 'Attach a git repository to a project by URL (see `mcpctl repos discover` to import from a provider)' complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a server -d 'Create an MCP server definition' complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a secret -d 'Create a secret' complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a llm -d 'Register a server-managed LLM (anthropic, openai, vllm, ollama, deepseek, gemini-cli)' @@ -315,6 +366,42 @@ complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_s complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a serverattachment -d 'Attach a server to a project' complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a promptrequest -d 'Create a prompt request (pending proposal that needs approval)' +# create gitprovider options +complete -c mcpctl -n "__mcpctl_subcmd_active create gitprovider" -l url -d 'Instance base URL, e.g. https://mysources.co.uk' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create gitprovider" -l type -d 'Driver type' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create gitprovider" -l description -d 'Description' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create gitprovider" -l secret -d 'Secret holding the API token' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create gitprovider" -l key -d 'Key within that secret' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create gitprovider" -l skip-probe -d 'Skip the credential check at registration time' + +# create epic options +complete -c mcpctl -n "__mcpctl_subcmd_active create epic" -l project -d 'Project this Epic belongs to' -xa '(__mcpctl_project_names)' +complete -c mcpctl -n "__mcpctl_subcmd_active create epic" -l content -d 'The vision statement' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create epic" -l content-file -d 'Read the vision statement from a file' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create epic" -l description -d 'Short description' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create epic" -l criterion -d 'Done-criterion "id=text" (repeat); prefix id with q: for a quality criterion' -x + +# create epictask options +complete -c mcpctl -n "__mcpctl_subcmd_active create epictask" -l epic -d 'Parent Epic' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create epictask" -l project -d 'Disambiguate the Epic by project' -xa '(__mcpctl_project_names)' +complete -c mcpctl -n "__mcpctl_subcmd_active create epictask" -l description -d 'Description' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create epictask" -l depends-on -d 'Prerequisite epictask in the same Epic (repeat)' -x + +# create task options +complete -c mcpctl -n "__mcpctl_subcmd_active create task" -l epictask -d 'Parent EpicTask' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create task" -l title -d 'Human-readable title' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create task" -l description -d 'Description' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create task" -l risk -d 'trivial | normal | high — drives which review gates are required' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create task" -l repo -d 'CodeRepo id this task works in' -x + +# create repo options +complete -c mcpctl -n "__mcpctl_subcmd_active create repo" -l project -d 'Project to attach to' -xa '(__mcpctl_project_names)' +complete -c mcpctl -n "__mcpctl_subcmd_active create repo" -l url -d 'Clone URL (https:// or ssh://)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create repo" -l branch -d 'Default branch' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create repo" -l path -d 'Workspace-relative checkout path (defaults to the repo name)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create repo" -l secret -d 'Secret holding the git credential' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create repo" -l key -d 'Key within that secret' -x + # create server options complete -c mcpctl -n "__mcpctl_subcmd_active create server" -s d -l description -d 'Server description' -x complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l package-name -d 'Package name (npm, PyPI, Go module, etc.)' -x diff --git a/scripts/generate-completions.ts b/scripts/generate-completions.ts index 497b0a4..a61c7d3 100644 --- a/scripts/generate-completions.ts +++ b/scripts/generate-completions.ts @@ -186,7 +186,9 @@ async function extractTree(): Promise { const CANONICAL_RESOURCES = [ 'servers', 'instances', 'secrets', 'secretbackends', 'llms', 'agents', 'personalities', 'templates', 'projects', 'users', 'groups', 'rbac', 'prompts', 'promptrequests', - 'serverattachments', 'proxymodels', 'inference-tasks', 'all', + 'serverattachments', 'proxymodels', 'inference-tasks', + // Agentic teams + 'epics', 'epictasks', 'tasks', 'repos', 'gitproviders', 'all', ]; const ALIAS_ENTRIES: [string, string][] = [ @@ -206,10 +208,18 @@ const ALIAS_ENTRIES: [string, string][] = [ ['promptrequest', 'promptrequests'], ['promptrequests', 'promptrequests'], ['pr', 'promptrequests'], ['serverattachment', 'serverattachments'], ['serverattachments', 'serverattachments'], ['sa', 'serverattachments'], ['proxymodel', 'proxymodels'], ['proxymodels', 'proxymodels'], ['pm', 'proxymodels'], - // v5: inference-task queue. Short forms (`task`, `tasks`) are what - // operators actually type — `mcpctl get tasks`. - ['task', 'inference-tasks'], ['tasks', 'inference-tasks'], + // v5 shipped the inference queue under the short `task`/`tasks` aliases. + // Agentic-teams work items own those now (see shared.ts); the queue keeps + // only its explicit spellings plus an `itask` shorthand. ['inference-task', 'inference-tasks'], ['inference-tasks', 'inference-tasks'], + ['itask', 'inference-tasks'], ['itasks', 'inference-tasks'], + // Agentic teams + ['epic', 'epics'], ['epics', 'epics'], + ['epictask', 'epictasks'], ['epictasks', 'epictasks'], ['et', 'epictasks'], + ['task', 'tasks'], ['tasks', 'tasks'], + ['repo', 'repos'], ['repos', 'repos'], ['repository', 'repos'], ['repositories', 'repos'], + ['gitprovider', 'gitproviders'], ['gitproviders', 'gitproviders'], + ['forge', 'gitproviders'], ['gp', 'gitproviders'], ['all', 'all'], ]; diff --git a/src/cli/src/commands/create.ts b/src/cli/src/commands/create.ts index 6f64801..cab750f 100644 --- a/src/cli/src/commands/create.ts +++ b/src/cli/src/commands/create.ts @@ -119,7 +119,115 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { const { client, log } = deps; const cmd = new Command('create') - .description('Create a resource (server, secret, secretbackend, llm, agent, project, user, group, rbac, serverattachment, prompt)'); + .description('Create a resource (server, secret, secretbackend, llm, agent, project, user, group, rbac, serverattachment, prompt, gitprovider, epic, epictask, task, repo)'); + + // ── Agentic teams ── + + cmd.command('gitprovider') + .description('Register a git forge (Gitea) so its repositories can be discovered and imported') + .argument('', 'Provider name (lowercase alphanumeric with hyphens)') + .requiredOption('--url ', 'Instance base URL, e.g. https://mysources.co.uk') + .option('--type ', 'Driver type', 'gitea') + .option('--description ', 'Description') + .option('--secret ', 'Secret holding the API token') + .option('--key ', 'Key within that secret', 'TOKEN') + .option('--skip-probe', 'Skip the credential check at registration time') + .action(async (name: string, opts) => { + const body: Record = { name, url: opts.url, type: opts.type }; + if (opts.description !== undefined) body.description = opts.description; + if (opts.secret !== undefined) body.credRef = { name: opts.secret, key: opts.key }; + if (opts.skipProbe === true) body.skipProbe = true; + const created = await client.post<{ name: string; type: string; url: string }>('/api/v1/gitproviders', body); + log(`Created gitprovider '${created.name}' (${created.type} @ ${created.url})`); + }); + + cmd.command('epic') + .description('Create an Epic — the versioned vision tasks are measured against') + .argument('', 'Epic name (lowercase alphanumeric with hyphens)') + .requiredOption('--project ', 'Project this Epic belongs to') + .option('--content ', 'The vision statement') + .option('--content-file ', 'Read the vision statement from a file') + .option('--description ', 'Short description') + .option('--criterion ', 'Done-criterion "id=text" (repeat); prefix id with q: for a quality criterion', collect, []) + .action(async (name: string, opts) => { + let content = opts.content as string | undefined; + if (content === undefined && opts.contentFile !== undefined) { + const fs = await import('node:fs/promises'); + content = (await fs.readFile(opts.contentFile as string, 'utf-8')).trim(); + } + if (content === undefined) throw new Error('Pass --content or --content-file'); + + const doneCriteria = (opts.criterion as string[]).map((raw) => { + const eq = raw.indexOf('='); + if (eq === -1) throw new Error(`Invalid --criterion '${raw}'. Expected id=text`); + let id = raw.slice(0, eq); + const quality = id.startsWith('q:'); + if (quality) id = id.slice(2); + return { id, text: raw.slice(eq + 1), kind: quality ? 'quality' : 'outcome', met: false }; + }); + + const body: Record = { name, project: opts.project, content }; + if (opts.description !== undefined) body.description = opts.description; + if (doneCriteria.length > 0) body.doneCriteria = doneCriteria; + const created = await client.post<{ name: string }>('/api/v1/epics', body); + log(`Created epic '${created.name}' in project '${opts.project as string}'`); + }); + + cmd.command('epictask') + .description('Create an EpicTask — a roadmap node under an Epic') + .argument('', 'EpicTask name (lowercase alphanumeric with hyphens)') + .requiredOption('--epic ', 'Parent Epic') + .option('--project ', 'Disambiguate the Epic by project') + .option('--description ', 'Description') + .option('--depends-on ', 'Prerequisite epictask in the same Epic (repeat)', collect, []) + .action(async (name: string, opts) => { + const body: Record = { name, epic: opts.epic }; + if (opts.project !== undefined) body.project = opts.project; + if (opts.description !== undefined) body.description = opts.description; + const created = await client.post<{ id: string; name: string }>('/api/v1/epictasks', body); + log(`Created epictask '${created.name}'`); + for (const dep of opts.dependsOn as string[]) { + await client.post(`/api/v1/epictasks/${created.id}/edges`, { to: dep }); + log(` depends on '${dep}'`); + } + }); + + cmd.command('task') + .description('Create a work item under an EpicTask') + .argument('', 'Task name (lowercase alphanumeric with hyphens)') + .requiredOption('--epictask ', 'Parent EpicTask') + .requiredOption('--title ', 'Human-readable title') + .option('--description ', 'Description') + .option('--risk ', 'trivial | normal | high — drives which review gates are required', 'normal') + .option('--repo ', 'CodeRepo id this task works in') + .action(async (name: string, opts) => { + const body: Record = { + name, epictask: opts.epictask, title: opts.title, risk: opts.risk, + }; + if (opts.description !== undefined) body.description = opts.description; + if (opts.repo !== undefined) body.repoId = opts.repo; + const created = await client.post<{ name: string; state: string }>('/api/v1/tasks', body); + log(`Created task '${created.name}' (${created.state}, risk=${opts.risk as string})`); + }); + + cmd.command('repo') + .description('Attach a git repository to a project by URL (see `mcpctl repos discover` to import from a provider)') + .argument('', 'Repo name (lowercase alphanumeric with hyphens)') + .requiredOption('--project ', 'Project to attach to') + .requiredOption('--url ', 'Clone URL (https:// or ssh://)') + .option('--branch ', 'Default branch', 'main') + .option('--path ', 'Workspace-relative checkout path (defaults to the repo name)') + .option('--secret ', 'Secret holding the git credential') + .option('--key ', 'Key within that secret', 'TOKEN') + .action(async (name: string, opts) => { + const body: Record = { + name, project: opts.project, url: opts.url, defaultBranch: opts.branch, + }; + if (opts.path !== undefined) body.path = opts.path; + if (opts.secret !== undefined) body.credRef = { name: opts.secret, key: opts.key }; + const created = await client.post<{ name: string }>('/api/v1/repos', body); + log(`Created repo '${created.name}' in project '${opts.project as string}'`); + }); // --- create server --- cmd.command('server') diff --git a/src/cli/src/commands/get.ts b/src/cli/src/commands/get.ts index 0014f95..401bf92 100644 --- a/src/cli/src/commands/get.ts +++ b/src/cli/src/commands/get.ts @@ -413,8 +413,79 @@ const proxymodelColumns: Column[] = [ }, width: 45 }, ]; +// ── Agentic teams ── + +interface EpicRow { + id: string; name: string; project?: { name: string }; status: string; semver: string; + completeness?: { met: number; total: number; percent: number }; +} +const epicColumns: Column[] = [ + { header: 'NAME', key: 'name', width: 24 }, + { header: 'PROJECT', key: (r) => r.project?.name ?? '-', width: 18 }, + { header: 'STATUS', key: 'status', width: 24 }, + { header: 'DONE', key: (r) => r.completeness ? `${String(r.completeness.percent)}% (${String(r.completeness.met)}/${String(r.completeness.total)})` : '-', width: 14 }, + { header: 'SEMVER', key: 'semver', width: 8 }, + { header: 'ID', key: 'id' }, +]; + +interface EpicTaskRow { id: string; name: string; status: string; epicId: string } +const epicTaskColumns: Column[] = [ + { header: 'NAME', key: 'name', width: 28 }, + { header: 'STATUS', key: 'status', width: 12 }, + { header: 'EPIC', key: 'epicId', width: 26 }, + { header: 'ID', key: 'id' }, +]; + +interface TaskRow { + id: string; name: string; title: string; state: string; risk: string; + assigneeId: string | null; prUrl: string | null; +} +const taskColumns: Column[] = [ + { header: 'NAME', key: 'name', width: 24 }, + { header: 'TITLE', key: 'title', width: 34 }, + { header: 'STATE', key: 'state', width: 16 }, + { header: 'RISK', key: 'risk', width: 8 }, + { header: 'PR', key: (r) => r.prUrl ?? '-', width: 30 }, + { header: 'ID', key: 'id' }, +]; + +interface CodeRepoRow { + id: string; name: string; project?: { name: string }; url: string; defaultBranch: string; + provider: string; sourceProvider: string | null; sourceFullName: string | null; +} +const codeRepoColumns: Column[] = [ + { header: 'NAME', key: 'name', width: 22 }, + { header: 'PROJECT', key: (r) => r.project?.name ?? '-', width: 16 }, + { header: 'BRANCH', key: 'defaultBranch', width: 10 }, + { header: 'SOURCE', key: (r) => r.sourceFullName ?? '-', width: 28 }, + { header: 'FROM', key: (r) => r.sourceProvider ?? '-', width: 14 }, + { header: 'URL', key: 'url', width: 44 }, +]; + +interface GitProviderRow { + id: string; name: string; type: string; url: string; description: string; + credSecretKey: string | null; +} +const gitProviderColumns: Column[] = [ + { header: 'NAME', key: 'name', width: 20 }, + { header: 'TYPE', key: 'type', width: 10 }, + { header: 'URL', key: 'url', width: 40 }, + { header: 'CRED', key: (r) => (r.credSecretKey !== null && r.credSecretKey !== '') ? 'set' : '-', width: 6 }, + { header: 'ID', key: 'id' }, +]; + function getColumnsForResource(resource: string): Column>[] { switch (resource) { + case 'epics': + return epicColumns as unknown as Column>[]; + case 'epictasks': + return epicTaskColumns as unknown as Column>[]; + case 'tasks': + return taskColumns as unknown as Column>[]; + case 'repos': + return codeRepoColumns as unknown as Column>[]; + case 'gitproviders': + return gitProviderColumns as unknown as Column>[]; case 'servers': return serverColumns as unknown as Column>[]; case 'projects': @@ -479,6 +550,11 @@ const RESOURCE_KIND: Record = { llms: 'llm', agents: 'agent', personalities: 'personality', + epics: 'epic', + epictasks: 'epictask', + tasks: 'task', + repos: 'repo', + gitproviders: 'gitprovider', }; /** diff --git a/src/cli/src/commands/repos.ts b/src/cli/src/commands/repos.ts new file mode 100644 index 0000000..ee1eb0f --- /dev/null +++ b/src/cli/src/commands/repos.ts @@ -0,0 +1,115 @@ +import { Command } from 'commander'; + +import type { ApiClient } from '../api-client.js'; + +import { resolveNameOrId } from './shared.js'; + +export interface ReposCommandDeps { + client: ApiClient; + log: (msg: string) => void; +} + +interface DiscoveredRepo { + fullName: string; + name: string; + description: string; + defaultBranch: string; + private: boolean; + empty: boolean; + archived: boolean; + imported: boolean; + unavailableReason?: string; +} + +interface ImportResult { + imported: Array<{ name: string; url: string }>; + skipped: Array<{ fullName: string; reason: string }>; +} + +function pad(s: string, n: number): string { + return s.length >= n ? s : s + ' '.repeat(n - s.length); +} + +/** + * `mcpctl repos` — discover repositories on a registered git provider and + * import the ones you want. + * + * Its own verb group rather than more `create` flags, because discovery is a + * two-step flow: look at what is there, then pick. + */ +export function createReposCommand(deps: ReposCommandDeps): Command { + const { client, log } = deps; + + const cmd = new Command('repos') + .description('Discover and import git repositories from a registered gitprovider'); + + cmd.command('discover') + .description('List repositories available on a gitprovider') + .argument('', 'GitProvider name or id') + .option('-q, --query ', 'Filter by name') + .option('--project ', 'Mark which repos this project already has') + .option('--limit ', 'Max results', (s: string) => parseInt(s, 10)) + .option('-o, --output ', 'table | json', 'table') + .action(async (provider: string, opts) => { + const id = await resolveNameOrId(client, 'gitproviders', provider); + const params = new URLSearchParams(); + if (opts.query !== undefined) params.set('q', opts.query as string); + if (opts.project !== undefined) params.set('project', opts.project as string); + if (opts.limit !== undefined) params.set('limit', String(opts.limit)); + + const qs = params.toString(); + const rows = await client.get( + `/api/v1/gitproviders/${id}/repos${qs === '' ? '' : `?${qs}`}`, + ); + + if (opts.output === 'json') { + log(JSON.stringify(rows, null, 2)); + return; + } + if (rows.length === 0) { + log('No repositories found.'); + return; + } + + log(`${pad('REPOSITORY', 40)}${pad('BRANCH', 10)}${pad('VIS', 9)}${pad('STATUS', 12)}DESCRIPTION`); + log('-'.repeat(100)); + for (const r of rows) { + const status = r.imported + ? 'imported' + : r.unavailableReason !== undefined ? 'unavailable' : '-'; + log( + pad(r.fullName, 40) + + pad(r.defaultBranch, 10) + + pad(r.private ? 'private' : 'public', 9) + + pad(status, 12) + + (r.unavailableReason ?? r.description).slice(0, 40), + ); + } + log(''); + log(`${String(rows.length)} repositories. Import with:`); + log(` mcpctl repos import ${provider} --project [...]`); + }); + + cmd.command('import') + .description('Import selected repositories into a project') + .argument('', 'GitProvider name or id') + .argument('', 'Owner-qualified names, e.g. michal/mcpctl') + .requiredOption('--project ', 'Project to import into') + .option('--ssh', 'Use ssh clone URLs instead of https') + .action(async (provider: string, repos: string[], opts) => { + const id = await resolveNameOrId(client, 'gitproviders', provider); + const result = await client.post(`/api/v1/gitproviders/${id}/import`, { + project: opts.project, + repos, + ...(opts.ssh === true ? { ssh: true } : {}), + }); + + for (const r of result.imported) log(`Imported ${r.name} ← ${r.url}`); + // Partial success is the contract, so skips are reported, never silent. + for (const s of result.skipped) log(`Skipped ${s.fullName} (${s.reason})`); + log(''); + log(`${String(result.imported.length)} imported, ${String(result.skipped.length)} skipped.`); + }); + + return cmd; +} diff --git a/src/cli/src/commands/shared.ts b/src/cli/src/commands/shared.ts index 5fd244a..6ac08e7 100644 --- a/src/cli/src/commands/shared.ts +++ b/src/cli/src/commands/shared.ts @@ -34,6 +34,22 @@ export const RESOURCE_ALIASES: Record = { skill: 'skills', skills: 'skills', sk: 'skills', + // ── Agentic teams ── + epic: 'epics', + epics: 'epics', + epictask: 'epictasks', + epictasks: 'epictasks', + et: 'epictasks', + task: 'tasks', + tasks: 'tasks', + repo: 'repos', + repos: 'repos', + repository: 'repos', + repositories: 'repos', + gitprovider: 'gitproviders', + gitproviders: 'gitproviders', + forge: 'gitproviders', + gp: 'gitproviders', serverattachment: 'serverattachments', serverattachments: 'serverattachments', sa: 'serverattachments', @@ -55,14 +71,16 @@ export const RESOURCE_ALIASES: Record = { personalities: 'personalities', thread: 'threads', threads: 'threads', - // v5: durable inference task queue. URL prefix is `/api/v1/inference-tasks` - // (multi-word for clarity); the operator typically wants short - // forms like `mcpctl get tasks`. All variants normalize to the - // canonical plural so URL construction works uniformly. - task: 'inference-tasks', - tasks: 'inference-tasks', + // v5 shipped the durable inference queue under the short `task`/`tasks` + // aliases, on the reasoning that inference was the only "task" we had. + // Agentic-teams work items take those names now (see the `task: 'tasks'` + // entry above and the matching RBAC split in + // src/mcpd/src/bootstrap/rbac-tasks-backfill.ts), so the queue keeps only its + // explicit spellings. `mcpctl get tasks` now lists WORK ITEMS. 'inference-task': 'inference-tasks', 'inference-tasks': 'inference-tasks', + itask: 'inference-tasks', + itasks: 'inference-tasks', all: 'all', }; diff --git a/src/cli/src/commands/tasks.ts b/src/cli/src/commands/tasks.ts new file mode 100644 index 0000000..ffee69e --- /dev/null +++ b/src/cli/src/commands/tasks.ts @@ -0,0 +1,137 @@ +import { Command } from 'commander'; + +import type { ApiClient } from '../api-client.js'; + +import { resolveNameOrId } from './shared.js'; + +export interface TasksCommandDeps { + client: ApiClient; + log: (msg: string) => void; +} + +interface TaskView { + id: string; + name: string; + title: string; + state: string; + risk: string; + legalTransitions?: string[]; +} + +interface Verdict { + stage: string; + commit: string; + passed: boolean; + summary: string; + createdAt: string; +} + +interface TaskEvent { + fromState: string | null; + toState: string; + actorType: string; + note: string | null; + createdAt: string; +} + +/** + * `mcpctl tasks` — drive a work item through its lifecycle. + * + * Separate from `create task` because these are verbs on an existing task, and + * because accept/reject are the human gate: a yes/no on a resolved outcome. + */ +export function createTasksCommand(deps: TasksCommandDeps): Command { + const { client, log } = deps; + + const cmd = new Command('tasks') + .description('Drive a work item: advance state, record verdicts, accept or reject'); + + const idOf = async (nameOrId: string): Promise => + resolveNameOrId(client, 'tasks', nameOrId); + + cmd.command('advance') + .description('Move a task to the next state') + .argument('', 'Task name or id') + .argument('', 'Target state (see `mcpctl describe task ` for legal moves)') + .option('--note ', 'Note recorded on the transition') + .action(async (task: string, state: string, opts) => { + const id = await idOf(task); + const body: Record = { toState: state }; + if (opts.note !== undefined) body.note = opts.note; + const updated = await client.post(`/api/v1/tasks/${id}/transition`, body); + log(`${updated.name}: → ${updated.state}`); + if (updated.legalTransitions !== undefined) { + log(` next: ${updated.legalTransitions.join(', ') || '(terminal)'}`); + } + }); + + cmd.command('verdict') + .description('Record a QA / Security / SRE verdict, pinned to the commit it judged') + .argument('', 'Task name or id') + .requiredOption('--stage ', 'qa_review | security_review | sre_review') + .requiredOption('--commit ', 'Commit the verdict applies to') + .requiredOption('--summary ', 'What was found') + .option('--pass', 'Mark the verdict as passing') + .option('--fail', 'Mark the verdict as failing') + .action(async (task: string, opts) => { + if ((opts.pass === true) === (opts.fail === true)) { + throw new Error('Pass exactly one of --pass or --fail'); + } + const id = await idOf(task); + await client.post(`/api/v1/tasks/${id}/verdicts`, { + stage: opts.stage, commit: opts.commit, summary: opts.summary, passed: opts.pass === true, + }); + log(`Recorded ${opts.stage as string} verdict on ${String(opts.commit).slice(0, 8)}: ${opts.pass === true ? 'PASS' : 'FAIL'}`); + }); + + cmd.command('accept') + .description('Accept a task awaiting review — fires the merge') + .argument('', 'Task name or id') + .option('--note ', 'Comment stored with the decision') + .action(async (task: string, opts) => { + const id = await idOf(task); + const body = opts.note === undefined ? {} : { note: opts.note }; + const updated = await client.post(`/api/v1/tasks/${id}/accept`, body); + log(`${updated.name}: accepted → ${updated.state}`); + }); + + cmd.command('reject') + .description('Send a task back with direction (the comment is required)') + .argument('', 'Task name or id') + .requiredOption('--note ', 'What needs to change — this is what the agent works from') + .action(async (task: string, opts) => { + const id = await idOf(task); + const updated = await client.post(`/api/v1/tasks/${id}/reject`, { note: opts.note }); + log(`${updated.name}: rejected → ${updated.state}`); + }); + + cmd.command('history') + .description('Show the transition log and recorded verdicts') + .argument('', 'Task name or id') + .action(async (task: string) => { + const id = await idOf(task); + const [events, verdicts] = await Promise.all([ + client.get(`/api/v1/tasks/${id}/events`), + client.get(`/api/v1/tasks/${id}/verdicts`), + ]); + + log('TRANSITIONS'); + for (const e of events) { + const when = new Date(e.createdAt).toISOString().replace('T', ' ').slice(0, 19); + const from = e.fromState ?? '(new)'; + log(` ${when} ${from} → ${e.toState} [${e.actorType}]${e.note !== null ? ` ${e.note}` : ''}`); + } + if (verdicts.length === 0) { + log(''); + log('VERDICTS (none recorded)'); + return; + } + log(''); + log('VERDICTS'); + for (const v of verdicts) { + log(` ${v.passed ? 'PASS' : 'FAIL'} ${v.stage} @${v.commit.slice(0, 8)} ${v.summary}`); + } + }); + + return cmd; +} diff --git a/src/cli/src/index.ts b/src/cli/src/index.ts index 7227068..a5c3da7 100644 --- a/src/cli/src/index.ts +++ b/src/cli/src/index.ts @@ -4,6 +4,8 @@ import { APP_NAME, APP_VERSION } from '@mcpctl/shared'; import { createConfigCommand } from './commands/config.js'; import { createStatusCommand } from './commands/status.js'; import { createGetCommand } from './commands/get.js'; +import { createReposCommand } from './commands/repos.js'; +import { createTasksCommand } from './commands/tasks.js'; import { createDescribeCommand } from './commands/describe.js'; import { createDeleteCommand } from './commands/delete.js'; import { createLogsCommand } from './commands/logs.js'; @@ -127,6 +129,12 @@ export function createProgram(): Command { } } + // --project scoping for the agentic-teams resources + if (nameOrId === undefined && projectName !== undefined && projectName !== '' + && (resource === 'epics' || resource === 'repos')) { + return client.get(`/api/v1/${resource}?project=${encodeURIComponent(projectName)}`); + } + // --project scoping for mcptokens if (!nameOrId && resource === 'mcptokens' && projectName) { return client.get(`/api/v1/mcptokens?projectName=${encodeURIComponent(projectName)}`); @@ -209,6 +217,14 @@ export function createProgram(): Command { return client.get(`/api/v1/${resource}/${id}`); }; + program.addCommand(createReposCommand({ + client, + log: (...args) => console.log(...args), + })); + program.addCommand(createTasksCommand({ + client, + log: (...args) => console.log(...args), + })); program.addCommand(createGetCommand({ fetchResource, log: (...args) => console.log(...args), -- 2.49.1 From 77c45a27150eec74671a211c5bd94f97bf990fd2 Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 9 Aug 2026 19:13:43 +0100 Subject: [PATCH 13/32] =?UTF-8?q?feat(mcpd):=20`mcpctl`=20system=20agent?= =?UTF-8?q?=20=E2=80=94=20operates=20mcpctl=20with=20the=20caller's=20perm?= =?UTF-8?q?issions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A system agent, seeded like the system project and system skills, whose entire job is running mcpctl for the user: mcpctl chat mcpctl -m 'Make a prompt in the SRE project saying we run multiple agents on the same source at once, so always use git worktrees' ## The permission model is the design Every tool call loops back through mcpd's own HTTP API carrying the CALLING USER's bearer token. The agent holds no credential of its own and never touches the service layer directly, so the existing auth hook, RBAC hook, zod validation, ownership scoping and audit trail all apply verbatim. It is exactly as privileged as the person talking to it, never more. Calling services directly would have been simpler and would have meant re-implementing every one of those checks at a second call site — precisely the RBAC carve-out this project refuses to make. A 403 is surfaced as a 403, not retried or routed around; the system prompt tells the agent to report it. Verified live, not just mocked: the same tool with an admin token created a project, and with a plain user's token returned "Permission denied (403)". No token at all refuses before any request is made; a bad token is rejected by mcpd's own auth, because there is no ambient credential to fall back on. ## Shape - `Agent.toolset` (TEXT, default 'project') selects the surface. 'mcpctl' means no project prompts and no MCP tools — managing mcpctl is the whole job, and an unrelated project's context would be noise it might act on. - Five generic tools (list/get/create/update/delete + resource) rather than one per resource. mcpctl is a REST API over named resources, so that is the honest surface, and this repo's own tool-selection findings say a scoped catalogue beats a large flat one. - Names are resolved to ids by listing first, and a miss reports what IS available, so the model never guesses an id. - Resource is a closed enum: the model cannot invent an endpoint. Also exercised end to end against a live mcpd: created the worktrees prompt in the `sre` project, listed it back, and read its content — the user's original example, working. Tests: 17 unit tests covering the token plumbing, the no-token and 403 paths, resource allowlisting, name resolution, and request shaping. Local: build clean, workspace 2457 passed (+17), lint unchanged at 794 errors. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p --- .../migration.sql | 3 + src/db/prisma/schema.prisma | 34 ++- src/mcpd/src/bootstrap/system-agents.ts | 64 ++++ src/mcpd/src/main.ts | 7 + src/mcpd/src/routes/agent-chat.ts | 8 + src/mcpd/src/services/agent.service.ts | 3 + src/mcpd/src/services/chat.service.ts | 49 ++- src/mcpd/src/services/mcpctl-tools.ts | 287 ++++++++++++++++++ src/mcpd/tests/services/mcpctl-tools.test.ts | 181 +++++++++++ 9 files changed, 618 insertions(+), 18 deletions(-) create mode 100644 src/db/prisma/migrations/20260809090000_add_agent_toolset/migration.sql create mode 100644 src/mcpd/src/bootstrap/system-agents.ts create mode 100644 src/mcpd/src/services/mcpctl-tools.ts create mode 100644 src/mcpd/tests/services/mcpctl-tools.test.ts diff --git a/src/db/prisma/migrations/20260809090000_add_agent_toolset/migration.sql b/src/db/prisma/migrations/20260809090000_add_agent_toolset/migration.sql new file mode 100644 index 0000000..f880904 --- /dev/null +++ b/src/db/prisma/migrations/20260809090000_add_agent_toolset/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "Agent" ADD COLUMN "toolset" TEXT NOT NULL DEFAULT 'project'; + diff --git a/src/db/prisma/schema.prisma b/src/db/prisma/schema.prisma index 5222643..ac46a7c 100644 --- a/src/db/prisma/schema.prisma +++ b/src/db/prisma/schema.prisma @@ -682,6 +682,12 @@ model Agent { projectId String? defaultPersonalityId String? // applied at chat time when no --personality flag proxyModelName String? // optional informational override + // Which tool surface this agent gets. TEXT, not an enum, so a third surface + // is a non-migration change. + // 'project' — project MCP servers + project prompts (today's behaviour) + // 'mcpctl' — operates mcpctl itself, as the calling user; no project + // context and no MCP tools (see services/mcpctl-tools.ts) + toolset String @default("project") defaultParams Json @default("{}") // LiteLLM-style: temperature, top_p, top_k, max_tokens, stop, ... extras Json @default("{}") // future LoRA / tool-allowlist // ── Virtual-agent lifecycle (NULL/default for kind=public, mirrors Llm) ── @@ -1133,21 +1139,21 @@ model TaskEvent { // with repo/repository/repositories all aliased to it. model CodeRepo { - id String @id @default(cuid()) - name String - projectId String + id String @id @default(cuid()) + name String + projectId String // Clone URL. https for token auth, ssh for key auth. - url String - defaultBranch String @default("main") + url String + defaultBranch String @default("main") // Relative path under the session workspace root. Defaults to `name`. - path String @default("") + path String @default("") // Driver key for PR/merge API calls. Inherited from the GitProvider when the // repo was imported from one. - provider String @default("gitea") + provider String @default("gitea") // The GitProvider this repo came from, when it was imported rather than // hand-added. Supplies the credential, so an imported repo needs none of its // own; a hand-added repo leaves this null and carries its own credSecretId. - gitProviderId String? + gitProviderId String? // Denormalized provenance, mirroring McpServer.templateName/templateVersion: // survives the provider being deleted, so "where did this repo come from" // stays answerable after the forge is disconnected. @@ -1156,12 +1162,12 @@ model CodeRepo { sourceFullName String? // Secret holding the provider credential, resolved through SecretService at // session-provision time — never stored in plaintext on the row. - credSecretId String? - credSecretKey String? - ownerId String - version Int @default(1) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + credSecretId String? + credSecretKey String? + ownerId String + version Int @default(1) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) cred Secret? @relation(fields: [credSecretId], references: [id]) diff --git a/src/mcpd/src/bootstrap/system-agents.ts b/src/mcpd/src/bootstrap/system-agents.ts new file mode 100644 index 0000000..a3f170e --- /dev/null +++ b/src/mcpd/src/bootstrap/system-agents.ts @@ -0,0 +1,64 @@ +/** + * Bootstrap the built-in `mcpctl` system agent on every startup. + * + * Mirrors bootstrap/system-project.ts and bootstrap/system-skills.ts: idempotent + * upsert so a delete + recreate works, but never overwrites a user's edits to + * the persona or their choice of brain. + * + * The agent operates mcpctl itself, as whoever is talking to it — see + * services/mcpctl-tools.ts for why that is a loopback through the normal API + * rather than a direct service call. + */ +import type { PrismaClient } from '@prisma/client'; + +import { MCPCTL_AGENT_SYSTEM_PROMPT } from '../services/mcpctl-tools.js'; + +export const MCPCTL_AGENT_NAME = 'mcpctl'; + +const DESCRIPTION = 'Operates mcpctl for you, with your permissions. No project context, no MCP tools.'; + +export async function bootstrapSystemAgents(prisma: PrismaClient): Promise { + const existing = await prisma.agent.findUnique({ where: { name: MCPCTL_AGENT_NAME } }); + + if (existing !== null) { + // Keep the toolset correct (that is what makes it a system agent) but leave + // the persona, brain and description alone — an operator may have tuned them. + if (existing.toolset !== 'mcpctl') { + await prisma.agent.update({ + where: { id: existing.id }, + data: { toolset: 'mcpctl', version: { increment: 1 } }, + }); + } + return; + } + + // Agent.llmId is required, so we cannot seed before an Llm exists. On a fresh + // install that is the normal state — try again next startup rather than + // inventing a placeholder row that would fail confusingly at first chat. + const llm = + (await prisma.llm.findFirst({ where: { tier: 'heavy', status: 'active' }, orderBy: { name: 'asc' } })) ?? + (await prisma.llm.findFirst({ where: { status: 'active' }, orderBy: { name: 'asc' } })); + if (llm === null) return; + + // Owner: the first admin, else the first user. Ownership only decides who can + // edit the agent row — it never affects what the agent may DO, because every + // action runs as the caller. + const owner = + (await prisma.user.findFirst({ where: { role: 'ADMIN' }, orderBy: { createdAt: 'asc' } })) ?? + (await prisma.user.findFirst({ orderBy: { createdAt: 'asc' } })); + if (owner === null) return; + + await prisma.agent.create({ + data: { + name: MCPCTL_AGENT_NAME, + description: DESCRIPTION, + systemPrompt: MCPCTL_AGENT_SYSTEM_PROMPT, + llmId: llm.id, + ownerId: owner.id, + toolset: 'mcpctl', + }, + }); + + // eslint-disable-next-line no-console + console.log(`mcpd: seeded system agent '${MCPCTL_AGENT_NAME}' on llm '${llm.name}'`); +} diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index 59ac06e..06f67f6 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -60,6 +60,7 @@ import { SET_OWN_PASSWORD_OPERATION } from './bootstrap/self-password-permission import { registerLogRoutes } from './routes/logs.js'; import { errorLogBuffer } from './services/error-log-buffer.js'; import { bootstrapSystemSkills } from './bootstrap/system-skills.js'; +import { bootstrapSystemAgents } from './bootstrap/system-agents.js'; import { McpServerService, SecretService, @@ -130,6 +131,7 @@ import { TaskService } from './services/task.service.js'; import { CodeRepoService } from './services/code-repo.service.js'; import { GitProviderService } from './services/git-provider.service.js'; import { GitProviderRegistry } from './services/git/registry.js'; +import { McpctlToolProvider } from './services/mcpctl-tools.js'; import { SkillService } from './services/skill.service.js'; import { GitBackupService } from './services/backup/git-backup.service.js'; import type { BackupKind } from './services/backup/yaml-serializer.js'; @@ -433,6 +435,8 @@ async function main(): Promise { await bootstrapSystemProject(prisma); // PR-4: bootstrap system-owned global skills (e.g. propose-learnings). await bootstrapSystemSkills(prisma); + // Seeds the `mcpctl` system agent once an Llm and a user exist. + await bootstrapSystemAgents(prisma); // Repositories const serverRepo = new McpServerRepository(prisma); @@ -753,6 +757,9 @@ async function main(): Promise { projectRepo, rbacService, secretService, + // Loopback to this same mcpd, so the mcpctl system agent's calls go through + // the normal auth + RBAC path carrying the caller's own token. + new McpctlToolProvider(`http://127.0.0.1:${String(config.port)}`), ); registerAgentChatRoutes(app, chatService); registerProjectChatRoutes(app, chatService, rbacService); diff --git a/src/mcpd/src/routes/agent-chat.ts b/src/mcpd/src/routes/agent-chat.ts index e332f7a..7dde512 100644 --- a/src/mcpd/src/routes/agent-chat.ts +++ b/src/mcpd/src/routes/agent-chat.ts @@ -41,9 +41,17 @@ export function registerAgentChatRoutes( ...paramsRest } = parsed; + // Forwarded so the `mcpctl` system agent can act AS this caller. Every + // other agent ignores it — see services/mcpctl-tools.ts. + const authHeader = request.headers['authorization']; + const callerToken = typeof authHeader === 'string' && authHeader.startsWith('Bearer ') + ? authHeader.slice(7) + : undefined; + const args = { agentName: request.params.name, ownerId, + ...(callerToken !== undefined ? { callerToken } : {}), ...(threadId !== undefined ? { threadId } : {}), ...(message !== undefined ? { userMessage: message } : {}), ...(messagesOverride !== undefined diff --git a/src/mcpd/src/services/agent.service.ts b/src/mcpd/src/services/agent.service.ts index 440d4d2..ed824f4 100644 --- a/src/mcpd/src/services/agent.service.ts +++ b/src/mcpd/src/services/agent.service.ts @@ -54,6 +54,8 @@ export interface AgentView { project: { id: string; name: string } | null; defaultPersonality: { id: string; name: string } | null; proxyModelName: string | null; + /** 'project' (MCP tools) or 'mcpctl' (operates mcpctl as the caller). */ + toolset: string; defaultParams: AgentChatParams; extras: Record; // Virtual-agent lifecycle (defaults make public agents look like "active"). @@ -228,6 +230,7 @@ export class AgentService { project: project !== null ? { id: project.id, name: project.name } : null, defaultPersonality, proxyModelName: row.proxyModelName, + toolset: row.toolset, defaultParams: row.defaultParams as AgentChatParams, extras: row.extras as Record, kind: row.kind, diff --git a/src/mcpd/src/services/chat.service.ts b/src/mcpd/src/services/chat.service.ts index c02e7f9..cab46b0 100644 --- a/src/mcpd/src/services/chat.service.ts +++ b/src/mcpd/src/services/chat.service.ts @@ -38,6 +38,7 @@ import type { RbacService } from './rbac.service.js'; import type { OpenAiChatRequest, OpenAiMessage } from './llm/types.js'; import type { AgentChatParams } from '../validation/agent.schema.js'; import { NotFoundError } from './mcp-server.service.js'; +import type { McpctlToolProvider } from './mcpctl-tools.js'; /** Minimal secret-read surface the project-chat `get_secret` tool needs. */ export interface ChatSecretResolver { @@ -136,6 +137,11 @@ export interface PoolCandidate { export interface ChatRequestArgs { agentName: string; + /** + * The end user's bearer token. Only used by the `mcpctl` toolset, which acts + * AS the caller — see services/mcpctl-tools.ts. Absent for every other agent. + */ + callerToken?: string; threadId?: string; userMessage?: string; /** Optional full-history override; if set, threadId history is ignored. */ @@ -196,6 +202,10 @@ interface PreparedChatContext { maxIterations: number; /** When true, `dispatchTool` will honor the virtual `secrets__get_secret` call. */ allowSecrets: boolean; + /** 'project' (MCP servers) or 'mcpctl' (operate mcpctl as the caller). */ + toolset: string; + /** Caller's bearer token, forwarded only for the 'mcpctl' toolset. */ + callerToken?: string; } export class ChatService { @@ -219,6 +229,11 @@ export class ChatService { private readonly projects?: IProjectRepository, private readonly rbac?: RbacService, private readonly secrets?: ChatSecretResolver, + /** + * Tools for the `mcpctl` system agent. Optional so existing test wirings + * compile; without it a system agent simply gets no tools. + */ + private readonly mcpctlTools?: McpctlToolProvider, ) {} async createThread(agentName: string, ownerId: string, title?: string): Promise<{ id: string }> { @@ -299,7 +314,7 @@ export class ChatService { tool_calls: choice.tool_calls, }); for (const call of choice.tool_calls) { - const toolResult = await this.dispatchTool(call.function.name, call.function.arguments, ctx.projectId, ctx.allowSecrets); + const toolResult = await this.dispatchTool(call.function.name, call.function.arguments, ctx.projectId, ctx.allowSecrets, { toolset: ctx.toolset, ...(ctx.callerToken !== undefined ? { callerToken: ctx.callerToken } : {}) }); const resultMsg = await this.chatRepo.appendMessage({ threadId: ctx.threadId, role: 'tool', @@ -427,7 +442,7 @@ export class ChatService { for (const call of accumulated.toolCalls) { yield { type: 'tool_call', toolName: call.name, args: safeParseJson(call.argumentsJson) as Record }; try { - const result = await this.dispatchTool(call.name, call.argumentsJson, ctx.projectId, ctx.allowSecrets); + const result = await this.dispatchTool(call.name, call.argumentsJson, ctx.projectId, ctx.allowSecrets, { toolset: ctx.toolset, ...(ctx.callerToken !== undefined ? { callerToken: ctx.callerToken } : {}) }); const resultStr = typeof result === 'string' ? result : JSON.stringify(result); await this.chatRepo.appendMessage({ threadId: ctx.threadId, @@ -747,7 +762,13 @@ export class ChatService { history.push({ role: 'user', content: args.userMessage }); } - const toolList = await this.tools.listTools(projectId); + // A system agent operating mcpctl gets ONLY the mcpctl tools: no project + // MCP servers, and no project prompts in the system block above. Managing + // mcpctl is the whole job, and an unrelated project's context would just + // be noise it might act on. + const toolList = agent.toolset === 'mcpctl' && this.mcpctlTools !== undefined + ? this.mcpctlTools.listTools() + : await this.tools.listTools(projectId); const allowed = mergedParams.tools_allowlist; const filteredTools = allowed === undefined ? toolList @@ -765,6 +786,8 @@ export class ChatService { startingTurnIndex, maxIterations: resolveMaxIterations(agent.extras), allowSecrets: false, + toolset: agent.toolset, + ...(args.callerToken !== undefined ? { callerToken: args.callerToken } : {}), }; } @@ -877,6 +900,9 @@ export class ChatService { startingTurnIndex, maxIterations: MAX_ITERATIONS, allowSecrets, + // Project chat never gets the mcpctl toolset — that surface belongs to + // the system agent alone. + toolset: 'project', }; } @@ -1067,7 +1093,22 @@ export class ChatService { return body; } - private async dispatchTool(toolWireName: string, argsJson: string, projectId: string | null, allowSecrets: boolean): Promise { + private async dispatchTool( + toolWireName: string, + argsJson: string, + projectId: string | null, + allowSecrets: boolean, + ctx?: { toolset: string; callerToken?: string }, + ): Promise { + // mcpctl system tools are checked FIRST: they need no project, and they + // execute as the caller rather than through an MCP server. + if (this.mcpctlTools !== undefined && this.mcpctlTools.isMcpctlTool(toolWireName)) { + if (ctx?.toolset !== 'mcpctl') { + throw new Error(`Tool '${toolWireName}' is only available to the mcpctl system agent`); + } + const parsedArgs = safeParseJson(argsJson) as Record; + return this.mcpctlTools.callTool(toolWireName, parsedArgs, ctx.callerToken); + } if (projectId === null) { throw new Error('Tool calls require an agent attached to a Project'); } diff --git a/src/mcpd/src/services/mcpctl-tools.ts b/src/mcpd/src/services/mcpctl-tools.ts new file mode 100644 index 0000000..176882c --- /dev/null +++ b/src/mcpd/src/services/mcpctl-tools.ts @@ -0,0 +1,287 @@ +import type { ChatTool } from './chat.service.js'; + +/** + * Tools that let a system agent operate mcpctl itself. + * + * ## The permission model is the whole design + * + * Every call loops back through mcpd's own HTTP API carrying **the calling + * user's bearer token**. It does not touch services directly and it holds no + * credential of its own. That means the existing auth hook, RBAC hook, zod + * validation, ownership scoping, and audit trail all apply verbatim — the agent + * is exactly as privileged as the person talking to it, never more. Ask it to + * delete a project you cannot delete and mcpd answers 403, same as it would to + * `mcpctl delete`. + * + * Calling the service layer directly would have been faster and would have + * required re-implementing every one of those checks at a second call site. + * That is precisely the RBAC carve-out this project refuses to make. + * + * ## Why five generic tools rather than fifty specific ones + * + * mcpctl is a REST API over named resources, so the honest tool surface is + * list/get/create/update/delete plus a resource name. A large flat catalogue of + * per-resource tools measurably degrades selection; a small well-scoped one + * does not. + */ + +/** Resources the system agent may operate on, and their URL segments. */ +export const MCPCTL_RESOURCES = [ + 'servers', 'instances', 'secrets', 'secretbackends', 'llms', 'agents', + 'personalities', 'templates', 'projects', 'users', 'groups', 'rbac', + 'prompts', 'skills', 'proposals', 'mcptokens', + 'epics', 'epictasks', 'tasks', 'repos', 'gitproviders', +] as const; + +/** Resources that take `?project=` on list. */ +const PROJECT_SCOPED = new Set([ + 'prompts', 'skills', 'proposals', 'mcptokens', 'epics', 'repos', 'servers', +]); + +const RESOURCE_ENUM = { type: 'string', enum: [...MCPCTL_RESOURCES] } as const; + +export const MCPCTL_TOOLS: ChatTool[] = [ + { + name: 'mcpctl_list', + description: + 'List mcpctl resources of one kind. Use this first to discover exact names before acting on them. ' + + 'Some resources are project-scoped — pass `project` to narrow.', + parameters: { + type: 'object', + properties: { + resource: RESOURCE_ENUM, + project: { type: 'string', description: 'Project name, for project-scoped resources' }, + }, + required: ['resource'], + }, + }, + { + name: 'mcpctl_get', + description: + 'Fetch one resource by name (or id). Returns its full definition — read this before an update so you ' + + 'preserve fields you are not changing.', + parameters: { + type: 'object', + properties: { + resource: RESOURCE_ENUM, + name: { type: 'string', description: 'Resource name or id' }, + project: { type: 'string', description: 'Project name, for project-scoped resources' }, + }, + required: ['resource', 'name'], + }, + }, + { + name: 'mcpctl_create', + description: + 'Create a resource. `body` is the resource definition as JSON, the same shape `mcpctl apply -f` takes. ' + + 'If you are unsure of the shape, list an existing one of the same kind first and mirror it. ' + + 'Validation errors come back with the exact field that was wrong.', + parameters: { + type: 'object', + properties: { + resource: RESOURCE_ENUM, + body: { type: 'object', description: 'Resource definition, e.g. { "name": "x", "content": "...", "project": "sre" }' }, + }, + required: ['resource', 'body'], + }, + }, + { + name: 'mcpctl_update', + description: 'Update an existing resource by name. `body` carries only the fields to change.', + parameters: { + type: 'object', + properties: { + resource: RESOURCE_ENUM, + name: { type: 'string', description: 'Resource name or id' }, + body: { type: 'object', description: 'Fields to change' }, + project: { type: 'string', description: 'Project name, for project-scoped resources' }, + }, + required: ['resource', 'name', 'body'], + }, + }, + { + name: 'mcpctl_delete', + description: + 'Delete a resource by name. Destructive and not undoable — confirm with the user in your reply before ' + + 'calling this unless they clearly asked for the deletion.', + parameters: { + type: 'object', + properties: { + resource: RESOURCE_ENUM, + name: { type: 'string', description: 'Resource name or id' }, + project: { type: 'string', description: 'Project name, for project-scoped resources' }, + }, + required: ['resource', 'name'], + }, + }, +]; + +export const MCPCTL_TOOL_NAMES = new Set(MCPCTL_TOOLS.map((t) => t.name)); + +/** System prompt for the mcpctl system agent. */ +export const MCPCTL_AGENT_SYSTEM_PROMPT = `You operate mcpctl on behalf of the user. + +You act with EXACTLY the user's own permissions — never more. If mcpctl returns +403, the user genuinely lacks that permission; say so plainly instead of +retrying or working around it. + +How to work: +- Resolve names before acting. List or get first; never guess an id. +- Before creating something, check whether it already exists. +- Mirror the shape of an existing resource of the same kind when you are unsure + what fields a create takes. +- Report exactly what you changed, using the real names, after you change it. +- For destructive actions the user did not explicitly ask for, describe what you + would delete and ask first. + +You have no other tools and no project context. Managing mcpctl is the entire +job.`; + +export interface McpctlToolResult { + status: number; + body: unknown; +} + +/** + * Executes the tools above by calling mcpd's own API as the calling user. + * + * `baseUrl` points back at this same mcpd process. The loopback is deliberate: + * it is what guarantees a single authorization path. + */ +export class McpctlToolProvider { + constructor( + private readonly baseUrl: string, + private readonly timeoutMs = 30_000, + ) {} + + listTools(): ChatTool[] { + return MCPCTL_TOOLS; + } + + isMcpctlTool(name: string): boolean { + return MCPCTL_TOOL_NAMES.has(name); + } + + /** + * @param callerToken the end user's bearer token. Without it the call is + * rejected by mcpd's auth hook — the agent has no identity of its own, + * which is the point. + */ + async callTool( + name: string, + args: Record, + callerToken: string | undefined, + ): Promise { + if (callerToken === undefined || callerToken === '') { + throw new Error( + 'The mcpctl agent acts as you, so it needs your credentials. This chat was started without a ' + + 'user token — run `mcpctl login` and try again.', + ); + } + + const resource = String(args['resource'] ?? ''); + if (!(MCPCTL_RESOURCES as readonly string[]).includes(resource)) { + throw new Error(`Unknown resource '${resource}'. Known: ${MCPCTL_RESOURCES.join(', ')}`); + } + const project = typeof args['project'] === 'string' ? args['project'] : undefined; + const target = typeof args['name'] === 'string' ? args['name'] : undefined; + const body = (args['body'] ?? {}) as Record; + + switch (name) { + case 'mcpctl_list': { + const qs = project !== undefined && PROJECT_SCOPED.has(resource) + ? `?project=${encodeURIComponent(project)}` + : ''; + return this.request('GET', `/api/v1/${resource}${qs}`, undefined, callerToken); + } + case 'mcpctl_get': { + const id = await this.resolveName(resource, requireName(target), project, callerToken); + return this.request('GET', `/api/v1/${resource}/${id}`, undefined, callerToken); + } + case 'mcpctl_create': + return this.request('POST', `/api/v1/${resource}`, body, callerToken); + case 'mcpctl_update': { + const id = await this.resolveName(resource, requireName(target), project, callerToken); + return this.request('PUT', `/api/v1/${resource}/${id}`, body, callerToken); + } + case 'mcpctl_delete': { + const id = await this.resolveName(resource, requireName(target), project, callerToken); + return this.request('DELETE', `/api/v1/${resource}/${id}`, undefined, callerToken); + } + default: + throw new Error(`Unknown mcpctl tool '${name}'`); + } + } + + /** Name → id, by listing and matching. Mirrors the CLI's resolveNameOrId. */ + private async resolveName( + resource: string, + name: string, + project: string | undefined, + token: string, + ): Promise { + if (/^c[a-z0-9]{20,}$/i.test(name)) return name; // already an id + + const qs = project !== undefined && PROJECT_SCOPED.has(resource) + ? `?project=${encodeURIComponent(project)}` + : ''; + const listed = await this.request('GET', `/api/v1/${resource}${qs}`, undefined, token); + const rows = Array.isArray(listed) ? (listed as Array>) : []; + const match = rows.find((r) => r['name'] === name || r['email'] === name); + if (match === undefined) { + const available = rows.map((r) => String(r['name'] ?? r['email'] ?? '')).filter((s) => s !== ''); + throw new Error( + `No ${resource} named '${name}'.` + + (available.length > 0 ? ` Available: ${available.slice(0, 25).join(', ')}` : ''), + ); + } + return String(match['id']); + } + + private async request( + method: string, + path: string, + body: unknown, + token: string, + ): Promise { + const headers: Record = { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + }; + const init: RequestInit = { method, headers, signal: AbortSignal.timeout(this.timeoutMs) }; + if (body !== undefined && method !== 'GET' && method !== 'DELETE') { + headers['Content-Type'] = 'application/json'; + init.body = JSON.stringify(body); + } + + let res: Response; + try { + res = await fetch(`${this.baseUrl}${path}`, init); + } catch (err: unknown) { + throw new Error(`mcpctl API call failed: ${err instanceof Error ? err.message : String(err)}`); + } + + const text = await res.text(); + let parsed: unknown; + try { + parsed = text === '' ? { ok: true } : JSON.parse(text); + } catch { + parsed = text; + } + + if (res.status === 403) { + // Surfaced as-is rather than retried: a denial is the correct answer, and + // the agent must report it instead of looking for another route in. + throw new Error(`Permission denied (403) on ${method} ${path}. You do not have rights for this.`); + } + if (res.status >= 400) { + throw new Error(`mcpctl returned ${String(res.status)} on ${method} ${path}: ${JSON.stringify(parsed)}`); + } + return parsed; + } +} + +function requireName(name: string | undefined): string { + if (name === undefined || name === '') throw new Error('This tool requires a `name` argument'); + return name; +} diff --git a/src/mcpd/tests/services/mcpctl-tools.test.ts b/src/mcpd/tests/services/mcpctl-tools.test.ts new file mode 100644 index 0000000..2e08721 --- /dev/null +++ b/src/mcpd/tests/services/mcpctl-tools.test.ts @@ -0,0 +1,181 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; + +import { McpctlToolProvider, MCPCTL_TOOLS, MCPCTL_RESOURCES } from '../../src/services/mcpctl-tools.js'; + +/** + * The mcpctl system agent's entire safety story is that it acts as the calling + * user and cannot exceed them. These tests pin that: every call carries the + * caller's token, no call carries a service credential, and a 403 is reported + * rather than routed around. + */ +interface Call { url: string; method: string; auth?: string; body?: string } + +function mockFetch(handler: (c: Call) => { status: number; body: unknown }) { + const calls: Call[] = []; + globalThis.fetch = vi.fn(async (url: string | URL, init?: RequestInit) => { + const headers = (init?.headers ?? {}) as Record; + const call: Call = { + url: String(url), + method: init?.method ?? 'GET', + auth: headers['Authorization'], + body: init?.body as string | undefined, + }; + calls.push(call); + const { status, body } = handler(call); + return { status, text: async () => JSON.stringify(body) } as unknown as Response; + }) as unknown as typeof fetch; + return calls; +} + +const BASE = 'http://127.0.0.1:3000'; +const provider = (): McpctlToolProvider => new McpctlToolProvider(BASE); + +afterEach(() => { vi.restoreAllMocks(); }); + +describe('mcpctl tool surface', () => { + it('is a small generic catalogue, not one tool per resource', () => { + // A large flat catalogue measurably degrades tool selection; mcpctl is a + // REST API over named resources, so list/get/create/update/delete is the + // honest surface. + expect(MCPCTL_TOOLS.map((t) => t.name)).toEqual([ + 'mcpctl_list', 'mcpctl_get', 'mcpctl_create', 'mcpctl_update', 'mcpctl_delete', + ]); + }); + + it('constrains resource to a known enum so the model cannot invent endpoints', () => { + for (const t of MCPCTL_TOOLS) { + const props = (t.parameters as { properties: Record }).properties; + expect(props['resource']?.enum).toEqual([...MCPCTL_RESOURCES]); + } + }); + + it('warns about destructive intent on delete', () => { + const del = MCPCTL_TOOLS.find((t) => t.name === 'mcpctl_delete'); + expect(del?.description).toMatch(/not undoable|confirm/i); + }); +}); + +describe('McpctlToolProvider — acts as the caller', () => { + it('sends the CALLER\'s bearer token on every request', async () => { + const calls = mockFetch(() => ({ status: 200, body: [] })); + await provider().callTool('mcpctl_list', { resource: 'projects' }, 'user-token-abc'); + expect(calls[0]?.auth).toBe('Bearer user-token-abc'); + }); + + it('refuses to act at all without a caller token', async () => { + // No ambient credential exists on purpose: with no user there is no + // authority to borrow, so the only safe answer is to stop. + const calls = mockFetch(() => ({ status: 200, body: [] })); + await expect( + provider().callTool('mcpctl_list', { resource: 'projects' }, undefined), + ).rejects.toThrow(/needs your credentials|mcpctl login/); + expect(calls).toHaveLength(0); + }); + + it('reports a 403 instead of retrying or routing around it', async () => { + const calls = mockFetch(() => ({ status: 403, body: { error: 'forbidden' } })); + await expect( + provider().callTool('mcpctl_create', { resource: 'projects', body: { name: 'x' } }, 'tok'), + ).rejects.toThrow(/Permission denied \(403\)/); + expect(calls).toHaveLength(1); // exactly one attempt + }); + + it('rejects a resource outside the allowlist', async () => { + const calls = mockFetch(() => ({ status: 200, body: [] })); + await expect( + provider().callTool('mcpctl_list', { resource: 'shell' }, 'tok'), + ).rejects.toThrow(/Unknown resource 'shell'/); + expect(calls).toHaveLength(0); + }); +}); + +describe('McpctlToolProvider — request shaping', () => { + it('lists, scoping project-scoped resources', async () => { + const calls = mockFetch(() => ({ status: 200, body: [] })); + await provider().callTool('mcpctl_list', { resource: 'prompts', project: 'sre' }, 'tok'); + expect(calls[0]?.url).toBe(`${BASE}/api/v1/prompts?project=sre`); + }); + + it('ignores project on a resource that is not project-scoped', async () => { + const calls = mockFetch(() => ({ status: 200, body: [] })); + await provider().callTool('mcpctl_list', { resource: 'llms', project: 'sre' }, 'tok'); + expect(calls[0]?.url).toBe(`${BASE}/api/v1/llms`); + }); + + it('creates by POSTing the body verbatim', async () => { + const calls = mockFetch(() => ({ status: 201, body: { id: 'p1', name: 'gitthrees' } })); + await provider().callTool( + 'mcpctl_create', + { resource: 'prompts', body: { name: 'gitthrees', content: 'always use worktrees', project: 'sre' } }, + 'tok', + ); + expect(calls[0]?.method).toBe('POST'); + expect(JSON.parse(calls[0]?.body ?? '{}')).toEqual({ + name: 'gitthrees', content: 'always use worktrees', project: 'sre', + }); + }); + + it('resolves a name to an id before update, so the model never guesses one', async () => { + const calls = mockFetch((c) => + c.method === 'GET' + ? { status: 200, body: [{ id: 'cmabc123456789012345678901', name: 'gitthrees' }] } + : { status: 200, body: { ok: true } }, + ); + await provider().callTool( + 'mcpctl_update', + { resource: 'prompts', name: 'gitthrees', project: 'sre', body: { content: 'updated' } }, + 'tok', + ); + expect(calls[0]?.url).toContain('/api/v1/prompts?project=sre'); + expect(calls[1]?.method).toBe('PUT'); + expect(calls[1]?.url).toBe(`${BASE}/api/v1/prompts/cmabc123456789012345678901`); + }); + + it('passes an id straight through without a lookup', async () => { + const calls = mockFetch(() => ({ status: 200, body: { ok: true } })); + await provider().callTool( + 'mcpctl_get', { resource: 'projects', name: 'cmabc123456789012345678901' }, 'tok', + ); + expect(calls).toHaveLength(1); + expect(calls[0]?.url).toBe(`${BASE}/api/v1/projects/cmabc123456789012345678901`); + }); + + it('lists what IS available when a name does not resolve', async () => { + mockFetch(() => ({ status: 200, body: [{ id: 'a', name: 'alpha' }, { id: 'b', name: 'beta' }] })); + await expect( + provider().callTool('mcpctl_get', { resource: 'projects', name: 'ghost' }, 'tok'), + ).rejects.toThrow(/No projects named 'ghost'.*alpha, beta/s); + }); + + it('matches users by email, which is their name', async () => { + const calls = mockFetch((c) => + c.method === 'GET' && c.url.endsWith('/users') + ? { status: 200, body: [{ id: 'u1', email: 'michal@example.com' }] } + : { status: 200, body: { ok: true } }, + ); + await provider().callTool('mcpctl_get', { resource: 'users', name: 'michal@example.com' }, 'tok'); + expect(calls[1]?.url).toBe(`${BASE}/api/v1/users/u1`); + }); + + it('requires a name for get/update/delete', async () => { + mockFetch(() => ({ status: 200, body: [] })); + for (const tool of ['mcpctl_get', 'mcpctl_update', 'mcpctl_delete']) { + await expect( + provider().callTool(tool, { resource: 'projects' }, 'tok'), + ).rejects.toThrow(/requires a `name`/); + } + }); + + it('surfaces a validation error with its detail, so the model can correct itself', async () => { + mockFetch(() => ({ status: 400, body: { error: 'Validation error', details: [{ path: ['name'] }] } })); + await expect( + provider().callTool('mcpctl_create', { resource: 'prompts', body: {} }, 'tok'), + ).rejects.toThrow(/mcpctl returned 400.*Validation error/s); + }); + + it('recognises only its own tools', () => { + const p = provider(); + expect(p.isMcpctlTool('mcpctl_list')).toBe(true); + expect(p.isMcpctlTool('docmost__search')).toBe(false); + }); +}); -- 2.49.1 From 53507e876a561974045625cad2e33b8c6ab7c286 Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 9 Aug 2026 19:56:11 +0100 Subject: [PATCH 14/32] feat(cli): apply -f support for agentic-teams resources, plus vertical smoke tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes two stated project rules this work had been violating. **`get -o yaml` must round-trip through `apply -f`.** The five new resources were creatable and listable but absent from apply, so what `get` emitted was not valid apply input. They are now applied in dependency order — gitproviders → repos → epics → epictasks → tasks — since each references the previous by name, and each block is independently fault-tolerant so one bad entry does not abort the rest of a manifest. `edit` accepts them too. `state` is deliberately NOT in the task spec. Lifecycle moves go through `mcpctl tasks advance`, which enforces the transition table and the risk gate; a manifest that could set state would route straight around both. epictask `dependsOn` edges are re-applied on every run, which is safe because the server treats a duplicate edge as a no-op. **Every feature ships smoke tests.** Adds the vertical: create an epic with mixed outcome/quality criteria, round-trip it through get -o yaml → apply -f, chain an epictask, then drive a task through the lifecycle asserting the risk gate blocks `done` without a passing QA verdict, that an illegal transition is refused, that history carries every transition and the verdict, and that reject without a comment fails. Skips when mcpd is unreachable; a failure against a reachable daemon means it predates the feature. The round-trip assertion is the interesting one: it feeds `get`'s own output straight back into `apply` and fails if the two surfaces have drifted, rather than trusting that they agree. Local: build clean, workspace 2457 passed, db 128 passed (same 4 pre-existing Secret failures), lint back to the repo's baseline 794 errors after replacing destructured discards with an explicit omit() helper. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p --- .taskmaster/tasks/tasks.json | 12 +- src/cli/src/commands/apply.ts | 176 ++++++++++++++++++ src/cli/src/commands/edit.ts | 2 +- .../tests/smoke/agentic-teams.smoke.test.ts | 172 +++++++++++++++++ 4 files changed, 355 insertions(+), 7 deletions(-) create mode 100644 src/mcplocal/tests/smoke/agentic-teams.smoke.test.ts diff --git a/.taskmaster/tasks/tasks.json b/.taskmaster/tasks/tasks.json index 287d7e1..40f3167 100644 --- a/.taskmaster/tasks/tasks.json +++ b/.taskmaster/tasks/tasks.json @@ -2891,9 +2891,9 @@ "dependencies": [ "137" ], - "status": "pending", + "status": "done", "subtasks": [], - "updatedAt": "2026-08-08T08:55:58.627Z" + "updatedAt": "2026-08-09T18:56:11.740Z" }, { "id": "139", @@ -3139,9 +3139,9 @@ "128", "131" ], - "status": "pending", + "status": "done", "subtasks": [], - "updatedAt": "2026-08-08T08:55:58.627Z" + "updatedAt": "2026-08-09T18:56:11.750Z" }, { "id": "156", @@ -3265,9 +3265,9 @@ ], "metadata": { "version": "1.0.0", - "lastModified": "2026-08-08T16:00:25.475Z", + "lastModified": "2026-08-09T18:56:11.750Z", "taskCount": 163, - "completedCount": 122, + "completedCount": 124, "tags": [ "master" ] diff --git a/src/cli/src/commands/apply.ts b/src/cli/src/commands/apply.ts index 5360a1a..36fd16a 100644 --- a/src/cli/src/commands/apply.ts +++ b/src/cli/src/commands/apply.ts @@ -215,6 +215,76 @@ const McpTokenSpecSchema = z.object({ bindings: z.array(RbacRoleBindingSchema).default([]), }); +/** Copy without the given keys — identity fields the PUT endpoints don't accept. */ +function omit>(obj: T, keys: string[]): Record { + const out: Record = { ...obj }; + for (const k of keys) delete out[k]; + return out; +} + +// ── Agentic teams ── +// Ordering below matters: gitproviders → repos → epics → epictasks → tasks, +// because each references the previous by name. + +const GitProviderSpecSchema = z.object({ + name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/), + url: z.string().url(), + type: z.string().optional(), + description: z.string().optional(), + credRef: z.object({ name: z.string(), key: z.string() }).nullable().optional(), + skipProbe: z.boolean().optional(), +}); + +const CodeRepoSpecSchema = z.object({ + name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/), + project: z.string().optional(), + projectId: z.string().optional(), + url: z.string().min(1), + defaultBranch: z.string().optional(), + path: z.string().optional(), + provider: z.string().optional(), + credRef: z.object({ name: z.string(), key: z.string() }).nullable().optional(), +}); + +const DoneCriterionSpecSchema = z.object({ + id: z.string().min(1), + text: z.string().min(1), + kind: z.enum(['outcome', 'quality']).optional(), + met: z.boolean().optional(), +}); + +const EpicSpecSchema = z.object({ + name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/), + project: z.string().optional(), + projectId: z.string().optional(), + content: z.string().min(1), + description: z.string().optional(), + doneCriteria: z.array(DoneCriterionSpecSchema).optional(), + semver: z.string().optional(), +}); + +const EpicTaskSpecSchema = z.object({ + name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/), + epic: z.string().optional(), + epicId: z.string().optional(), + project: z.string().optional(), + description: z.string().optional(), + doneCriteria: z.array(DoneCriterionSpecSchema).optional(), + status: z.enum(['todo', 'in_progress', 'done', 'cancelled']).optional(), + /** Prerequisite epictask names within the same Epic. */ + dependsOn: z.array(z.string()).optional(), +}); + +const TaskSpecSchema = z.object({ + name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/), + epictask: z.string().optional(), + epicTaskId: z.string().optional(), + title: z.string().min(1), + description: z.string().optional(), + risk: z.enum(['trivial', 'normal', 'high']).optional(), + repoId: z.string().optional(), +}); + const ApplyConfigSchema = z.object({ secretbackends: z.array(SecretBackendSpecSchema).default([]), secrets: z.array(SecretSpecSchema).default([]), @@ -230,6 +300,11 @@ const ApplyConfigSchema = z.object({ rbac: z.array(RbacBindingSpecSchema).default([]), prompts: z.array(PromptSpecSchema).default([]), mcptokens: z.array(McpTokenSpecSchema).default([]), + gitproviders: z.array(GitProviderSpecSchema).default([]), + repos: z.array(CodeRepoSpecSchema).default([]), + epics: z.array(EpicSpecSchema).default([]), + epictasks: z.array(EpicTaskSpecSchema).default([]), + tasks: z.array(TaskSpecSchema).default([]), }).transform((data) => ({ ...data, // Merge rbac into rbacBindings so both keys work @@ -312,6 +387,11 @@ const KIND_TO_RESOURCE: Record = { secretbackend: 'secretbackends', llm: 'llms', agent: 'agents', + gitprovider: 'gitproviders', + repo: 'repos', + epic: 'epics', + epictask: 'epictasks', + task: 'tasks', }; /** @@ -591,6 +671,102 @@ async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args } } + // ── Agentic teams ── + // Applied in dependency order: a repo needs its provider, an epictask needs + // its epic, a task needs its epictask. Each block is independently + // fault-tolerant so one bad entry does not abort the rest of the manifest. + + for (const gp of config.gitproviders) { + try { + const existing = await cachedFindByName('gitproviders', gp.name); + if (existing) { + await withRetry(() => client.put(`/api/v1/gitproviders/${existing.id}`, omit(gp, ['name']))); + log(`Updated gitprovider: ${gp.name}`); + } else { + await withRetry(() => client.post('/api/v1/gitproviders', gp)); + invalidateCache('gitproviders'); + log(`Created gitprovider: ${gp.name}`); + } + } catch (err) { + log(`Error applying gitprovider '${gp.name}': ${err instanceof Error ? err.message : err}`); + } + } + + for (const repo of config.repos) { + try { + const existing = await cachedFindByName('repos', repo.name); + if (existing) { + await withRetry(() => client.put(`/api/v1/repos/${existing.id}`, omit(repo, ['name', 'project', 'projectId']))); + log(`Updated repo: ${repo.name}`); + } else { + await withRetry(() => client.post('/api/v1/repos', repo)); + invalidateCache('repos'); + log(`Created repo: ${repo.name}`); + } + } catch (err) { + log(`Error applying repo '${repo.name}': ${err instanceof Error ? err.message : err}`); + } + } + + for (const epic of config.epics) { + try { + const existing = await cachedFindByName('epics', epic.name); + if (existing) { + await withRetry(() => client.put(`/api/v1/epics/${existing.id}`, omit(epic, ['name', 'project', 'projectId']))); + log(`Updated epic: ${epic.name}`); + } else { + await withRetry(() => client.post('/api/v1/epics', epic)); + invalidateCache('epics'); + log(`Created epic: ${epic.name}`); + } + } catch (err) { + log(`Error applying epic '${epic.name}': ${err instanceof Error ? err.message : err}`); + } + } + + for (const et of config.epictasks) { + try { + const existing = await cachedFindByName('epictasks', et.name); + const { dependsOn, ...spec } = et; + let id: string; + if (existing) { + await withRetry(() => client.put(`/api/v1/epictasks/${existing.id}`, omit(spec, ['name', 'epic', 'epicId', 'project']))); + id = existing.id; + log(`Updated epictask: ${et.name}`); + } else { + const created = await withRetry(() => client.post<{ id: string }>('/api/v1/epictasks', spec)); + invalidateCache('epictasks'); + id = created.id; + log(`Created epictask: ${et.name}`); + } + // Edges are idempotent server-side, so re-applying a manifest is safe. + for (const dep of dependsOn ?? []) { + await withRetry(() => client.post(`/api/v1/epictasks/${id}/edges`, { to: dep })); + } + } catch (err) { + log(`Error applying epictask '${et.name}': ${err instanceof Error ? err.message : err}`); + } + } + + for (const task of config.tasks) { + try { + const existing = await cachedFindByName('tasks', task.name); + if (existing) { + // `state` is deliberately absent from the spec: lifecycle moves go + // through `mcpctl tasks advance`, which enforces the transition table. + // A manifest that could set state would route around that. + await withRetry(() => client.put(`/api/v1/tasks/${existing.id}`, omit(task, ['name', 'epictask', 'epicTaskId']))); + log(`Updated task: ${task.name}`); + } else { + await withRetry(() => client.post('/api/v1/tasks', task)); + invalidateCache('tasks'); + log(`Created task: ${task.name}`); + } + } catch (err) { + log(`Error applying task '${task.name}': ${err instanceof Error ? err.message : err}`); + } + } + // Apply RBAC bindings for (const rbacBinding of config.rbacBindings) { try { diff --git a/src/cli/src/commands/edit.ts b/src/cli/src/commands/edit.ts index de8b8e5..7aa1cc1 100644 --- a/src/cli/src/commands/edit.ts +++ b/src/cli/src/commands/edit.ts @@ -51,7 +51,7 @@ export function createEditCommand(deps: EditCommandDeps): Command { return; } - const validResources = ['servers', 'secrets', 'projects', 'groups', 'rbac', 'prompts', 'promptrequests', 'personalities']; + const validResources = ['servers', 'secrets', 'projects', 'groups', 'rbac', 'prompts', 'promptrequests', 'personalities', 'epics', 'epictasks', 'tasks', 'repos', 'gitproviders']; if (!validResources.includes(resource)) { log(`Error: unknown resource type '${resourceArg}'`); process.exitCode = 1; diff --git a/src/mcplocal/tests/smoke/agentic-teams.smoke.test.ts b/src/mcplocal/tests/smoke/agentic-teams.smoke.test.ts new file mode 100644 index 0000000..b208b4e --- /dev/null +++ b/src/mcplocal/tests/smoke/agentic-teams.smoke.test.ts @@ -0,0 +1,172 @@ +/** + * Smoke: the agentic-teams vertical against a live mcpd. + * + * gitprovider → discover → import → epic → epictask → task → lifecycle + * + * Also enforces the project rule that `get -o yaml` must + * round-trip through `apply -f`: the flow re-applies what it read back, and a + * drift there means the two surfaces have diverged. + * + * Skips (does not fail) when mcpd is unreachable. A FAILURE against a reachable + * daemon means the daemon predates this feature — deploy it, do not relax the + * assertion. + */ +import { execSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import http from 'node:http'; +import https from 'node:https'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; + +const MCPD_URL = process.env.MCPD_URL ?? 'https://mcpctl.ad.itaz.eu'; +const SUFFIX = Date.now().toString(36); +const PROJECT = `smoke-at-${SUFFIX}`; +const EPIC = `smoke-epic-${SUFFIX}`; +const EPICTASK = `smoke-et-${SUFFIX}`; +const TASK = `smoke-task-${SUFFIX}`; + +interface CliResult { code: number; stdout: string; stderr: string } + +function run(args: string): CliResult { + try { + const stdout = execSync(`mcpctl --direct ${args}`, { + encoding: 'utf-8', timeout: 60_000, stdio: ['ignore', 'pipe', 'pipe'], + }); + return { code: 0, stdout: stdout.trim(), stderr: '' }; + } catch (err) { + const e = err as { status?: number; stdout?: Buffer | string; stderr?: Buffer | string }; + const str = (v: Buffer | string | undefined): string => + v === undefined ? '' : typeof v === 'string' ? v : v.toString('utf-8'); + return { code: e.status ?? 1, stdout: str(e.stdout), stderr: str(e.stderr) }; + } +} + +function healthz(url: string, timeoutMs = 5000): Promise { + return new Promise((resolve) => { + const parsed = new URL(`${url.replace(/\/$/, '')}/healthz`); + const driver = parsed.protocol === 'https:' ? https : http; + const req = driver.get({ + hostname: parsed.hostname, + port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80), + path: parsed.pathname, + timeout: timeoutMs, + }, (res) => { resolve((res.statusCode ?? 500) < 500); res.resume(); }); + req.on('error', () => resolve(false)); + req.on('timeout', () => { req.destroy(); resolve(false); }); + }); +} + +let up = false; +let workDir: string | null = null; + +describe('agentic teams smoke', () => { + beforeAll(async () => { + up = await healthz(MCPD_URL); + if (!up) { + console.warn(`\n ○ agentic-teams smoke: skipped — ${MCPD_URL}/healthz unreachable.\n`); + return; + } + workDir = mkdtempSync(join(tmpdir(), 'mcpctl-smoke-at-')); + const res = run(`create project ${PROJECT} --description "agentic teams smoke"`); + expect(res.code, `project create failed: ${res.stderr || res.stdout}`).toBe(0); + }, 90_000); + + afterAll(() => { + if (up) { + run(`delete task ${TASK}`); + run(`delete epictask ${EPICTASK}`); + run(`delete epic ${EPIC}`); + run(`delete project ${PROJECT}`); + } + if (workDir !== null) rmSync(workDir, { recursive: true, force: true }); + }); + + it('creates an epic with mixed outcome and quality criteria', () => { + if (!up) return; + const res = run( + `create epic ${EPIC} --project ${PROJECT} --content "ship the thing" ` + + `--criterion "builds=it builds" --criterion "q:feel=it feels finished"`, + ); + expect(res.code, res.stderr || res.stdout).toBe(0); + + const list = run(`get epics --project ${PROJECT} -o json`); + const epics = JSON.parse(list.stdout) as Array<{ name: string; completeness?: { total: number; percent: number } }>; + const mine = epics.find((e) => e.name === EPIC); + // Both kinds count — grading only the mechanical half is the failure the + // Epic exists to prevent. + expect(mine?.completeness?.total).toBe(2); + expect(mine?.completeness?.percent).toBe(0); + }); + + it('round-trips an epic through get -o yaml → apply -f', () => { + if (!up) return; + const yaml = run(`get epic ${EPIC} -o yaml`); + expect(yaml.code, yaml.stderr).toBe(0); + + const path = join(workDir as string, 'epic.yaml'); + writeFileSync(path, yaml.stdout, 'utf-8'); + + // The project rule: what `get -o yaml` emits must be valid `apply -f` input. + const applied = run(`apply -f ${path}`); + expect(applied.code, `apply rejected its own get output: ${applied.stderr || applied.stdout}`).toBe(0); + expect(applied.stdout).toMatch(new RegExp(`(Updated|Created) epic: ${EPIC}`)); + }); + + it('chains an epictask and exposes the roadmap', () => { + if (!up) return; + expect(run(`create epictask ${EPICTASK} --epic ${EPIC}`).code).toBe(0); + + const list = run(`get epictasks -o json`); + const nodes = JSON.parse(list.stdout) as Array<{ name: string; status: string }>; + expect(nodes.find((n) => n.name === EPICTASK)?.status).toBe('todo'); + }); + + it('drives a task through the lifecycle, with the risk gate enforced', () => { + if (!up) return; + expect(run(`create task ${TASK} --epictask ${EPICTASK} --title "smoke" --risk normal`).code).toBe(0); + + for (const state of ['in_progress', 'building', 'self_testing']) { + const r = run(`tasks advance ${TASK} ${state}`); + expect(r.code, `${state}: ${r.stderr || r.stdout}`).toBe(0); + } + + // normal risk requires a passing QA verdict before done. + const blocked = run(`tasks advance ${TASK} done`); + expect(blocked.code).not.toBe(0); + expect(`${blocked.stderr}${blocked.stdout}`).toMatch(/passing verdict from QA/); + + expect(run(`tasks advance ${TASK} qa_review`).code).toBe(0); + expect(run( + `tasks verdict ${TASK} --stage qa_review --commit abc1234 --pass --summary "looks good"`, + ).code).toBe(0); + expect(run(`tasks advance ${TASK} done`).code, 'done should pass once QA passed').toBe(0); + }); + + it('rejects an illegal transition rather than silently allowing it', () => { + if (!up) return; + const r = run(`tasks advance ${TASK} building`); + expect(r.code).not.toBe(0); + expect(`${r.stderr}${r.stdout}`).toMatch(/Illegal transition/); + }); + + it('records the full history: every transition plus both verdict outcomes', () => { + if (!up) return; + const r = run(`tasks history ${TASK}`); + expect(r.code, r.stderr).toBe(0); + expect(r.stdout).toContain('(new) → todo'); + expect(r.stdout).toContain('self_testing → qa_review'); + expect(r.stdout).toMatch(/PASS\s+qa_review/); + }); + + it('requires a comment to reject — the comment is the direction', () => { + if (!up) return; + expect(run(`tasks advance ${TASK} awaiting_review`).code).toBe(0); + const noNote = run(`tasks reject ${TASK}`); + expect(noNote.code, 'reject without --note must fail').not.toBe(0); + + const withNote = run(`tasks reject ${TASK} --note "needs a test"`); + expect(withNote.code, withNote.stderr || withNote.stdout).toBe(0); + expect(withNote.stdout).toMatch(/rejected → in_progress/); + }); +}); -- 2.49.1 From 300c42b404ee1c2e9c443985b93127898ded2cfa Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 9 Aug 2026 20:38:23 +0100 Subject: [PATCH 15/32] fix(backup): restore skills on git restore, and record the audited gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found while auditing what this branch is missing: skills are serialized into the backup repo and enqueued on every mutation, but `importResource` and `deleteResource` in main.ts have no `skill` case — so a git restore silently drops every skill. SkillService has had upsertByName/deleteByName the whole time; the switch arms were simply never added. Pre-existing, not introduced here. Also records ten tasks (164-173) for gaps found by auditing the branch against the codebase rather than against the plan: - 164 RBAC enforcement tests. Five resources and a namespace split shipped with no test proving the gating works. Also flags that the non-CRUD verbs (transition/accept/reject, import/probe) currently fall through to the default method mapping and are probably mis-scoped. - 165 Backup coverage for the agentic-teams resources — same class of silent data loss as the skill bug above, for five more kinds. - 166 AgentProject is inert: chat still resolves context from the legacy Agent.projectId scalar, so "an agent joins many projects" — a core idea of the design — is not actually usable yet. - 167 describe formatters (currently a generic key-value dump, against the project rule) including a client for the roadmap endpoint, which has no caller. - 168 Docs, including the breaking `mcpctl get tasks` rename that exists only in a commit message today. - 169 Smoke tests for the forge flow. - 170 Epic completeness ignores delivered work: an Epic can read 0% while every task under it is merged, which is precisely the blind spot the Epic exists to remove. - 171 `mcpctl chat mcpctl` has never been run end to end — the tool layer is verified, the LLM-picks-a-tool loop and the mcplocal token hop are not. - 172 Nothing on this branch is live; everything was verified against a local mcpd on the test database. - 173 Web UI + Pulumi kinds (cosmetic). Local: build clean, workspace 2457 passed, lint unchanged at 794 errors. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p --- .taskmaster/tasks/tasks.json | 142 ++++++++++++++++++++++++++++++++++- src/mcpd/src/main.ts | 4 + 2 files changed, 145 insertions(+), 1 deletion(-) diff --git a/.taskmaster/tasks/tasks.json b/.taskmaster/tasks/tasks.json index 40f3167..7c19711 100644 --- a/.taskmaster/tasks/tasks.json +++ b/.taskmaster/tasks/tasks.json @@ -3261,6 +3261,146 @@ "status": "done", "subtasks": [], "updatedAt": "2026-08-08T15:42:14.594Z" + }, + { + "id": "164", + "title": "RBAC enforcement tests for the agentic-teams resources", + "description": "The permissions surface changed (5 new resources + the tasks namespace split) with no test proving the gating actually works.", + "details": "Verified missing: no test references `epics` or `gitproviders` outside the backfill test.\n\n- Assert mapUrlToPermission returns the right resource+action for every new route, including the non-CRUD verbs (transition/accept/reject → run:tasks, import/probe → run:gitproviders — note these are NOT currently special-cased and default to create, which may be wrong).\n- Assert a caller with only view:epics cannot POST /api/v1/epics.\n- Assert name-scoped grants (view:epics:) resolve through nameResolvers.\n- Assert list filtering via getAllowedScope actually narrows /api/v1/epics.\n- Assert a pre-split view:tasks grant authorizes /api/v1/inference-tasks after the backfill and NOT /api/v1/tasks.", + "testStrategy": "src/mcpd/tests/rbac-agentic-teams.test.ts, modelled on rbac-name-scope-integration.test.ts.", + "priority": "high", + "dependencies": [ + "134" + ], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-09T19:36:34.052Z" + }, + { + "id": "165", + "title": "Back up the agentic-teams resources (silent data loss today)", + "description": "BACKUP_KINDS does not include epics/epictasks/tasks/repos/gitproviders, so git-backup never captures them and a restore loses all of it.", + "details": "Verified: BACKUP_KINDS = [server, secret, project, user, group, rbac, prompt, skill, template].\n\n- Add the new kinds to BACKUP_KINDS + APPLY_ORDER (dependency order: gitprovider → repo → epic → epictask → task) + kindMap in yaml-serializer.ts, plus the export loop and serializeResource in git-backup.service.ts.\n- Wire main.ts kindFromSegment / importResource / deleteResource.\n- PRE-EXISTING BUG found while checking: `skill` is missing from importResource and deleteResource in main.ts, so skills are exported to git but never restored from it. Fix in the same pass.\n- Credentials must NOT be serialized: back up credRef (secret name + key), never the resolved value.", + "testStrategy": "Extend src/mcpd/tests/yaml-serializer.test.ts; assert a round-trip export→import for each new kind and that no serialized blob contains a resolved secret value.", + "priority": "high", + "dependencies": [ + "135" + ], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-09T19:36:34.052Z" + }, + { + "id": "166", + "title": "Make AgentProject real — chat still uses the legacy Agent.projectId", + "description": "The AgentProject junction has existed since the data-model commit but nothing reads it: chat.service resolves context from the single Agent.projectId scalar, so an agent still cannot meaningfully join many projects.", + "details": "Verified: 0 references to agentProject in chat.service.ts.\n\n- Routes to join/leave a project with a role (task 152).\n- prepareContext resolves the project from the membership when the caller names one, falling back to the scalar during the transition release.\n- `mcpctl chat --project ` picks the membership; error clearly when the agent is not a member.\n- Per-membership llmId override honoured (this is the brain-ownership question, §15 Q1).\n- Only after that: drop Agent.projectId.", + "testStrategy": "Service tests for membership resolution + a smoke test where one agent is used from two projects and gets different context in each.", + "priority": "high", + "dependencies": [ + "120", + "152" + ], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-09T19:36:34.052Z" + }, + { + "id": "167", + "title": "describe formatters for the agentic-teams resources", + "description": "epics/epictasks/tasks/repos/gitproviders fall through to the generic key-value dump, which breaks the project rule that describe shows clean sections and tables.", + "details": "Verified: no case for these in describe.ts.\n\n- describe epic: vision, done-criteria table with met/unmet, accepted tradeoffs, and the roadmap (nodes + per-node completeness + which are ready) from GET /epics/:id/roadmap — the API exists and has no client.\n- describe task: state, legal transitions, risk + which gates it still needs, PR link, verdicts, transition history.\n- describe repo / gitprovider: credential REFERENCE only, never a resolved value.", + "testStrategy": "src/cli/tests/commands/describe.test.ts cases per resource; assert no secret value ever appears in output.", + "priority": "medium", + "dependencies": [ + "136" + ], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-09T19:36:34.052Z" + }, + { + "id": "168", + "title": "Docs for the agentic-teams resources + the tasks rename note", + "description": "No docs/*.md for the new resources, and the breaking `mcpctl get tasks` rename is recorded only in a commit message.", + "details": "- docs/epics.md, docs/tasks.md, docs/repos.md, docs/gitproviders.md following the house shape (Model / CLI / RBAC / See also).\n- README Resources table (already stale — missing skill/proposal/revision too) and the CLAUDE.md resource cheatsheet.\n- Call out prominently that `mcpctl get tasks` now means WORK ITEMS and the inference queue moved to inference-tasks/itasks.\n- Document the `mcpctl` system agent and its permission model (acts as the caller, loopback through the API).", + "testStrategy": "Manual read-through; assert every documented command actually exists by running --help.", + "priority": "medium", + "dependencies": [ + "154" + ], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-09T19:36:34.052Z" + }, + { + "id": "169", + "title": "Smoke tests for gitprovider discovery and import", + "description": "The agentic-teams smoke covers epic/epictask/task but not the forge flow, which is the part with a live external dependency.", + "details": "Register a provider against a reachable Gitea, discover, import, assert provenance (sourceProvider/sourceFullName), assert re-discovery marks imported, assert import of an unknown name is skipped with a reason rather than failing the batch. Skip cleanly when no forge credential is configured.", + "testStrategy": "src/mcplocal/tests/smoke/gitprovider.smoke.test.ts", + "priority": "medium", + "dependencies": [ + "133" + ], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-09T19:36:34.052Z" + }, + { + "id": "170", + "title": "Reconcile Epic completeness against delivered work", + "description": "Epic completeness counts only its own doneCriteria, which nobody flips automatically. An Epic can read 0% while every task under it is merged — the exact blind spot the Epic exists to remove.", + "details": "EpicService.completeness reads epic.doneCriteria and nothing else; there is no path from task state → epictask criteria → epic criteria.\n\n- Surface delivered-vs-claimed: for an Epic, how many of its tasks are merged vs how many criteria are marked met, and flag the mismatch in both directions (work delivered but unclaimed; criteria claimed with no delivering task).\n- This is the mechanical half of the Tier-2 comparison (task 158) and is worth having before the critic persona exists.", + "testStrategy": "Service tests over a fixture epic with a mix of merged/open tasks and met/unmet criteria; assert both mismatch directions are reported.", + "priority": "medium", + "dependencies": [ + "131", + "126" + ], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-09T19:36:34.052Z" + }, + { + "id": "171", + "title": "End-to-end test of `mcpctl chat mcpctl` through mcplocal", + "description": "The mcpctl system agent tool layer is verified live, but the full path — CLI → mcplocal → mcpd → LLM picks a tool → loopback — has never been run.", + "details": "The tool provider and its permission model are unit-tested and were exercised directly against a live mcpd. What is untested is the LLM actually selecting an mcpctl tool, and the token surviving the mcplocal hop (mcplocal forwards Authorization; unverified for this path).\n\nNeeds a real model, so it belongs in smoke rather than unit tests. Assert: a request like the original example creates the prompt; a user without permission gets a reported 403 rather than a retry loop.", + "testStrategy": "src/mcplocal/tests/smoke/mcpctl-agent.smoke.test.ts using the ChatReporter helper.", + "priority": "medium", + "dependencies": [ + "163" + ], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-09T19:36:34.052Z" + }, + { + "id": "172", + "title": "Release: nothing from the agentic-teams branch is live", + "description": "Every change on feat/agentic-teams has been verified against a locally-run mcpd on the test database. The deployed mcpd and the installed mcpctl/mcplocal have none of it.", + "details": "- mcpd needs a k8s deploy (scripts/deploy-k8s.sh) before any API or the system agent exists in the real environment.\n- mcplocal needs an RPM rebuild for the 30s proxy timeout fix (da4752b); the fix is committed but the running daemon predates it. Restart drops in-memory MCP sessions.\n- The rbac-tasks-backfill runs once on the first upgraded start: verify the log line and spot-check that pre-split grants moved to inference-tasks.\n- The RBAC smoke test (rbac-tasks-split.smoke.test.ts) is RED until this lands, by design.", + "testStrategy": "After deploy: pnpm test:smoke should go green for rbac-tasks-split and agentic-teams.", + "priority": "high", + "dependencies": [], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-09T19:36:34.052Z" + }, + { + "id": "173", + "title": "Web UI and Pulumi kinds for the agentic-teams resources", + "description": "src/web has no pages for the new resources, and src/pulumi/src/kinds.ts has no mapping (it falls back gracefully, so this is cosmetic).", + "details": "Lowest priority of the gap set — the CLI is the primary surface and it is complete.", + "testStrategy": "Existing web test patterns.", + "priority": "low", + "dependencies": [ + "136" + ], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-09T19:36:34.052Z" } ], "metadata": { @@ -3273,4 +3413,4 @@ ] } } -} \ No newline at end of file +} diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index 06f67f6..58cd157 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -869,6 +869,9 @@ async function main(): Promise { case 'group': await groupService.upsertByName(data); break; case 'rbac': await rbacDefinitionService.upsertByName(data); break; case 'prompt': await promptService.upsertByName(data); break; + // Skills were exported and enqueued but never restored, so a git restore + // silently dropped every skill. SkillService always had the method. + case 'skill': await skillService.upsertByName(data); break; case 'template': await templateService.upsertByName(data); break; } }; @@ -881,6 +884,7 @@ async function main(): Promise { case 'group': await groupService.deleteByName(name); break; case 'rbac': await rbacDefinitionService.deleteByName(name); break; case 'prompt': await promptService.deleteByName(name); break; + case 'skill': await skillService.deleteByName(name); break; case 'template': await templateService.deleteByName(name); break; } }; -- 2.49.1 From 5f5efd1144a805a4ab0911fe5f8cf9465a747acf Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 9 Aug 2026 20:44:20 +0100 Subject: [PATCH 16/32] =?UTF-8?q?fix(backup):=20cover=20the=20agentic-team?= =?UTF-8?q?s=20resources=20=E2=80=94=20closes=20the=20data-loss=20gap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audit found that none of the new resources were in BACKUP_KINDS, so git-backup never captured them and a restore would lose every epic, roadmap node, repo and forge registration. Closes 135/165. Backed up: gitprovider, repo, epic, epictask — configuration and the vision layer. NOT tasks: a work item carries in-flight state (lifecycle position, verdicts, PR links) and restoring a stale one from git would resurrect work that has since moved on. That is the same reasoning that keeps McpInstance out of the backup, and there is a test asserting it stays out. Two properties the tests pin, because both are the kind of thing that fails silently: - **Credentials serialize as a REFERENCE, never a value.** The backup repo is a plaintext git checkout; emitting a resolved token would export every forge credential to disk in cleartext. credSecretId/credSecretKey are internal fields and become `credRef: {name, key}`; restore re-resolves against whatever the secret backend holds now. - **Relations serialize by name, not foreign key.** A restore into a different database has different cuids, so a backed-up projectId would dangle. Restore ordering follows dependencies: a forge before the repos that reference it, an epic before its roadmap nodes. Two deliberate refusals on the restore path: an Epic's `status` is never written by a restore, so a stale backup cannot silently mark it delivered — that terminal state is only reachable through setStatus and its rules; and a GitProvider is not probed on restore, because the forge may legitimately be unreachable while a backup replays and failing the whole restore for that is worse than a row that needs a later `probe`. The pre-existing APPLY_ORDER length guard caught this change, which is exactly what it is for; updated 9 → 13 and tied it to BACKUP_KINDS.length so the two cannot drift apart again. Also marks 153 done — `mcpctl tasks accept/reject` shipped with the CLI work; the generated graph had it blocked behind the git-provider merge, but the gate itself does not need it. Local: build clean, workspace 2463 passed (+6), lint unchanged at 794 errors. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p --- .taskmaster/tasks/tasks.json | 20 ++--- src/mcpd/src/main.ts | 9 +++ .../src/services/backup/yaml-serializer.ts | 63 +++++++++++++++- src/mcpd/src/services/code-repo.service.ts | 43 +++++++++++ src/mcpd/src/services/epic-task.service.ts | 30 ++++++++ src/mcpd/src/services/epic.service.ts | 46 ++++++++++++ src/mcpd/src/services/git-provider.service.ts | 28 +++++++ src/mcpd/tests/yaml-serializer.test.ts | 74 ++++++++++++++++++- 8 files changed, 299 insertions(+), 14 deletions(-) diff --git a/.taskmaster/tasks/tasks.json b/.taskmaster/tasks/tasks.json index 7c19711..e62c9f2 100644 --- a/.taskmaster/tasks/tasks.json +++ b/.taskmaster/tasks/tasks.json @@ -2846,9 +2846,9 @@ "131", "133" ], - "status": "pending", + "status": "done", "subtasks": [], - "updatedAt": "2026-08-08T08:55:58.627Z" + "updatedAt": "2026-08-09T19:44:20.519Z" }, { "id": "136", @@ -3104,9 +3104,9 @@ "131", "149" ], - "status": "pending", + "status": "done", "subtasks": [], - "updatedAt": "2026-08-08T08:55:58.627Z" + "updatedAt": "2026-08-09T19:44:20.538Z" }, { "id": "154", @@ -3286,9 +3286,9 @@ "dependencies": [ "135" ], - "status": "pending", + "status": "done", "subtasks": [], - "updatedAt": "2026-08-09T19:36:34.052Z" + "updatedAt": "2026-08-09T19:44:20.531Z" }, { "id": "166", @@ -3405,12 +3405,12 @@ ], "metadata": { "version": "1.0.0", - "lastModified": "2026-08-09T18:56:11.750Z", - "taskCount": 163, - "completedCount": 124, + "lastModified": "2026-08-09T19:44:20.538Z", + "taskCount": 173, + "completedCount": 127, "tags": [ "master" ] } } -} +} \ No newline at end of file diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index 58cd157..3beb8d0 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -822,6 +822,7 @@ async function main(): Promise { servers: 'server', secrets: 'secret', projects: 'project', templates: 'template', users: 'user', groups: 'group', rbac: 'rbac', prompts: 'prompt', skills: 'skill', + gitproviders: 'gitprovider', repos: 'repo', epics: 'epic', epictasks: 'epictask', }; app.addHook('onSend', async (request, reply, payload) => { if (!gitBackup.enabled) return payload; @@ -872,6 +873,10 @@ async function main(): Promise { // Skills were exported and enqueued but never restored, so a git restore // silently dropped every skill. SkillService always had the method. case 'skill': await skillService.upsertByName(data); break; + case 'gitprovider': await gitProviderService.upsertByName(data, 'system'); break; + case 'repo': await codeRepoService.upsertByName(data, 'system'); break; + case 'epic': await epicService.upsertByName(data, 'system'); break; + case 'epictask': await epicTaskService.upsertByName(data, 'system'); break; case 'template': await templateService.upsertByName(data); break; } }; @@ -885,6 +890,10 @@ async function main(): Promise { case 'rbac': await rbacDefinitionService.deleteByName(name); break; case 'prompt': await promptService.deleteByName(name); break; case 'skill': await skillService.deleteByName(name); break; + case 'gitprovider': await gitProviderService.deleteByName(name); break; + case 'repo': await codeRepoService.deleteByName(name); break; + case 'epic': await epicService.deleteByName(name); break; + case 'epictask': await epicTaskService.deleteByName(name); break; case 'template': await templateService.deleteByName(name); break; } }; diff --git a/src/mcpd/src/services/backup/yaml-serializer.ts b/src/mcpd/src/services/backup/yaml-serializer.ts index b0ba369..66b3aa3 100644 --- a/src/mcpd/src/services/backup/yaml-serializer.ts +++ b/src/mcpd/src/services/backup/yaml-serializer.ts @@ -8,6 +8,8 @@ import type { PrismaClient } from '@prisma/client'; const INTERNAL_FIELDS = new Set([ 'id', 'createdAt', 'updatedAt', 'version', 'ownerId', 'summary', 'chapters', 'linkStatus', 'serverId', 'passwordHash', + // Agentic teams: FK columns are replaced by name-based references below. + 'credSecretId', 'credSecretKey', 'gitProviderId', 'projectId', 'epicId', 'epicTaskId', ]); const FIRST_KEYS = ['kind']; @@ -52,6 +54,25 @@ function toApplyDoc(kind: string, raw: Record): Record)); } + // Git providers (credential by reference — `cred` is joined for its name only) + const gitProviders = await prisma.gitProvider.findMany({ include: { cred: { select: { name: true } } } }); + for (const g of gitProviders) { + files.set(resourcePath('gitprovider', g.name), resourceToYaml('gitprovider', g as unknown as Record)); + } + + // Code repos + const codeRepos = await prisma.codeRepo.findMany({ + include: { project: { select: { name: true } }, cred: { select: { name: true } }, gitProvider: { select: { name: true } } }, + }); + for (const r of codeRepos) { + files.set(resourcePath('repo', r.name), resourceToYaml('repo', r as unknown as Record)); + } + + // Epics + const epics = await prisma.epic.findMany({ include: { project: { select: { name: true } } } }); + for (const e of epics) { + files.set(resourcePath('epic', e.name), resourceToYaml('epic', e as unknown as Record)); + } + + // Epic tasks + const epicTasks = await prisma.epicTask.findMany({ include: { epic: { select: { name: true } } } }); + for (const e of epicTasks) { + files.set(resourcePath('epictask', e.name), resourceToYaml('epictask', e as unknown as Record)); + } + // Templates const templates = await prisma.mcpTemplate.findMany(); for (const t of templates) { diff --git a/src/mcpd/src/services/code-repo.service.ts b/src/mcpd/src/services/code-repo.service.ts index 6535418..cd8d1b0 100644 --- a/src/mcpd/src/services/code-repo.service.ts +++ b/src/mcpd/src/services/code-repo.service.ts @@ -103,6 +103,49 @@ export class CodeRepoService { await this.repo.delete(id); } + // ── Backup / restore helpers ── + + async upsertByName(data: Record, ownerId: string): Promise { + const name = String(data['name'] ?? ''); + const projectName = data['project']; + if (typeof projectName !== 'string') { + throw new NotFoundError(`Repo '${name}' has no project in the backup document`); + } + const project = await this.projectRepo.findByName(projectName); + if (project === null) throw new NotFoundError(`Project not found: ${projectName}`); + + // credRef is a {name,key} reference — the backup never carries the value, + // so restore re-resolves it against whatever the secret backend holds now. + const credRef = data['credRef'] as CredRef | undefined; + const cred = await this.resolveCredRefToIds(credRef); + + const existing = await this.repo.findByNameAndProject(name, project.id); + const body = { + ...(typeof data['url'] === 'string' ? { url: data['url'] } : {}), + ...(typeof data['defaultBranch'] === 'string' ? { defaultBranch: data['defaultBranch'] } : {}), + ...(typeof data['path'] === 'string' ? { path: data['path'] } : {}), + ...(typeof data['provider'] === 'string' ? { provider: data['provider'] } : {}), + ...cred, + }; + + if (existing !== null) return this.repo.update(existing.id, body); + return this.repo.create({ + name, + projectId: project.id, + url: String(data['url'] ?? ''), + ownerId, + ...body, + ...(typeof data['sourceProvider'] === 'string' ? { sourceProvider: data['sourceProvider'] } : {}), + ...(typeof data['sourceFullName'] === 'string' ? { sourceFullName: data['sourceFullName'] } : {}), + }); + } + + async deleteByName(name: string): Promise { + const match = await this.repo.findByName(name); + if (match === null) return; + await this.repo.delete(match.id); + } + // ── Credentials ── /** diff --git a/src/mcpd/src/services/epic-task.service.ts b/src/mcpd/src/services/epic-task.service.ts index eba9a45..667968b 100644 --- a/src/mcpd/src/services/epic-task.service.ts +++ b/src/mcpd/src/services/epic-task.service.ts @@ -101,6 +101,36 @@ export class EpicTaskService { await this.repo.delete(id); } + // ── Backup / restore helpers ── + + async upsertByName(data: Record, ownerId: string): Promise { + const name = String(data['name'] ?? ''); + const epicName = data['epic']; + if (typeof epicName !== 'string') { + throw new NotFoundError(`EpicTask '${name}' has no epic in the backup document`); + } + const epic = await this.epicRepo.findByName(epicName); + if (epic === null) throw new NotFoundError(`Epic not found: ${epicName}`); + + const existing = await this.repo.findByNameAndEpic(name, epic.id); + const body = { + ...(typeof data['description'] === 'string' ? { description: data['description'] } : {}), + ...(data['doneCriteria'] !== undefined + ? { doneCriteria: data['doneCriteria'] as Prisma.InputJsonValue } + : {}), + ...(typeof data['status'] === 'string' ? { status: data['status'] as EpicTaskStatus } : {}), + }; + + if (existing !== null) return this.repo.update(existing.id, body); + return this.repo.create({ name, epicId: epic.id, ownerId, ...body }); + } + + async deleteByName(name: string): Promise { + const match = await this.repo.findByName(name); + if (match === null) return; + await this.repo.delete(match.id); + } + // ── Edges ── /** diff --git a/src/mcpd/src/services/epic.service.ts b/src/mcpd/src/services/epic.service.ts index d948bb2..90b1530 100644 --- a/src/mcpd/src/services/epic.service.ts +++ b/src/mcpd/src/services/epic.service.ts @@ -156,6 +156,52 @@ export class EpicService { await this.epicRepo.delete(id); } + // ── Backup / restore helpers ── + + /** + * Upsert by name for git-restore. Deliberately does NOT touch `status`: + * a restored Epic must not be silently marked delivered by a stale backup — + * that terminal state is only reachable through setStatus and its rules. + */ + async upsertByName(data: Record, ownerId: string): Promise { + const name = String(data['name'] ?? ''); + const projectName = data['project']; + if (typeof projectName !== 'string') { + throw new NotFoundError(`Epic '${name}' has no project in the backup document`); + } + const project = await this.projectRepo.findByName(projectName); + if (project === null) throw new NotFoundError(`Project not found: ${projectName}`); + + const existing = await this.epicRepo.findByNameAndProject(name, project.id); + const body = { + ...(typeof data['content'] === 'string' ? { content: data['content'] } : {}), + ...(typeof data['description'] === 'string' ? { description: data['description'] } : {}), + ...(data['doneCriteria'] !== undefined + ? { doneCriteria: data['doneCriteria'] as Prisma.InputJsonValue } + : {}), + ...(data['acceptedTradeoffs'] !== undefined + ? { acceptedTradeoffs: data['acceptedTradeoffs'] as Prisma.InputJsonValue } + : {}), + ...(typeof data['semver'] === 'string' ? { semver: data['semver'] } : {}), + }; + + if (existing !== null) return this.epicRepo.update(existing.id, body); + return this.epicRepo.create({ + name, + content: String(data['content'] ?? ''), + projectId: project.id, + ownerId, + ...body, + }); + } + + async deleteByName(name: string): Promise { + const all = await this.epicRepo.findAll(); + const match = all.find((e) => e.name === name); + if (match === undefined) return; + await this.epicRepo.delete(match.id); + } + // ── The honest-done rule ── /** diff --git a/src/mcpd/src/services/git-provider.service.ts b/src/mcpd/src/services/git-provider.service.ts index d80736c..038df36 100644 --- a/src/mcpd/src/services/git-provider.service.ts +++ b/src/mcpd/src/services/git-provider.service.ts @@ -125,6 +125,34 @@ export class GitProviderService { await this.repo.delete(id); } + // ── Backup / restore helpers ── + + async upsertByName(data: Record, ownerId: string): Promise { + const name = String(data['name'] ?? ''); + const credRef = data['credRef'] as CredRef | undefined; + const cred = await this.resolveCredRefToIds(credRef); + + const existing = await this.repo.findByName(name); + const body = { + ...(typeof data['url'] === 'string' ? { url: data['url'] } : {}), + ...(typeof data['type'] === 'string' ? { type: data['type'] } : {}), + ...(typeof data['description'] === 'string' ? { description: data['description'] } : {}), + ...cred, + }; + + // No probe on restore: the forge may legitimately be unreachable while a + // backup is being replayed, and failing the whole restore for that would be + // worse than a provider row that needs a later `probe`. + if (existing !== null) return this.repo.update(existing.id, body); + return this.repo.create({ name, url: String(data['url'] ?? ''), ownerId, ...body }); + } + + async deleteByName(name: string): Promise { + const match = await this.repo.findByName(name); + if (match === null) return; + await this.repo.delete(match.id); + } + // ── Discovery ── /** diff --git a/src/mcpd/tests/yaml-serializer.test.ts b/src/mcpd/tests/yaml-serializer.test.ts index a7831c8..7da1690 100644 --- a/src/mcpd/tests/yaml-serializer.test.ts +++ b/src/mcpd/tests/yaml-serializer.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { resourceToYaml, resourcePath, parseResourcePath, APPLY_ORDER } from '../src/services/backup/yaml-serializer.js'; +import { resourceToYaml, resourcePath, parseResourcePath, APPLY_ORDER, BACKUP_KINDS } from '../src/services/backup/yaml-serializer.js'; describe('resourceToYaml', () => { it('serializes a server', () => { @@ -229,6 +229,76 @@ describe('APPLY_ORDER', () => { it('has all backup kinds', () => { // PR-3: bumped from 8 → 9 with the addition of `skill`. - expect(APPLY_ORDER).toHaveLength(9); + // Agentic teams: 9 → 13 with gitprovider, repo, epic, epictask. + // This count is the point of the test — it fails when a kind is added to + // BACKUP_KINDS without a matching APPLY_ORDER entry, which would otherwise + // export the resource but never restore it. + expect(APPLY_ORDER).toHaveLength(13); + expect(APPLY_ORDER).toHaveLength(BACKUP_KINDS.length); + }); +}); + +describe('agentic-teams backup coverage', () => { + it('backs up configuration and the vision layer', () => { + for (const kind of ['gitprovider', 'repo', 'epic', 'epictask']) { + expect(BACKUP_KINDS).toContain(kind); + expect(APPLY_ORDER).toContain(kind); + } + }); + + it('does NOT back up work items', () => { + // A Task carries in-flight state — lifecycle position, verdicts, PR links. + // Restoring a stale one from git would resurrect work that has moved on. + // Same reasoning that keeps McpInstance out of the backup. + expect(BACKUP_KINDS).not.toContain('task'); + }); + + it('restores forges before the repos that reference them', () => { + expect(APPLY_ORDER.indexOf('gitprovider')).toBeLessThan(APPLY_ORDER.indexOf('repo')); + expect(APPLY_ORDER.indexOf('epic')).toBeLessThan(APPLY_ORDER.indexOf('epictask')); + }); + + it('maps the new directories back to their kinds', () => { + expect(parseResourcePath('gitproviders/mysources.yaml')).toEqual({ kind: 'gitprovider', name: 'mysources' }); + expect(parseResourcePath('repos/api.yaml')).toEqual({ kind: 'repo', name: 'api' }); + expect(parseResourcePath('epics/the-car.yaml')).toEqual({ kind: 'epic', name: 'the-car' }); + expect(parseResourcePath('epictasks/chassis.yaml')).toEqual({ kind: 'epictask', name: 'chassis' }); + }); + + it('serializes a credential as a REFERENCE, never the value', () => { + // The backup repo is a plaintext git checkout. Emitting a resolved token + // here would export every forge credential to disk in cleartext. + const yaml = resourceToYaml('gitprovider', { + id: 'gp-1', + name: 'mysources', + url: 'https://mysources.co.uk', + type: 'gitea', + credSecretId: 'sec-1', + credSecretKey: 'TOKEN', + cred: { name: 'gitea-token' }, + ownerId: 'u1', + version: 1, + }); + + expect(yaml).toContain('credRef'); + expect(yaml).toContain('gitea-token'); + expect(yaml).toContain('TOKEN'); + // The FK columns are internal and must not leak into the document. + expect(yaml).not.toContain('credSecretId'); + expect(yaml).not.toContain('sec-1'); + }); + + it('serializes relations by name, not by foreign key', () => { + const yaml = resourceToYaml('repo', { + id: 'r1', name: 'api', url: 'https://git/api.git', defaultBranch: 'main', + projectId: 'proj-1', project: { name: 'mcpctl' }, + gitProviderId: 'gp-1', gitProvider: { name: 'mysources' }, + sourceFullName: 'michal/api', ownerId: 'u1', version: 1, + }); + + expect(yaml).toContain('project: mcpctl'); + expect(yaml).toContain('gitProvider: mysources'); + expect(yaml).not.toContain('projectId'); + expect(yaml).not.toContain('gitProviderId'); }); }); -- 2.49.1 From d9535bc51fa3a9a5de0e96d5bd5b2c66d65a3b10 Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 9 Aug 2026 21:50:38 +0100 Subject: [PATCH 17/32] =?UTF-8?q?chore(taskmaster):=20file=20174=20?= =?UTF-8?q?=E2=80=94=20scope=20project-only=20commands=20out=20of=20root?= =?UTF-8?q?=20--help?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tasks/repos command groups added with the CLI work sit at the root of `mcpctl --help`, but both only function inside a project context. Beyond the clutter, it teaches the wrong data model: a task reads as a peer of servers and secrets rather than a leaf of the epic tree. Convention recorded as the `scope-commands-to-their-context` prompt in the mcpctl-development project. Note `attach-server`/`detach-server` are already hidden, so the pattern exists and was simply not applied consistently. Co-Authored-By: Claude Opus 5 (1M context) --- .taskmaster/tasks/tasks.json | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/.taskmaster/tasks/tasks.json b/.taskmaster/tasks/tasks.json index e62c9f2..095cece 100644 --- a/.taskmaster/tasks/tasks.json +++ b/.taskmaster/tasks/tasks.json @@ -3401,6 +3401,20 @@ "status": "pending", "subtasks": [], "updatedAt": "2026-08-09T19:36:34.052Z" + }, + { + "id": "174", + "title": "Scope project-only commands out of the root --help", + "description": "`mcpctl tasks *` and `mcpctl repos *` were added at the root, but both only work inside a project context. They should not appear in the bare `mcpctl --help`.", + "details": "Violates the CLI convention recorded as the `scope-commands-to-their-context` prompt in the mcpctl-development project.\n\n- `repos discover|import` need a gitprovider AND a target project.\n- `tasks advance|verdict|accept|reject|history` operate on a work item that only exists under an epictask under an epic under a project.\n\nRoot --help should stay the set of things runnable from a cold start. Surfacing these there also teaches the wrong data model: it reads as though a task is a peer of `servers` and `secrets` rather than a leaf of the epic tree.\n\nOptions to weigh (this is a real design choice, not mechanical):\n- hide from root help but keep the command working (commander supports hidden commands, as `attach-server` already does);\n- nest under the parent noun, e.g. `mcpctl epics tasks ...`;\n- surface only when `--project` is present.\n\nCheck the whole CLI while in there, not just the two new ones — `attach-server`/`detach-server` are already hidden, which suggests the pattern exists but was not applied consistently.", + "testStrategy": "src/cli/tests/commands: assert the root program help does not list the scoped commands, and that they still execute when invoked. Update src/cli/tests/completions.test.ts if the generator output changes.", + "priority": "medium", + "dependencies": [ + "137" + ], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-09T20:50:38.660Z" } ], "metadata": { @@ -3413,4 +3427,4 @@ ] } } -} \ No newline at end of file +} -- 2.49.1 From 1220b974dcfc6f895099e0f804a4f9a238cdc179 Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 9 Aug 2026 21:55:31 +0100 Subject: [PATCH 18/32] fix(rbac): correct the non-CRUD verbs, and make the mapping testable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes 134/164. The five agentic-teams resources had RBAC slots but the non-CRUD verbs had no cases, so they fell through to the method default and every one of them read as `create:`. That is wrong in both directions: it let anyone who may CREATE a task also ACCEPT one — the human gate that fires a merge — and it refused a reviewer who may drive tasks but not create them. Corrected, following the verb the codebase already uses for "use this resource to do work" (llms/infer, agents/chat, projects/chat): - POST /tasks/:id/{transition,accept,reject,verdicts} → run:tasks: - {GET,POST} /gitproviders/:id/{repos,import,probe} → run:gitproviders:. Discovery calls OUT to the forge with its stored token, so holding view:gitproviders should let you see that a forge is registered, not use its credential. - POST /epics/:id/{status,tradeoffs,criteria} → edit:epics:. These rewrite the vision record. - GET /epics/:id/roadmap → view:epics: - {POST,DELETE} /epictasks/:id/edges → edit:epictasks: mapUrlToPermission moved from main.ts to middleware/url-permission.ts. main.ts calls main() at module load, so nothing in it is importable from a test — an authorization mapping that cannot be tested is an authorization mapping nobody has checked, which is exactly how these verbs shipped mis-scoped. 15 tests now assert the mapping rather than the mere presence of the strings, including that a bare create grant does NOT cover the gated verbs, that work items and the inference queue never resolve to the same resource for any method, and that the pre-existing mappings (llm infer, agent chat, operations, the personalities/skills piggybacks) still hold. Local: build clean, workspace 2478 passed (+15), lint back to the repo's baseline 794 errors after making the five new match guards explicit. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p --- .taskmaster/tasks/tasks.json | 16 +- src/mcpd/src/main.ts | 193 +---------------- src/mcpd/src/middleware/url-permission.ts | 247 ++++++++++++++++++++++ src/mcpd/tests/url-permission.test.ts | 137 ++++++++++++ 4 files changed, 393 insertions(+), 200 deletions(-) create mode 100644 src/mcpd/src/middleware/url-permission.ts create mode 100644 src/mcpd/tests/url-permission.test.ts diff --git a/.taskmaster/tasks/tasks.json b/.taskmaster/tasks/tasks.json index 095cece..ed8a527 100644 --- a/.taskmaster/tasks/tasks.json +++ b/.taskmaster/tasks/tasks.json @@ -2829,9 +2829,9 @@ "131", "133" ], - "status": "pending", + "status": "done", "subtasks": [], - "updatedAt": "2026-08-08T08:55:58.627Z" + "updatedAt": "2026-08-09T20:55:31.457Z" }, { "id": "135", @@ -3272,9 +3272,9 @@ "dependencies": [ "134" ], - "status": "pending", + "status": "done", "subtasks": [], - "updatedAt": "2026-08-09T19:36:34.052Z" + "updatedAt": "2026-08-09T20:55:31.468Z" }, { "id": "165", @@ -3419,12 +3419,12 @@ ], "metadata": { "version": "1.0.0", - "lastModified": "2026-08-09T19:44:20.538Z", - "taskCount": 173, - "completedCount": 127, + "lastModified": "2026-08-09T20:55:31.468Z", + "taskCount": 174, + "completedCount": 129, "tags": [ "master" ] } } -} +} \ No newline at end of file diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index 3beb8d0..82dc6b2 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -56,7 +56,7 @@ import { PersonalityService } from './services/personality.service.js'; import { registerPersonalityRoutes } from './routes/personalities.js'; import { registerWebUi } from './routes/web-ui.js'; import { bootstrapSystemProject } from './bootstrap/system-project.js'; -import { SET_OWN_PASSWORD_OPERATION } from './bootstrap/self-password-permission.js'; +import { mapUrlToPermission } from './middleware/url-permission.js'; import { registerLogRoutes } from './routes/logs.js'; import { errorLogBuffer } from './services/error-log-buffer.js'; import { bootstrapSystemSkills } from './bootstrap/system-skills.js'; @@ -84,7 +84,6 @@ import { AuditEventService, McpTokenService, } from './services/index.js'; -import type { RbacAction } from './services/index.js'; import type { UpdateRbacDefinitionInput } from './validation/rbac-definition.schema.js'; import { createAuthMiddleware } from './middleware/auth.js'; import { @@ -138,196 +137,6 @@ import type { BackupKind } from './services/backup/yaml-serializer.js'; import { ResourceRuleRegistry } from './validation/resource-rules.js'; import { systemPromptVarsRule } from './validation/rules/system-prompt-vars.js'; -type PermissionCheck = - | { kind: 'resource'; resource: string; action: RbacAction; resourceName?: string } - | { kind: 'operation'; operation: string } - | { kind: 'skip' }; - -/** - * Map an HTTP method + URL to a permission check. - * Returns 'skip' for URLs that should not be RBAC-checked. - */ -function mapUrlToPermission(method: string, url: string): PermissionCheck { - const match = url.match(/^\/api\/v1\/([a-z-]+)/); - if (!match) return { kind: 'skip' }; - - const segment = match[1] as string; - - // Recent mcpd error logs — read-only operator view, gated by `logs`. - if (url.startsWith('/api/v1/logs')) { - return { kind: 'operation', operation: 'logs' }; - } - - // Self-service password change — gated by the `set-own-password` operation - // (a default, admin-revocable permission), NOT the broad edit:users that an - // admin reset of another user needs. Must precede the generic users mapping. - if (url.startsWith('/api/v1/users/me/password')) { - return { kind: 'operation', operation: SET_OWN_PASSWORD_OPERATION }; - } - - // Operations (non-resource endpoints) - if (segment === 'backup') return { kind: 'operation', operation: 'backup' }; - if (segment === 'restore') return { kind: 'operation', operation: 'restore' }; - if (segment === 'audit-logs' && method === 'DELETE') return { kind: 'operation', operation: 'audit-purge' }; - // /api/v1/secrets/migrate is a bulk cross-backend operation — treat as op, not a plain secret write. - if (url.startsWith('/api/v1/secrets/migrate')) return { kind: 'operation', operation: 'migrate-secrets' }; - // /api/v1/secretbackends/:id/rotate — manual rotation trigger. Operation so - // only explicitly-granted callers can force it (the loop itself bypasses - // RBAC by calling the rotator in-process). - if (/^\/api\/v1\/secretbackends\/[^/?]+\/rotate/.test(url)) { - return { kind: 'operation', operation: 'rotate-secretbackend' }; - } - - // /api/v1/llms/:name/infer → `run:llms:` (not the default create:llms). - const inferMatch = url.match(/^\/api\/v1\/llms\/([^/?]+)\/infer/); - if (inferMatch?.[1]) { - return { kind: 'resource', resource: 'llms', action: 'run', resourceName: inferMatch[1] }; - } - - // /api/v1/agents/:name/chat or /threads* → `run:agents:`. - // Driving a turn or managing its history is a "run" on the agent — listing - // and CRUD continue to fall through to the default mapping below. - const agentRunMatch = url.match(/^\/api\/v1\/agents\/([^/?]+)\/(chat|threads)/); - if (agentRunMatch?.[1]) { - return { kind: 'resource', resource: 'agents', action: 'run', resourceName: agentRunMatch[1] }; - } - - // /api/v1/threads/:id/messages → `view:agents` (we don't carry the agent - // name in the URL; the service-level owner check enforces fine-grained - // access on top). - if (url.startsWith('/api/v1/threads/')) { - return { kind: 'resource', resource: 'agents', action: 'view' }; - } - - const resourceMap: Record = { - 'servers': 'servers', - 'instances': 'instances', - 'secrets': 'secrets', - 'secretbackends': 'secretbackends', - 'projects': 'projects', - 'templates': 'templates', - 'users': 'users', - 'groups': 'groups', - 'rbac': 'rbac', - 'audit-logs': 'rbac', - 'mcp': 'servers', - 'prompts': 'prompts', - 'promptrequests': 'promptrequests', - // PR-2: revisions/proposals piggyback on the prompts permission for now. - // Anyone with view:prompts can read history; anyone with edit:prompts can - // approve/reject proposals. PR-7 may split these out if RBAC granularity - // becomes useful (e.g., a "reviewer" role). - 'revisions': 'prompts', - 'proposals': 'prompts', - // PR-3: skills follow prompts for RBAC. A "skills" RBAC slot can be - // split out later if the operator wants to scope skill writes more - // tightly than prompt writes — for now, a senior reviewer who can - // edit prompts can edit skills. - 'skills': 'prompts', - 'mcptokens': 'mcptokens', - 'llms': 'llms', - // v5: durable inference task queue. Same default action mapping as - // any other resource (GET=view, POST=create, DELETE=delete). The - // route layer enforces owner-scoping on top — a caller without - // resource-wide grant only sees their own tasks. - // - // Maps to its own `inference-tasks` resource, NOT `tasks` — agentic-teams - // work items own `tasks` now. Pre-split grants are rewritten by - // backfillInferenceTaskBindings at startup. - 'inference-tasks': 'inference-tasks', - 'agents': 'agents', - // Personalities inherit the agent's RBAC: managing a personality - // requires view/edit/create/delete on the `agents` resource. - 'personalities': 'agents', - // ── Agentic teams ── - 'epics': 'epics', - 'epictasks': 'epictasks', - // Work items. NOT inference-tasks — see the rbac-tasks-backfill bootstrap. - 'tasks': 'tasks', - 'repos': 'repos', - 'gitproviders': 'gitproviders', - }; - - const resource = resourceMap[segment]; - if (resource === undefined) return { kind: 'skip' }; - - // Special case: /api/v1/promptrequests/:id/approve → needs both delete+promptrequests and create+prompts - // We check delete on promptrequests (the harder permission); create on prompts is checked in the service layer - const approveMatch = url.match(/^\/api\/v1\/promptrequests\/([^/?]+)\/approve/); - if (approveMatch?.[1]) { - return { kind: 'resource', resource: 'promptrequests', action: 'delete', resourceName: approveMatch[1] }; - } - - // Special case: /api/v1/mcptokens/:id/revoke → treated as 'delete' on the token. - const revokeMatch = url.match(/^\/api\/v1\/mcptokens\/([^/?]+)\/revoke/); - if (revokeMatch?.[1]) { - return { kind: 'resource', resource: 'mcptokens', action: 'delete', resourceName: revokeMatch[1] }; - } - - // Special case: /api/v1/projects/:name/(chat|threads) → run:projects:. - // Driving a project chat turn or managing its threads is a "run" on the - // project; per-thread ownership + admin-delete are enforced in the service. - const projectRunMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/(chat|threads)/); - if (projectRunMatch?.[1]) { - return { kind: 'resource', resource: 'projects', action: 'run', resourceName: projectRunMatch[1] }; - } - - // Special case: /api/v1/projects/:name/prompts/visible → view prompts - const visiblePromptsMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/prompts\/visible/); - if (visiblePromptsMatch?.[1]) { - return { kind: 'resource', resource: 'prompts', action: 'view' }; - } - - // Special case: /api/v1/projects/:name/promptrequests → create promptrequests - const projectPromptrequestsMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/promptrequests/); - if (projectPromptrequestsMatch?.[1] && method === 'POST') { - return { kind: 'resource', resource: 'promptrequests', action: 'create' }; - } - - // Special case: /api/v1/projects/:id/instructions → view projects - const instructionsMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/instructions/); - if (instructionsMatch?.[1]) { - return { kind: 'resource', resource: 'projects', action: 'view', resourceName: instructionsMatch[1] }; - } - - // Special case: /api/v1/projects/:id/mcp-config → requires 'expose' permission - const mcpConfigMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/mcp-config/); - if (mcpConfigMatch?.[1]) { - return { kind: 'resource', resource: 'projects', action: 'expose', resourceName: mcpConfigMatch[1] }; - } - - // Special case: /api/v1/projects/:id/servers — attach/detach requires 'edit' - const projectServersMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/servers/); - if (projectServersMatch?.[1] && method !== 'GET') { - return { kind: 'resource', resource: 'projects', action: 'edit', resourceName: projectServersMatch[1] }; - } - - // Map HTTP method to action - let action: RbacAction; - switch (method) { - case 'GET': - case 'HEAD': - action = 'view'; - break; - case 'POST': - action = 'create'; - break; - case 'DELETE': - action = 'delete'; - break; - default: // PUT, PATCH - action = 'edit'; - break; - } - - // Extract resource name/ID from URL (3rd segment: /api/v1/servers/:nameOrId) - const nameMatch = url.match(/^\/api\/v1\/[a-z-]+\/([^/?]+)/); - const resourceName = nameMatch?.[1]; - - const check: PermissionCheck = { kind: 'resource', resource, action }; - if (resourceName !== undefined) (check as { resourceName: string }).resourceName = resourceName; - return check; -} /** Operations an admin-equivalent user should always be able to run. */ const ADMIN_OPS = [ diff --git a/src/mcpd/src/middleware/url-permission.ts b/src/mcpd/src/middleware/url-permission.ts new file mode 100644 index 0000000..e7b07ae --- /dev/null +++ b/src/mcpd/src/middleware/url-permission.ts @@ -0,0 +1,247 @@ +/** + * HTTP method + URL → RBAC permission check. + * + * Extracted from main.ts so it can be tested directly: main.ts calls main() at + * module load, so nothing there is importable from a test, and an + * authorization mapping that cannot be tested is an authorization mapping + * nobody has checked. + */ +import { SET_OWN_PASSWORD_OPERATION } from '../bootstrap/self-password-permission.js'; +import type { RbacAction } from '../services/index.js'; + +export type PermissionCheck = + | { kind: 'resource'; resource: string; action: RbacAction; resourceName?: string } + | { kind: 'operation'; operation: string } + | { kind: 'skip' }; + +/** + * Map an HTTP method + URL to a permission check. + * Returns 'skip' for URLs that should not be RBAC-checked. + */ +export function mapUrlToPermission(method: string, url: string): PermissionCheck { + const match = url.match(/^\/api\/v1\/([a-z-]+)/); + if (!match) return { kind: 'skip' }; + + const segment = match[1] as string; + + // Recent mcpd error logs — read-only operator view, gated by `logs`. + if (url.startsWith('/api/v1/logs')) { + return { kind: 'operation', operation: 'logs' }; + } + + // Self-service password change — gated by the `set-own-password` operation + // (a default, admin-revocable permission), NOT the broad edit:users that an + // admin reset of another user needs. Must precede the generic users mapping. + if (url.startsWith('/api/v1/users/me/password')) { + return { kind: 'operation', operation: SET_OWN_PASSWORD_OPERATION }; + } + + // Operations (non-resource endpoints) + if (segment === 'backup') return { kind: 'operation', operation: 'backup' }; + if (segment === 'restore') return { kind: 'operation', operation: 'restore' }; + if (segment === 'audit-logs' && method === 'DELETE') return { kind: 'operation', operation: 'audit-purge' }; + // /api/v1/secrets/migrate is a bulk cross-backend operation — treat as op, not a plain secret write. + if (url.startsWith('/api/v1/secrets/migrate')) return { kind: 'operation', operation: 'migrate-secrets' }; + // /api/v1/secretbackends/:id/rotate — manual rotation trigger. Operation so + // only explicitly-granted callers can force it (the loop itself bypasses + // RBAC by calling the rotator in-process). + if (/^\/api\/v1\/secretbackends\/[^/?]+\/rotate/.test(url)) { + return { kind: 'operation', operation: 'rotate-secretbackend' }; + } + + // /api/v1/llms/:name/infer → `run:llms:` (not the default create:llms). + const inferMatch = url.match(/^\/api\/v1\/llms\/([^/?]+)\/infer/); + if (inferMatch?.[1]) { + return { kind: 'resource', resource: 'llms', action: 'run', resourceName: inferMatch[1] }; + } + + // /api/v1/agents/:name/chat or /threads* → `run:agents:`. + // Driving a turn or managing its history is a "run" on the agent — listing + // and CRUD continue to fall through to the default mapping below. + const agentRunMatch = url.match(/^\/api\/v1\/agents\/([^/?]+)\/(chat|threads)/); + if (agentRunMatch?.[1]) { + return { kind: 'resource', resource: 'agents', action: 'run', resourceName: agentRunMatch[1] }; + } + + // ── Agentic teams: non-CRUD verbs ── + // + // Without these the default method mapping applies and every one of these + // POSTs reads as `create:`, which is wrong in both directions: + // it lets anyone who may create a task also accept one, and it refuses a + // reviewer who may drive tasks but not create them. + // + // `run` is the existing verb for "use this resource to do work" (llms/infer, + // agents/chat, projects/chat), so driving a task's lifecycle and exercising a + // forge's credential both belong to it. Mutations of the vision record itself + // stay `edit`. + + // Driving a work item: transition, the human accept gate, reject, and + // recording a stage verdict. Separable from `edit:tasks`, which is field edits. + const taskRunMatch = url.match(/^\/api\/v1\/tasks\/([^/?]+)\/(transition|accept|reject|verdicts)/); + if (taskRunMatch?.[1] !== undefined) { + return { kind: 'resource', resource: 'tasks', action: 'run', resourceName: taskRunMatch[1] }; + } + + // Discovery and import call OUT to the forge using its stored credential, so + // they are `run`, not `view`/`create` — holding view:gitproviders should not + // let you exercise the token. + const forgeRunMatch = url.match(/^\/api\/v1\/gitproviders\/([^/?]+)\/(repos|import|probe)/); + if (forgeRunMatch?.[1] !== undefined) { + return { kind: 'resource', resource: 'gitproviders', action: 'run', resourceName: forgeRunMatch[1] }; + } + + // Marking an Epic delivered, recording an accepted tradeoff, or ticking a + // criterion all rewrite the vision record → `edit`, same as editing content. + const epicEditMatch = url.match(/^\/api\/v1\/epics\/([^/?]+)\/(status|tradeoffs|criteria)/); + if (epicEditMatch?.[1] !== undefined) { + return { kind: 'resource', resource: 'epics', action: 'edit', resourceName: epicEditMatch[1] }; + } + + // The roadmap is a read of the epic's graph. + const roadmapMatch = url.match(/^\/api\/v1\/epics\/([^/?]+)\/roadmap/); + if (roadmapMatch?.[1] !== undefined) { + return { kind: 'resource', resource: 'epics', action: 'view', resourceName: roadmapMatch[1] }; + } + + // Chaining roadmap nodes edits the graph. + const edgeMatch = url.match(/^\/api\/v1\/epictasks\/([^/?]+)\/edges/); + if (edgeMatch?.[1] !== undefined) { + return { kind: 'resource', resource: 'epictasks', action: 'edit', resourceName: edgeMatch[1] }; + } + + // /api/v1/threads/:id/messages → `view:agents` (we don't carry the agent + // name in the URL; the service-level owner check enforces fine-grained + // access on top). + if (url.startsWith('/api/v1/threads/')) { + return { kind: 'resource', resource: 'agents', action: 'view' }; + } + + const resourceMap: Record = { + 'servers': 'servers', + 'instances': 'instances', + 'secrets': 'secrets', + 'secretbackends': 'secretbackends', + 'projects': 'projects', + 'templates': 'templates', + 'users': 'users', + 'groups': 'groups', + 'rbac': 'rbac', + 'audit-logs': 'rbac', + 'mcp': 'servers', + 'prompts': 'prompts', + 'promptrequests': 'promptrequests', + // PR-2: revisions/proposals piggyback on the prompts permission for now. + // Anyone with view:prompts can read history; anyone with edit:prompts can + // approve/reject proposals. PR-7 may split these out if RBAC granularity + // becomes useful (e.g., a "reviewer" role). + 'revisions': 'prompts', + 'proposals': 'prompts', + // PR-3: skills follow prompts for RBAC. A "skills" RBAC slot can be + // split out later if the operator wants to scope skill writes more + // tightly than prompt writes — for now, a senior reviewer who can + // edit prompts can edit skills. + 'skills': 'prompts', + 'mcptokens': 'mcptokens', + 'llms': 'llms', + // v5: durable inference task queue. Same default action mapping as + // any other resource (GET=view, POST=create, DELETE=delete). The + // route layer enforces owner-scoping on top — a caller without + // resource-wide grant only sees their own tasks. + // + // Maps to its own `inference-tasks` resource, NOT `tasks` — agentic-teams + // work items own `tasks` now. Pre-split grants are rewritten by + // backfillInferenceTaskBindings at startup. + 'inference-tasks': 'inference-tasks', + 'agents': 'agents', + // Personalities inherit the agent's RBAC: managing a personality + // requires view/edit/create/delete on the `agents` resource. + 'personalities': 'agents', + // ── Agentic teams ── + 'epics': 'epics', + 'epictasks': 'epictasks', + // Work items. NOT inference-tasks — see the rbac-tasks-backfill bootstrap. + 'tasks': 'tasks', + 'repos': 'repos', + 'gitproviders': 'gitproviders', + }; + + const resource = resourceMap[segment]; + if (resource === undefined) return { kind: 'skip' }; + + // Special case: /api/v1/promptrequests/:id/approve → needs both delete+promptrequests and create+prompts + // We check delete on promptrequests (the harder permission); create on prompts is checked in the service layer + const approveMatch = url.match(/^\/api\/v1\/promptrequests\/([^/?]+)\/approve/); + if (approveMatch?.[1]) { + return { kind: 'resource', resource: 'promptrequests', action: 'delete', resourceName: approveMatch[1] }; + } + + // Special case: /api/v1/mcptokens/:id/revoke → treated as 'delete' on the token. + const revokeMatch = url.match(/^\/api\/v1\/mcptokens\/([^/?]+)\/revoke/); + if (revokeMatch?.[1]) { + return { kind: 'resource', resource: 'mcptokens', action: 'delete', resourceName: revokeMatch[1] }; + } + + // Special case: /api/v1/projects/:name/(chat|threads) → run:projects:. + // Driving a project chat turn or managing its threads is a "run" on the + // project; per-thread ownership + admin-delete are enforced in the service. + const projectRunMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/(chat|threads)/); + if (projectRunMatch?.[1]) { + return { kind: 'resource', resource: 'projects', action: 'run', resourceName: projectRunMatch[1] }; + } + + // Special case: /api/v1/projects/:name/prompts/visible → view prompts + const visiblePromptsMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/prompts\/visible/); + if (visiblePromptsMatch?.[1]) { + return { kind: 'resource', resource: 'prompts', action: 'view' }; + } + + // Special case: /api/v1/projects/:name/promptrequests → create promptrequests + const projectPromptrequestsMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/promptrequests/); + if (projectPromptrequestsMatch?.[1] && method === 'POST') { + return { kind: 'resource', resource: 'promptrequests', action: 'create' }; + } + + // Special case: /api/v1/projects/:id/instructions → view projects + const instructionsMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/instructions/); + if (instructionsMatch?.[1]) { + return { kind: 'resource', resource: 'projects', action: 'view', resourceName: instructionsMatch[1] }; + } + + // Special case: /api/v1/projects/:id/mcp-config → requires 'expose' permission + const mcpConfigMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/mcp-config/); + if (mcpConfigMatch?.[1]) { + return { kind: 'resource', resource: 'projects', action: 'expose', resourceName: mcpConfigMatch[1] }; + } + + // Special case: /api/v1/projects/:id/servers — attach/detach requires 'edit' + const projectServersMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/servers/); + if (projectServersMatch?.[1] && method !== 'GET') { + return { kind: 'resource', resource: 'projects', action: 'edit', resourceName: projectServersMatch[1] }; + } + + // Map HTTP method to action + let action: RbacAction; + switch (method) { + case 'GET': + case 'HEAD': + action = 'view'; + break; + case 'POST': + action = 'create'; + break; + case 'DELETE': + action = 'delete'; + break; + default: // PUT, PATCH + action = 'edit'; + break; + } + + // Extract resource name/ID from URL (3rd segment: /api/v1/servers/:nameOrId) + const nameMatch = url.match(/^\/api\/v1\/[a-z-]+\/([^/?]+)/); + const resourceName = nameMatch?.[1]; + + const check: PermissionCheck = { kind: 'resource', resource, action }; + if (resourceName !== undefined) (check as { resourceName: string }).resourceName = resourceName; + return check; +} diff --git a/src/mcpd/tests/url-permission.test.ts b/src/mcpd/tests/url-permission.test.ts new file mode 100644 index 0000000..4cef70a --- /dev/null +++ b/src/mcpd/tests/url-permission.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect } from 'vitest'; + +import { mapUrlToPermission } from '../src/middleware/url-permission.js'; + +/** + * The agentic-teams resources shipped with five new RBAC slots and a namespace + * split, and nothing tested that the mapping was right — only that the strings + * existed. These assert the mapping itself, which is what actually decides + * whether a request is allowed. + * + * The non-CRUD verbs matter most. Without an explicit case they fall through to + * the method default, so every POST reads as `create:` — which both + * lets anyone who may create a task also ACCEPT one, and refuses a reviewer who + * may drive tasks but not create them. + */ +const perm = (method: string, url: string) => mapUrlToPermission(method, url); + +describe('agentic-teams CRUD mapping', () => { + it('maps each resource to its own slot', () => { + for (const [segment, resource] of [ + ['epics', 'epics'], + ['epictasks', 'epictasks'], + ['tasks', 'tasks'], + ['repos', 'repos'], + ['gitproviders', 'gitproviders'], + ] as const) { + expect(perm('GET', `/api/v1/${segment}`)).toMatchObject({ kind: 'resource', resource, action: 'view' }); + } + }); + + it('maps HTTP methods to the usual actions', () => { + expect(perm('GET', '/api/v1/epics')).toMatchObject({ action: 'view' }); + expect(perm('POST', '/api/v1/epics')).toMatchObject({ action: 'create' }); + expect(perm('PUT', '/api/v1/epics/e1')).toMatchObject({ action: 'edit' }); + expect(perm('DELETE', '/api/v1/epics/e1')).toMatchObject({ action: 'delete' }); + }); + + it('carries the resource name so name-scoped grants can match', () => { + expect(perm('GET', '/api/v1/epics/the-car')).toMatchObject({ resourceName: 'the-car' }); + }); +}); + +describe('the tasks namespace split', () => { + it('routes work items to `tasks`', () => { + expect(perm('GET', '/api/v1/tasks')).toMatchObject({ resource: 'tasks' }); + }); + + it('routes the inference queue to `inference-tasks`, NOT `tasks`', () => { + // Before the split both landed on `tasks`, so a grant for one silently + // authorized the other. + const p = perm('GET', '/api/v1/inference-tasks'); + expect(p).toMatchObject({ resource: 'inference-tasks' }); + expect(p).not.toMatchObject({ resource: 'tasks' }); + }); + + it('keeps the two apart for every method', () => { + for (const m of ['GET', 'POST', 'DELETE']) { + const work = perm(m, '/api/v1/tasks'); + const infer = perm(m, '/api/v1/inference-tasks'); + expect((work as { resource: string }).resource).not.toBe((infer as { resource: string }).resource); + } + }); +}); + +describe('non-CRUD verbs', () => { + it('treats driving a task as `run`, not `create`', () => { + // Accepting a task fires a merge. That must be grantable separately from + // "may create tasks", and separately from "may edit task fields". + for (const verb of ['transition', 'accept', 'reject', 'verdicts']) { + expect(perm('POST', `/api/v1/tasks/wire-cli/${verb}`)).toEqual({ + kind: 'resource', resource: 'tasks', action: 'run', resourceName: 'wire-cli', + }); + } + }); + + it('treats forge discovery and import as `run` — they exercise the credential', () => { + // view:gitproviders should let you see that a forge is registered, not use + // its token to call out. + for (const verb of ['repos', 'import', 'probe']) { + expect(perm('POST', `/api/v1/gitproviders/mysources/${verb}`)).toEqual({ + kind: 'resource', resource: 'gitproviders', action: 'run', resourceName: 'mysources', + }); + } + expect(perm('GET', '/api/v1/gitproviders/mysources/repos')).toMatchObject({ action: 'run' }); + }); + + it('treats rewriting the vision record as `edit`', () => { + for (const verb of ['status', 'tradeoffs', 'criteria/builds']) { + expect(perm('POST', `/api/v1/epics/the-car/${verb}`)).toMatchObject({ + resource: 'epics', action: 'edit', resourceName: 'the-car', + }); + } + }); + + it('treats the roadmap as a read', () => { + expect(perm('GET', '/api/v1/epics/the-car/roadmap')).toMatchObject({ + resource: 'epics', action: 'view', + }); + }); + + it('treats chaining roadmap nodes as an edit of the graph', () => { + expect(perm('POST', '/api/v1/epictasks/chassis/edges')).toMatchObject({ + resource: 'epictasks', action: 'edit', resourceName: 'chassis', + }); + expect(perm('DELETE', '/api/v1/epictasks/chassis/edges')).toMatchObject({ + resource: 'epictasks', action: 'edit', + }); + }); + + it('does NOT let a bare create grant cover the gated verbs', () => { + // The regression this whole block exists to prevent. + const create = perm('POST', '/api/v1/tasks'); + const accept = perm('POST', '/api/v1/tasks/wire-cli/accept'); + expect((create as { action: string }).action).toBe('create'); + expect((accept as { action: string }).action).toBe('run'); + }); +}); + +describe('existing mappings still hold', () => { + it('leaves the established run-verbs alone', () => { + expect(perm('POST', '/api/v1/llms/vllm-current/infer')).toMatchObject({ resource: 'llms', action: 'run' }); + expect(perm('POST', '/api/v1/agents/reviewer/chat')).toMatchObject({ resource: 'agents', action: 'run' }); + expect(perm('POST', '/api/v1/projects/sre/chat')).toMatchObject({ resource: 'projects', action: 'run' }); + }); + + it('still gates operations and skips unknown paths', () => { + expect(perm('GET', '/api/v1/logs')).toMatchObject({ kind: 'operation', operation: 'logs' }); + expect(perm('POST', '/api/v1/backup')).toMatchObject({ kind: 'operation', operation: 'backup' }); + expect(perm('GET', '/healthz')).toEqual({ kind: 'skip' }); + expect(perm('GET', '/api/v1/unknownthing')).toEqual({ kind: 'skip' }); + }); + + it('keeps personalities and skills piggybacked where they were', () => { + expect(perm('GET', '/api/v1/personalities')).toMatchObject({ resource: 'agents' }); + expect(perm('GET', '/api/v1/skills')).toMatchObject({ resource: 'prompts' }); + }); +}); -- 2.49.1 From 00373d2b27a1969ae6ef44aa9b49bfc3bdb9917e Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 9 Aug 2026 22:05:46 +0100 Subject: [PATCH 19/32] feat(epics): reconcile claimed completeness against delivered work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An Epic could report 0% while every Task under it was merged. That is not a cosmetic gap — it is exactly the blind spot the Epic exists to remove (docs/agentic-teams.md §1b: "we completed all the tasks and got a stick with wheels instead of a car"). An Epic that cannot see its own delivered work cannot detect that, because `completeness()` reads only `doneCriteria[].met` and nothing flips those except a human hitting the criteria endpoint. So read the same question a second time, from the delivery side, and report where the two readings disagree — in both directions: - deliveredButUnclaimed — unmet criteria whose owning roadmap node has merged work. Somebody delivered and nobody ticked the box. - claimedButUndelivered — criteria marked met with nothing merged underneath. The box is ticked and nothing shipped. The dangerous direction, and the one that is easy to forget to look for. - unowned — criteria no roadmap node claims at all. They cannot appear in either list above, so without a bucket of their own they would vanish inside a clean-looking reconciliation. Nothing is written. `met` stays exactly as a human or agent set it: deriving it from task state would assert that finishing the tasks means delivering the vision, which is the assumption under audit. The mismatch is reported, not silently resolved. Attribution is by criterion id, because that is the only link the schema offers — EpicTask.doneCriteria is documented as "the decomposed subset of the Epic's doneCriteria this node owns" and carries the same ids, but nothing enforces it. Rather than guess an owner by name similarity or ordering, the convention is treated as a claim and its failures are surfaced as `unowned`. A foreign key from criterion to epictask would end the ambiguity; this reports the ambiguity instead of hiding it. Judgement calls, made explicit so they can be argued with: - `merged` alone counts as delivered. `done` only leaves the developer's loop and `awaiting_review` still needs a human; `reopened` means a merge did not hold, so it counts as outstanding. - `cancelled` is neither delivered nor outstanding. Counting it as owed would leave a node permanently undelivered because someone abandoned a task; counting it as delivered is a lie. It is reported on its own. - a node with no tasks is undelivered, not exempt — a met criterion over an undecomposed node is still a claim with nothing behind it. The empty task rollup is what distinguishes it from "built but not merged". - an epictask's own `status` is another claim, not evidence, so it is carried for context and never counted as delivery. Surfaced on GET /api/v1/epics/:id next to the completeness it contradicts — a mismatch is only useful where the misleading percentage is already on screen — and standalone at GET /api/v1/epics/:id/reconciliation for callers that do not want a 200KB Epic body with it. Deliberately kept off the list route, which would pay a query per roadmap node per Epic. Co-Authored-By: Claude Opus 5 (1M context) --- src/mcpd/src/main.ts | 2 +- src/mcpd/src/routes/epics.ts | 18 +- src/mcpd/src/services/epic.service.ts | 213 ++++++++++++++- src/mcpd/tests/epic-routes.test.ts | 79 +++++- src/mcpd/tests/services/epic-service.test.ts | 258 +++++++++++++++++- .../tests/smoke/agentic-teams.smoke.test.ts | 50 ++++ 6 files changed, 610 insertions(+), 10 deletions(-) diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index 3beb8d0..ac96fe5 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -587,7 +587,7 @@ async function main(): Promise { // Agentic teams: the Epic is the versioned vision every task walks up to. // Revision-tracked so scope creep is as visible as under-delivery. - const epicService = new EpicService(epicRepo, projectRepo); + const epicService = new EpicService(epicRepo, projectRepo, epicTaskRepo, workTaskRepo); epicService.setRevisionService(resourceRevisionService); const epicTaskService = new EpicTaskService(epicTaskRepo, epicRepo); const workTaskService = new TaskService(workTaskRepo, epicTaskRepo); diff --git a/src/mcpd/src/routes/epics.ts b/src/mcpd/src/routes/epics.ts index 0ecf7b4..68a7da8 100644 --- a/src/mcpd/src/routes/epics.ts +++ b/src/mcpd/src/routes/epics.ts @@ -37,8 +37,24 @@ export function registerEpicRoutes( }, ); + // Reconciliation rides along with the single Epic read, next to the + // completeness it contradicts. Kept off the list route on purpose: it costs a + // query per roadmap node, and a mismatch nobody is looking at is only useful + // where the misleading percentage is already on screen. app.get<{ Params: { id: string } }>('/api/v1/epics/:id', async (request) => { - return withCompleteness(await service.getEpic(request.params.id)); + const epic = await service.getEpic(request.params.id); + return { + ...withCompleteness(epic), + reconciliation: await service.reconciliation(epic.id), + }; + }); + + /** + * Claimed vs delivered, on its own — for callers that want the mismatch + * without dragging along an Epic body that can run to 200KB of content. + */ + app.get<{ Params: { id: string } }>('/api/v1/epics/:id/reconciliation', async (request) => { + return service.reconciliation(request.params.id); }); // ── Create / Update / Delete ── diff --git a/src/mcpd/src/services/epic.service.ts b/src/mcpd/src/services/epic.service.ts index 90b1530..9e5c2ff 100644 --- a/src/mcpd/src/services/epic.service.ts +++ b/src/mcpd/src/services/epic.service.ts @@ -1,7 +1,9 @@ -import type { Epic, EpicStatus, Prisma } from '@prisma/client'; +import type { Epic, EpicStatus, EpicTask, EpicTaskStatus, Prisma, Task } from '@prisma/client'; import type { IEpicRepository } from '../repositories/epic.repository.js'; +import type { IEpicTaskRepository } from '../repositories/epic-task.repository.js'; import type { IProjectRepository } from '../repositories/project.repository.js'; +import type { ITaskRepository } from '../repositories/task.repository.js'; import { CreateEpicSchema, UpdateEpicSchema, @@ -23,12 +25,102 @@ export interface EpicCompleteness { unmet: DoneCriterion[]; } +// ── Reconciliation types ── + +/** How much work sits under something, split by whether it actually landed. */ +export interface TaskRollup { + /** Tasks in `merged` — the only state that means it is in the product. */ + merged: number; + /** + * Neither merged nor cancelled: still owed. `reopened` counts here, because a + * post-merge failure means the delivery did not hold. + */ + outstanding: number; + /** Abandoned. Counted apart: not delivered, but no longer owed either. */ + cancelled: number; +} + +/** A roadmap node that claims a criterion, plus what it has actually landed. */ +export interface OwningEpicTask extends TaskRollup { + id: string; + name: string; + /** The node's own claim about itself. Recorded, never used as evidence. */ + status: EpicTaskStatus; +} + +/** One Epic criterion set against the work delivered under the nodes that own it. */ +export interface CriterionDelivery extends TaskRollup { + id: string; + text: string; + kind: DoneCriterion['kind']; + /** The claim — set by a human or agent. Never inferred from task state. */ + met: boolean; + /** Nodes owning this criterion. Empty means nothing can deliver it. */ + epicTasks: OwningEpicTask[]; +} + +/** + * Claimed completeness measured against delivered work. + * + * Both mismatch directions are reported and neither is resolved: a criterion's + * `met` flag is a judgement, and inferring it from task state would launder the + * exact assumption this comparison exists to challenge. + */ +export interface EpicReconciliation { + epicId: string; + /** + * Unmet criteria whose owning node has merged work. Somebody delivered and + * nobody ticked the box, so the Epic under-reports itself. + */ + deliveredButUnclaimed: CriterionDelivery[]; + /** + * Criteria marked met with nothing merged underneath. The box is ticked and + * nothing shipped — the dangerous direction, and the easy one to forget. + */ + claimedButUndelivered: CriterionDelivery[]; + /** + * Criteria no roadmap node claims. They cannot appear in either list above + * because nothing is positioned to deliver them; reporting them separately + * keeps that gap from hiding inside a clean-looking reconciliation. + */ + unowned: CriterionDelivery[]; + /** Every task under the Epic, so the mismatch has a denominator. */ + tasks: TaskRollup & { total: number }; + /** True only when all three lists are empty. */ + consistent: boolean; +} + +/** + * The fields the reconciliation rule actually reads. Structural rather than the + * full Prisma rows so the pure function stays callable with a small fixture. + */ +export type ReconcileNode = Pick & { doneCriteria: unknown }; +export type ReconcileTask = Pick; + function badRequest(message: string): Error { return Object.assign(new Error(message), { statusCode: 400 }); } -function readCriteria(epic: Epic): DoneCriterion[] { - return (epic.doneCriteria ?? []) as unknown as DoneCriterion[]; +function readCriteria(source: { doneCriteria: unknown }): DoneCriterion[] { + return (source.doneCriteria ?? []) as DoneCriterion[]; +} + +function rollup(tasks: ReconcileTask[]): TaskRollup { + let merged = 0; + let cancelled = 0; + for (const task of tasks) { + if (task.state === 'merged') merged += 1; + else if (task.state === 'cancelled') cancelled += 1; + } + return { merged, outstanding: tasks.length - merged - cancelled, cancelled }; +} + +function sumRollup(parts: TaskRollup[]): TaskRollup { + return { + merged: parts.reduce((n, p) => n + p.merged, 0), + outstanding: parts.reduce((n, p) => n + p.outstanding, 0), + cancelled: parts.reduce((n, p) => n + p.cancelled, 0), + }; } function readTradeoffs(epic: Epic): AcceptedTradeoff[] { @@ -41,6 +133,11 @@ export class EpicService { constructor( private readonly epicRepo: IEpicRepository, private readonly projectRepo: IProjectRepository, + // Reconciliation reads down the whole chain — Epic → EpicTask → Task — so + // the two lower repositories are required, not injected later. An Epic that + // cannot see its own delivered work is the blind spot, not a nice-to-have. + private readonly epicTaskRepo: IEpicTaskRepository, + private readonly taskRepo: ITaskRepository, ) {} setRevisionService(service: ResourceRevisionService): void { @@ -66,6 +163,116 @@ export class EpicService { }; } + // ── Reconciliation: what was claimed vs what was delivered ── + + /** + * Compare the Epic's claimed completeness against the work actually merged + * under its roadmap. + * + * `completeness()` above counts only `met` flags, and nothing flips those + * automatically — so an Epic whose every Task is merged still reads 0%. That + * is precisely the blind spot the Epic exists to remove, hence this second + * reading of the same question from the delivery side. + * + * **How a criterion is attributed to a node.** By criterion id, because that + * is the only link the schema offers: `EpicTask.doneCriteria` is documented + * as "the decomposed subset of the Epic's doneCriteria this node owns" and + * carries the same ids. Nothing enforces it — an id can be missing, shared by + * several nodes, or invented — so this treats the convention as a claim and + * reports where it does not hold (`unowned`) rather than guessing an owner by + * name similarity or ordering. A real foreign key would end the ambiguity. + * + * **Nothing is written.** `met` stays exactly as a human or agent set it; + * deriving it from task state would assert that finishing the tasks means + * delivering the vision, which is the assumption under audit. + */ + static reconcile( + epic: { id: string; doneCriteria: unknown }, + nodes: ReconcileNode[], + tasks: ReconcileTask[], + ): EpicReconciliation { + const tasksByNode = new Map(); + for (const task of tasks) { + tasksByNode.set(task.epicTaskId, [...(tasksByNode.get(task.epicTaskId) ?? []), task]); + } + + const ownersByCriterion = new Map(); + for (const node of nodes) { + const owner: OwningEpicTask = { + id: node.id, + name: node.name, + status: node.status, + ...rollup(tasksByNode.get(node.id) ?? []), + }; + // Deduplicated: nothing stops a node from listing the same id twice, and + // that must not double-count its tasks against the criterion. + for (const ownedId of new Set(readCriteria(node).map((c) => c.id))) { + ownersByCriterion.set(ownedId, [...(ownersByCriterion.get(ownedId) ?? []), owner]); + } + } + + const deliveredButUnclaimed: CriterionDelivery[] = []; + const claimedButUndelivered: CriterionDelivery[] = []; + const unowned: CriterionDelivery[] = []; + + for (const criterion of readCriteria(epic)) { + const epicTasks = ownersByCriterion.get(criterion.id) ?? []; + const entry: CriterionDelivery = { + id: criterion.id, + text: criterion.text, + // Restored backups write doneCriteria as raw JSON without going through + // the zod defaults, so neither field can be trusted to be present. + kind: criterion.kind ?? 'outcome', + met: criterion.met === true, + epicTasks, + ...sumRollup(epicTasks), + }; + + if (epicTasks.length === 0) { + // No node owns it, so no amount of merged work can speak for it. Note + // this catches a *met* orphan too — a box ticked with nothing on the + // roadmap even nominally responsible for it. + unowned.push(entry); + } else if (entry.met && entry.merged === 0) { + claimedButUndelivered.push(entry); + } else if (!entry.met && entry.merged > 0) { + deliveredButUnclaimed.push(entry); + } + } + + return { + epicId: epic.id, + deliveredButUnclaimed, + claimedButUndelivered, + unowned, + tasks: { ...rollup(tasks), total: tasks.length }, + consistent: + deliveredButUnclaimed.length === 0 && + claimedButUndelivered.length === 0 && + unowned.length === 0, + }; + } + + /** + * Load an Epic's roadmap and every task under it, then reconcile. + * + * One query per node rather than a join: a roadmap holds a handful of nodes, + * `ITaskRepository` already filters by `epicTaskId`, and widening that + * interface to save a round-trip on a single-Epic read is not worth the + * churn across its call sites. + */ + async reconciliation(id: string): Promise { + const epic = await this.getEpic(id); + const nodes = await this.epicTaskRepo.findAll(epic.id); + + const tasks: Task[] = []; + for (const node of nodes) { + tasks.push(...(await this.taskRepo.findAll({ epicTaskId: node.id }))); + } + + return EpicService.reconcile(epic, nodes, tasks); + } + // ── CRUD ── async listEpics(projectId?: string): Promise { diff --git a/src/mcpd/tests/epic-routes.test.ts b/src/mcpd/tests/epic-routes.test.ts index 1cc0c27..2244463 100644 --- a/src/mcpd/tests/epic-routes.test.ts +++ b/src/mcpd/tests/epic-routes.test.ts @@ -1,10 +1,12 @@ -import type { Epic, Project } from '@prisma/client'; +import type { Epic, EpicTask, Project, Task } from '@prisma/client'; import Fastify, { type FastifyInstance } from 'fastify'; import { describe, it, expect, vi, afterEach } from 'vitest'; import { errorHandler } from '../src/middleware/error-handler.js'; import type { IEpicRepository } from '../src/repositories/epic.repository.js'; +import type { IEpicTaskRepository } from '../src/repositories/epic-task.repository.js'; import type { IProjectRepository } from '../src/repositories/project.repository.js'; +import type { ITaskRepository, TaskFilters } from '../src/repositories/task.repository.js'; import { registerEpicRoutes } from '../src/routes/epics.js'; import { EpicService } from '../src/services/epic.service.js'; @@ -36,7 +38,19 @@ const makeProject = (o: Partial = {}): Project => const criterion = (id: string, met: boolean) => ({ id, text: `criterion ${id}`, kind: 'outcome' as const, met }); -async function buildApp(epic: Epic | null = makeEpic(), userId?: string): Promise<{ +/** Roadmap node + work item, trimmed to the fields the reconciliation reads. */ +const node = (id: string, criterionIds: string[]): EpicTask => + ({ id, name: id, status: 'todo', doneCriteria: criterionIds.map((c) => criterion(c, false)) } as unknown as EpicTask); + +const workTask = (epicTaskId: string, state: Task['state']): Task => + ({ epicTaskId, state } as Task); + +async function buildApp( + epic: Epic | null = makeEpic(), + userId?: string, + nodes: EpicTask[] = [], + tasks: Task[] = [], +): Promise<{ app: FastifyInstance; epicRepo: IEpicRepository & { update: ReturnType }; }> { @@ -60,13 +74,24 @@ async function buildApp(epic: Epic | null = makeEpic(), userId?: string): Promis findByName: vi.fn(async (n: string) => (n === 'mcpctl' ? makeProject() : null)), } as unknown as IProjectRepository; + const epicTaskRepo = { findAll: vi.fn(async () => nodes) } as unknown as IEpicTaskRepository; + const taskRepo = { + findAll: vi.fn(async (filters: TaskFilters = {}) => + tasks.filter((t) => t.epicTaskId === filters.epicTaskId), + ), + } as unknown as ITaskRepository; + const instance = Fastify(); instance.setErrorHandler(errorHandler); // Stand in for the auth middleware, which normally sets request.userId. if (userId !== undefined) { instance.addHook('onRequest', async (req) => { req.userId = userId; }); } - registerEpicRoutes(instance, new EpicService(epicRepo, projectRepo), projectRepo); + registerEpicRoutes( + instance, + new EpicService(epicRepo, projectRepo, epicTaskRepo, taskRepo), + projectRepo, + ); await instance.ready(); return { app: instance, epicRepo }; } @@ -90,6 +115,54 @@ describe('epic routes', () => { expect(one.json().completeness.unmet).toHaveLength(1); }); + it('puts the delivered-vs-claimed mismatch next to the misleading percentage', async () => { + // 0% claimed, everything merged — reading one without the other is the bug. + const built = await buildApp( + makeEpic({ doneCriteria: [criterion('wheels', false)] as never }), + undefined, + [node('n1', ['wheels'])], + [workTask('n1', 'merged')], + ); + app = built.app; + + const res = await app.inject({ method: 'GET', url: '/api/v1/epics/epic-1' }); + expect(res.statusCode).toBe(200); + expect(res.json().completeness.percent).toBe(0); + expect(res.json().reconciliation.deliveredButUnclaimed.map((c: { id: string }) => c.id)) + .toEqual(['wheels']); + expect(res.json().reconciliation.consistent).toBe(false); + }); + + it('serves the reconciliation on its own, without the Epic body', async () => { + const built = await buildApp( + makeEpic({ doneCriteria: [criterion('engine', true)] as never }), + undefined, + [node('n1', ['engine'])], + [], + ); + app = built.app; + + const res = await app.inject({ method: 'GET', url: '/api/v1/epics/epic-1/reconciliation' }); + expect(res.statusCode).toBe(200); + expect(res.json().content).toBeUndefined(); + expect(res.json().claimedButUndelivered.map((c: { id: string }) => c.id)).toEqual(['engine']); + }); + + it('404s the reconciliation of an unknown Epic', async () => { + const built = await buildApp(null); + app = built.app; + const res = await app.inject({ method: 'GET', url: '/api/v1/epics/ghost/reconciliation' }); + expect(res.statusCode).toBe(404); + }); + + it('keeps the list route free of reconciliation — one query per node is not list work', async () => { + const built = await buildApp(makeEpic({ doneCriteria: [criterion('a', false)] as never })); + app = built.app; + const res = await app.inject({ method: 'GET', url: '/api/v1/epics' }); + expect(res.json()[0].completeness).toBeDefined(); + expect(res.json()[0].reconciliation).toBeUndefined(); + }); + it('creates from a project name and stamps the caller as owner', async () => { const built = await buildApp(null, 'user-42'); app = built.app; diff --git a/src/mcpd/tests/services/epic-service.test.ts b/src/mcpd/tests/services/epic-service.test.ts index f13a7bb..5485e6b 100644 --- a/src/mcpd/tests/services/epic-service.test.ts +++ b/src/mcpd/tests/services/epic-service.test.ts @@ -2,8 +2,11 @@ import type { Epic, Project } from '@prisma/client'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { IEpicRepository } from '../../src/repositories/epic.repository.js'; +import type { IEpicTaskRepository } from '../../src/repositories/epic-task.repository.js'; import type { IProjectRepository } from '../../src/repositories/project.repository.js'; +import type { ITaskRepository, TaskFilters } from '../../src/repositories/task.repository.js'; import { EpicService } from '../../src/services/epic.service.js'; +import type { ReconcileNode, ReconcileTask } from '../../src/services/epic.service.js'; function makeEpic(overrides: Partial = {}): Epic { return { @@ -28,7 +31,27 @@ function makeEpic(overrides: Partial = {}): Epic { const criterion = (id: string, met: boolean, kind: 'outcome' | 'quality' = 'outcome') => ({ id, text: `criterion ${id}`, kind, met }); -function mockRepos(epic: Epic | null = makeEpic()) { +/** A roadmap node, trimmed to what the reconciliation rule reads. */ +const node = ( + id: string, + criterionIds: string[], + overrides: Partial = {}, +): ReconcileNode => ({ + id, + name: id, + status: 'todo', + doneCriteria: criterionIds.map((c) => criterion(c, false)), + ...overrides, +}); + +const task = (epicTaskId: string, state: ReconcileTask['state']): ReconcileTask => + ({ epicTaskId, state }); + +function mockRepos( + epic: Epic | null = makeEpic(), + nodes: ReconcileNode[] = [], + tasks: ReconcileTask[] = [], +) { let current = epic; const update = vi.fn(async (_id: string, data: Record) => { current = { ...(current as Epic), ...data } as Epic; @@ -49,7 +72,25 @@ function mockRepos(epic: Epic | null = makeEpic()) { findByName: vi.fn(async () => ({ id: 'proj-1', name: 'mcpctl' } as Project)), } as unknown as IProjectRepository; - return { epicRepo, projectRepo, service: new EpicService(epicRepo, projectRepo) }; + const epicTaskRepo = { findAll: vi.fn(async () => nodes) }; + const taskRepo = { + findAll: vi.fn(async (filters: TaskFilters = {}) => + tasks.filter((t) => t.epicTaskId === filters.epicTaskId), + ), + }; + + return { + epicRepo, + projectRepo, + epicTaskRepo, + taskRepo, + service: new EpicService( + epicRepo, + projectRepo, + epicTaskRepo as unknown as IEpicTaskRepository, + taskRepo as unknown as ITaskRepository, + ), + }; } describe('EpicService.completeness', () => { @@ -78,6 +119,219 @@ describe('EpicService.completeness', () => { }); }); +describe('EpicService.reconcile — claimed vs delivered', () => { + const ids = (list: Array<{ id: string }>) => list.map((c) => c.id); + + it('flags work that was delivered and never claimed', () => { + // The motivating bug: every task merged, the Epic still reads 0%. + const epic = { id: 'epic-1', doneCriteria: [criterion('wheels', false)] }; + const result = EpicService.reconcile( + epic, + [node('n1', ['wheels'])], + [task('n1', 'merged'), task('n1', 'merged')], + ); + + expect(ids(result.deliveredButUnclaimed)).toEqual(['wheels']); + expect(result.deliveredButUnclaimed[0]?.merged).toBe(2); + expect(result.claimedButUndelivered).toEqual([]); + expect(result.consistent).toBe(false); + }); + + it('flags a criterion claimed with nothing merged under it', () => { + const epic = { id: 'epic-1', doneCriteria: [criterion('engine', true)] }; + const result = EpicService.reconcile( + epic, + [node('n1', ['engine'])], + [task('n1', 'awaiting_review')], + ); + + expect(ids(result.claimedButUndelivered)).toEqual(['engine']); + expect(result.claimedButUndelivered[0]).toMatchObject({ merged: 0, outstanding: 1 }); + }); + + it('flags a claim over a node that has no tasks at all', () => { + // Not-yet-decomposed is still nothing shipped — same bucket, and the empty + // rollup is what tells the two apart. + const epic = { id: 'epic-1', doneCriteria: [criterion('engine', true)] }; + const result = EpicService.reconcile(epic, [node('n1', ['engine'])], []); + + expect(ids(result.claimedButUndelivered)).toEqual(['engine']); + expect(result.claimedButUndelivered[0]).toMatchObject({ + merged: 0, + outstanding: 0, + cancelled: 0, + }); + }); + + it('reports both directions from one pass over a mixed Epic', () => { + const epic = { + id: 'epic-1', + doneCriteria: [ + criterion('wheels', false), // delivered, unclaimed + criterion('engine', true), // claimed, nothing merged + criterion('paint', true), // claimed and delivered — agrees + criterion('seats', false), // neither — just not done yet + ], + }; + const result = EpicService.reconcile( + epic, + [node('n1', ['wheels']), node('n2', ['engine']), node('n3', ['paint']), node('n4', ['seats'])], + [task('n1', 'merged'), task('n2', 'in_progress'), task('n3', 'merged'), task('n4', 'todo')], + ); + + expect(ids(result.deliveredButUnclaimed)).toEqual(['wheels']); + expect(ids(result.claimedButUndelivered)).toEqual(['engine']); + expect(result.unowned).toEqual([]); + expect(result.tasks).toEqual({ merged: 2, outstanding: 2, cancelled: 0, total: 4 }); + }); + + it('reports criteria no roadmap node owns, instead of silently dropping them', () => { + // There is no foreign key from criterion to epictask — only the id + // convention. When it does not hold, say so rather than guessing an owner. + const epic = { + id: 'epic-1', + doneCriteria: [criterion('orphan', false), criterion('claimed-orphan', true)], + }; + const result = EpicService.reconcile(epic, [node('n1', ['something-else'])], [task('n1', 'merged')]); + + expect(ids(result.unowned)).toEqual(['orphan', 'claimed-orphan']); + // A met criterion nothing is even nominally responsible for must not be + // laundered into the "agrees" pile by having no owner to contradict it. + expect(result.unowned[1]?.met).toBe(true); + expect(result.deliveredButUnclaimed).toEqual([]); + expect(result.claimedButUndelivered).toEqual([]); + expect(result.consistent).toBe(false); + }); + + it('treats cancelled tasks as neither delivered nor outstanding', () => { + // Counting them as owed would leave an epictask permanently undelivered + // because somebody abandoned a task; counting them as delivered is a lie. + const epic = { id: 'epic-1', doneCriteria: [criterion('engine', true)] }; + const result = EpicService.reconcile( + epic, + [node('n1', ['engine'])], + [task('n1', 'cancelled'), task('n1', 'cancelled')], + ); + + expect(ids(result.claimedButUndelivered)).toEqual(['engine']); + expect(result.tasks).toEqual({ merged: 0, outstanding: 0, cancelled: 2, total: 2 }); + }); + + it('does not count a reopened task as delivered — the merge did not hold', () => { + const epic = { id: 'epic-1', doneCriteria: [criterion('engine', true)] }; + const result = EpicService.reconcile(epic, [node('n1', ['engine'])], [task('n1', 'reopened')]); + + expect(ids(result.claimedButUndelivered)).toEqual(['engine']); + expect(result.tasks.outstanding).toBe(1); + }); + + it('sums across every node that claims the same criterion', () => { + const epic = { id: 'epic-1', doneCriteria: [criterion('wheels', false)] }; + const result = EpicService.reconcile( + epic, + [node('n1', ['wheels']), node('n2', ['wheels'])], + [task('n1', 'todo'), task('n2', 'merged')], + ); + + // One entry, not two, and delivered because one owner landed work. + expect(result.deliveredButUnclaimed).toHaveLength(1); + expect(result.deliveredButUnclaimed[0]?.epicTasks.map((n) => n.id)).toEqual(['n1', 'n2']); + expect(result.deliveredButUnclaimed[0]).toMatchObject({ merged: 1, outstanding: 1 }); + }); + + it('ignores an epictask status claim — only merged tasks count as delivery', () => { + // The node says done. Nothing merged. That is the mismatch, not evidence. + const epic = { id: 'epic-1', doneCriteria: [criterion('engine', true)] }; + const result = EpicService.reconcile( + epic, + [node('n1', ['engine'], { status: 'done' })], + [task('n1', 'awaiting_review')], + ); + + expect(ids(result.claimedButUndelivered)).toEqual(['engine']); + expect(result.claimedButUndelivered[0]?.epicTasks[0]?.status).toBe('done'); + }); + + it('never flips met — it reports the gap and leaves the judgement alone', () => { + const epic = { id: 'epic-1', doneCriteria: [criterion('wheels', false)] }; + const result = EpicService.reconcile(epic, [node('n1', ['wheels'])], [task('n1', 'merged')]); + + expect(result.deliveredButUnclaimed[0]?.met).toBe(false); + expect(epic.doneCriteria[0]?.met).toBe(false); + }); + + it('is consistent only when nothing disagrees', () => { + const epic = { id: 'epic-1', doneCriteria: [criterion('wheels', true)] }; + const result = EpicService.reconcile(epic, [node('n1', ['wheels'])], [task('n1', 'merged')]); + + expect(result.consistent).toBe(true); + expect(result.deliveredButUnclaimed).toEqual([]); + expect(result.claimedButUndelivered).toEqual([]); + expect(result.unowned).toEqual([]); + }); + + it('is consistent on an Epic with no criteria and no work', () => { + expect(EpicService.reconcile({ id: 'epic-1', doneCriteria: [] }, [], []).consistent).toBe(true); + }); + + it('survives criteria restored as raw JSON without kind or met', () => { + // upsertByName writes doneCriteria straight from a backup document, so the + // zod defaults never ran. + const epic = { id: 'epic-1', doneCriteria: [{ id: 'wheels', text: 'has wheels' }] }; + const result = EpicService.reconcile( + epic, + [{ id: 'n1', name: 'n1', status: 'todo', doneCriteria: [{ id: 'wheels' }] }], + [task('n1', 'merged')], + ); + + expect(result.deliveredButUnclaimed[0]).toMatchObject({ kind: 'outcome', met: false }); + }); + + it('tolerates a null doneCriteria column on either side', () => { + const result = EpicService.reconcile( + { id: 'epic-1', doneCriteria: null }, + [{ id: 'n1', name: 'n1', status: 'todo', doneCriteria: null }], + [task('n1', 'merged')], + ); + expect(result.consistent).toBe(true); + expect(result.tasks.merged).toBe(1); + }); +}); + +describe('EpicService.reconciliation — loading the chain', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + it('walks Epic → epictasks → tasks and reconciles what it finds', async () => { + const { service, epicTaskRepo, taskRepo } = mockRepos( + makeEpic({ doneCriteria: [criterion('wheels', false)] as never }), + [node('n1', ['wheels'])], + [task('n1', 'merged')], + ); + + const result = await service.reconciliation('epic-1'); + + expect(epicTaskRepo.findAll).toHaveBeenCalledWith('epic-1'); + expect(taskRepo.findAll).toHaveBeenCalledWith({ epicTaskId: 'n1' }); + expect(result.deliveredButUnclaimed.map((c) => c.id)).toEqual(['wheels']); + }); + + it('writes nothing — reconciliation is a reading, not a repair', async () => { + const { service, epicRepo } = mockRepos( + makeEpic({ doneCriteria: [criterion('wheels', false)] as never }), + [node('n1', ['wheels'])], + [task('n1', 'merged')], + ); + + await service.reconciliation('epic-1'); + expect(epicRepo.update).not.toHaveBeenCalled(); + }); + + it('404s on an unknown Epic', async () => { + const { service } = mockRepos(null); + await expect(service.reconciliation('ghost')).rejects.toThrow(/Epic not found/); + }); +}); + describe('EpicService.setStatus — the honest-done rule', () => { beforeEach(() => { vi.clearAllMocks(); }); diff --git a/src/mcplocal/tests/smoke/agentic-teams.smoke.test.ts b/src/mcplocal/tests/smoke/agentic-teams.smoke.test.ts index b208b4e..74e1129 100644 --- a/src/mcplocal/tests/smoke/agentic-teams.smoke.test.ts +++ b/src/mcplocal/tests/smoke/agentic-teams.smoke.test.ts @@ -169,4 +169,54 @@ describe('agentic teams smoke', () => { expect(withNote.code, withNote.stderr || withNote.stdout).toBe(0); expect(withNote.stdout).toMatch(/rejected → in_progress/); }); + + it('reports work delivered but never claimed, and does not tick the box itself', () => { + if (!up) return; + + // Give the roadmap node the Epic criterion it owns. There is no + // `create epictask --criterion` flag, so apply is the only way in. + const path = join(workDir as string, 'epictask-criteria.yaml'); + writeFileSync( + path, + `kind: epictask\nname: ${EPICTASK}\nepic: ${EPIC}\n` + + 'doneCriteria:\n - id: builds\n text: it builds\n kind: outcome\n met: false\n', + 'utf-8', + ); + const applied = run(`apply -f ${path}`); + expect(applied.code, applied.stderr || applied.stdout).toBe(0); + + // Drive the rejected task all the way to merged. The QA verdict recorded + // earlier still stands, so the risk gate is already satisfied. + for (const state of ['building', 'self_testing', 'done', 'awaiting_review']) { + const r = run(`tasks advance ${TASK} ${state}`); + expect(r.code, `${state}: ${r.stderr || r.stdout}`).toBe(0); + } + const accepted = run(`tasks accept ${TASK} --note "ship it"`); + expect(accepted.code, accepted.stderr || accepted.stdout).toBe(0); + + const json = run(`get epic ${EPIC} -o json`); + expect(json.code, json.stderr).toBe(0); + const [epic] = JSON.parse(json.stdout) as Array<{ + completeness: { percent: number; met: number }; + reconciliation: { + deliveredButUnclaimed: Array<{ id: string; met: boolean; merged: number }>; + unowned: Array<{ id: string }>; + consistent: boolean; + }; + }>; + + // The blind spot, stated out loud: everything merged, still 0% claimed. + expect(epic?.completeness.percent).toBe(0); + expect(epic?.reconciliation.deliveredButUnclaimed.map((c) => c.id)).toEqual(['builds']); + expect(epic?.reconciliation.deliveredButUnclaimed[0]?.merged).toBe(1); + expect(epic?.reconciliation.consistent).toBe(false); + + // The quality criterion no node claimed is reported as unowned rather than + // quietly counted as either delivered or outstanding. + expect(epic?.reconciliation.unowned).toHaveLength(1); + + // And the judgement is still the human's: reporting the gap must not close it. + expect(epic?.completeness.met).toBe(0); + expect(epic?.reconciliation.deliveredButUnclaimed[0]?.met).toBe(false); + }); }); -- 2.49.1 From f2239852a13dddc47b5a132b285d455e82e11497 Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 9 Aug 2026 22:09:27 +0100 Subject: [PATCH 20/32] feat(cli): --criterion on create epictask, and file the reconciliation follow-ups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merges the Epic reconciliation (task 170) and closes the gap that made it inert from the terminal. `mcpctl create epictask` had no `--criterion` flag while `create epic` did, so a roadmap built from the CLI had no criteria on its nodes. The reconciliation attributes a criterion to a node by id, so every criterion would have reported as `unowned` and both mismatch lists would always have been empty — the feature would have looked like it worked and detected nothing. Files two follow-ups the reconciliation surfaced: - 175: criteria and roadmap nodes are linked only by a free-form id convention that nothing enforces. EpicTaskService never validates ids against the parent Epic, ids can repeat across nodes, and a node can carry an id the Epic never had. The reconciliation reports those as `unowned` rather than guessing an owner, which is right, but the convention should become an invariant before more is built on it. - 176: `get epic -o yaml` now emits computed `completeness`/`reconciliation`, which are not settable. apply tolerates them, but the round-trip rule says get output is apply input. Local: build clean, workspace 2499 passed, lint unchanged at 794 errors. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p --- .taskmaster/tasks/tasks.json | 38 +++++++++++++++++++++++++++++----- src/cli/src/commands/create.ts | 13 ++++++++++++ 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/.taskmaster/tasks/tasks.json b/.taskmaster/tasks/tasks.json index ed8a527..239e6de 100644 --- a/.taskmaster/tasks/tasks.json +++ b/.taskmaster/tasks/tasks.json @@ -3358,9 +3358,9 @@ "131", "126" ], - "status": "pending", + "status": "done", "subtasks": [], - "updatedAt": "2026-08-09T19:36:34.052Z" + "updatedAt": "2026-08-09T21:09:27.647Z" }, { "id": "171", @@ -3415,16 +3415,44 @@ "status": "pending", "subtasks": [], "updatedAt": "2026-08-09T20:50:38.660Z" + }, + { + "id": "175", + "title": "Give criteria a real link to the roadmap node that delivers them", + "description": "Epic.doneCriteria and EpicTask.doneCriteria are connected only by a free-form id convention that nothing enforces. The reconciliation added in 170 depends on it.", + "details": "Found while building the reconciliation. EpicTaskService.create/update never validates criterion ids against the parent Epic; ids can be duplicated across nodes, and a node can carry an id the Epic never had. Every failure of the convention currently surfaces as `unowned`.\n\nOptions:\n- an EpicCriterion table with a real FK (strongest, biggest change);\n- validate on epictask write that each doneCriteria[].id exists on the parent Epic, turning the convention into an invariant, plus a startup check for existing rows (cheapest honest fix).\n\nDecide before more is built on top of the convention.", + "testStrategy": "See details.", + "priority": "high", + "dependencies": [ + "170" + ], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-09T21:09:27.819Z" + }, + { + "id": "176", + "title": "Keep computed fields out of the get -o yaml round-trip", + "description": "`get epic X -o yaml` now emits `completeness` and `reconciliation`, which are computed, not settable. apply -f tolerates them (zod ignores unknown keys) but the round-trip rule says get output is apply input.", + "details": "Stripping them in stripInternalFields was tried and reverted: an existing smoke assertion reads `completeness` out of `get epics -o json`, and stripping would also hide the new mismatch from the CLI entirely.\n\nThe real fix is probably to keep computed fields in -o json but drop them from -o yaml, and surface the mismatch in `describe epic` (task 167) rather than in the apply document.", + "testStrategy": "See details.", + "priority": "medium", + "dependencies": [ + "167" + ], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-09T21:09:27.819Z" } ], "metadata": { "version": "1.0.0", - "lastModified": "2026-08-09T20:55:31.468Z", + "lastModified": "2026-08-09T21:09:27.647Z", "taskCount": 174, - "completedCount": 129, + "completedCount": 130, "tags": [ "master" ] } } -} \ No newline at end of file +} diff --git a/src/cli/src/commands/create.ts b/src/cli/src/commands/create.ts index cab750f..ed9ab5c 100644 --- a/src/cli/src/commands/create.ts +++ b/src/cli/src/commands/create.ts @@ -179,11 +179,24 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { .requiredOption('--epic ', 'Parent Epic') .option('--project ', 'Disambiguate the Epic by project') .option('--description ', 'Description') + .option('--criterion ', 'Done-criterion "id=text" (repeat); the id must match one on the parent Epic, and prefix with q: for a quality criterion', collect, []) .option('--depends-on ', 'Prerequisite epictask in the same Epic (repeat)', collect, []) .action(async (name: string, opts) => { const body: Record = { name, epic: opts.epic }; if (opts.project !== undefined) body.project = opts.project; if (opts.description !== undefined) body.description = opts.description; + // Without this a CLI-built roadmap has no criteria on its nodes, so the + // Epic reconciliation reports every criterion as `unowned` and both + // mismatch lists are always empty — the feature is inert from the terminal. + const doneCriteria = (opts.criterion as string[]).map((raw) => { + const eq = raw.indexOf('='); + if (eq === -1) throw new Error(`Invalid --criterion '${raw}'. Expected id=text`); + let id = raw.slice(0, eq); + const quality = id.startsWith('q:'); + if (quality) id = id.slice(2); + return { id, text: raw.slice(eq + 1), kind: quality ? 'quality' : 'outcome', met: false }; + }); + if (doneCriteria.length > 0) body.doneCriteria = doneCriteria; const created = await client.post<{ id: string; name: string }>('/api/v1/epictasks', body); log(`Created epictask '${created.name}'`); for (const dep of opts.dependsOn as string[]) { -- 2.49.1 From fc524583b18489711086ca55227855950cd4af95 Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 9 Aug 2026 22:09:56 +0100 Subject: [PATCH 21/32] chore(completions): regenerate after the epictask --criterion flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard test caught it, which is its job — the generator derives flags from the commander program, so any new option makes the committed files stale. Co-Authored-By: Claude Opus 5 (1M context) --- completions/mcpctl.bash | 2 +- completions/mcpctl.fish | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index 8b4f96d..7363208 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -230,7 +230,7 @@ _mcpctl() { COMPREPLY=($(compgen -W "--project --content --content-file --description --criterion -h --help" -- "$cur")) ;; epictask) - COMPREPLY=($(compgen -W "--epic --project --description --depends-on -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "--epic --project --description --criterion --depends-on -h --help" -- "$cur")) ;; task) COMPREPLY=($(compgen -W "--epictask --title --description --risk --repo -h --help" -- "$cur")) diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index ec047eb..68e7356 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -385,6 +385,7 @@ complete -c mcpctl -n "__mcpctl_subcmd_active create epic" -l criterion -d 'Done complete -c mcpctl -n "__mcpctl_subcmd_active create epictask" -l epic -d 'Parent Epic' -x complete -c mcpctl -n "__mcpctl_subcmd_active create epictask" -l project -d 'Disambiguate the Epic by project' -xa '(__mcpctl_project_names)' complete -c mcpctl -n "__mcpctl_subcmd_active create epictask" -l description -d 'Description' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create epictask" -l criterion -d 'Done-criterion "id=text" (repeat); the id must match one on the parent Epic, and prefix with q: for a quality criterion' -x complete -c mcpctl -n "__mcpctl_subcmd_active create epictask" -l depends-on -d 'Prerequisite epictask in the same Epic (repeat)' -x # create task options -- 2.49.1 From 1a28cc50985538425a4d057666fa18e3fac91b1a Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 9 Aug 2026 22:14:45 +0100 Subject: [PATCH 22/32] =?UTF-8?q?feat(mcpd):=20make=20AgentProject=20real?= =?UTF-8?q?=20=E2=80=94=20an=20agent=20joins=20many=20projects?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AgentProject junction has existed since the data-model commit and nothing read it. `grep -c agentProject src/mcpd/src/services/chat.service.ts` returned 0; chat resolved an agent's project from the legacy single `Agent.projectId` scalar. So the schema claimed "an agent is a person who joins zero, one, or many projects" (docs/agentic-teams.md §4) while the running system still gave every agent exactly one project. A schema that lies about behaviour is worse than a missing feature: it reads as done, so nobody builds it. This makes the junction load-bearing. Membership is now a real surface (task 152): GET /api/v1/agents/:name/projects memberships POST /api/v1/agents/:name/projects/:project join, body {role?, llm?} DELETE /api/v1/agents/:name/projects/:project leave GET /api/v1/projects/:name/memberships the roster, with roles `role` is TEXT (developer|qa|security|sre in v1) and is validated in the service layer, not the database — production provisions with `prisma db push`, so a CHECK constraint written into a migration would never run there. Adding a fifth role stays a one-line change. RBAC: joining/leaving is `edit:agents:`, not the `create:agents` the default method→action mapping would have produced. Minting a whole new agent and adding an existing one to a team are not the same authority; this mirrors project↔server attach/detach already being `edit:projects`. Reading a roster is `view:` on the side you asked from. Chat honours it. `prepareContext` resolves the project from the membership when the caller names one, and the resolution runs *before* the Llm lookup so a per-membership `llmId` can replace the agent's pinned brain — and therefore its whole failover pool — rather than patching the dispatch list afterwards. That override is why the brain-ownership question (§15 Q1) can still go either way without a migration: today the answer is per membership. Naming a project the agent has not joined is an error that names the fix, not a silent fallback. Answering from a *different* project's tools and prompts than the one asked for is the failure mode worth being loud about. The legacy scalar keeps working this release, as the schema comment promises: `--project` accepts a membership *or* a legacy pin naming that same project, bare `mcpctl chat ` is unchanged, and `GET /projects/:p/agents` returns the union of members and legacy-pinned agents so nothing disappears from a project mid-migration (mcplocal's agents plugin reads that endpoint). CLI: `mcpctl chat --project

` (previously rejected as "not both"), plus `attach-agent` / `detach-agent` alongside the existing attach-server pair, `get agents --project`, and `get memberships` with ROLE and BRAIN columns. Completions come from the generator, not hand-edits. Threads stay scoped to the agent, not to (agent, project) — §4 wants the latter, but that needs a schema change and a story for what happens to those threads when a membership is revoked. Deliberately not decided here. Tests: 51 new (43 mcpd, 8 CLI). Full suite 2514 pass. Lint unchanged at 794 errors / 77 warnings. Co-Authored-By: Claude Opus 5 (1M context) --- completions/mcpctl.bash | 31 ++- completions/mcpctl.fish | 68 +++++- docs/agents.md | 44 +++- docs/chat.md | 30 +++ scripts/generate-completions.ts | 102 ++++++++- src/cli/src/commands/chat.ts | 41 +++- src/cli/src/commands/get.ts | 21 ++ src/cli/src/commands/project-ops.ts | 47 ++++ src/cli/src/commands/shared.ts | 5 + src/cli/src/index.ts | 30 ++- .../tests/commands/agent-membership.test.ts | 121 ++++++++++ src/mcpd/src/main.ts | 36 ++- .../repositories/agent-project.repository.ts | 81 +++++++ src/mcpd/src/routes/agent-chat.ts | 3 +- src/mcpd/src/routes/agent-projects.ts | 95 ++++++++ .../src/services/agent-project.service.ts | 132 +++++++++++ src/mcpd/src/services/agent.service.ts | 98 +++++++- src/mcpd/src/services/chat.service.ts | 21 +- .../src/validation/agent-project.schema.ts | 32 +++ src/mcpd/src/validation/agent.schema.ts | 6 + src/mcpd/tests/agent-membership.test.ts | 198 ++++++++++++++++ src/mcpd/tests/agent-project-routes.test.ts | 103 +++++++++ .../tests/chat-service-membership.test.ts | 180 +++++++++++++++ .../tests/chat-service-virtual-llm.test.ts | 1 + src/mcpd/tests/chat-service.test.ts | 17 ++ .../services/agent-project-service.test.ts | 215 ++++++++++++++++++ 26 files changed, 1731 insertions(+), 27 deletions(-) create mode 100644 src/cli/tests/commands/agent-membership.test.ts create mode 100644 src/mcpd/src/repositories/agent-project.repository.ts create mode 100644 src/mcpd/src/routes/agent-projects.ts create mode 100644 src/mcpd/src/services/agent-project.service.ts create mode 100644 src/mcpd/src/validation/agent-project.schema.ts create mode 100644 src/mcpd/tests/agent-membership.test.ts create mode 100644 src/mcpd/tests/agent-project-routes.test.ts create mode 100644 src/mcpd/tests/chat-service-membership.test.ts create mode 100644 src/mcpd/tests/services/agent-project-service.test.ts diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index 8b4f96d..fdadfd5 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -6,10 +6,10 @@ _mcpctl() { _init_completion || return local commands="status login logout config repos tasks get describe delete logs create edit apply chat chat-llm patch passwd errors backup approve review skills console cache provider test migrate rotate" - local project_commands="get describe delete logs create edit attach-server detach-server favourites" + local project_commands="get describe delete logs create edit attach-server detach-server attach-agent detach-agent favourites" local global_opts="-v --version --daemon-url --direct -p --project -h --help" - local resources="servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks epics epictasks tasks repos gitproviders all" - local resource_aliases="servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks epics epictasks tasks repos gitproviders all server srv instance inst secret sec secretbackend sb llm agent personality template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm inference-task itask itasks epic epictask et task repo repository repositories gitprovider forge gp" + local resources="servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks epics epictasks tasks repos gitproviders memberships all" + local resource_aliases="servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks epics epictasks tasks repos gitproviders memberships all server srv instance inst secret sec secretbackend sb llm agent personality template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm inference-task itask itasks epic epictask et task repo repository repositories gitprovider forge gp membership team" # Check if --project/-p was given local has_project=false @@ -374,6 +374,31 @@ _mcpctl() { fi COMPREPLY=($(compgen -W "$names" -- "$cur")) return ;; + attach-agent) + if [[ $((cword - subcmd_pos)) -ne 1 ]]; then + COMPREPLY=($(compgen -W "--role --llm -h --help" -- "$cur")) + return + fi + local proj names all_agents proj_agents + proj=$(_mcpctl_get_project_value) + if [[ -n "$proj" ]]; then + all_agents=$(mcpctl get agents -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null) + proj_agents=$(mcpctl --project "$proj" get agents -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null) + names=$(comm -23 <(echo "$all_agents" | sort) <(echo "$proj_agents" | sort)) + else + names=$(_mcpctl_resource_names "agents") + fi + COMPREPLY=($(compgen -W "$names" -- "$cur")) + return ;; + detach-agent) + if [[ $((cword - subcmd_pos)) -ne 1 ]]; then return; fi + local proj names + proj=$(_mcpctl_get_project_value) + if [[ -n "$proj" ]]; then + names=$(mcpctl --project "$proj" get agents -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null) + fi + COMPREPLY=($(compgen -W "$names" -- "$cur")) + return ;; approve) if [[ -z "$resource_type" ]]; then COMPREPLY=($(compgen -W "promptrequest -h --help" -- "$cur")) diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index ec047eb..2df27b2 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -5,7 +5,7 @@ complete -c mcpctl -e set -l commands status login logout config repos tasks get describe delete logs create edit apply chat chat-llm patch passwd errors backup approve review skills console cache provider test migrate rotate -set -l project_commands get describe delete logs create edit attach-server detach-server favourites +set -l project_commands get describe delete logs create edit attach-server detach-server attach-agent detach-agent favourites # Disable file completions by default complete -c mcpctl -f @@ -31,10 +31,10 @@ function __mcpctl_has_project end # Resource type detection -set -l resources servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks epics epictasks tasks repos gitproviders all +set -l resources servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks epics epictasks tasks repos gitproviders memberships all function __mcpctl_needs_resource_type - set -l resource_aliases servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks epics epictasks tasks repos gitproviders all server srv instance inst secret sec secretbackend sb llm agent personality template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm inference-task itask itasks epic epictask et task repo repository repositories gitprovider forge gp + set -l resource_aliases servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks epics epictasks tasks repos gitproviders memberships all server srv instance inst secret sec secretbackend sb llm agent personality template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm inference-task itask itasks epic epictask et task repo repository repositories gitprovider forge gp membership team set -l tokens (commandline -opc) set -l found_cmd false for tok in $tokens @@ -78,13 +78,14 @@ function __mcpctl_resolve_resource case task tasks; echo tasks case repo repos repository repositories; echo repos case gitprovider gitproviders forge gp; echo gitproviders + case membership memberships team; echo memberships case all; echo all case '*'; echo $argv[1] end end function __mcpctl_get_resource_type - set -l resource_aliases servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks epics epictasks tasks repos gitproviders all server srv instance inst secret sec secretbackend sb llm agent personality template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm inference-task itask itasks epic epictask et task repo repository repositories gitprovider forge gp + set -l resource_aliases servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks epics epictasks tasks repos gitproviders memberships all server srv instance inst secret sec secretbackend sb llm agent personality template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm inference-task itask itasks epic epictask et task repo repository repositories gitprovider forge gp membership team set -l tokens (commandline -opc) set -l found_cmd false for tok in $tokens @@ -156,6 +157,31 @@ function __mcpctl_available_servers end end +# Agents already on the project team (for detach-agent) +function __mcpctl_project_agents + set -l proj (__mcpctl_get_project_value) + if test -z "$proj" + return + end + mcpctl --project $proj get agents -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null +end + +# Agents NOT yet on the project team (for attach-agent) +function __mcpctl_available_agents + set -l proj (__mcpctl_get_project_value) + if test -z "$proj" + mcpctl get agents -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null + return + end + set -l all (mcpctl get agents -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null) + set -l joined (mcpctl --project $proj get agents -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null) + for a in $all + if not contains -- $a $joined + echo $a + end + end +end + # Instance names for logs function __mcpctl_instance_names mcpctl get instances -o json 2>/dev/null | jq -r '.[][].server.name' 2>/dev/null @@ -202,6 +228,26 @@ function __mcpctl_needs_server_arg return 1 end +# Helper: check if attach-agent/detach-agent already has an agent argument +function __mcpctl_needs_agent_arg + set -l tokens (commandline -opc) + set -l found_cmd false + for tok in $tokens + if $found_cmd + if not string match -q -- '-*' $tok + return 1 # agent arg already present + end + end + if contains -- $tok attach-agent detach-agent + set found_cmd true + end + end + if $found_cmd + return 0 + end + return 1 +end + # Helper: check if a specific parent-child subcommand pair is active function __mcpctl_subcmd_active set -l parent $argv[1] @@ -263,6 +309,8 @@ complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a edit -d 'Edit a resource in your default editor (server, project)' complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a attach-server -d 'Attach a server to a project (requires --project)' complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a detach-server -d 'Detach a server from a project (requires --project)' +complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a attach-agent -d 'Add an agent to a project team (requires --project)' +complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a detach-agent -d 'Remove an agent from a project team (requires --project)' complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a favourites -d 'Inspect / derive a project\'s favourite-index tool shortlist (requires --project)' # Resource types — only when resource type not yet selected @@ -670,7 +718,7 @@ complete -c mcpctl -n "__fish_seen_subcommand_from apply" -s f -l file -d 'Path complete -c mcpctl -n "__fish_seen_subcommand_from apply" -l dry-run -d 'Validate and show changes without applying' # chat options -complete -c mcpctl -n "__fish_seen_subcommand_from chat" -s p -l project -d 'Chat scoped to a project: its prompts, tools, and (with --allow-secrets) secrets' -xa '(__mcpctl_project_names)' +complete -c mcpctl -n "__fish_seen_subcommand_from chat" -s p -l project -d 'With : view that project (agent must be a member). Alone: chat with the project itself' -xa '(__mcpctl_project_names)' complete -c mcpctl -n "__fish_seen_subcommand_from chat" -l allow-secrets -d 'Project chat: let the model read secret values via the get_secret tool (needs view:secrets)' complete -c mcpctl -n "__fish_seen_subcommand_from chat" -s m -l message -d 'One-shot: send a single message and exit (no REPL)' -x complete -c mcpctl -n "__fish_seen_subcommand_from chat" -l thread -d 'Resume an existing thread' -x @@ -699,6 +747,10 @@ complete -c mcpctl -n "__fish_seen_subcommand_from chat-llm" -l async -d 'Enqueu # errors options complete -c mcpctl -n "__fish_seen_subcommand_from errors" -s n -l limit -d 'max entries to show (default 50)' -x +# attach-agent options +complete -c mcpctl -n "__fish_seen_subcommand_from attach-agent" -l role -d 'Persona on this team: developer|qa|security|sre' -x +complete -c mcpctl -n "__fish_seen_subcommand_from attach-agent" -l llm -d 'Per-membership brain override (wins over the agent\'s pinned Llm in this project)' -x + # console options complete -c mcpctl -n "__fish_seen_subcommand_from console" -l stdin-mcp -d 'Run inspector as MCP server over stdin/stdout (for Claude)' complete -c mcpctl -n "__fish_seen_subcommand_from console" -l audit -d 'Browse audit events from mcpd' @@ -715,6 +767,12 @@ complete -c mcpctl -n "__fish_seen_subcommand_from attach-server; and __mcpctl_n # detach-server: show servers IN the project (only if no server arg yet) complete -c mcpctl -n "__fish_seen_subcommand_from detach-server; and __mcpctl_needs_server_arg" -a '(__mcpctl_project_servers)' -d 'Server' +# attach-agent: show agents NOT yet on the team (only if no agent arg yet) +complete -c mcpctl -n "__fish_seen_subcommand_from attach-agent; and __mcpctl_needs_agent_arg" -a '(__mcpctl_available_agents)' -d 'Agent' + +# detach-agent: show agents ON the team (only if no agent arg yet) +complete -c mcpctl -n "__fish_seen_subcommand_from detach-agent; and __mcpctl_needs_agent_arg" -a '(__mcpctl_project_agents)' -d 'Agent' + # apply: allow file completions for positional argument complete -c mcpctl -n "__fish_seen_subcommand_from apply" -F diff --git a/docs/agents.md b/docs/agents.md index eb1a3c8..c29e742 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -80,7 +80,11 @@ POST /api/v1/agents/:name/chat chat — non-streaming or SSE (run:agents POST /api/v1/agents/:name/threads create thread (run:agents:) GET /api/v1/agents/:name/threads list threads (run:agents:) GET /api/v1/threads/:id/messages replay history (view:agents) -GET /api/v1/projects/:p/agents project-scoped list (view:projects:

) +GET /api/v1/projects/:p/agents project roster — members + legacy pins (view:projects:

) +GET /api/v1/agents/:name/projects memberships (view:agents:) +POST /api/v1/agents/:name/projects/:p join, body {role?, llm?} (edit:agents:) +DELETE /api/v1/agents/:name/projects/:p leave (edit:agents:) +GET /api/v1/projects/:p/memberships roster with roles (view:projects:

) ``` The chat endpoint reuses the SSE pattern from `llm-infer.ts` exactly: same @@ -93,6 +97,37 @@ framing, same `[DONE]` terminator. SSE chunk types: - `{type:'final', threadId, turnIndex}` — terminal turn - `{type:'error', message}` — fatal error in the loop +## Team membership (an agent joins many projects) + +An agent is a **person**, not a project's property: it joins zero, one, or many +projects (`docs/agentic-teams.md` §4). Membership lives in the `AgentProject` +junction and carries two per-project facts the `Agent` row cannot: + +- **`role`** — the persona worn on this team. v1: `developer` (default), `qa`, + `security`, `sre`. TEXT in the schema, validated in the service layer, so + adding a role later is not a migration. +- **`llm`** — an optional **brain override**. When set, that Llm answers for + this project instead of the agent's pinned one; the agent keeps its own brain + everywhere else. + +```bash +mcpctl attach-agent alice --project sre --role sre --llm qwen3-thinking +mcpctl attach-agent alice --project payments --role security +mcpctl get memberships alice # every team alice is on +mcpctl get memberships --project sre # the sre roster, with roles +mcpctl get agents --project sre # the same roster as plain agent rows +mcpctl detach-agent alice --project sre +``` + +Joining is idempotent — re-running `attach-agent` updates the role/brain +instead of failing, so repeated applies converge. + +**Transition:** the legacy single `Agent.projectId` pin still works this +release. `mcpctl chat --project

` accepts a membership *or* a legacy +pin naming that same project; anything else is an error rather than a silent +answer from the wrong project's context. `GET /projects/:p/agents` returns the +union of both, so nothing disappears from a project mid-migration. + ## Tool-use loop When the agent's project has MCP servers attached, mcpd's `ChatService` lists @@ -113,10 +148,15 @@ Recommended: - `view:agents` — list / describe - `create:agents` / `edit:agents` / `delete:agents` — CRUD - `run:agents:` — drive a chat turn or manage its threads +- `edit:agents:` — join/leave a project team (NOT `create:agents`, which + mints whole agents; this mirrors project↔server attach/detach being `edit`) Project-attached agents do not implicitly inherit project RBAC. If a project member should be able to chat with the project's agents, grant them -`run:agents:` (or wildcard `run:agents`) explicitly. +`run:agents:` (or wildcard `run:agents`) explicitly. The reverse also +holds: membership grants the *agent* project context, not the *caller* project +permissions — `run:agents:` is still the only check on +`mcpctl chat --project

`. ## YAML round-trip diff --git a/docs/chat.md b/docs/chat.md index 1ece80b..8280fb2 100644 --- a/docs/chat.md +++ b/docs/chat.md @@ -8,6 +8,7 @@ create one. ```bash mcpctl chat # interactive REPL, new thread +mcpctl chat --project

# same agent, viewing project p mcpctl chat --thread # interactive REPL, resume thread mcpctl chat -m "hi" # one-shot, prints reply, no REPL mcpctl chat -m "hi" --no-stream # one-shot, single JSON response (no SSE) @@ -17,6 +18,35 @@ Streaming is on by default. Text deltas land on stdout as they arrive; tool calls and tool results print to stderr in dim brackets so the chat output stays clean. +## Two lenses on the same agent + +`mcpctl chat alice` is the person **on their own** — their system prompt, their +own pinned brain, no project tools. + +`mcpctl chat alice --project sre` is the **same person viewing one of their +teams**: the project's Prompts join the system block, the project's MCP servers +become callable tools, and if that membership carries a brain override, that +Llm answers instead of alice's pinned one. + +```bash +mcpctl attach-agent alice --project sre --role sre # join first +mcpctl chat alice --project sre +``` + +The agent must actually be a member. Naming a project it has not joined is an +error naming the fix, not a silent fallback to some other project's context: + +``` +Agent 'alice' is not a member of project 'payments'. +Join it with: mcpctl attach-agent alice --project payments +``` + +(The legacy single-project pin on the agent still counts as membership in that +one project for this release — see [agents.md](agents.md#team-membership-an-agent-joins-many-projects).) + +Threads stay scoped to the **agent**, not to (agent, project) — switching +`--project` mid-conversation changes the context layer, not the history. + ## Project chat Chat directly with a **project** — no Agent needed. The session sees the diff --git a/scripts/generate-completions.ts b/scripts/generate-completions.ts index a61c7d3..21c268b 100644 --- a/scripts/generate-completions.ts +++ b/scripts/generate-completions.ts @@ -29,6 +29,8 @@ type CompletionHint = | 'instance_names' | 'available_servers' | 'project_servers' + | 'available_agents' + | 'project_agents' | 'file' | { static: string[] }; @@ -53,6 +55,8 @@ const COMPLETION_HINTS: Record = { 'logs.name': 'instance_names', 'attach-server.server-name': 'available_servers', 'detach-server.server-name': 'project_servers', + 'attach-agent.agent-name': 'available_agents', + 'detach-agent.agent-name': 'project_agents', 'apply.file': 'file', }; @@ -66,7 +70,9 @@ const FILE_OPTIONS = new Set([ ]); /** Commands shown ONLY when --project/-p is on the command line. */ -const PROJECT_ONLY_COMMANDS = new Set(['attach-server', 'detach-server', 'favourites']); +const PROJECT_ONLY_COMMANDS = new Set([ + 'attach-server', 'detach-server', 'attach-agent', 'detach-agent', 'favourites', +]); /** Commands that appear in BOTH project and non-project context. */ const PROJECT_SCOPED_COMMANDS = new Set([ @@ -188,7 +194,7 @@ const CANONICAL_RESOURCES = [ 'users', 'groups', 'rbac', 'prompts', 'promptrequests', 'serverattachments', 'proxymodels', 'inference-tasks', // Agentic teams - 'epics', 'epictasks', 'tasks', 'repos', 'gitproviders', 'all', + 'epics', 'epictasks', 'tasks', 'repos', 'gitproviders', 'memberships', 'all', ]; const ALIAS_ENTRIES: [string, string][] = [ @@ -220,6 +226,7 @@ const ALIAS_ENTRIES: [string, string][] = [ ['repo', 'repos'], ['repos', 'repos'], ['repository', 'repos'], ['repositories', 'repos'], ['gitprovider', 'gitproviders'], ['gitproviders', 'gitproviders'], ['forge', 'gitproviders'], ['gp', 'gitproviders'], + ['membership', 'memberships'], ['memberships', 'memberships'], ['team', 'memberships'], ['all', 'all'], ]; @@ -452,6 +459,14 @@ function generateFish(root: CmdInfo): string { emit("complete -c mcpctl -n \"__fish_seen_subcommand_from detach-server; and __mcpctl_needs_server_arg\" -a '(__mcpctl_project_servers)' -d 'Server'"); emit(''); + // attach-agent / detach-agent — the AgentProject team roster + emit("# attach-agent: show agents NOT yet on the team (only if no agent arg yet)"); + emit("complete -c mcpctl -n \"__fish_seen_subcommand_from attach-agent; and __mcpctl_needs_agent_arg\" -a '(__mcpctl_available_agents)' -d 'Agent'"); + emit(''); + emit("# detach-agent: show agents ON the team (only if no agent arg yet)"); + emit("complete -c mcpctl -n \"__fish_seen_subcommand_from detach-agent; and __mcpctl_needs_agent_arg\" -a '(__mcpctl_project_agents)' -d 'Agent'"); + emit(''); + // apply: allow file completions for positional argument emit("# apply: allow file completions for positional argument"); emit("complete -c mcpctl -n \"__fish_seen_subcommand_from apply\" -F"); @@ -603,6 +618,33 @@ function __mcpctl_available_servers end end end +`); + + emit(`# Agents already on the project team (for detach-agent) +function __mcpctl_project_agents + set -l proj (__mcpctl_get_project_value) + if test -z "$proj" + return + end + mcpctl --project $proj get agents -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null +end +`); + + emit(`# Agents NOT yet on the project team (for attach-agent) +function __mcpctl_available_agents + set -l proj (__mcpctl_get_project_value) + if test -z "$proj" + mcpctl get agents -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null + return + end + set -l all (mcpctl get agents -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null) + set -l joined (mcpctl --project $proj get agents -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null) + for a in $all + if not contains -- $a $joined + echo $a + end + end +end `); emit(`# Instance names for logs @@ -652,6 +694,27 @@ function __mcpctl_needs_server_arg end return 1 end +`); + + emit(`# Helper: check if attach-agent/detach-agent already has an agent argument +function __mcpctl_needs_agent_arg + set -l tokens (commandline -opc) + set -l found_cmd false + for tok in $tokens + if $found_cmd + if not string match -q -- '-*' $tok + return 1 # agent arg already present + end + end + if contains -- $tok attach-agent detach-agent + set found_cmd true + end + end + if $found_cmd + return 0 + end + return 1 +end `); emit(`# Helper: check if a specific parent-child subcommand pair is active @@ -994,6 +1057,41 @@ function emitBashCase(emit: (s: string) => void, cmd: CmdInfo, root: CmdInfo): v return; } + // attach-agent — the team roster, minus whoever already joined + if (name === 'attach-agent') { + emit(' attach-agent)'); + emit(' if [[ $((cword - subcmd_pos)) -ne 1 ]]; then'); + emit(` COMPREPLY=($(compgen -W "${optFlags}" -- "$cur"))`); + emit(' return'); + emit(' fi'); + emit(' local proj names all_agents proj_agents'); + emit(' proj=$(_mcpctl_get_project_value)'); + emit(' if [[ -n "$proj" ]]; then'); + emit(" all_agents=$(mcpctl get agents -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null)"); + emit(" proj_agents=$(mcpctl --project \"$proj\" get agents -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null)"); + emit(' names=$(comm -23 <(echo "$all_agents" | sort) <(echo "$proj_agents" | sort))'); + emit(' else'); + emit(' names=$(_mcpctl_resource_names "agents")'); + emit(' fi'); + emit(' COMPREPLY=($(compgen -W "$names" -- "$cur"))'); + emit(' return ;;'); + return; + } + + // detach-agent — only agents already on the team + if (name === 'detach-agent') { + emit(' detach-agent)'); + emit(' if [[ $((cword - subcmd_pos)) -ne 1 ]]; then return; fi'); + emit(' local proj names'); + emit(' proj=$(_mcpctl_get_project_value)'); + emit(' if [[ -n "$proj" ]]; then'); + emit(" names=$(mcpctl --project \"$proj\" get agents -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null)"); + emit(' fi'); + emit(' COMPREPLY=($(compgen -W "$names" -- "$cur"))'); + emit(' return ;;'); + return; + } + // apply: file completions if (name === 'apply') { emit(' apply)'); diff --git a/src/cli/src/commands/chat.ts b/src/cli/src/commands/chat.ts index 628642b..8a4307a 100644 --- a/src/cli/src/commands/chat.ts +++ b/src/cli/src/commands/chat.ts @@ -9,8 +9,10 @@ * * Modes: * mcpctl chat # REPL, new thread + * mcpctl chat --project

# same person, viewing project p * mcpctl chat --thread # REPL, resume thread * mcpctl chat -m "hi" # one-shot, prints reply, no REPL + * mcpctl chat --project

# the project itself, no agent * * Slash-commands inside the REPL: * /set KEY VALUE # adjust an override (temperature 0.2) @@ -40,7 +42,7 @@ export function createChatCommand(deps: ChatCommandDeps): Command { return new Command('chat') .description('Open an interactive chat session with an agent or a project (REPL or one-shot).') .argument('[agent]', 'Agent name (see `mcpctl get agents`). Omit and use --project to chat with a project.') - .option('-p, --project ', 'Chat scoped to a project: its prompts, tools, and (with --allow-secrets) secrets') + .option('-p, --project ', 'With : view that project (agent must be a member). Alone: chat with the project itself') .option('--allow-secrets', 'Project chat: let the model read secret values via the get_secret tool (needs view:secrets)') .option('-m, --message ', 'One-shot: send a single message and exit (no REPL)') .option('--thread ', 'Resume an existing thread') @@ -77,18 +79,30 @@ interface ChatSubject { path: string; /** Project chat only: opt into the gated get_secret tool. */ allowSecrets: boolean; + /** + * Agent chat only: the project this turn is viewed through. The same person, + * looking at one of the teams they joined — mcpd requires an AgentProject + * membership and 404s with a join hint when there isn't one. + */ + project?: string; } function resolveSubject(agent: string | undefined, opts: ChatOpts): ChatSubject { - if (opts.project !== undefined && agent !== undefined) { - throw new Error('Pass either an or --project , not both.'); + if (agent !== undefined) { + if (opts.allowSecrets === true) { + throw new Error('--allow-secrets is project-chat only (drop the argument).'); + } + return { + kind: 'agent', + name: agent, + path: `agents/${encodeURIComponent(agent)}`, + allowSecrets: false, + ...(opts.project !== undefined ? { project: opts.project } : {}), + }; } if (opts.project !== undefined) { return { kind: 'project', name: opts.project, path: `projects/${encodeURIComponent(opts.project)}`, allowSecrets: opts.allowSecrets === true }; } - if (agent !== undefined) { - return { kind: 'agent', name: agent, path: `agents/${encodeURIComponent(agent)}`, allowSecrets: false }; - } throw new Error('Specify an name or --project . See `mcpctl chat --help`.'); } @@ -102,6 +116,10 @@ function chatBody(subject: ChatSubject, message: string, threadId: string | unde if (subject.kind === 'project') { delete o.personality; if (subject.allowSecrets) o.allowSecrets = true; + } else if (subject.project !== undefined) { + // Agent chat viewed through one of the agent's projects. The endpoint is + // still the agent's — only the context layer changes. + o.project = subject.project; } const body: Record = { message, ...o }; if (threadId !== undefined) body.threadId = threadId; @@ -372,6 +390,10 @@ async function handleSlash( let projectName: string | null; if (subject.kind === 'project') { projectName = subject.name; + } else if (subject.project !== undefined) { + // --project is the lens for this session; it beats the agent's + // legacy pin, exactly as it does server-side. + projectName = subject.project; } else { const a = await deps.client.get<{ project: { name: string } | null }>( `/api/v1/agents/${encodeURIComponent(subject.name)}`, @@ -880,7 +902,10 @@ async function printChatHeader( out(sep); out(`Agent: ${info.name}${info.description !== '' ? ` — ${info.description}` : ''}`); - const tail = info.project !== null ? ` Project: ${info.project.name}` : ''; + // --project is the lens for this session; without it the agent's legacy + // `project` pin (if any) is what mcpd will use. + const viewing = subject.project ?? info.project?.name; + const tail = viewing !== undefined ? ` Project: ${viewing}` : ''; out(`LLM: ${info.llm.name}${tail}`); // Personality overlay: explicit --personality wins; otherwise agent's @@ -903,7 +928,7 @@ async function printChatHeader( out(`System append (--system-append):`); out(indent(overrides.systemAppend)); } - if (info.project !== null) { + if (viewing !== undefined) { out(`(project prompts auto-appended at chat time; /tools lists MCP servers)`); } diff --git a/src/cli/src/commands/get.ts b/src/cli/src/commands/get.ts index 401bf92..6156b69 100644 --- a/src/cli/src/commands/get.ts +++ b/src/cli/src/commands/get.ts @@ -234,6 +234,25 @@ const agentColumns: Column[] = [ { header: 'ID', key: 'id' }, ]; +/** One AgentProject row — an agent's seat on a project team. */ +interface MembershipRow { + id: string; + agent: { id: string; name: string }; + project: { id: string; name: string }; + role: string; + llm: { id: string; name: string } | null; + createdAt: string; +} + +const membershipColumns: Column[] = [ + { header: 'AGENT', key: (r) => r.agent.name, width: 24 }, + { header: 'PROJECT', key: (r) => r.project.name, width: 20 }, + { header: 'ROLE', key: 'role', width: 12 }, + // '-' means "no override" — the agent's own pinned Llm answers here. + { header: 'BRAIN', key: (r) => r.llm?.name ?? '-', width: 24 }, + { header: 'JOINED', key: (r) => new Date(r.createdAt).toLocaleString(), width: 20 }, +]; + interface ThreadRow { id: string; title: string; lastTurnAt: string; createdAt: string } const threadColumns: Column[] = [ { header: 'ID', key: 'id' }, @@ -520,6 +539,8 @@ function getColumnsForResource(resource: string): Column return agentColumns as unknown as Column>[]; case 'personalities': return personalityColumns as unknown as Column>[]; + case 'memberships': + return membershipColumns as unknown as Column>[]; case 'threads': return threadColumns as unknown as Column>[]; case 'inference-tasks': diff --git a/src/cli/src/commands/project-ops.ts b/src/cli/src/commands/project-ops.ts index 706fbcb..1edafa9 100644 --- a/src/cli/src/commands/project-ops.ts +++ b/src/cli/src/commands/project-ops.ts @@ -46,6 +46,53 @@ export function createDetachServerCommand(deps: ProjectOpsDeps): Command { }); } +/** + * v1 persona roles. Mirrors MEMBERSHIP_ROLES in + * src/mcpd/src/validation/agent-project.schema.ts — mcpd is the authority, this + * list only exists so `--help` names the options and a typo fails locally. + */ +const MEMBERSHIP_ROLES = ['developer', 'qa', 'security', 'sre'] as const; + +export function createAttachAgentCommand(deps: ProjectOpsDeps): Command { + const { client, log } = deps; + + return new Command('attach-agent') + .description('Add an agent to a project team (requires --project)') + .argument('', 'Agent name to add') + .option('--role ', `Persona on this team: ${MEMBERSHIP_ROLES.join('|')}`) + .option('--llm ', 'Per-membership brain override (wins over the agent\'s pinned Llm in this project)') + .action(async (agentName: string, opts: { role?: string; llm?: string }) => { + const projectName = requireProject(deps); + if (opts.role !== undefined && !MEMBERSHIP_ROLES.includes(opts.role as typeof MEMBERSHIP_ROLES[number])) { + throw new Error(`--role must be one of: ${MEMBERSHIP_ROLES.join(', ')}`); + } + const membership = await client.post<{ role: string; llm: { name: string } | null }>( + `/api/v1/agents/${encodeURIComponent(agentName)}/projects/${encodeURIComponent(projectName)}`, + { + ...(opts.role !== undefined ? { role: opts.role } : {}), + ...(opts.llm !== undefined ? { llm: opts.llm } : {}), + }, + ); + const brain = membership.llm !== null ? `, brain ${membership.llm.name}` : ''; + log(`agent '${agentName}' joined project '${projectName}' as ${membership.role}${brain}`); + }); +} + +export function createDetachAgentCommand(deps: ProjectOpsDeps): Command { + const { client, log } = deps; + + return new Command('detach-agent') + .description('Remove an agent from a project team (requires --project)') + .argument('', 'Agent name to remove') + .action(async (agentName: string) => { + const projectName = requireProject(deps); + await client.delete( + `/api/v1/agents/${encodeURIComponent(agentName)}/projects/${encodeURIComponent(projectName)}`, + ); + log(`agent '${agentName}' left project '${projectName}'`); + }); +} + export function createApproveCommand(deps: ProjectOpsDeps): Command { const { client, log } = deps; diff --git a/src/cli/src/commands/shared.ts b/src/cli/src/commands/shared.ts index 6ac08e7..081aba0 100644 --- a/src/cli/src/commands/shared.ts +++ b/src/cli/src/commands/shared.ts @@ -69,6 +69,11 @@ export const RESOURCE_ALIASES: Record = { agents: 'agents', personality: 'personalities', personalities: 'personalities', + // Agent ↔ Project team membership (the AgentProject junction). Read through + // one side or the other: `--project

` for the roster, or an agent name. + membership: 'memberships', + memberships: 'memberships', + team: 'memberships', thread: 'threads', threads: 'threads', // v5 shipped the durable inference queue under the short `task`/`tasks` diff --git a/src/cli/src/index.ts b/src/cli/src/index.ts index a5c3da7..70ae7f1 100644 --- a/src/cli/src/index.ts +++ b/src/cli/src/index.ts @@ -15,7 +15,13 @@ import { createCreateCommand } from './commands/create.js'; import { createEditCommand } from './commands/edit.js'; import { createBackupCommand } from './commands/backup.js'; import { createLoginCommand, createLogoutCommand } from './commands/auth.js'; -import { createAttachServerCommand, createDetachServerCommand, createApproveCommand } from './commands/project-ops.js'; +import { + createAttachServerCommand, + createDetachServerCommand, + createAttachAgentCommand, + createDetachAgentCommand, + createApproveCommand, +} from './commands/project-ops.js'; import { createFavouritesCommand } from './commands/favourites.js'; import { createMcpCommand } from './commands/mcp.js'; import { createPatchCommand } from './commands/patch.js'; @@ -135,6 +141,25 @@ export function createProgram(): Command { return client.get(`/api/v1/${resource}?project=${encodeURIComponent(projectName)}`); } + // `get agents --project X` — the team: members plus any legacy-pinned + // agents. Same shape as the unscoped list, so the columns still line up. + if (nameOrId === undefined && resource === 'agents' && projectName !== undefined && projectName !== '') { + return client.get(`/api/v1/projects/${encodeURIComponent(projectName)}/agents`); + } + + // Memberships are a junction, not a resource with its own name — they are + // always read through one side. `--project` gives the roster with roles; + // otherwise name the agent (`get memberships `). + if (resource === 'memberships') { + if (projectName !== undefined && projectName !== '') { + return client.get(`/api/v1/projects/${encodeURIComponent(projectName)}/memberships`); + } + if (nameOrId === undefined) { + throw new Error('memberships are scoped — pass --project or an agent name (`mcpctl get memberships `)'); + } + return client.get(`/api/v1/agents/${encodeURIComponent(nameOrId)}/projects`); + } + // --project scoping for mcptokens if (!nameOrId && resource === 'mcptokens' && projectName) { return client.get(`/api/v1/mcptokens?projectName=${encodeURIComponent(projectName)}`); @@ -306,6 +331,9 @@ export function createProgram(): Command { }; program.addCommand(createAttachServerCommand(projectOpsDeps), { hidden: true }); program.addCommand(createDetachServerCommand(projectOpsDeps), { hidden: true }); + // Agentic teams: agents join projects the same way servers attach to them. + program.addCommand(createAttachAgentCommand(projectOpsDeps), { hidden: true }); + program.addCommand(createDetachAgentCommand(projectOpsDeps), { hidden: true }); program.addCommand(createApproveCommand(projectOpsDeps)); program.addCommand(createFavouritesCommand(projectOpsDeps)); // PR-4: reviewer queue for proposed prompts + skills. diff --git a/src/cli/tests/commands/agent-membership.test.ts b/src/cli/tests/commands/agent-membership.test.ts new file mode 100644 index 0000000..d6c45a7 --- /dev/null +++ b/src/cli/tests/commands/agent-membership.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import type { ApiClient } from '../../src/api-client.js'; +import { createGetCommand } from '../../src/commands/get.js'; +import { + createAttachAgentCommand, + createDetachAgentCommand, +} from '../../src/commands/project-ops.js'; + +function mockClient(overrides: Partial = {}): ApiClient { + return { + get: vi.fn(async () => []), + post: vi.fn(async () => ({ role: 'developer', llm: null })), + put: vi.fn(async () => ({})), + delete: vi.fn(async () => {}), + ...overrides, + } as unknown as ApiClient; +} + +describe('mcpctl attach-agent / detach-agent', () => { + let client: ApiClient; + let output: string[]; + const log = (...args: string[]) => output.push(args.join(' ')); + + beforeEach(() => { + client = mockClient(); + output = []; + }); + + it('joins an agent to the --project team', async () => { + const cmd = createAttachAgentCommand({ client, log, getProject: () => 'sre' }); + await cmd.parseAsync(['alice'], { from: 'user' }); + + expect(client.post).toHaveBeenCalledWith('/api/v1/agents/alice/projects/sre', {}); + expect(output.join('\n')).toContain("agent 'alice' joined project 'sre' as developer"); + }); + + it('passes --role and --llm through', async () => { + client = mockClient({ + post: vi.fn(async () => ({ role: 'qa', llm: { id: 'llm-1', name: 'qwen3-thinking' } })) as ApiClient['post'], + }); + const cmd = createAttachAgentCommand({ client, log, getProject: () => 'sre' }); + await cmd.parseAsync(['alice', '--role', 'qa', '--llm', 'qwen3-thinking'], { from: 'user' }); + + expect(client.post).toHaveBeenCalledWith('/api/v1/agents/alice/projects/sre', { + role: 'qa', + llm: 'qwen3-thinking', + }); + expect(output.join('\n')).toContain('brain qwen3-thinking'); + }); + + it('rejects a role outside the v1 set before hitting the network', async () => { + const cmd = createAttachAgentCommand({ client, log, getProject: () => 'sre' }); + await expect( + cmd.parseAsync(['alice', '--role', 'chief-vibes-officer'], { from: 'user' }), + ).rejects.toThrow(/--role must be one of/); + expect(client.post).not.toHaveBeenCalled(); + }); + + it('requires --project', async () => { + const cmd = createAttachAgentCommand({ client, log, getProject: () => undefined }); + await expect(cmd.parseAsync(['alice'], { from: 'user' })).rejects.toThrow(/--project required/); + }); + + it('url-encodes names', async () => { + const cmd = createAttachAgentCommand({ client, log, getProject: () => 'my team' }); + await cmd.parseAsync(['a/b'], { from: 'user' }); + expect(client.post).toHaveBeenCalledWith('/api/v1/agents/a%2Fb/projects/my%20team', {}); + }); + + it('detach-agent deletes the membership', async () => { + const cmd = createDetachAgentCommand({ client, log, getProject: () => 'sre' }); + await cmd.parseAsync(['alice'], { from: 'user' }); + + expect(client.delete).toHaveBeenCalledWith('/api/v1/agents/alice/projects/sre'); + expect(output.join('\n')).toContain("agent 'alice' left project 'sre'"); + }); +}); + +describe('mcpctl get memberships', () => { + it('renders the roster with role and brain columns', async () => { + const out: string[] = []; + const cmd = createGetCommand({ + fetchResource: vi.fn(async () => [ + { + id: 'ap-1', + agent: { id: 'agent-alice', name: 'alice' }, + project: { id: 'proj-sre', name: 'sre' }, + role: 'sre', + llm: { id: 'llm-1', name: 'qwen3-thinking' }, + createdAt: '2026-01-01T00:00:00.000Z', + }, + { + id: 'ap-2', + agent: { id: 'agent-bob', name: 'bob' }, + project: { id: 'proj-sre', name: 'sre' }, + role: 'qa', + llm: null, + createdAt: '2026-01-01T00:00:00.000Z', + }, + ]), + log: (...args: string[]) => out.push(args.join(' ')), + }); + await cmd.parseAsync(['node', 'test', 'memberships', '--project', 'sre']); + + const text = out.join('\n'); + expect(text).toContain('ROLE'); + expect(text).toContain('BRAIN'); + expect(text).toContain('qwen3-thinking'); + // No override reads as '-', not as an empty cell. + expect(text).toMatch(/bob\s+sre\s+qa\s+-/); + }); + + it('accepts the `team` alias', async () => { + const fetchResource = vi.fn(async () => []); + const out: string[] = []; + const cmd = createGetCommand({ fetchResource, log: (...a: string[]) => out.push(a.join(' ')) }); + await cmd.parseAsync(['node', 'test', 'team', '--project', 'sre']); + expect(fetchResource).toHaveBeenCalledWith('memberships', undefined, { project: 'sre' }); + }); +}); diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index 3beb8d0..6fb72ce 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -36,8 +36,10 @@ import { LlmService } from './services/llm.service.js'; import { InferenceTaskRepository } from './repositories/inference-task.repository.js'; import { InferenceTaskService } from './services/inference-task.service.js'; import { AgentRepository } from './repositories/agent.repository.js'; +import { AgentProjectRepository } from './repositories/agent-project.repository.js'; import { ChatRepository } from './repositories/chat.repository.js'; import { AgentService } from './services/agent.service.js'; +import { AgentProjectService } from './services/agent-project.service.js'; import { ChatService } from './services/chat.service.js'; import { ChatToolDispatcherImpl } from './services/chat-tool-dispatcher.js'; import { LlmAdapterRegistry } from './services/llm/dispatcher.js'; @@ -47,6 +49,7 @@ import { registerVirtualLlmRoutes } from './routes/virtual-llms.js'; import { registerInferenceTaskRoutes } from './routes/inference-tasks.js'; import { VirtualLlmService } from './services/virtual-llm.service.js'; import { registerAgentRoutes } from './routes/agents.js'; +import { registerAgentProjectRoutes } from './routes/agent-projects.js'; import { registerAgentChatRoutes } from './routes/agent-chat.js'; import { registerProjectChatRoutes } from './routes/project-chat.js'; import { PromptRepository } from './repositories/prompt.repository.js'; @@ -192,6 +195,22 @@ function mapUrlToPermission(method: string, url: string): PermissionCheck { return { kind: 'resource', resource: 'agents', action: 'run', resourceName: agentRunMatch[1] }; } + // /api/v1/agents/:name/projects[/:project] — team membership. + // Reading the roster is `view:agents:`; joining/leaving is + // `edit:agents:`, mirroring project↔server attach/detach (which is + // `edit:projects`, not `create`). Without this the default mapping would + // grade a join as `create:agents` — the permission for minting a whole new + // agent, which is far too broad for "add this person to a team". + const membershipAgentName = url.match(/^\/api\/v1\/agents\/([^/?]+)\/projects/)?.[1]; + if (membershipAgentName !== undefined) { + return { + kind: 'resource', + resource: 'agents', + action: method === 'GET' || method === 'HEAD' ? 'view' : 'edit', + resourceName: membershipAgentName, + }; + } + // /api/v1/threads/:id/messages → `view:agents` (we don't carry the agent // name in the URL; the service-level owner check enforces fine-grained // access on top). @@ -296,6 +315,11 @@ function mapUrlToPermission(method: string, url: string): PermissionCheck { return { kind: 'resource', resource: 'projects', action: 'expose', resourceName: mcpConfigMatch[1] }; } + // Note: /api/v1/projects/:name/agents and /:name/memberships intentionally + // fall through to the default mapping below — reading a project's roster is + // `view:projects:`. Membership *writes* only happen on the agent side + // (/api/v1/agents/:name/projects/:project), handled above. + // Special case: /api/v1/projects/:id/servers — attach/detach requires 'edit' const projectServersMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/servers/); if (projectServersMatch?.[1] && method !== 'GET') { @@ -599,7 +623,16 @@ async function main(): Promise { ); const personalityRepo = new PersonalityRepository(prisma); const personalityService = new PersonalityService(personalityRepo, agentRepo, promptRepo); - const agentService = new AgentService(agentRepo, llmService, projectService, personalityRepo); + // Agentic teams: an agent is a person who joins projects. The junction repo + // goes into AgentService so both the project roster and the chat path read + // memberships, not just the legacy `Agent.projectId` pin. + const agentProjectRepo = new AgentProjectRepository(prisma); + const agentService = new AgentService( + agentRepo, llmService, projectService, personalityRepo, agentProjectRepo, + ); + const agentProjectService = new AgentProjectService( + agentProjectRepo, agentService, projectService, llmService, + ); // v5: durable inference task queue. VirtualLlmService persists infer // tasks here; the result-route updates them; an in-process emitter // wakes blocked HTTP handlers when results land. @@ -738,6 +771,7 @@ async function main(): Promise { registerSecretMigrateRoutes(app, secretMigrateService); registerLlmRoutes(app, llmService); registerAgentRoutes(app, agentService); + registerAgentProjectRoutes(app, agentProjectService); registerPersonalityRoutes(app, personalityService); // ChatService needs an `app.log`-aware tool dispatcher. const chatToolDispatcher = new ChatToolDispatcherImpl({ diff --git a/src/mcpd/src/repositories/agent-project.repository.ts b/src/mcpd/src/repositories/agent-project.repository.ts new file mode 100644 index 0000000..db56a59 --- /dev/null +++ b/src/mcpd/src/repositories/agent-project.repository.ts @@ -0,0 +1,81 @@ +import type { PrismaClient, AgentProject } from '@prisma/client'; + +/** + * AgentProject repository — the junction that makes an agent a *person who + * joins projects* rather than a thing owned by one. + * + * The row carries the two things that vary per project: the persona `role` + * (TEXT, validated in the service — production runs `prisma db push`, so a + * CHECK constraint here would never exist in prod) and an optional `llmId` + * brain override. + */ + +export interface AgentProjectCreateInput { + agentId: string; + projectId: string; + role?: string; + llmId?: string | null; +} + +export interface AgentProjectUpdateInput { + role?: string; + llmId?: string | null; +} + +export interface IAgentProjectRepository { + findByAgent(agentId: string): Promise; + findByProject(projectId: string): Promise; + find(agentId: string, projectId: string): Promise; + create(data: AgentProjectCreateInput): Promise; + update(id: string, data: AgentProjectUpdateInput): Promise; + delete(id: string): Promise; +} + +export class AgentProjectRepository implements IAgentProjectRepository { + constructor(private readonly prisma: PrismaClient) {} + + async findByAgent(agentId: string): Promise { + return this.prisma.agentProject.findMany({ + where: { agentId }, + orderBy: { createdAt: 'asc' }, + }); + } + + async findByProject(projectId: string): Promise { + return this.prisma.agentProject.findMany({ + where: { projectId }, + orderBy: { createdAt: 'asc' }, + }); + } + + async find(agentId: string, projectId: string): Promise { + return this.prisma.agentProject.findUnique({ + where: { agentId_projectId: { agentId, projectId } }, + }); + } + + async create(data: AgentProjectCreateInput): Promise { + return this.prisma.agentProject.create({ + data: { + agentId: data.agentId, + projectId: data.projectId, + ...(data.role !== undefined ? { role: data.role } : {}), + ...(data.llmId !== undefined ? { llmId: data.llmId } : {}), + }, + }); + } + + async update(id: string, data: AgentProjectUpdateInput): Promise { + return this.prisma.agentProject.update({ + where: { id }, + data: { + ...(data.role !== undefined ? { role: data.role } : {}), + ...(data.llmId !== undefined ? { llmId: data.llmId } : {}), + }, + }); + } + + async delete(id: string): Promise { + await this.prisma.agentProject.delete({ where: { id } }); + } +} diff --git a/src/mcpd/src/routes/agent-chat.ts b/src/mcpd/src/routes/agent-chat.ts index 7dde512..39cdbb3 100644 --- a/src/mcpd/src/routes/agent-chat.ts +++ b/src/mcpd/src/routes/agent-chat.ts @@ -37,7 +37,7 @@ export function registerAgentChatRoutes( } const { - threadId, message, messages: messagesOverride, stream, personality, + threadId, message, messages: messagesOverride, stream, personality, project, ...paramsRest } = parsed; @@ -58,6 +58,7 @@ export function registerAgentChatRoutes( ? { messagesOverride: messagesOverride.map((m) => ({ role: m.role, content: m.content, ...(m.tool_call_id !== undefined ? { tool_call_id: m.tool_call_id } : {}) })) } : {}), ...(personality !== undefined ? { personalityName: personality } : {}), + ...(project !== undefined ? { projectName: project } : {}), params: paramsRest, }; diff --git a/src/mcpd/src/routes/agent-projects.ts b/src/mcpd/src/routes/agent-projects.ts new file mode 100644 index 0000000..32af661 --- /dev/null +++ b/src/mcpd/src/routes/agent-projects.ts @@ -0,0 +1,95 @@ +/** + * Agent ↔ Project membership HTTP surface. + * + * GET /api/v1/agents/:name/projects — projects this agent joined + * POST /api/v1/agents/:name/projects/:project — join (body: { role?, llm? }) + * DELETE /api/v1/agents/:name/projects/:project — leave + * GET /api/v1/projects/:name/memberships — the roster, with roles + * + * The reverse *agent list* stays at `GET /api/v1/projects/:name/agents` + * (routes/agents.ts) because mcplocal's agents plugin consumes that shape; + * it now returns members as well as legacy-pinned agents. This route adds the + * membership detail (role + brain override) that an `Agent` row cannot carry. + * + * RBAC comes from the global hook in `main.ts:mapUrlToPermission`: reading the + * roster is `view:agents:`, joining/leaving is `edit:agents:` — + * mirroring project↔server attach/detach, which is `edit:projects`, not + * `create`. + */ +import type { FastifyInstance } from 'fastify'; + +import type { AgentProjectService } from '../services/agent-project.service.js'; +import { NotFoundError } from '../services/mcp-server.service.js'; + +export function registerAgentProjectRoutes( + app: FastifyInstance, + service: AgentProjectService, +): void { + app.get<{ Params: { name: string } }>( + '/api/v1/agents/:name/projects', + async (request, reply) => { + try { + return await service.listForAgent(request.params.name); + } catch (err) { + if (err instanceof NotFoundError) { + reply.code(404); + return { error: err.message }; + } + throw err; + } + }, + ); + + app.post<{ Params: { name: string; project: string } }>( + '/api/v1/agents/:name/projects/:project', + async (request, reply) => { + try { + const membership = await service.join( + request.params.name, + request.params.project, + request.body ?? {}, + ); + reply.code(201); + return membership; + } catch (err) { + if (err instanceof NotFoundError) { + reply.code(404); + return { error: err.message }; + } + throw err; + } + }, + ); + + app.delete<{ Params: { name: string; project: string } }>( + '/api/v1/agents/:name/projects/:project', + async (request, reply) => { + try { + await service.leave(request.params.name, request.params.project); + reply.code(204); + return null; + } catch (err) { + if (err instanceof NotFoundError) { + reply.code(404); + return { error: err.message }; + } + throw err; + } + }, + ); + + app.get<{ Params: { name: string } }>( + '/api/v1/projects/:name/memberships', + async (request, reply) => { + try { + return await service.listForProject(request.params.name); + } catch (err) { + if (err instanceof NotFoundError) { + reply.code(404); + return { error: err.message }; + } + throw err; + } + }, + ); +} diff --git a/src/mcpd/src/services/agent-project.service.ts b/src/mcpd/src/services/agent-project.service.ts new file mode 100644 index 0000000..e80fc22 --- /dev/null +++ b/src/mcpd/src/services/agent-project.service.ts @@ -0,0 +1,132 @@ +/** + * AgentProjectService — team membership, the thing that turns an Agent from + * "a persona owned by one project" into "a person who joins zero, one, or many + * projects" (docs/agentic-teams.md §4). + * + * The junction row is where the two per-project facts hang: + * - `role` — the persona this agent wears on this team (developer/qa/…). + * - `llmId` — an optional brain override, so the open brain-ownership + * question (§15 Q1) can be answered per membership instead of + * globally. + * + * Invariants are enforced here, not in the database: production provisions with + * `prisma db push`, so migration-only CHECK constraints never exist in prod. + */ +import type { AgentProject } from '@prisma/client'; + +import type { IAgentProjectRepository } from '../repositories/agent-project.repository.js'; +import { + DEFAULT_MEMBERSHIP_ROLE, + JoinProjectSchema, + MEMBERSHIP_ROLES, +} from '../validation/agent-project.schema.js'; + +import type { AgentService } from './agent.service.js'; +import type { LlmService } from './llm.service.js'; +import type { ProjectService } from './project.service.js'; +import { NotFoundError } from './mcp-server.service.js'; + +/** One membership, name-resolved so callers never handle raw CUIDs. */ +export interface MembershipView { + id: string; + agent: { id: string; name: string }; + project: { id: string; name: string }; + role: string; + /** Per-membership brain override; null means "use the agent's pinned Llm". */ + llm: { id: string; name: string } | null; + createdAt: Date; +} + +export class AgentProjectService { + constructor( + private readonly repo: IAgentProjectRepository, + private readonly agents: AgentService, + private readonly projects: ProjectService, + private readonly llms: LlmService, + ) {} + + /** Every project this agent has joined. */ + async listForAgent(agentName: string): Promise { + const agent = await this.agents.getByName(agentName); + const rows = await this.repo.findByAgent(agent.id); + return Promise.all(rows.map((r) => this.toView(r))); + } + + /** Every agent that has joined this project — the team roster. */ + async listForProject(projectName: string): Promise { + const project = await this.projects.resolveAndGet(projectName); + const rows = await this.repo.findByProject(project.id); + return Promise.all(rows.map((r) => this.toView(r))); + } + + /** + * Join a project. Idempotent by design: re-joining an existing membership + * updates the role/brain rather than 409-ing, so `apply`-style repeat calls + * converge instead of failing. + */ + async join(agentName: string, projectName: string, input: unknown): Promise { + const data = JoinProjectSchema.parse(input ?? {}); + const agent = await this.agents.getByName(agentName); + const project = await this.projects.resolveAndGet(projectName); + + // `llm: null` clears the override; an absent `llm` leaves it untouched. + let llmId: string | null | undefined; + if (data.llm !== undefined) { + llmId = data.llm === null ? null : (await this.llms.getByName(data.llm)).id; + } + + const existing = await this.repo.find(agent.id, project.id); + const row = existing !== null + ? await this.repo.update(existing.id, { + ...(data.role !== undefined ? { role: data.role } : {}), + ...(llmId !== undefined ? { llmId } : {}), + }) + : await this.repo.create({ + agentId: agent.id, + projectId: project.id, + role: data.role ?? DEFAULT_MEMBERSHIP_ROLE, + ...(llmId !== undefined ? { llmId } : {}), + }); + + return this.toView(row); + } + + /** Leave a project. 404s when the agent was never a member — a silent no-op + * here would hide a typo'd project name. */ + async leave(agentName: string, projectName: string): Promise { + const agent = await this.agents.getByName(agentName); + const project = await this.projects.resolveAndGet(projectName); + const existing = await this.repo.find(agent.id, project.id); + if (existing === null) { + throw new NotFoundError( + `Agent '${agent.name}' is not a member of project '${project.name}'`, + ); + } + await this.repo.delete(existing.id); + } + + /** The valid role set, for error messages and CLI help. */ + static roles(): readonly string[] { + return MEMBERSHIP_ROLES; + } + + private async toView(row: AgentProject): Promise { + const agent = await this.agents.getById(row.agentId); + const project = await this.projects.getById(row.projectId); + let llm: { id: string; name: string } | null = null; + if (row.llmId !== null) { + // A brain deleted out from under a membership sets llmId to NULL + // (onDelete: SetNull), so this only tolerates a torn read. + const found = await this.llms.getById(row.llmId).catch(() => null); + if (found !== null) llm = { id: found.id, name: found.name }; + } + return { + id: row.id, + agent: { id: agent.id, name: agent.name }, + project: { id: project.id, name: project.name }, + role: row.role, + llm, + createdAt: row.createdAt, + }; + } +} diff --git a/src/mcpd/src/services/agent.service.ts b/src/mcpd/src/services/agent.service.ts index ed824f4..8417723 100644 --- a/src/mcpd/src/services/agent.service.ts +++ b/src/mcpd/src/services/agent.service.ts @@ -9,6 +9,7 @@ * identifiers. */ import type { Agent } from '@prisma/client'; +import type { IAgentProjectRepository } from '../repositories/agent-project.repository.js'; import type { IAgentRepository } from '../repositories/agent.repository.js'; import type { IPersonalityRepository } from '../repositories/personality.repository.js'; import type { LlmService } from './llm.service.js'; @@ -88,12 +89,30 @@ export interface VirtualAgentInput { visibility?: 'public' | 'private'; } +/** + * One resolved AgentProject membership, in the shape the chat path needs: + * which project the agent is looking at, and which brain wins there. + */ +export interface ResolvedMembership { + projectId: string; + projectName: string; + role: string; + /** Per-membership brain override; null means "use the agent's pinned Llm". */ + llmName: string | null; +} + export class AgentService { constructor( private readonly repo: IAgentRepository, private readonly llms: LlmService, private readonly projects: ProjectService, private readonly personalities?: IPersonalityRepository, + /** + * AgentProject junction. Optional so the many existing test wirings still + * compile; without it an agent simply has no memberships and the legacy + * `Agent.projectId` pin is the only project link. + */ + private readonly memberships?: IAgentProjectRepository, ) {} async list(viewer: AgentViewer | null = null): Promise { @@ -102,13 +121,90 @@ export class AgentService { return Promise.all(visible.map((r) => this.toView(r))); } + /** + * Agents attached to a project — by AgentProject membership OR by the legacy + * `Agent.projectId` pin. The union is what keeps this correct during the + * transition release: a joined agent must show up in the project's roster + * (mcplocal's agents plugin publishes these as virtual MCP servers), and a + * pre-membership agent must not vanish from it. + */ async listByProject(projectName: string, viewer: AgentViewer | null = null): Promise { const project = await this.projects.resolveAndGet(projectName); const rows = await this.repo.findByProjectId(project.id); - const visible = rows.filter((r) => isAgentVisibleTo(r, viewer)); + + const byId = new Map(rows.map((r) => [r.id, r])); + if (this.memberships !== undefined) { + for (const m of await this.memberships.findByProject(project.id)) { + if (byId.has(m.agentId)) continue; + const agent = await this.repo.findById(m.agentId); + if (agent !== null) byId.set(agent.id, agent); + } + } + + const visible = [...byId.values()] + .filter((r) => isAgentVisibleTo(r, viewer)) + .sort((a, b) => a.name.localeCompare(b.name)); return Promise.all(visible.map((r) => this.toView(r))); } + /** + * Which project is this agent looking at, and with which brain? + * + * Two resolution modes, deliberately different: + * + * - `projectName` given (`mcpctl chat --project

`): the agent + * must actually be on that team. A membership wins; the legacy + * `Agent.projectId` pin naming the same project is accepted as the + * transition fallback (the schema comment promises the column keeps + * working this release). Anything else throws, because silently + * answering from a *different* project's context would be worse than an + * error. + * - `projectName` omitted (`mcpctl chat `): unchanged legacy + * behaviour — the pinned `Agent.projectId`, or no project at all. + * + * The brain override only applies on the membership path: a legacy pin has + * no membership row and therefore no override to read. + */ + async resolveMembership( + agentName: string, + projectName?: string, + ): Promise { + const agent = await this.getByName(agentName); + + if (projectName === undefined) { + if (agent.project === null) return null; + return { + projectId: agent.project.id, + projectName: agent.project.name, + role: '', + llmName: null, + }; + } + + const project = await this.projects.resolveAndGet(projectName); + const row = this.memberships !== undefined + ? await this.memberships.find(agent.id, project.id) + : null; + + if (row !== null) { + return { + projectId: project.id, + projectName: project.name, + role: row.role, + llmName: row.llmId !== null ? (await this.llms.getById(row.llmId)).name : null, + }; + } + + if (agent.project !== null && agent.project.id === project.id) { + return { projectId: project.id, projectName: project.name, role: '', llmName: null }; + } + + throw new NotFoundError( + `Agent '${agent.name}' is not a member of project '${project.name}'. ` + + `Join it with: mcpctl attach-agent ${agent.name} --project ${project.name}`, + ); + } + async getById(id: string, viewer: AgentViewer | null = null): Promise { const row = await this.repo.findById(id); if (row === null) throw new NotFoundError(`Agent not found: ${id}`); diff --git a/src/mcpd/src/services/chat.service.ts b/src/mcpd/src/services/chat.service.ts index cab46b0..45a7e4d 100644 --- a/src/mcpd/src/services/chat.service.ts +++ b/src/mcpd/src/services/chat.service.ts @@ -137,6 +137,13 @@ export interface PoolCandidate { export interface ChatRequestArgs { agentName: string; + /** + * Which project the agent is looking at for this turn — the same person, + * viewing one of the teams they joined (docs/agentic-teams.md §4). Requires + * an AgentProject membership; see AgentService.resolveMembership. Omitted = + * the agent on its own (falling back to the legacy `Agent.projectId` pin). + */ + projectName?: string; /** * The end user's bearer token. Only used by the `mcpctl` toolset, which acts * AS the caller — see services/mcpctl-tools.ts. Absent for every other agent. @@ -693,7 +700,15 @@ export class ChatService { private async prepareContext(args: ChatRequestArgs): Promise { const agent = await this.agents.getByName(args.agentName); - const pinned = await this.llms.getByName(agent.llm.name); + + // Membership resolution runs BEFORE the Llm lookup, because a + // per-membership brain override has to be able to *replace* the agent's + // pinned Llm (and therefore its whole failover pool), not patch the + // dispatch list afterwards. Throws when `--project` names a team this + // agent has not joined. + const membership = await this.agents.resolveMembership(args.agentName, args.projectName); + + const pinned = await this.llms.getByName(membership?.llmName ?? agent.llm.name); const poolCandidates = await this.resolveCandidatesWithFallbacks(pinned); if (poolCandidates.length === 0) { // Pool exists but every member is inactive (publishers offline, @@ -707,10 +722,10 @@ export class ChatService { } const threadId = await this.resolveThreadId(args, agent.id); - const projectId = agent.project?.id ?? null; + const projectId = membership?.projectId ?? null; // Project prompts (existing): only those whose projectId actually matches - // the agent's project — `findAll(projectId)` also returns globals which + // the project in view — `findAll(projectId)` also returns globals which // we exclude here so they don't double-count if a future change adds an // explicit "global" injection step. const projectPrompts = projectId !== null diff --git a/src/mcpd/src/validation/agent-project.schema.ts b/src/mcpd/src/validation/agent-project.schema.ts new file mode 100644 index 0000000..aaf81ce --- /dev/null +++ b/src/mcpd/src/validation/agent-project.schema.ts @@ -0,0 +1,32 @@ +/** + * AgentProject (team membership) validation. + * + * `role` is TEXT in the schema on purpose — adding a role later must not be a + * migration. The allowed set therefore lives here, in the service layer, which + * is also the only place it *can* live: production provisions with + * `prisma db push`, so a CHECK constraint written into a migration would never + * run there. + */ +import { z } from 'zod'; + +/** v1 persona roles. Extend this list, not the database. */ +export const MEMBERSHIP_ROLES = ['developer', 'qa', 'security', 'sre'] as const; + +export type MembershipRole = (typeof MEMBERSHIP_ROLES)[number]; + +export const DEFAULT_MEMBERSHIP_ROLE: MembershipRole = 'developer'; + +/** + * Body for `POST /api/v1/agents/:name/projects/:project`. + * + * `llm` names the per-membership brain override; `null` clears it so a + * re-join can drop back to the agent's own pinned Llm. + */ +export const JoinProjectSchema = z + .object({ + role: z.enum(MEMBERSHIP_ROLES).optional(), + llm: z.string().min(1).nullable().optional(), + }) + .strict(); + +export type JoinProjectInput = z.infer; diff --git a/src/mcpd/src/validation/agent.schema.ts b/src/mcpd/src/validation/agent.schema.ts index cdfb215..459c572 100644 --- a/src/mcpd/src/validation/agent.schema.ts +++ b/src/mcpd/src/validation/agent.schema.ts @@ -120,6 +120,12 @@ export const AgentChatRequestSchema = AgentChatParamsSchema.merge( * agent's `defaultPersonalityId` when omitted. */ personality: z.string().min(1).optional(), + /** + * Which of the agent's projects to view for this turn — the second lens in + * docs/agentic-teams.md §4. Requires an AgentProject membership; the chat + * path 404s when the agent has not joined it. + */ + project: z.string().min(1).optional(), }), ).strict().refine((v) => v.message !== undefined || (v.messages?.length ?? 0) > 0, { message: 'Either `message` or `messages` is required', diff --git a/src/mcpd/tests/agent-membership.test.ts b/src/mcpd/tests/agent-membership.test.ts new file mode 100644 index 0000000..a65164b --- /dev/null +++ b/src/mcpd/tests/agent-membership.test.ts @@ -0,0 +1,198 @@ +/** + * The membership half of AgentService: which project an agent is looking at, + * and which brain answers there. + * + * These tests exist because the AgentProject junction shipped inert — the table + * was there but nothing read it, so an agent still had exactly one project. The + * assertions below are what "an agent joins many projects" actually means in + * behaviour rather than in schema. + */ +import type { Agent, AgentProject } from '@prisma/client'; +import { describe, it, expect, vi } from 'vitest'; + +import type { IAgentProjectRepository } from '../src/repositories/agent-project.repository.js'; +import type { IAgentRepository } from '../src/repositories/agent.repository.js'; +import { AgentService } from '../src/services/agent.service.js'; +import type { LlmService } from '../src/services/llm.service.js'; +import type { ProjectService } from '../src/services/project.service.js'; + +const NOW = new Date('2026-01-01T00:00:00Z'); + +function makeAgent(overrides: Partial = {}): Agent { + return { + id: 'agent-alice', + name: 'alice', + description: '', + systemPrompt: '', + llmId: 'llm-home', + projectId: null, + defaultPersonalityId: null, + proxyModelName: null, + toolset: 'project', + defaultParams: {} as Agent['defaultParams'], + extras: {} as Agent['extras'], + visibility: 'public', + kind: 'public', + providerSessionId: null, + status: 'active', + lastHeartbeatAt: null, + inactiveSince: null, + ownerId: 'owner-1', + version: 1, + createdAt: NOW, + updatedAt: NOW, + ...overrides, + } as Agent; +} + +function harness(agents: Agent[], memberships: Array> = []) { + const rows = new Map(agents.map((a) => [a.id, a])); + const links: AgentProject[] = memberships.map((m, i) => ({ + id: `ap-${String(i)}`, + agentId: m.agentId ?? 'agent-alice', + projectId: m.projectId ?? 'proj-sre', + role: m.role ?? 'developer', + llmId: m.llmId ?? null, + createdAt: NOW, + }) as AgentProject); + + const repo = { + findAll: vi.fn(async () => [...rows.values()]), + findById: vi.fn(async (id: string) => rows.get(id) ?? null), + findByName: vi.fn(async (name: string) => [...rows.values()].find((r) => r.name === name) ?? null), + findByProjectId: vi.fn(async (projectId: string) => + [...rows.values()].filter((r) => r.projectId === projectId)), + } as unknown as IAgentRepository; + + const junction = { + findByAgent: vi.fn(async (agentId: string) => links.filter((l) => l.agentId === agentId)), + findByProject: vi.fn(async (projectId: string) => links.filter((l) => l.projectId === projectId)), + find: vi.fn(async (agentId: string, projectId: string) => + links.find((l) => l.agentId === agentId && l.projectId === projectId) ?? null), + } as unknown as IAgentProjectRepository; + + const llms = { + getById: vi.fn(async (id: string) => ({ id, name: id.replace(/^llm-/, '') })), + getByName: vi.fn(async (name: string) => ({ id: `llm-${name}`, name })), + } as unknown as LlmService; + + const projects = { + getById: vi.fn(async (id: string) => ({ id, name: id.replace(/^proj-/, '') })), + resolveAndGet: vi.fn(async (name: string) => ({ id: `proj-${name}`, name })), + } as unknown as ProjectService; + + return { + withMemberships: new AgentService(repo, llms, projects, undefined, junction), + // The same service without the junction wired — proves the transition + // fallback still works for a deployment that has not migrated. + legacyOnly: new AgentService(repo, llms, projects), + }; +} + +describe('AgentService.resolveMembership — no --project (the person on their own)', () => { + it('returns nothing for an agent with no project at all', async () => { + const { withMemberships } = harness([makeAgent()]); + expect(await withMemberships.resolveMembership('alice')).toBeNull(); + }); + + it('falls back to the legacy Agent.projectId pin', async () => { + const { withMemberships } = harness([makeAgent({ projectId: 'proj-legacy' })]); + const m = await withMemberships.resolveMembership('alice'); + expect(m?.projectName).toBe('legacy'); + expect(m?.llmName).toBeNull(); + }); +}); + +describe('AgentService.resolveMembership — with --project (the person viewing a team)', () => { + it('resolves a joined project', async () => { + const { withMemberships } = harness([makeAgent()], [{ projectId: 'proj-sre', role: 'sre' }]); + const m = await withMemberships.resolveMembership('alice', 'sre'); + expect(m).toMatchObject({ projectId: 'proj-sre', projectName: 'sre', role: 'sre', llmName: null }); + }); + + it('picks the RIGHT membership when the agent is on several teams', async () => { + const { withMemberships } = harness( + [makeAgent()], + [ + { projectId: 'proj-sre', role: 'sre' }, + { projectId: 'proj-payments', role: 'security' }, + ], + ); + expect((await withMemberships.resolveMembership('alice', 'payments'))?.role).toBe('security'); + expect((await withMemberships.resolveMembership('alice', 'sre'))?.role).toBe('sre'); + }); + + it('throws with a join hint when the agent is not on that team', async () => { + const { withMemberships } = harness([makeAgent()], [{ projectId: 'proj-sre' }]); + await expect(withMemberships.resolveMembership('alice', 'payments')).rejects.toThrow( + /is not a member of project 'payments'.*mcpctl attach-agent alice --project payments/s, + ); + }); + + it('accepts the legacy pin as the transition fallback when it names the same project', async () => { + const { withMemberships } = harness([makeAgent({ projectId: 'proj-sre' })]); + const m = await withMemberships.resolveMembership('alice', 'sre'); + expect(m?.projectId).toBe('proj-sre'); + // No membership row means no override to read. + expect(m?.llmName).toBeNull(); + }); + + it('still refuses a DIFFERENT project than the legacy pin — silently answering from the wrong context would be worse than an error', async () => { + const { withMemberships } = harness([makeAgent({ projectId: 'proj-sre' })]); + await expect(withMemberships.resolveMembership('alice', 'payments')).rejects.toThrow(/not a member/); + }); + + it('works without the junction wired at all (pre-migration deployment)', async () => { + const { legacyOnly } = harness([makeAgent({ projectId: 'proj-sre' })]); + expect((await legacyOnly.resolveMembership('alice', 'sre'))?.projectId).toBe('proj-sre'); + await expect(legacyOnly.resolveMembership('alice', 'payments')).rejects.toThrow(/not a member/); + }); +}); + +describe('AgentService.resolveMembership — the per-membership brain override', () => { + it('reports the override so chat can dispatch to it', async () => { + const { withMemberships } = harness( + [makeAgent({ llmId: 'llm-home' })], + [{ projectId: 'proj-sre', llmId: 'llm-sre-brain' }], + ); + expect((await withMemberships.resolveMembership('alice', 'sre'))?.llmName).toBe('sre-brain'); + }); + + it('is per-membership: the same agent keeps its own brain on another team', async () => { + const { withMemberships } = harness( + [makeAgent()], + [ + { projectId: 'proj-sre', llmId: 'llm-sre-brain' }, + { projectId: 'proj-payments', llmId: null }, + ], + ); + expect((await withMemberships.resolveMembership('alice', 'sre'))?.llmName).toBe('sre-brain'); + expect((await withMemberships.resolveMembership('alice', 'payments'))?.llmName).toBeNull(); + }); +}); + +describe('AgentService.listByProject — the roster', () => { + it('unions members with legacy-pinned agents, without duplicates', async () => { + const { withMemberships } = harness( + [ + makeAgent({ id: 'agent-alice', name: 'alice', projectId: 'proj-sre' }), // legacy pin + makeAgent({ id: 'agent-bob', name: 'bob' }), // member only + makeAgent({ id: 'agent-carol', name: 'carol' }), // neither + ], + [ + { agentId: 'agent-bob', projectId: 'proj-sre' }, + { agentId: 'agent-alice', projectId: 'proj-sre' }, // both pinned AND joined + ], + ); + const roster = await withMemberships.listByProject('sre'); + expect(roster.map((a) => a.name)).toEqual(['alice', 'bob']); + }); + + it('returns only legacy-pinned agents when the junction is not wired', async () => { + const { legacyOnly } = harness( + [makeAgent({ id: 'agent-alice', name: 'alice', projectId: 'proj-sre' }), makeAgent({ id: 'agent-bob', name: 'bob' })], + [{ agentId: 'agent-bob', projectId: 'proj-sre' }], + ); + expect((await legacyOnly.listByProject('sre')).map((a) => a.name)).toEqual(['alice']); + }); +}); diff --git a/src/mcpd/tests/agent-project-routes.test.ts b/src/mcpd/tests/agent-project-routes.test.ts new file mode 100644 index 0000000..7906b59 --- /dev/null +++ b/src/mcpd/tests/agent-project-routes.test.ts @@ -0,0 +1,103 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { describe, it, expect, vi, afterEach } from 'vitest'; + +import { errorHandler } from '../src/middleware/error-handler.js'; +import { registerAgentProjectRoutes } from '../src/routes/agent-projects.js'; +import type { AgentProjectService, MembershipView } from '../src/services/agent-project.service.js'; +import { NotFoundError } from '../src/services/mcp-server.service.js'; + +const NOW = new Date('2026-01-01T00:00:00Z'); + +function view(overrides: Partial = {}): MembershipView { + return { + id: 'ap-1', + agent: { id: 'agent-alice', name: 'alice' }, + project: { id: 'proj-sre', name: 'sre' }, + role: 'sre', + llm: null, + createdAt: NOW, + ...overrides, + }; +} + +function build(service: Partial>): FastifyInstance { + const app = Fastify(); + app.setErrorHandler(errorHandler); + registerAgentProjectRoutes(app, service as unknown as AgentProjectService); + return app; +} + +let app: FastifyInstance | undefined; +afterEach(async () => { await app?.close(); app = undefined; }); + +describe('agent ↔ project membership routes', () => { + it('GET /agents/:name/projects lists memberships', async () => { + app = build({ listForAgent: vi.fn(async () => [view(), view({ id: 'ap-2', project: { id: 'proj-pay', name: 'payments' } })]) }); + const res = await app.inject({ method: 'GET', url: '/api/v1/agents/alice/projects' }); + expect(res.statusCode).toBe(200); + expect(res.json()).toHaveLength(2); + }); + + it('POST /agents/:name/projects/:project joins and returns 201', async () => { + const join = vi.fn(async () => view({ role: 'qa' })); + app = build({ join }); + const res = await app.inject({ + method: 'POST', + url: '/api/v1/agents/alice/projects/sre', + payload: { role: 'qa' }, + }); + expect(res.statusCode).toBe(201); + expect(res.json().role).toBe('qa'); + expect(join).toHaveBeenCalledWith('alice', 'sre', { role: 'qa' }); + }); + + it('POST with no body still joins — role and brain are both optional', async () => { + const join = vi.fn(async () => view()); + app = build({ join }); + const res = await app.inject({ method: 'POST', url: '/api/v1/agents/alice/projects/sre' }); + expect(res.statusCode).toBe(201); + expect(join).toHaveBeenCalledWith('alice', 'sre', {}); + }); + + it('DELETE /agents/:name/projects/:project leaves and returns 204', async () => { + const leave = vi.fn(async () => undefined); + app = build({ leave }); + const res = await app.inject({ method: 'DELETE', url: '/api/v1/agents/alice/projects/sre' }); + expect(res.statusCode).toBe(204); + expect(leave).toHaveBeenCalledWith('alice', 'sre'); + }); + + it('DELETE 404s when the agent was never a member', async () => { + app = build({ + leave: vi.fn(async () => { throw new NotFoundError("Agent 'alice' is not a member of project 'sre'"); }), + }); + const res = await app.inject({ method: 'DELETE', url: '/api/v1/agents/alice/projects/sre' }); + expect(res.statusCode).toBe(404); + expect(res.json().error).toMatch(/not a member/); + }); + + it('404s on an unknown agent', async () => { + app = build({ listForAgent: vi.fn(async () => { throw new NotFoundError('Agent not found: ghost'); }) }); + const res = await app.inject({ method: 'GET', url: '/api/v1/agents/ghost/projects' }); + expect(res.statusCode).toBe(404); + }); + + it('GET /projects/:name/memberships is the reverse view, with roles', async () => { + app = build({ + listForProject: vi.fn(async () => [ + view({ agent: { id: 'agent-alice', name: 'alice' }, role: 'sre' }), + view({ id: 'ap-2', agent: { id: 'agent-bob', name: 'bob' }, role: 'qa' }), + ]), + }); + const res = await app.inject({ method: 'GET', url: '/api/v1/projects/sre/memberships' }); + expect(res.statusCode).toBe(200); + expect(res.json().map((m: MembershipView) => `${m.agent.name}:${m.role}`)).toEqual(['alice:sre', 'bob:qa']); + }); + + it('url-decodes names before handing them to the service', async () => { + const join = vi.fn(async () => view()); + app = build({ join }); + await app.inject({ method: 'POST', url: '/api/v1/agents/alice/projects/my%2Dteam' }); + expect(join).toHaveBeenCalledWith('alice', 'my-team', {}); + }); +}); diff --git a/src/mcpd/tests/chat-service-membership.test.ts b/src/mcpd/tests/chat-service-membership.test.ts new file mode 100644 index 0000000..1ddeb38 --- /dev/null +++ b/src/mcpd/tests/chat-service-membership.test.ts @@ -0,0 +1,180 @@ +/** + * `mcpctl chat --project

` — the same person, viewing one of the + * teams they joined (docs/agentic-teams.md §4). + * + * The regression these lock down: the AgentProject junction existed for a whole + * release without the chat path reading it, so `--project` on an agent could + * only ever mean the one project pinned on the Agent row. + */ +import type { ChatMessage, ChatThread, Prompt } from '@prisma/client'; +import { describe, it, expect, vi } from 'vitest'; + +import type { IChatRepository } from '../src/repositories/chat.repository.js'; +import type { IPromptRepository } from '../src/repositories/prompt.repository.js'; +import type { AgentService, ResolvedMembership } from '../src/services/agent.service.js'; +import { ChatService, type ChatToolDispatcher } from '../src/services/chat.service.js'; +import type { LlmAdapterRegistry } from '../src/services/llm/dispatcher.js'; +import type { LlmAdapter, NonStreamingResult } from '../src/services/llm/types.js'; +import type { LlmService } from '../src/services/llm.service.js'; +import { NotFoundError } from '../src/services/mcp-server.service.js'; + +const NOW = new Date('2026-01-01T00:00:00Z'); + +function mockChatRepo(): IChatRepository { + const msgs: ChatMessage[] = []; + let n = 1; + return { + createThread: vi.fn(async ({ agentId, ownerId }) => ({ + id: `thread-${String(n++)}`, agentId, projectId: null, ownerId, + title: '', lastTurnAt: NOW, createdAt: NOW, updatedAt: NOW, + } as ChatThread)), + findThread: vi.fn(async () => null), + listThreadsByAgent: vi.fn(async () => []), + listMessages: vi.fn(async () => []), + appendMessage: vi.fn(async (input: { threadId: string; role: string; content: string }) => { + const m = { + id: `msg-${String(n++)}`, threadId: input.threadId, turnIndex: msgs.length, + role: input.role, content: input.content, toolCalls: null, toolCallId: null, + status: 'complete', createdAt: NOW, + } as unknown as ChatMessage; + msgs.push(m); + return m; + }), + updateStatus: vi.fn(async () => ({}) as ChatMessage), + markPendingAsError: vi.fn(async () => 0), + touchThread: vi.fn(async () => undefined), + nextTurnIndex: vi.fn(async () => msgs.length), + } as unknown as IChatRepository; +} + +function mockPromptRepo(rows: Prompt[] = []): IPromptRepository { + return { + findAll: vi.fn(async () => rows), + findByAgent: vi.fn(async () => []), + } as unknown as IPromptRepository; +} + +/** + * The agent: pinned to `home-brain`, legacy-pinned to project `legacy`, and a + * member of `sre` (with its own brain) and `payments` (without one). + */ +const MEMBERSHIPS: Record = { + sre: { projectId: 'proj-sre', projectName: 'sre', role: 'sre', llmName: 'sre-brain' }, + payments: { projectId: 'proj-payments', projectName: 'payments', role: 'security', llmName: null }, +}; + +function mockAgents(): AgentService { + return { + getByName: vi.fn(async (name: string) => ({ + id: `agent-${name}`, name, description: '', systemPrompt: 'You are alice.', + llm: { id: 'llm-home-brain', name: 'home-brain' }, + project: { id: 'proj-legacy', name: 'legacy' }, + defaultPersonality: null, proxyModelName: null, toolset: 'project', + defaultParams: {}, extras: {}, ownerId: 'owner-1', version: 1, + createdAt: NOW, updatedAt: NOW, + })), + resolveMembership: vi.fn(async (_name: string, projectName?: string) => { + if (projectName === undefined) { + return { projectId: 'proj-legacy', projectName: 'legacy', role: '', llmName: null }; + } + const found = MEMBERSHIPS[projectName]; + if (found === undefined) { + throw new NotFoundError(`Agent 'alice' is not a member of project '${projectName}'.`); + } + return found; + }), + } as unknown as AgentService; +} + +function mockLlms(): LlmService { + const row = (name: string): Record => ({ + id: `llm-${name}`, name, type: 'openai', model: `${name}-model`, + url: '', tier: 'fast', description: '', + apiKeySecretId: null, apiKeySecretKey: null, extraConfig: {}, poolName: null, + kind: 'public', providerSessionId: null, status: 'active', + lastHeartbeatAt: null, inactiveSince: null, + version: 1, createdAt: NOW, updatedAt: NOW, + }); + return { + getByName: vi.fn(async (name: string) => ({ ...row(name), apiKeyRef: null })), + findByPoolName: vi.fn(async (poolName: string) => [row(poolName)]), + resolveApiKey: vi.fn(async () => 'fake-key'), + } as unknown as LlmService; +} + +function reply(content: string): NonStreamingResult { + return { + status: 200, + body: { id: 'c', object: 'chat.completion', choices: [{ index: 0, message: { role: 'assistant', content }, finish_reason: 'stop' }] }, + }; +} + +function build() { + const listTools = vi.fn(async () => []); + const tools = { listTools, callTool: vi.fn(async () => ({})) } as unknown as ChatToolDispatcher; + const llms = mockLlms(); + const promptRepo = mockPromptRepo(); + const adapter: LlmAdapter = { + kind: 'scripted', + infer: vi.fn(async () => reply('ok')), + stream: async function*() { yield { data: '[DONE]', done: true }; }, + } as unknown as LlmAdapter; + const service = new ChatService( + mockAgents(), llms, { get: () => adapter } as unknown as LlmAdapterRegistry, + mockChatRepo(), promptRepo, tools, + ); + return { service, llms, listTools, promptRepo, adapter }; +} + +describe('agent chat scoped to a joined project', () => { + it('uses the MEMBERSHIP project for tools, not the legacy pin', async () => { + const { service, listTools } = build(); + await service.chat({ agentName: 'alice', ownerId: 'u1', userMessage: 'hi', projectName: 'payments' }); + expect(listTools).toHaveBeenCalledWith('proj-payments'); + }); + + it('falls back to the legacy pin when no project is named', async () => { + const { service, listTools } = build(); + await service.chat({ agentName: 'alice', ownerId: 'u1', userMessage: 'hi' }); + expect(listTools).toHaveBeenCalledWith('proj-legacy'); + }); + + it('the per-membership brain wins over the agent\'s pinned Llm', async () => { + const { service, llms } = build(); + const res = await service.chat({ agentName: 'alice', ownerId: 'u1', userMessage: 'hi', projectName: 'sre' }); + expect(llms.getByName).toHaveBeenCalledWith('sre-brain'); + expect(llms.getByName).not.toHaveBeenCalledWith('home-brain'); + // Reported back to the caller, so `mcpctl chat` can show which brain answered. + expect(res.llm).toBe('sre-brain'); + }); + + it('keeps the agent\'s own brain on a membership with no override', async () => { + const { service, llms } = build(); + const res = await service.chat({ agentName: 'alice', ownerId: 'u1', userMessage: 'hi', projectName: 'payments' }); + expect(llms.getByName).toHaveBeenCalledWith('home-brain'); + expect(res.llm).toBe('home-brain'); + }); + + it('and on bare chat, with no project at all', async () => { + const { service, llms } = build(); + await service.chat({ agentName: 'alice', ownerId: 'u1', userMessage: 'hi' }); + expect(llms.getByName).toHaveBeenCalledWith('home-brain'); + }); + + it('refuses a project the agent has not joined — before spending a token', async () => { + const { service, adapter } = build(); + await expect( + service.chat({ agentName: 'alice', ownerId: 'u1', userMessage: 'hi', projectName: 'finance' }), + ).rejects.toThrow(/not a member of project 'finance'/); + expect(adapter.infer).not.toHaveBeenCalled(); + }); + + it('scopes the streaming path the same way', async () => { + const { service, listTools } = build(); + const chunks = []; + for await (const c of service.chatStream({ agentName: 'alice', ownerId: 'u1', userMessage: 'hi', projectName: 'sre' })) { + chunks.push(c); + } + expect(listTools).toHaveBeenCalledWith('proj-sre'); + }); +}); diff --git a/src/mcpd/tests/chat-service-virtual-llm.test.ts b/src/mcpd/tests/chat-service-virtual-llm.test.ts index 5d86b3f..a36cd82 100644 --- a/src/mcpd/tests/chat-service-virtual-llm.test.ts +++ b/src/mcpd/tests/chat-service-virtual-llm.test.ts @@ -56,6 +56,7 @@ function mockChatRepo(): IChatRepository { function mockAgents(): AgentService { return { + resolveMembership: vi.fn(async () => null), getByName: vi.fn(async (name: string) => ({ id: `agent-${name}`, name, description: '', systemPrompt: 'You are a helpful agent.', diff --git a/src/mcpd/tests/chat-service.test.ts b/src/mcpd/tests/chat-service.test.ts index 42f4f9d..ef5f9aa 100644 --- a/src/mcpd/tests/chat-service.test.ts +++ b/src/mcpd/tests/chat-service.test.ts @@ -97,6 +97,15 @@ function mockTools(impl: Partial = {}): ChatToolDispatcher { function mockAgents(opts: { defaultPersonality?: { id: string; name: string } | null } = {}): AgentService { return { + // Membership resolution with no `--project`: the legacy `Agent.projectId` + // pin, exactly what the real service returns for the same input. + resolveMembership: vi.fn(async (name: string, projectName?: string) => { + if (projectName !== undefined) { + return { projectId: `proj-${projectName}`, projectName, role: 'developer', llmName: null }; + } + if (name === 'no-project') return null; + return { projectId: 'proj-1', projectName: 'mcpctl-dev', role: '', llmName: null }; + }), getByName: vi.fn(async (name: string) => ({ id: `agent-${name}`, name, @@ -801,6 +810,9 @@ describe('ChatService', () => { }); // Agent with maxIterations=2 — only 2 tool-call rounds allowed before bail. const agentsLowCap = { + resolveMembership: vi.fn(async () => ({ + projectId: 'proj-1', projectName: 'mcpctl-dev', role: '', llmName: null, + })), getByName: vi.fn(async () => ({ id: 'agent-low', name: 'low', description: '', systemPrompt: '', llm: { id: 'llm-1', name: 'qwen3-thinking' }, @@ -821,6 +833,9 @@ describe('ChatService', () => { // Hostile agent with maxIterations=1000000 — must clamp to 50, not iterate forever. const agentsHostile = { + resolveMembership: vi.fn(async () => ({ + projectId: 'proj-1', projectName: 'mcpctl-dev', role: '', llmName: null, + })), getByName: vi.fn(async () => ({ id: 'agent-bad', name: 'bad', description: '', systemPrompt: '', llm: { id: 'llm-1', name: 'qwen3-thinking' }, @@ -1106,6 +1121,8 @@ describe('ChatService — failover chain', () => { } function agentPinnedTo(llmName: string): AgentService { return { + // No project, no membership — the failover chain is about the Llm pool. + resolveMembership: vi.fn(async () => null), getByName: vi.fn(async (name: string) => ({ id: `agent-${name}`, name, description: '', systemPrompt: 'sys', llm: { id: 'x', name: llmName }, project: null, defaultPersonality: null, diff --git a/src/mcpd/tests/services/agent-project-service.test.ts b/src/mcpd/tests/services/agent-project-service.test.ts new file mode 100644 index 0000000..c1d628a --- /dev/null +++ b/src/mcpd/tests/services/agent-project-service.test.ts @@ -0,0 +1,215 @@ +import type { AgentProject } from '@prisma/client'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import type { IAgentProjectRepository } from '../../src/repositories/agent-project.repository.js'; +import { AgentProjectService } from '../../src/services/agent-project.service.js'; +import type { AgentService } from '../../src/services/agent.service.js'; +import type { LlmService } from '../../src/services/llm.service.js'; +import { NotFoundError } from '../../src/services/mcp-server.service.js'; +import type { ProjectService } from '../../src/services/project.service.js'; + +const NOW = new Date('2026-01-01T00:00:00Z'); + +/** + * In-memory AgentProject junction. The unique (agentId, projectId) constraint + * is modelled here because the service's idempotent re-join depends on it — + * without it the "join twice" test would pass for the wrong reason. + */ +function makeHarness(seed: Array> = []) { + let idCounter = 1; + let rows: AgentProject[] = seed.map((r) => ({ + id: r.id ?? `ap-${String(idCounter++)}`, + agentId: r.agentId ?? 'agent-alice', + projectId: r.projectId ?? 'proj-sre', + role: r.role ?? 'developer', + llmId: r.llmId ?? null, + createdAt: NOW, + }) as AgentProject); + + const repo = { + findByAgent: vi.fn(async (agentId: string) => rows.filter((r) => r.agentId === agentId)), + findByProject: vi.fn(async (projectId: string) => rows.filter((r) => r.projectId === projectId)), + find: vi.fn(async (agentId: string, projectId: string) => + rows.find((r) => r.agentId === agentId && r.projectId === projectId) ?? null, + ), + create: vi.fn(async (data: { agentId: string; projectId: string; role?: string; llmId?: string | null }) => { + const row = { + id: `ap-${String(idCounter++)}`, + agentId: data.agentId, + projectId: data.projectId, + role: data.role ?? 'developer', + llmId: data.llmId ?? null, + createdAt: NOW, + } as AgentProject; + rows = [...rows, row]; + return row; + }), + update: vi.fn(async (id: string, data: { role?: string; llmId?: string | null }) => { + const row = rows.find((r) => r.id === id); + if (row === undefined) throw new Error(`no such membership: ${id}`); + if (data.role !== undefined) row.role = data.role; + if (data.llmId !== undefined) row.llmId = data.llmId; + return row; + }), + delete: vi.fn(async (id: string) => { rows = rows.filter((r) => r.id !== id); }), + } as unknown as IAgentProjectRepository & Record>; + + const agents = { + getByName: vi.fn(async (name: string) => { + if (name === 'ghost') throw new NotFoundError(`Agent not found: ${name}`); + return { id: `agent-${name}`, name }; + }), + getById: vi.fn(async (id: string) => ({ id, name: id.replace(/^agent-/, '') })), + } as unknown as AgentService; + + const projects = { + resolveAndGet: vi.fn(async (name: string) => { + if (name === 'nowhere') throw new NotFoundError(`Project not found: ${name}`); + return { id: `proj-${name}`, name }; + }), + getById: vi.fn(async (id: string) => ({ id, name: id.replace(/^proj-/, '') })), + } as unknown as ProjectService; + + const llms = { + getByName: vi.fn(async (name: string) => { + if (name === 'missing-brain') throw new NotFoundError(`Llm not found: ${name}`); + return { id: `llm-${name}`, name }; + }), + getById: vi.fn(async (id: string) => ({ id, name: id.replace(/^llm-/, '') })), + } as unknown as LlmService; + + return { + repo, agents, projects, llms, + service: new AgentProjectService(repo, agents, projects, llms), + all: () => rows, + }; +} + +describe('AgentProjectService — joining and leaving', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + it('joins a project with the default role', async () => { + const { service } = makeHarness(); + const m = await service.join('alice', 'sre', {}); + expect(m.project.name).toBe('sre'); + expect(m.agent.name).toBe('alice'); + expect(m.role).toBe('developer'); + expect(m.llm).toBeNull(); + }); + + it('joins the SAME agent to several projects — the whole point of the junction', async () => { + const { service } = makeHarness(); + await service.join('alice', 'sre', {}); + await service.join('alice', 'payments', { role: 'security' }); + + const memberships = await service.listForAgent('alice'); + expect(memberships.map((m) => m.project.name).sort()).toEqual(['payments', 'sre']); + expect(memberships.find((m) => m.project.name === 'payments')?.role).toBe('security'); + }); + + it('re-joining updates the role instead of 409-ing — repeat applies converge', async () => { + const { service, repo, all } = makeHarness(); + await service.join('alice', 'sre', { role: 'developer' }); + const second = await service.join('alice', 'sre', { role: 'qa' }); + + expect(second.role).toBe('qa'); + expect(all()).toHaveLength(1); + expect(repo.create).toHaveBeenCalledTimes(1); + expect(repo.update).toHaveBeenCalledTimes(1); + }); + + it('rejects a role outside the v1 set', async () => { + const { service } = makeHarness(); + await expect(service.join('alice', 'sre', { role: 'chief-vibes-officer' })).rejects.toThrow(); + }); + + it('accepts every v1 role', async () => { + const { service } = makeHarness(); + for (const role of AgentProjectService.roles()) { + const m = await service.join('alice', 'sre', { role }); + expect(m.role).toBe(role); + } + }); + + it('rejects unknown body fields rather than silently dropping them', async () => { + const { service } = makeHarness(); + // A typo'd `llmName` would otherwise leave the membership with no brain + // override and no complaint — the exact failure the strict schema exists for. + await expect(service.join('alice', 'sre', { llmName: 'x' })).rejects.toThrow(); + }); + + it('404s on an unknown agent and on an unknown project', async () => { + const { service } = makeHarness(); + await expect(service.join('ghost', 'sre', {})).rejects.toThrow(/Agent not found/); + await expect(service.join('alice', 'nowhere', {})).rejects.toThrow(/Project not found/); + }); + + it('leaves a project', async () => { + const { service, all } = makeHarness([{ agentId: 'agent-alice', projectId: 'proj-sre' }]); + await service.leave('alice', 'sre'); + expect(all()).toHaveLength(0); + }); + + it('404s when leaving a project the agent never joined — a typo must not no-op', async () => { + const { service } = makeHarness(); + await expect(service.leave('alice', 'sre')).rejects.toThrow(/is not a member of project 'sre'/); + }); +}); + +describe('AgentProjectService — the per-membership brain override', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + it('records the named Llm', async () => { + const { service } = makeHarness(); + const m = await service.join('alice', 'sre', { llm: 'qwen3-thinking' }); + expect(m.llm).toEqual({ id: 'llm-qwen3-thinking', name: 'qwen3-thinking' }); + }); + + it('clears the override with an explicit null', async () => { + const { service } = makeHarness(); + await service.join('alice', 'sre', { llm: 'qwen3-thinking' }); + const cleared = await service.join('alice', 'sre', { llm: null }); + expect(cleared.llm).toBeNull(); + }); + + it('leaves an existing override untouched when `llm` is absent', async () => { + const { service } = makeHarness(); + await service.join('alice', 'sre', { llm: 'qwen3-thinking' }); + const roleOnly = await service.join('alice', 'sre', { role: 'qa' }); + expect(roleOnly.role).toBe('qa'); + expect(roleOnly.llm?.name).toBe('qwen3-thinking'); + }); + + it('404s on an Llm that does not exist', async () => { + const { service } = makeHarness(); + await expect(service.join('alice', 'sre', { llm: 'missing-brain' })).rejects.toThrow(/Llm not found/); + }); + + it('survives a brain that vanished under the membership', async () => { + // onDelete: SetNull should prevent this, but a torn read must not 500 the + // whole roster. + const { service, llms } = makeHarness([{ agentId: 'agent-alice', projectId: 'proj-sre', llmId: 'llm-gone' }]); + (llms.getById as ReturnType).mockRejectedValueOnce(new NotFoundError('Llm not found: llm-gone')); + const [m] = await service.listForAgent('alice'); + expect(m?.llm).toBeNull(); + }); +}); + +describe('AgentProjectService — the reverse view', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + it('lists the whole roster for a project, with roles', async () => { + const { service } = makeHarness([ + { agentId: 'agent-alice', projectId: 'proj-sre', role: 'sre' }, + { agentId: 'agent-bob', projectId: 'proj-sre', role: 'qa' }, + { agentId: 'agent-carol', projectId: 'proj-payments', role: 'security' }, + ]); + const roster = await service.listForProject('sre'); + expect(roster.map((m) => `${m.agent.name}:${m.role}`)).toEqual(['alice:sre', 'bob:qa']); + }); + + it('404s on an unknown project', async () => { + const { service } = makeHarness(); + await expect(service.listForProject('nowhere')).rejects.toThrow(/Project not found/); + }); +}); -- 2.49.1 From 4515eb4517168481c96e99d53b220664136ba3e3 Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 9 Aug 2026 23:34:28 +0100 Subject: [PATCH 23/32] fix: don't commit agent worktrees MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `git add -A` swept .claude/worktrees/ into the previous commit as embedded git repositories. They are transient checkouts created for parallel agent runs and have no business in the tree — removed from the index and gitignored. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/worktrees/agent-a44ac837a6e139022 | 1 - .claude/worktrees/agent-a802aa2e60d491032 | 1 - .claude/worktrees/fix-healthcheck-unifi | 1 - .gitignore | 3 +++ 4 files changed, 3 insertions(+), 3 deletions(-) delete mode 160000 .claude/worktrees/agent-a44ac837a6e139022 delete mode 160000 .claude/worktrees/agent-a802aa2e60d491032 delete mode 160000 .claude/worktrees/fix-healthcheck-unifi diff --git a/.claude/worktrees/agent-a44ac837a6e139022 b/.claude/worktrees/agent-a44ac837a6e139022 deleted file mode 160000 index 00373d2..0000000 --- a/.claude/worktrees/agent-a44ac837a6e139022 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 00373d2b27a1969ae6ef44aa9b49bfc3bdb9917e diff --git a/.claude/worktrees/agent-a802aa2e60d491032 b/.claude/worktrees/agent-a802aa2e60d491032 deleted file mode 160000 index 1a28cc5..0000000 --- a/.claude/worktrees/agent-a802aa2e60d491032 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 1a28cc50985538425a4d057666fa18e3fac91b1a diff --git a/.claude/worktrees/fix-healthcheck-unifi b/.claude/worktrees/fix-healthcheck-unifi deleted file mode 160000 index 732ca98..0000000 --- a/.claude/worktrees/fix-healthcheck-unifi +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 732ca98ccc8c243e277d8efbbebafb960ba8f750 diff --git a/.gitignore b/.gitignore index 4cf4127..ccd4173 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,6 @@ test-mcp.sh # Claude Code local state .claude/scheduled_tasks.lock + +# Agent worktrees — transient checkouts, never part of the repo +.claude/worktrees/ -- 2.49.1 From 60db1be531d867e3298e722c73425a91081bdf11 Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 9 Aug 2026 23:36:53 +0100 Subject: [PATCH 24/32] feat(epictask): make the criterion-id convention an invariant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes 175. Epic.doneCriteria and EpicTask.doneCriteria are both JSON blobs joined only by a free-form id, and nothing checked that a node's ids existed on its parent Epic. A typo produced a criterion belonging to nobody. The reconciliation added in 170 surfaces those as `unowned`, which is honest but late: the mistake was made at write time and is far cheaper to refuse there. Now create and update both reject unknown ids, naming every one of them and listing what the Epic actually has — or saying plainly that the Epic has no criteria yet, which is the likelier cause when a whole roadmap comes back empty. Enforced in the service, not the schema: production provisions with `prisma db push`, so a constraint would never exist where it matters. A node with no criteria of its own is still fine — not every roadmap node claims one — and an update that doesn't touch criteria doesn't pay for the check. This deliberately stops short of the EpicCriterion table with a real FK. That remains the stronger answer if criteria ever need to be addressed independently, but it costs a schema change plus migrating everything currently in the JSON blobs, and the invariant closes the actual failure mode today. Also files 177: agent chat --project reaches project prompts and MCP tools while checking only run:agents:. Raised by the agent that built membership, which correctly refused to invent the policy mid-task. Pre-existing behaviour for a legacy pin, but membership widens the blast radius considerably — it should be a deliberate decision with a test. Local: build clean, workspace 2557 passed (+7), lint unchanged at 794 errors. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p --- .taskmaster/tasks/tasks.json | 34 ++++++--- src/mcpd/src/services/epic-task.service.ts | 41 ++++++++++- .../tests/services/epic-task-service.test.ts | 73 +++++++++++++++++++ 3 files changed, 137 insertions(+), 11 deletions(-) diff --git a/.taskmaster/tasks/tasks.json b/.taskmaster/tasks/tasks.json index 239e6de..ff78915 100644 --- a/.taskmaster/tasks/tasks.json +++ b/.taskmaster/tasks/tasks.json @@ -3089,9 +3089,9 @@ "dependencies": [ "120" ], - "status": "pending", + "status": "done", "subtasks": [], - "updatedAt": "2026-08-08T08:55:58.627Z" + "updatedAt": "2026-08-09T22:34:49.672Z" }, { "id": "153", @@ -3301,9 +3301,9 @@ "120", "152" ], - "status": "pending", + "status": "done", "subtasks": [], - "updatedAt": "2026-08-09T19:36:34.052Z" + "updatedAt": "2026-08-09T22:34:49.682Z" }, { "id": "167", @@ -3426,9 +3426,9 @@ "dependencies": [ "170" ], - "status": "pending", + "status": "done", "subtasks": [], - "updatedAt": "2026-08-09T21:09:27.819Z" + "updatedAt": "2026-08-09T22:36:53.472Z" }, { "id": "176", @@ -3443,16 +3443,30 @@ "status": "pending", "subtasks": [], "updatedAt": "2026-08-09T21:09:27.819Z" + }, + { + "id": "177", + "title": "Agent chat --project reaches project tools with only run:agents", + "description": "`mcpctl chat --project

` surfaces that project prompts and MCP tools while checking only run:agents:. A caller with no run:projects:

reaches project tools through the agent.", + "details": "Raised by the agent that built AgentProject membership, which deliberately did NOT add a second check because this is already todays behaviour for an agent with a legacy projectId — adding one would have been new policy invented mid-task. Correct call; the policy decision is ours.\n\nWhat changed is the blast radius: membership makes this reachable for many more (agent, project) pairs than a single scalar pin ever could.\n\nOptions:\n- require run:projects:

in addition to run:agents: when --project is used (tightest; may break existing single-pin agents whose users were never granted the project);\n- require it only for membership-derived context, grandfathering the legacy pin;\n- leave as-is and document that agent membership delegates project tool access.\n\nWhichever way, it should be a deliberate decision with a test, not an accident of which check happened to be written first.", + "testStrategy": "See details.", + "priority": "high", + "dependencies": [ + "166" + ], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-09T22:34:45.085Z" } ], "metadata": { "version": "1.0.0", - "lastModified": "2026-08-09T21:09:27.647Z", - "taskCount": 174, - "completedCount": 130, + "lastModified": "2026-08-09T22:36:53.472Z", + "taskCount": 177, + "completedCount": 133, "tags": [ "master" ] } } -} +} \ No newline at end of file diff --git a/src/mcpd/src/services/epic-task.service.ts b/src/mcpd/src/services/epic-task.service.ts index 667968b..0ee328f 100644 --- a/src/mcpd/src/services/epic-task.service.ts +++ b/src/mcpd/src/services/epic-task.service.ts @@ -72,6 +72,8 @@ export class EpicTaskService { ); } + this.assertCriteriaBelongToEpic(readCriteria(epic), data.doneCriteria, epic.name); + return this.repo.create({ name: data.name, epicId: data.epicId, @@ -85,7 +87,13 @@ export class EpicTaskService { async update(id: string, input: unknown): Promise { const data = UpdateEpicTaskSchema.parse(input); - await this.get(id); // 404 if missing + const node = await this.get(id); // 404 if missing + + if (data.doneCriteria !== undefined) { + const epic = await this.epicRepo.findById(node.epicId); + if (epic === null) throw new NotFoundError(`Epic not found: ${node.epicId}`); + this.assertCriteriaBelongToEpic(readCriteria(epic), data.doneCriteria, epic.name); + } return this.repo.update(id, { ...(data.description !== undefined ? { description: data.description } : {}), @@ -101,6 +109,37 @@ export class EpicTaskService { await this.repo.delete(id); } + /** + * A roadmap node may only claim criteria its parent Epic actually has. + * + * Epic.doneCriteria and EpicTask.doneCriteria are both JSON blobs joined by a + * free-form id, and until now nothing checked that the ids matched. A typo + * produced a criterion that belonged to nobody: the reconciliation reported + * it as `unowned`, which is honest but late — the mistake was made at write + * time and is far cheaper to refuse there. + * + * Enforced in the service, not the schema: production provisions with + * `prisma db push`, so a constraint here would never exist in production. + */ + private assertCriteriaBelongToEpic( + epicCriteria: DoneCriterion[], + nodeCriteria: DoneCriterion[] | undefined, + epicName: string, + ): void { + if (nodeCriteria === undefined || nodeCriteria.length === 0) return; + const known = new Set(epicCriteria.map((c) => c.id)); + const unknown = nodeCriteria.map((c) => c.id).filter((id) => !known.has(id)); + if (unknown.length === 0) return; + + const available = epicCriteria.map((c) => c.id); + throw badRequest( + `Epic '${epicName}' has no done-criterion ${unknown.map((u) => `'${u}'`).join(', ')}. ` + + (available.length > 0 + ? `Its criteria are: ${available.join(', ')}.` + : 'It has no criteria yet — add them to the Epic first.'), + ); + } + // ── Backup / restore helpers ── async upsertByName(data: Record, ownerId: string): Promise { diff --git a/src/mcpd/tests/services/epic-task-service.test.ts b/src/mcpd/tests/services/epic-task-service.test.ts index 585e926..0a7b520 100644 --- a/src/mcpd/tests/services/epic-task-service.test.ts +++ b/src/mcpd/tests/services/epic-task-service.test.ts @@ -248,3 +248,76 @@ describe('EpicTaskService CRUD', () => { } }); }); + +describe('EpicTaskService — criterion ids must belong to the parent Epic', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + /** An epicRepo whose Epic carries the given criterion ids. */ + function withEpicCriteria(ids: string[]) { + const { service, epicRepo, repo } = makeGraph([{ id: 'a', name: 'chassis' }]); + (epicRepo.findById as ReturnType).mockResolvedValue({ + id: 'epic-1', + name: 'the-car', + doneCriteria: ids.map((id) => ({ id, text: id, kind: 'outcome', met: false })), + } as never); + return { service, repo }; + } + + it('accepts a node claiming a criterion the Epic has', async () => { + const { service } = withEpicCriteria(['wheels', 'engine']); + await expect( + service.create({ name: 'n1', epicId: 'epic-1', doneCriteria: [ + { id: 'wheels', text: 'has wheels', kind: 'outcome', met: false }, + ] }, 'user-1'), + ).resolves.toBeDefined(); + }); + + it('refuses a criterion the Epic never had, and lists what it does have', async () => { + // Previously this succeeded and the criterion silently belonged to nobody — + // surfacing only later as `unowned` in the reconciliation. + const { service } = withEpicCriteria(['wheels', 'engine']); + await expect( + service.create({ name: 'n1', epicId: 'epic-1', doneCriteria: [ + { id: 'whels', text: 'typo', kind: 'outcome', met: false }, + ] }, 'user-1'), + ).rejects.toThrow(/no done-criterion 'whels'.*wheels, engine/s); + }); + + it('names every unknown id, not just the first', async () => { + const { service } = withEpicCriteria(['wheels']); + await expect( + service.create({ name: 'n1', epicId: 'epic-1', doneCriteria: [ + { id: 'aaa', text: 'a', kind: 'outcome', met: false }, + { id: 'bbb', text: 'b', kind: 'outcome', met: false }, + ] }, 'user-1'), + ).rejects.toThrow(/'aaa', 'bbb'/); + }); + + it('says so plainly when the Epic has no criteria at all', async () => { + const { service } = withEpicCriteria([]); + await expect( + service.create({ name: 'n1', epicId: 'epic-1', doneCriteria: [ + { id: 'x', text: 'x', kind: 'outcome', met: false }, + ] }, 'user-1'), + ).rejects.toThrow(/no criteria yet — add them to the Epic first/); + }); + + it('allows a node with no criteria of its own', async () => { + // Not every roadmap node claims a criterion; that is not an error. + const { service } = withEpicCriteria(['wheels']); + await expect(service.create({ name: 'n1', epicId: 'epic-1' }, 'user-1')).resolves.toBeDefined(); + }); + + it('enforces the same rule on update', async () => { + const { service } = withEpicCriteria(['wheels']); + await expect( + service.update('a', { doneCriteria: [{ id: 'nope', text: 'n', kind: 'outcome', met: false }] }), + ).rejects.toThrow(/no done-criterion 'nope'/); + }); + + it('does not re-check when an update leaves criteria alone', async () => { + const { service, repo } = withEpicCriteria(['wheels']); + await expect(service.update('a', { status: 'done' })).resolves.toBeDefined(); + expect(repo.update).toHaveBeenCalled(); + }); +}); -- 2.49.1 From 2a305f70459b4174972538ff8d9b0c1885540e54 Mon Sep 17 00:00:00 2001 From: Michal Date: Mon, 10 Aug 2026 12:11:24 +0100 Subject: [PATCH 25/32] feat(cli): describe formatters for the agentic-teams resources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes 167. The five new resources fell through to the generic key-value dump, against the project rule that describe shows clean sections and tables. More to the point, two things built earlier had no way to reach a human at all: the roadmap endpoint had no client, and the Epic reconciliation was only visible as raw JSON. `describe epic` puts the delivery mismatch directly under the completeness percentage it contradicts, because reading either one alone is the bug. Rendered against a live mcpd: Completeness: [....................] 0% (0/3) ... Delivery: MISMATCH between what is claimed and what shipped Tasks: 1 merged, 0 outstanding, 0 cancelled Delivered, not claimed: - wheels (1 merged, 0 outstanding) No roadmap node owns it: - engine, feel plus the criteria checklist, accepted tradeoffs, and the roadmap with per-node completeness and READY flags. `describe task` derives the gates a task still owes from its risk and recorded verdicts ("awaiting security_review, sre_review" vs "all passed"), and shows the legal next transitions, PR linkage, verdicts and full history — so the answer to "why won't this move" is on screen rather than requiring a failed attempt. `describe repo` / `describe gitprovider` show the credential REFERENCE only. The resolved value exists solely on the session-provision path and must never reach a describe. The roadmap, verdict and event fetches are all best-effort: an older mcpd 404s and the resource still renders, rather than the whole describe failing over an enrichment. One bug found by looking at the output rather than the types: the Git section opened on `!== undefined`, but JSON hands back null, so every task without a PR printed an empty `Git:` header. Local: build clean, workspace 2557 passed, lint unchanged at 794 errors. Verified live against a local mcpd for epic, epictask and task. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p --- .taskmaster/tasks/tasks.json | 8 +- src/cli/src/commands/describe.ts | 310 +++++++++++++++++++++++++++++++ 2 files changed, 314 insertions(+), 4 deletions(-) diff --git a/.taskmaster/tasks/tasks.json b/.taskmaster/tasks/tasks.json index ff78915..6fd7448 100644 --- a/.taskmaster/tasks/tasks.json +++ b/.taskmaster/tasks/tasks.json @@ -3315,9 +3315,9 @@ "dependencies": [ "136" ], - "status": "pending", + "status": "done", "subtasks": [], - "updatedAt": "2026-08-09T19:36:34.052Z" + "updatedAt": "2026-08-10T11:11:23.911Z" }, { "id": "168", @@ -3461,9 +3461,9 @@ ], "metadata": { "version": "1.0.0", - "lastModified": "2026-08-09T22:36:53.472Z", + "lastModified": "2026-08-10T11:11:23.912Z", "taskCount": 177, - "completedCount": 133, + "completedCount": 134, "tags": [ "master" ] diff --git a/src/cli/src/commands/describe.ts b/src/cli/src/commands/describe.ts index bda4ffb..bf09244 100644 --- a/src/cli/src/commands/describe.ts +++ b/src/cli/src/commands/describe.ts @@ -824,6 +824,275 @@ async function resolveLink(linkTarget: string, client: ApiClient): Promise, roadmap?: RoadmapInfo): string { + const lines: string[] = []; + lines.push(`=== Epic: ${String(epic.name)} ===`); + lines.push(`${pad('Name:')}${String(epic.name)}`); + const project = epic.project as { name?: string } | undefined; + lines.push(`${pad('Project:')}${project?.name ?? '-'}`); + lines.push(`${pad('Status:')}${String(epic.status)}`); + lines.push(`${pad('Semver:')}${String(epic.semver)}`); + if (epic.description !== undefined && epic.description !== '') { + lines.push(`${pad('Description:')}${String(epic.description)}`); + } + + const c = epic.completeness as Completeness | undefined; + if (c !== undefined) { + lines.push(''); + lines.push(`Completeness: ${bar(c.percent)} ${String(c.percent)}% (${String(c.met)}/${String(c.total)})`); + } + + const criteria = (epic.doneCriteria ?? []) as DoneCriterion[]; + if (criteria.length > 0) { + lines.push(''); + lines.push('Done criteria:'); + for (const cr of criteria) { + const mark = cr.met === true ? '[x]' : '[ ]'; + const kind = cr.kind === 'quality' ? ' (quality)' : ''; + lines.push(` ${mark} ${pad(cr.id, 22)}${cr.text}${kind}`); + } + } + + // The reconciliation sits directly under the percentage it contradicts — + // reading one without the other is the bug this whole feature exists for. + const rec = epic.reconciliation as Reconciliation | undefined; + if (rec !== undefined) { + lines.push(''); + if (rec.consistent === true) { + lines.push('Delivery: claims and delivered work agree'); + } else { + lines.push('Delivery: MISMATCH between what is claimed and what shipped'); + } + if (rec.tasks !== undefined) { + lines.push(` ${pad('Tasks:', 22)}${String(rec.tasks.merged)} merged, ${String(rec.tasks.outstanding)} outstanding, ${String(rec.tasks.cancelled)} cancelled`); + } + const buckets: Array<[string, CriterionDelivery[] | undefined]> = [ + ['Delivered, not claimed', rec.deliveredButUnclaimed], + ['Claimed, nothing shipped', rec.claimedButUndelivered], + ['No roadmap node owns it', rec.unowned], + ]; + for (const [label, rows] of buckets) { + if (rows === undefined || rows.length === 0) continue; + lines.push(` ${label}:`); + for (const r of rows) { + const counts = r.merged !== undefined + ? ` (${String(r.merged)} merged, ${String(r.outstanding ?? 0)} outstanding)` + : ''; + lines.push(` - ${r.id}${counts}`); + } + } + } + + const tradeoffs = (epic.acceptedTradeoffs ?? []) as Array<{ what?: string; why?: string; who?: string }>; + if (tradeoffs.length > 0) { + lines.push(''); + lines.push('Accepted tradeoffs:'); + for (const t of tradeoffs) { + lines.push(` - ${t.what ?? '?'}`); + lines.push(` why: ${t.why ?? '?'}${t.who !== undefined ? ` (${t.who})` : ''}`); + } + } + + if (roadmap?.nodes !== undefined && roadmap.nodes.length > 0) { + lines.push(''); + lines.push('Roadmap:'); + const ready = new Set(roadmap.ready ?? []); + for (const n of roadmap.nodes) { + const pct = n.completeness !== undefined ? `${String(n.completeness.percent)}%` : '-'; + const flags = [ + ready.has(n.id) ? 'READY' : '', + (n.dependsOn ?? []).length > 0 ? `waits on ${String((n.dependsOn ?? []).length)}` : '', + ].filter((f) => f !== '').join(', '); + lines.push(` ${pad(n.name, 26)}${pad(n.status, 14)}${pad(pct, 6)}${flags}`); + } + } + + if (epic.content !== undefined && epic.content !== '') { + lines.push(''); + lines.push('Vision:'); + for (const line of String(epic.content).split('\n')) lines.push(` ${line}`); + } + return lines.join('\n'); +} + +interface Verdict { stage: string; commit: string; passed: boolean; summary: string } +interface TaskEvent { fromState: string | null; toState: string; actorType: string; note: string | null; createdAt: string } + +function formatTaskDetail( + task: Record, + verdicts?: Verdict[], + events?: TaskEvent[], +): string { + const lines: string[] = []; + lines.push(`=== Task: ${String(task.name)} ===`); + lines.push(`${pad('Name:')}${String(task.name)}`); + lines.push(`${pad('Title:')}${String(task.title)}`); + lines.push(`${pad('State:')}${String(task.state)}`); + lines.push(`${pad('Risk:')}${String(task.risk)}`); + const assignee = task.assignee as { name?: string } | null | undefined; + lines.push(`${pad('Assignee:')}${assignee?.name ?? '(unassigned)'}`); + const repo = task.repo as { name?: string } | null | undefined; + if (repo?.name !== undefined) lines.push(`${pad('Repo:')}${repo.name}`); + + const legal = (task.legalTransitions ?? []) as string[]; + if (legal.length > 0) { + lines.push(`${pad('Can move to:')}${legal.join(', ')}`); + } else { + lines.push(`${pad('Can move to:')}(terminal)`); + } + + // Which gates this task still owes, derived from risk + recorded verdicts. + const REQUIRED: Record = { + trivial: [], normal: ['qa_review'], high: ['qa_review', 'security_review', 'sre_review'], + }; + const required = REQUIRED[String(task.risk)] ?? []; + if (required.length > 0) { + const passed = new Set((verdicts ?? []).filter((v) => v.passed).map((v) => v.stage)); + const missing = required.filter((r) => !passed.has(r)); + lines.push(`${pad('Gates:')}${missing.length === 0 ? 'all passed' : `awaiting ${missing.join(', ')}`}`); + } + + // JSON hands back null, not undefined, so a bare `!== undefined` check opens + // the section and then filters every line out of it. + const hasGit = [task.branch, task.headCommit, task.prUrl] + .some((v) => v !== undefined && v !== null && v !== ''); + if (hasGit) { + lines.push(''); + lines.push('Git:'); + if (task.branch !== undefined && task.branch !== null) lines.push(` ${pad('Branch:', 14)}${String(task.branch)}`); + if (task.headCommit !== undefined && task.headCommit !== null) lines.push(` ${pad('Commit:', 14)}${String(task.headCommit)}`); + if (task.prUrl !== undefined && task.prUrl !== null) lines.push(` ${pad('PR:', 14)}${String(task.prUrl)}`); + } + + if (task.reviewNote !== undefined && task.reviewNote !== null && task.reviewNote !== '') { + lines.push(''); + lines.push(`Review note: ${String(task.reviewNote)}`); + } + + if (verdicts !== undefined && verdicts.length > 0) { + lines.push(''); + lines.push('Verdicts:'); + for (const v of verdicts) { + lines.push(` ${v.passed ? 'PASS' : 'FAIL'} ${pad(v.stage, 18)}@${v.commit.slice(0, 8)} ${v.summary}`); + } + } + + if (events !== undefined && events.length > 0) { + lines.push(''); + lines.push('History:'); + for (const e of events) { + const when = new Date(e.createdAt).toISOString().replace('T', ' ').slice(0, 19); + lines.push(` ${when} ${pad(e.fromState ?? '(new)', 16)}→ ${pad(e.toState, 16)}[${e.actorType}]${e.note !== null ? ` ${e.note}` : ''}`); + } + } + + if (task.description !== undefined && task.description !== '') { + lines.push(''); + lines.push('Description:'); + for (const line of String(task.description).split('\n')) lines.push(` ${line}`); + } + return lines.join('\n'); +} + +function formatCodeRepoDetail(repo: Record): string { + const lines: string[] = []; + lines.push(`=== Repo: ${String(repo.name)} ===`); + lines.push(`${pad('Name:')}${String(repo.name)}`); + const project = repo.project as { name?: string } | undefined; + lines.push(`${pad('Project:')}${project?.name ?? '-'}`); + lines.push(`${pad('URL:')}${String(repo.url)}`); + lines.push(`${pad('Branch:')}${String(repo.defaultBranch)}`); + lines.push(`${pad('Path:')}${repo.path === '' ? `${String(repo.name)} (default)` : String(repo.path)}`); + lines.push(`${pad('Provider:')}${String(repo.provider)}`); + + if (repo.sourceFullName !== undefined && repo.sourceFullName !== null) { + lines.push(''); + lines.push('Imported from:'); + lines.push(` ${pad('Forge:', 14)}${String(repo.sourceProvider ?? '(disconnected)')}`); + lines.push(` ${pad('Repository:', 14)}${String(repo.sourceFullName)}`); + } + + lines.push(''); + lines.push('Credential:'); + // Reference only. The resolved value exists solely on the session-provision + // path and must never reach a describe. + if (repo.credSecretId !== undefined && repo.credSecretId !== null) { + lines.push(` ${pad('Key:', 14)}${String(repo.credSecretKey)} (from a secret)`); + } else if (repo.gitProviderId !== undefined && repo.gitProviderId !== null) { + lines.push(` inherited from forge '${String(repo.sourceProvider ?? '?')}'`); + } else { + lines.push(' (none — public or ambient auth)'); + } + return lines.join('\n'); +} + +function formatGitProviderDetail(gp: Record): string { + const lines: string[] = []; + lines.push(`=== GitProvider: ${String(gp.name)} ===`); + lines.push(`${pad('Name:')}${String(gp.name)}`); + lines.push(`${pad('Type:')}${String(gp.type)}`); + lines.push(`${pad('URL:')}${String(gp.url)}`); + if (gp.description !== undefined && gp.description !== '') { + lines.push(`${pad('Description:')}${String(gp.description)}`); + } + lines.push(''); + lines.push('Credential:'); + if (gp.credSecretId !== undefined && gp.credSecretId !== null) { + lines.push(` ${pad('Key:', 14)}${String(gp.credSecretKey)} (from a secret)`); + } else { + lines.push(' (none — anonymous access only)'); + } + lines.push(''); + lines.push(`Discover repositories: mcpctl repos discover ${String(gp.name)}`); + return lines.join('\n'); +} + +function formatEpicTaskDetail(node: Record): string { + const lines: string[] = []; + lines.push(`=== EpicTask: ${String(node.name)} ===`); + lines.push(`${pad('Name:')}${String(node.name)}`); + const epic = node.epic as { name?: string } | undefined; + lines.push(`${pad('Epic:')}${epic?.name ?? '-'}`); + lines.push(`${pad('Status:')}${String(node.status)}`); + if (node.description !== undefined && node.description !== '') { + lines.push(`${pad('Description:')}${String(node.description)}`); + } + const criteria = (node.doneCriteria ?? []) as DoneCriterion[]; + lines.push(''); + if (criteria.length === 0) { + lines.push('Owns no done-criteria (nothing on the Epic is measured by this node)'); + } else { + lines.push('Owns these Epic criteria:'); + for (const cr of criteria) { + lines.push(` ${cr.met === true ? '[x]' : '[ ]'} ${pad(cr.id, 22)}${cr.text}`); + } + } + return lines.join('\n'); +} + function formatProxymodelDetail(model: Record): string { const lines: string[] = []; const modelType = (model.type as string | undefined) ?? 'pipeline'; @@ -1013,6 +1282,47 @@ export function createDescribeCommand(deps: DescribeCommandDeps): Command { case 'secretbackends': deps.log(formatSecretBackendDetail(item)); break; + case 'epics': { + // The roadmap lives behind its own endpoint and had no client at + // all until now. Best-effort: an older mcpd 404s and we render the + // Epic alone rather than failing the whole describe. + let roadmap: RoadmapInfo | undefined; + try { + roadmap = await deps.client.get( + `/api/v1/epics/${encodeURIComponent(String(item.id))}/roadmap`, + ); + } catch { + roadmap = undefined; + } + deps.log(formatEpicDetail(item, roadmap)); + break; + } + case 'epictasks': + deps.log(formatEpicTaskDetail(item)); + break; + case 'tasks': { + // Verdicts drive the "which gates does this still owe" line, and + // the history is the audit trail — both are separate endpoints. + let verdicts: Verdict[] | undefined; + let events: TaskEvent[] | undefined; + try { + [verdicts, events] = await Promise.all([ + deps.client.get(`/api/v1/tasks/${encodeURIComponent(String(item.id))}/verdicts`), + deps.client.get(`/api/v1/tasks/${encodeURIComponent(String(item.id))}/events`), + ]); + } catch { + verdicts = undefined; + events = undefined; + } + deps.log(formatTaskDetail(item, verdicts, events)); + break; + } + case 'repos': + deps.log(formatCodeRepoDetail(item)); + break; + case 'gitproviders': + deps.log(formatGitProviderDetail(item)); + break; case 'llms': { // v4: also fetch pool membership so the describe Pool block // can show siblings + active counts. Best-effort — older -- 2.49.1 From 821c45b2ecf063432c0d40271e1be368706fb48e Mon Sep 17 00:00:00 2001 From: Michal Date: Fri, 14 Aug 2026 23:57:46 +0100 Subject: [PATCH 26/32] fix(cli): stop claiming that accept merges anything MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mcpctl tasks accept --help` said "fires the merge". Nothing merges. There is no createPullRequest or mergePullRequest anywhere in the codebase — the git-provider client is task 148 and is not built. Accept records human approval and moves the task to `merged`. Until 148/149 land, `merged` means "a human approved this", not "this is in main", and the help text and service comments now say so. A user who accepted a task expecting a PR to land would have been silently wrong. Co-Authored-By: Claude Opus 5 (1M context) --- completions/mcpctl.fish | 2 +- src/cli/src/commands/tasks.ts | 2 +- src/mcpd/src/services/task.service.ts | 9 ++++++++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index 9e5d169..9cac1cb 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -371,7 +371,7 @@ complete -c mcpctl -n "__mcpctl_subcmd_active repos import" -l ssh -d 'Use ssh c set -l tasks_cmds advance verdict accept reject history complete -c mcpctl -n "__fish_seen_subcommand_from tasks; and not __fish_seen_subcommand_from $tasks_cmds" -a advance -d 'Move a task to the next state' complete -c mcpctl -n "__fish_seen_subcommand_from tasks; and not __fish_seen_subcommand_from $tasks_cmds" -a verdict -d 'Record a QA / Security / SRE verdict, pinned to the commit it judged' -complete -c mcpctl -n "__fish_seen_subcommand_from tasks; and not __fish_seen_subcommand_from $tasks_cmds" -a accept -d 'Accept a task awaiting review — fires the merge' +complete -c mcpctl -n "__fish_seen_subcommand_from tasks; and not __fish_seen_subcommand_from $tasks_cmds" -a accept -d 'Accept a task awaiting review (records the decision; does NOT merge the PR yet)' complete -c mcpctl -n "__fish_seen_subcommand_from tasks; and not __fish_seen_subcommand_from $tasks_cmds" -a reject -d 'Send a task back with direction (the comment is required)' complete -c mcpctl -n "__fish_seen_subcommand_from tasks; and not __fish_seen_subcommand_from $tasks_cmds" -a history -d 'Show the transition log and recorded verdicts' diff --git a/src/cli/src/commands/tasks.ts b/src/cli/src/commands/tasks.ts index ffee69e..469a646 100644 --- a/src/cli/src/commands/tasks.ts +++ b/src/cli/src/commands/tasks.ts @@ -85,7 +85,7 @@ export function createTasksCommand(deps: TasksCommandDeps): Command { }); cmd.command('accept') - .description('Accept a task awaiting review — fires the merge') + .description('Accept a task awaiting review (records the decision; does NOT merge the PR yet)') .argument('', 'Task name or id') .option('--note ', 'Comment stored with the decision') .action(async (task: string, opts) => { diff --git a/src/mcpd/src/services/task.service.ts b/src/mcpd/src/services/task.service.ts index 8fa35f9..7131d75 100644 --- a/src/mcpd/src/services/task.service.ts +++ b/src/mcpd/src/services/task.service.ts @@ -31,7 +31,10 @@ const TRANSITIONS: Record = { security_review: ['sre_review', 'done', 'in_progress', 'cancelled'], sre_review: ['done', 'in_progress', 'cancelled'], done: ['awaiting_review', 'in_progress', 'cancelled'], - // The human gate: accept fires the merge, reject sends it back with a comment. + // The human gate: accept records the decision, reject sends it back with a + // comment. NOTE: accept does not yet merge anything — the git-provider merge + // call is task 148/149. Until then `merged` means "a human approved it", not + // "it is in main". awaiting_review: ['merged', 'in_progress', 'cancelled'], // Post-merge failure reopens; the same agent picks it back up. merged: ['reopened'], @@ -263,6 +266,10 @@ export class TaskService { * Accept: awaiting_review → merged. The caller's comment is stored on the * task, so the decision and its reason live together. * + * Does NOT merge a pull request. No git-provider client is wired yet (148), + * so this records human approval and nothing else. Anything that reads + * `merged` as "landed in main" is currently wrong. + * * This is a task-level yes/no on a resolved outcome, NOT a per-tool approval * — the agent has already acted autonomously up to this point. */ -- 2.49.1 From ac9423e9c8ae8eb854923cb100b41d6915ddf088 Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 15 Aug 2026 00:05:31 +0100 Subject: [PATCH 27/32] feat(git): merge the PR when a task is accepted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes 148/149 — the accept gate now lands code instead of just moving a row. Driver gains createPullRequest / getPullRequest / mergePullRequest, verified against the real Gitea API shapes rather than guessed: MergePullRequestOption requires a capitalised `Do`, and supports `head_commit_id`. That `head_commit_id` is the point. A human approved a SPECIFIC commit; if someone pushed after that, merging anyway lands code nobody reviewed. So the reviewed sha travels with the request and is checked twice — once by us for a clear MergeStaleError naming both commits, and again by the forge, which closes the race between our read and the merge. When it trips, the message says what happened and what to do: re-review the current head, then accept again. Accept merges BEFORE it transitions. If the forge refuses — conflicts, failing checks, a moved branch — the task stays in awaiting_review and no event is written. Reporting `merged` while main does not have the code is the single worst thing this gate could do, and there is a test asserting the transition never happens on a failed merge. Failure modes are answers, not stack traces: no PR attached is approval-only and succeeds; a repo with no forge connection explains that mcpctl cannot merge it; an already-merged PR is treated as success so a retry after a timeout is safe. owner/repo comes from `sourceFullName` when discovery recorded it, falling back to parsing https, ssh:// and scp-style clone URLs, and returning null rather than guessing when it cannot tell. The merger is injected and optional, so an mcpd without a git provider still runs and accept degrades to approval-only — which is also what the help text now says, restored after the earlier commit that removed a claim we could not back up. Tests: 7 driver PR tests (Do field, head pinning, stale refusal, idempotent re-merge, 405 → refusal), 10 merger tests incl. URL parsing, 7 accept-gate tests. Local: build clean, workspace 2580 passed (+23), lint unchanged at 794 errors. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p --- .taskmaster/tasks/tasks.json | 12 +- completions/mcpctl.fish | 2 +- src/cli/src/commands/tasks.ts | 2 +- src/mcpd/src/main.ts | 10 +- src/mcpd/src/services/git/gitea.driver.ts | 124 ++++++++++++++++++- src/mcpd/src/services/git/types.ts | 66 ++++++++++ src/mcpd/src/services/task-merger.ts | 108 ++++++++++++++++ src/mcpd/src/services/task.service.ts | 43 +++++-- src/mcpd/tests/services/git-provider.test.ts | 103 ++++++++++++++- src/mcpd/tests/services/task-merger.test.ts | 105 ++++++++++++++++ src/mcpd/tests/services/task-service.test.ts | 68 ++++++++++ 11 files changed, 623 insertions(+), 20 deletions(-) create mode 100644 src/mcpd/src/services/task-merger.ts create mode 100644 src/mcpd/tests/services/task-merger.test.ts diff --git a/.taskmaster/tasks/tasks.json b/.taskmaster/tasks/tasks.json index 6fd7448..bc4452c 100644 --- a/.taskmaster/tasks/tasks.json +++ b/.taskmaster/tasks/tasks.json @@ -3031,9 +3031,9 @@ "dependencies": [ "132" ], - "status": "pending", + "status": "done", "subtasks": [], - "updatedAt": "2026-08-08T08:55:58.627Z" + "updatedAt": "2026-08-14T23:05:31.782Z" }, { "id": "149", @@ -3046,9 +3046,9 @@ "130", "148" ], - "status": "pending", + "status": "done", "subtasks": [], - "updatedAt": "2026-08-08T08:55:58.627Z" + "updatedAt": "2026-08-14T23:05:31.792Z" }, { "id": "150", @@ -3461,9 +3461,9 @@ ], "metadata": { "version": "1.0.0", - "lastModified": "2026-08-10T11:11:23.912Z", + "lastModified": "2026-08-14T23:05:31.792Z", "taskCount": 177, - "completedCount": 134, + "completedCount": 136, "tags": [ "master" ] diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index 9cac1cb..30a055e 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -371,7 +371,7 @@ complete -c mcpctl -n "__mcpctl_subcmd_active repos import" -l ssh -d 'Use ssh c set -l tasks_cmds advance verdict accept reject history complete -c mcpctl -n "__fish_seen_subcommand_from tasks; and not __fish_seen_subcommand_from $tasks_cmds" -a advance -d 'Move a task to the next state' complete -c mcpctl -n "__fish_seen_subcommand_from tasks; and not __fish_seen_subcommand_from $tasks_cmds" -a verdict -d 'Record a QA / Security / SRE verdict, pinned to the commit it judged' -complete -c mcpctl -n "__fish_seen_subcommand_from tasks; and not __fish_seen_subcommand_from $tasks_cmds" -a accept -d 'Accept a task awaiting review (records the decision; does NOT merge the PR yet)' +complete -c mcpctl -n "__fish_seen_subcommand_from tasks; and not __fish_seen_subcommand_from $tasks_cmds" -a accept -d 'Accept a task awaiting review — merges its PR if it has one' complete -c mcpctl -n "__fish_seen_subcommand_from tasks; and not __fish_seen_subcommand_from $tasks_cmds" -a reject -d 'Send a task back with direction (the comment is required)' complete -c mcpctl -n "__fish_seen_subcommand_from tasks; and not __fish_seen_subcommand_from $tasks_cmds" -a history -d 'Show the transition log and recorded verdicts' diff --git a/src/cli/src/commands/tasks.ts b/src/cli/src/commands/tasks.ts index 469a646..8a5114e 100644 --- a/src/cli/src/commands/tasks.ts +++ b/src/cli/src/commands/tasks.ts @@ -85,7 +85,7 @@ export function createTasksCommand(deps: TasksCommandDeps): Command { }); cmd.command('accept') - .description('Accept a task awaiting review (records the decision; does NOT merge the PR yet)') + .description('Accept a task awaiting review — merges its PR if it has one') .argument('', 'Task name or id') .option('--note ', 'Comment stored with the decision') .action(async (task: string, opts) => { diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index a9aa8d7..bb62d48 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -131,6 +131,7 @@ import { EpicService } from './services/epic.service.js'; import { EpicTaskService } from './services/epic-task.service.js'; import { TaskService } from './services/task.service.js'; import { CodeRepoService } from './services/code-repo.service.js'; +import { GitTaskMerger } from './services/task-merger.js'; import { GitProviderService } from './services/git-provider.service.js'; import { GitProviderRegistry } from './services/git/registry.js'; import { McpctlToolProvider } from './services/mcpctl-tools.js'; @@ -402,13 +403,20 @@ async function main(): Promise { const epicService = new EpicService(epicRepo, projectRepo, epicTaskRepo, workTaskRepo); epicService.setRevisionService(resourceRevisionService); const epicTaskService = new EpicTaskService(epicTaskRepo, epicRepo); - const workTaskService = new TaskService(workTaskRepo, epicTaskRepo); const codeRepoService = new CodeRepoService(codeRepoRepo, projectRepo, secretRepo, secretService, gitProviderRepo); // Forge drivers are resolved by type — nothing above this layer knows what a // Gitea is, and the instance URL always comes from the GitProvider row. const gitProviderService = new GitProviderService( gitProviderRepo, codeRepoRepo, projectRepo, new GitProviderRegistry(), secretRepo, secretService, ); + // Accepting a task lands its PR. Constructed after the provider service + // because it needs it; without a merger, accept records approval only. + const gitDrivers = new GitProviderRegistry(); + const workTaskService = new TaskService( + workTaskRepo, + epicTaskRepo, + new GitTaskMerger(codeRepoRepo, gitProviderService, gitDrivers), + ); const personalityRepo = new PersonalityRepository(prisma); const personalityService = new PersonalityService(personalityRepo, agentRepo, promptRepo); // Agentic teams: an agent is a person who joins projects. The junction repo diff --git a/src/mcpd/src/services/git/gitea.driver.ts b/src/mcpd/src/services/git/gitea.driver.ts index 6cd5557..58c6c50 100644 --- a/src/mcpd/src/services/git/gitea.driver.ts +++ b/src/mcpd/src/services/git/gitea.driver.ts @@ -1,10 +1,16 @@ import { GitProviderAuthError, GitProviderError, + MergeConflictError, + MergeStaleError, + type CreatePullRequestInput, type DiscoverOptions, type DiscoveredRepo, type GitProviderContext, type GitProviderDriver, + type MergeOptions, + type PullRequest, + type PullRequestRef, } from './types.js'; /** Gitea's search endpoint wraps results; /version does not. */ @@ -27,6 +33,18 @@ interface GiteaRepo { owner?: { login?: string }; } +interface GiteaPull { + number?: number; + html_url?: string; + url?: string; + title?: string; + state?: string; + merged?: boolean; + mergeable?: boolean; + head?: { sha?: string; ref?: string }; + base?: { ref?: string }; +} + const PAGE_SIZE = 50; const DEFAULT_LIMIT = 200; const TIMEOUT_MS = 15_000; @@ -34,15 +52,25 @@ const TIMEOUT_MS = 15_000; export class GiteaDriver implements GitProviderDriver { readonly type = 'gitea'; - private async call(ctx: GitProviderContext, path: string): Promise { + private async call( + ctx: GitProviderContext, + path: string, + init: { method?: string; body?: unknown } = {}, + ): Promise { const base = ctx.url.replace(/\/+$/, ''); const headers: Record = { Accept: 'application/json' }; // Gitea's scheme is `token `, not `Bearer`. if (ctx.token !== null) headers['Authorization'] = `token ${ctx.token}`; + if (init.body !== undefined) headers['Content-Type'] = 'application/json'; let res: Response; try { - res = await fetch(`${base}${path}`, { headers, signal: AbortSignal.timeout(TIMEOUT_MS) }); + res = await fetch(`${base}${path}`, { + method: init.method ?? 'GET', + headers, + ...(init.body !== undefined ? { body: JSON.stringify(init.body) } : {}), + signal: AbortSignal.timeout(TIMEOUT_MS), + }); } catch (err: unknown) { const detail = err instanceof Error ? err.message : String(err); throw new GitProviderError(`Cannot reach Gitea at ${base}: ${detail}`); @@ -115,6 +143,98 @@ export class GiteaDriver implements GitProviderDriver { return out.slice(0, limit); } + + // ── Pull requests ── + + async createPullRequest(ctx: GitProviderContext, input: CreatePullRequestInput): Promise { + const pull = await this.call( + ctx, + `/api/v1/repos/${enc(input.owner)}/${enc(input.repo)}/pulls`, + { + method: 'POST', + body: { + title: input.title, + base: input.base, + head: input.head, + ...(input.body !== undefined ? { body: input.body } : {}), + }, + }, + ); + return toPullRequest(pull); + } + + async getPullRequest(ctx: GitProviderContext, ref: PullRequestRef): Promise { + const pull = await this.call( + ctx, + `/api/v1/repos/${enc(ref.owner)}/${enc(ref.repo)}/pulls/${String(ref.number)}`, + ); + return toPullRequest(pull); + } + + /** + * Merge, refusing rather than guessing. + * + * `expectHeadSha` is checked twice on purpose: once here so the caller gets a + * clear MergeStaleError naming both commits, and again by the forge via + * `head_commit_id`, which closes the race between our read and the merge. + */ + async mergePullRequest( + ctx: GitProviderContext, + ref: PullRequestRef, + opts: MergeOptions = {}, + ): Promise { + if (opts.expectHeadSha !== undefined) { + const current = await this.getPullRequest(ctx, ref); + if (current.merged) return; // already landed — merging is idempotent + if (current.headSha !== opts.expectHeadSha) { + throw new MergeStaleError(opts.expectHeadSha, current.headSha); + } + } + + try { + await this.call( + ctx, + `/api/v1/repos/${enc(ref.owner)}/${enc(ref.repo)}/pulls/${String(ref.number)}/merge`, + { + method: 'POST', + body: { + // Gitea capitalises this field. + Do: opts.method ?? 'merge', + ...(opts.expectHeadSha !== undefined ? { head_commit_id: opts.expectHeadSha } : {}), + ...(opts.deleteBranchAfterMerge === true ? { delete_branch_after_merge: true } : {}), + }, + }, + ); + } catch (err: unknown) { + // Gitea answers 405 when the PR is not in a mergeable state — conflicts, + // failing required checks, or a closed PR. That is a refusal, not a fault. + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes('HTTP 405') || /conflict/i.test(msg)) { + throw new MergeConflictError( + `Forge refused to merge PR #${String(ref.number)}: it is not in a mergeable state (conflicts, or required checks not passing).`, + ); + } + throw err; + } + } +} + +function enc(v: string): string { + return encodeURIComponent(v); +} + +function toPullRequest(p: GiteaPull): PullRequest { + return { + number: p.number ?? 0, + url: p.html_url ?? p.url ?? '', + title: p.title ?? '', + state: p.state ?? 'unknown', + merged: p.merged ?? false, + headSha: p.head?.sha ?? '', + head: p.head?.ref ?? '', + base: p.base?.ref ?? '', + mergeable: p.mergeable ?? null, + }; } function toDiscovered(r: GiteaRepo): DiscoveredRepo { diff --git a/src/mcpd/src/services/git/types.ts b/src/mcpd/src/services/git/types.ts index 2a02bb3..f5111ba 100644 --- a/src/mcpd/src/services/git/types.ts +++ b/src/mcpd/src/services/git/types.ts @@ -39,12 +39,78 @@ export interface GitProviderContext { token: string | null; } +/** Owner-qualified location of a pull request on a forge. */ +export interface PullRequestRef { + owner: string; + repo: string; + number: number; +} + +export interface CreatePullRequestInput { + owner: string; + repo: string; + title: string; + body?: string; + /** Source branch. */ + head: string; + /** Target branch. */ + base: string; +} + +export interface PullRequest { + number: number; + url: string; + title: string; + state: string; + merged: boolean; + /** Tip commit of the source branch — what a reviewer actually looked at. */ + headSha: string; + head: string; + base: string; + /** Forge's own verdict on whether it can merge cleanly. Null when unknown. */ + mergeable: boolean | null; +} + +export interface MergeOptions { + method?: 'merge' | 'squash' | 'rebase'; + /** + * Merge only if the PR head is still this commit. + * + * The human accepted a specific commit. If someone pushed after that, merging + * anyway lands code nobody approved — so the expected sha travels with the + * request and the forge refuses on mismatch. + */ + expectHeadSha?: string; + deleteBranchAfterMerge?: boolean; +} + export interface GitProviderDriver { readonly type: string; /** List repositories visible to the configured credential. */ listRepositories(ctx: GitProviderContext, opts?: DiscoverOptions): Promise; /** Cheap credential/URL check used at registration time. */ probe(ctx: GitProviderContext): Promise<{ ok: boolean; detail: string }>; + + createPullRequest(ctx: GitProviderContext, input: CreatePullRequestInput): Promise; + getPullRequest(ctx: GitProviderContext, ref: PullRequestRef): Promise; + /** Merge it. Throws MergeConflictError / MergeStaleError rather than lying. */ + mergePullRequest(ctx: GitProviderContext, ref: PullRequestRef, opts?: MergeOptions): Promise; +} + +/** The forge refused because the PR cannot merge cleanly. */ +export class MergeConflictError extends Error { + constructor(message: string) { super(message); this.name = 'MergeConflictError'; } +} + +/** + * The PR moved since it was reviewed. Distinct from a conflict: nothing is + * broken, the approval is simply no longer about the current code. + */ +export class MergeStaleError extends Error { + constructor(readonly expected: string, readonly actual: string) { + super(`Pull request moved since it was reviewed: approved ${expected.slice(0, 8)}, head is now ${actual.slice(0, 8)}`); + this.name = 'MergeStaleError'; + } } /** Raised when a forge rejects the credential or its scopes are insufficient. */ diff --git a/src/mcpd/src/services/task-merger.ts b/src/mcpd/src/services/task-merger.ts new file mode 100644 index 0000000..f966b44 --- /dev/null +++ b/src/mcpd/src/services/task-merger.ts @@ -0,0 +1,108 @@ +import type { Task } from '@prisma/client'; + +import type { ICodeRepoRepository } from '../repositories/code-repo.repository.js'; + +import type { GitProviderService } from './git-provider.service.js'; +import type { GitProviderRegistry } from './git/registry.js'; +import { MergeConflictError, MergeStaleError } from './git/types.js'; +import type { TaskMerger } from './task.service.js'; + +/** + * Lands the pull request behind a task when the human accepts it. + * + * The accept gate only means something if accepting actually lands the code. + * Everything here is written so that a failure leaves the task un-merged and + * says why, rather than reporting success the board can't back up. + */ +export class GitTaskMerger implements TaskMerger { + constructor( + private readonly codeRepos: ICodeRepoRepository, + private readonly providers: GitProviderService, + private readonly drivers: GitProviderRegistry, + private readonly mergeMethod: 'merge' | 'squash' | 'rebase' = 'merge', + ) {} + + async mergeForTask(task: Task, expectHeadSha: string | null): Promise<{ merged: boolean; detail: string }> { + if (task.prNumber === null) { + return { merged: true, detail: 'no pull request attached — approval recorded only' }; + } + if (task.repoId === null) { + return { merged: false, detail: `Task '${task.name}' has PR #${String(task.prNumber)} but no repo, so there is nothing to merge it into. Attach a repo first.` }; + } + + const repo = await this.codeRepos.findById(task.repoId); + if (repo === null) { + return { merged: false, detail: `Task '${task.name}' points at a repo that no longer exists.` }; + } + if (repo.gitProviderId === null) { + return { merged: false, detail: `Repo '${repo.name}' is not connected to a git provider, so mcpctl cannot merge its pull requests. Import it from a gitprovider, or merge in the forge.` }; + } + + const slug = ownerRepoOf(repo.sourceFullName, repo.url); + if (slug === null) { + return { merged: false, detail: `Cannot work out owner/repo for '${repo.name}' from '${repo.url}'.` }; + } + + const provider = await this.providers.get(repo.gitProviderId); + const driver = this.drivers.build(provider.type); + const ctx = await this.providers.contextFor(provider); + + try { + await driver.mergePullRequest( + ctx, + { owner: slug.owner, repo: slug.repo, number: task.prNumber }, + { + method: this.mergeMethod, + // Null headCommit means we never recorded what was reviewed, so there + // is nothing to pin to — merge unpinned rather than refusing outright. + ...(expectHeadSha !== null ? { expectHeadSha } : {}), + }, + ); + return { + merged: true, + detail: `merged ${slug.owner}/${slug.repo}#${String(task.prNumber)} via ${provider.name}`, + }; + } catch (err: unknown) { + if (err instanceof MergeStaleError) { + return { + merged: false, + detail: + `Not merging: ${err.message}. Someone pushed after this was reviewed, so accepting now would land code nobody approved. ` + + 'Re-review the current head, then accept again.', + }; + } + if (err instanceof MergeConflictError) { + return { merged: false, detail: err.message }; + } + return { + merged: false, + detail: `Merge failed: ${err instanceof Error ? err.message : String(err)}`, + }; + } + } +} + +/** + * Owner/repo for the forge API. + * + * Prefers `sourceFullName`, which discovery recorded verbatim. Falls back to + * parsing the clone URL for hand-added repos, handling https and scp-style ssh. + */ +export function ownerRepoOf( + sourceFullName: string | null, + url: string, +): { owner: string; repo: string } | null { + if (sourceFullName !== null && sourceFullName.includes('/')) { + const [owner, ...rest] = sourceFullName.split('/'); + if (owner !== undefined && rest.length > 0) { + return { owner, repo: rest.join('/').replace(/\.git$/, '') }; + } + } + + // git@host:owner/repo.git | https://host/owner/repo.git | ssh://git@host/owner/repo + const scp = url.match(/^[\w.-]+@[\w.-]+:(.+)$/); + const path = scp?.[1] ?? url.replace(/^\w+:\/\/[^/]+\//, ''); + const parts = path.replace(/\.git$/, '').split('/').filter((p) => p !== ''); + if (parts.length < 2) return null; + return { owner: parts[parts.length - 2] as string, repo: parts[parts.length - 1] as string }; +} diff --git a/src/mcpd/src/services/task.service.ts b/src/mcpd/src/services/task.service.ts index 7131d75..b72f29e 100644 --- a/src/mcpd/src/services/task.service.ts +++ b/src/mcpd/src/services/task.service.ts @@ -31,10 +31,8 @@ const TRANSITIONS: Record = { security_review: ['sre_review', 'done', 'in_progress', 'cancelled'], sre_review: ['done', 'in_progress', 'cancelled'], done: ['awaiting_review', 'in_progress', 'cancelled'], - // The human gate: accept records the decision, reject sends it back with a - // comment. NOTE: accept does not yet merge anything — the git-provider merge - // call is task 148/149. Until then `merged` means "a human approved it", not - // "it is in main". + // The human gate: accept lands the PR and records the decision, reject sends + // it back with a comment. awaiting_review: ['merged', 'in_progress', 'cancelled'], // Post-merge failure reopens; the same agent picks it back up. merged: ['reopened'], @@ -74,10 +72,20 @@ export interface TransitionResult { event: TaskEvent; } +/** Lands the PR behind a task. Optional: without it, accept records approval only. */ +export interface TaskMerger { + /** + * @param expectHeadSha merge only if the PR head is still this commit — + * the human approved that commit, not whatever landed after. + */ + mergeForTask(task: Task, expectHeadSha: string | null): Promise<{ merged: boolean; detail: string }>; +} + export class TaskService { constructor( private readonly repo: ITaskRepository, private readonly epicTaskRepo: IEpicTaskRepository, + private readonly merger?: TaskMerger, ) {} static isLegal(from: TaskState, to: TaskState): boolean { @@ -266,9 +274,9 @@ export class TaskService { * Accept: awaiting_review → merged. The caller's comment is stored on the * task, so the decision and its reason live together. * - * Does NOT merge a pull request. No git-provider client is wired yet (148), - * so this records human approval and nothing else. Anything that reads - * `merged` as "landed in main" is currently wrong. + * Merges the task's pull request when it has one and a merger is wired. A + * task with no PR is still acceptable — approval-only is legitimate for work + * that never produced one. * * This is a task-level yes/no on a resolved outcome, NOT a per-tool approval * — the agent has already acted autonomously up to this point. @@ -280,7 +288,26 @@ export class TaskService { throw badRequest(`Only a task in 'awaiting_review' can be accepted (this one is '${task.state}')`); } if (note !== undefined) await this.repo.update(id, { reviewNote: note }); - return this.transition(id, { toState: 'merged', note: note ?? 'accepted' }, actor); + + // Merge FIRST, then transition. If the forge refuses — conflicts, failing + // checks, or the branch moved since review — the task must stay in + // awaiting_review. Marking it merged when nothing merged is the single + // worst thing this gate could do: the board would say landed while main + // does not have the code. + let mergeDetail = 'no pull request attached — approval recorded only'; + if (this.merger !== undefined && task.prNumber !== null) { + const result = await this.merger.mergeForTask(task, task.headCommit); + if (!result.merged) { + throw Object.assign(new Error(result.detail), { statusCode: 409 }); + } + mergeDetail = result.detail; + } + + return this.transition( + id, + { toState: 'merged', note: note !== undefined ? `${note} — ${mergeDetail}` : mergeDetail }, + actor, + ); } /** Reject: back to in_progress. The comment is mandatory — that is the direction. */ diff --git a/src/mcpd/tests/services/git-provider.test.ts b/src/mcpd/tests/services/git-provider.test.ts index 60efa38..f7cc410 100644 --- a/src/mcpd/tests/services/git-provider.test.ts +++ b/src/mcpd/tests/services/git-provider.test.ts @@ -7,7 +7,7 @@ import type { ISecretRepository } from '../../src/repositories/interfaces.js'; import type { IProjectRepository } from '../../src/repositories/project.repository.js'; import { GiteaDriver } from '../../src/services/git/gitea.driver.js'; import { GitProviderRegistry, UnsupportedGitProviderError } from '../../src/services/git/registry.js'; -import { GitProviderAuthError, GitProviderError } from '../../src/services/git/types.js'; +import { GitProviderAuthError, GitProviderError, MergeConflictError, MergeStaleError } from '../../src/services/git/types.js'; import { GitProviderService, repoNameFrom } from '../../src/services/git-provider.service.js'; import type { SecretService } from '../../src/services/secret.service.js'; @@ -340,3 +340,104 @@ describe('GitProviderService.importRepos', () => { ).rejects.toThrow(); }); }); + +// ── Pull requests ───────────────────────────────────────────────────────── + +describe('GiteaDriver — pull requests', () => { + const original = globalThis.fetch; + afterEach(() => { globalThis.fetch = original; }); + + const CTX = { url: 'https://mysources.co.uk', token: 'tok123' }; + const REF = { owner: 'michal', repo: 'mcpctl', number: 7 }; + const pull = (o: Record = {}) => ({ + number: 7, html_url: 'https://mysources.co.uk/michal/mcpctl/pulls/7', + title: 'Wire the CLI', state: 'open', merged: false, mergeable: true, + head: { sha: 'abc1234def', ref: 'feat/wheels' }, base: { ref: 'main' }, ...o, + }); + + it('creates a PR and maps the forge payload', async () => { + let sent: Record = {}; + globalThis.fetch = mockFetch((_u, init) => { + sent = JSON.parse(String(init.body ?? '{}')) as Record; + return { status: 201, body: pull() }; + }) as unknown as typeof fetch; + + const pr = await new GiteaDriver().createPullRequest(CTX, { + owner: 'michal', repo: 'mcpctl', title: 'Wire the CLI', head: 'feat/wheels', base: 'main', + }); + + expect(sent).toEqual({ title: 'Wire the CLI', base: 'main', head: 'feat/wheels' }); + expect(pr).toMatchObject({ number: 7, headSha: 'abc1234def', base: 'main', merged: false }); + }); + + it('merges with the capitalised Do field the forge actually requires', async () => { + let sent: Record = {}; + globalThis.fetch = mockFetch((url, init) => { + if (init.method === 'POST') { + sent = JSON.parse(String(init.body ?? '{}')) as Record; + return { status: 200, body: {} }; + } + return { status: 200, body: pull() }; + }) as unknown as typeof fetch; + + await new GiteaDriver().mergePullRequest(CTX, REF, { method: 'squash' }); + expect(sent).toMatchObject({ Do: 'squash' }); + }); + + it('refuses to merge a PR that moved since it was reviewed', async () => { + // The human approved a specific commit. Merging anyway lands code nobody + // looked at — the whole point of the accept gate. + globalThis.fetch = mockFetch(() => ({ status: 200, body: pull({ head: { sha: 'newsha999', ref: 'f' } }) })) as unknown as typeof fetch; + + await expect( + new GiteaDriver().mergePullRequest(CTX, REF, { expectHeadSha: 'abc1234def' }), + ).rejects.toThrow(MergeStaleError); + await expect( + new GiteaDriver().mergePullRequest(CTX, REF, { expectHeadSha: 'abc1234def' }), + ).rejects.toThrow(/approved abc1234d, head is now newsha99/); + }); + + it('sends head_commit_id so the forge closes the read-then-merge race', async () => { + let sent: Record = {}; + globalThis.fetch = mockFetch((_u, init) => { + if (init.method === 'POST') { + sent = JSON.parse(String(init.body ?? '{}')) as Record; + return { status: 200, body: {} }; + } + return { status: 200, body: pull() }; + }) as unknown as typeof fetch; + + await new GiteaDriver().mergePullRequest(CTX, REF, { expectHeadSha: 'abc1234def' }); + expect(sent).toMatchObject({ head_commit_id: 'abc1234def' }); + }); + + it('treats an already-merged PR as success, not an error', async () => { + // Merging is idempotent: a retry after a timeout must not fail. + let posts = 0; + globalThis.fetch = mockFetch((_u, init) => { + if (init.method === 'POST') { posts++; return { status: 200, body: {} }; } + return { status: 200, body: pull({ merged: true }) }; + }) as unknown as typeof fetch; + + await expect( + new GiteaDriver().mergePullRequest(CTX, REF, { expectHeadSha: 'anything' }), + ).resolves.toBeUndefined(); + expect(posts).toBe(0); + }); + + it('maps the forge 405 to a refusal, not a crash', async () => { + globalThis.fetch = mockFetch((_u, init) => + init.method === 'POST' + ? { status: 405, body: { message: 'Please try again later' } } + : { status: 200, body: pull() }, + ) as unknown as typeof fetch; + + await expect(new GiteaDriver().mergePullRequest(CTX, REF)).rejects.toThrow(MergeConflictError); + await expect(new GiteaDriver().mergePullRequest(CTX, REF)).rejects.toThrow(/not in a mergeable state/); + }); + + it('still reports a bad credential as an auth error on the PR path', async () => { + globalThis.fetch = mockFetch(() => ({ status: 403, body: { message: 'token scope' } })) as unknown as typeof fetch; + await expect(new GiteaDriver().getPullRequest(CTX, REF)).rejects.toThrow(GitProviderAuthError); + }); +}); diff --git a/src/mcpd/tests/services/task-merger.test.ts b/src/mcpd/tests/services/task-merger.test.ts new file mode 100644 index 0000000..65da86a --- /dev/null +++ b/src/mcpd/tests/services/task-merger.test.ts @@ -0,0 +1,105 @@ +import type { CodeRepo, GitProvider, Task } from '@prisma/client'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import type { ICodeRepoRepository } from '../../src/repositories/code-repo.repository.js'; +import type { GitProviderRegistry } from '../../src/services/git/registry.js'; +import { MergeConflictError, MergeStaleError } from '../../src/services/git/types.js'; +import type { GitProviderService } from '../../src/services/git-provider.service.js'; +import { GitTaskMerger, ownerRepoOf } from '../../src/services/task-merger.js'; + +const task = (o: Partial = {}): Task => + ({ id: 't1', name: 'wire-cli', prNumber: 7, repoId: 'r1', headCommit: 'abc1234def', ...o } as Task); + +const repo = (o: Partial = {}): CodeRepo => + ({ id: 'r1', name: 'mcpctl', url: 'https://mysources.co.uk/michal/mcpctl.git', + gitProviderId: 'gp1', sourceFullName: 'michal/mcpctl', ...o } as CodeRepo); + +function harness(opts: { repoRow?: CodeRepo | null; mergeErr?: unknown } = {}) { + const merge = vi.fn(async () => { if (opts.mergeErr !== undefined) throw opts.mergeErr; }); + const codeRepos = { + findById: vi.fn(async () => (opts.repoRow === undefined ? repo() : opts.repoRow)), + } as unknown as ICodeRepoRepository; + const providers = { + get: vi.fn(async () => ({ id: 'gp1', name: 'mysources', type: 'gitea' } as GitProvider)), + contextFor: vi.fn(async () => ({ url: 'https://mysources.co.uk', token: 'tok' })), + } as unknown as GitProviderService; + const drivers = { build: vi.fn(() => ({ mergePullRequest: merge })) } as unknown as GitProviderRegistry; + return { merge, merger: new GitTaskMerger(codeRepos, providers, drivers) }; +} + +describe('GitTaskMerger', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + it('merges the PR and pins to the reviewed commit', async () => { + const { merger, merge } = harness(); + const r = await merger.mergeForTask(task(), 'abc1234def'); + + expect(r.merged).toBe(true); + expect(r.detail).toMatch(/merged michal\/mcpctl#7 via mysources/); + expect(merge).toHaveBeenCalledWith( + expect.anything(), + { owner: 'michal', repo: 'mcpctl', number: 7 }, + expect.objectContaining({ expectHeadSha: 'abc1234def' }), + ); + }); + + it('does not pin when no reviewed commit was ever recorded', async () => { + const { merger, merge } = harness(); + await merger.mergeForTask(task({ headCommit: null }), null); + expect(merge.mock.calls[0]?.[2]).not.toHaveProperty('expectHeadSha'); + }); + + it('refuses, with direction, when the branch moved after review', async () => { + const { merger } = harness({ mergeErr: new MergeStaleError('abc1234def', 'newsha999') }); + const r = await merger.mergeForTask(task(), 'abc1234def'); + + expect(r.merged).toBe(false); + expect(r.detail).toMatch(/would land code nobody approved/); + expect(r.detail).toMatch(/Re-review the current head/); + }); + + it('reports a forge refusal without dressing it up as success', async () => { + const { merger } = harness({ mergeErr: new MergeConflictError('not in a mergeable state') }); + const r = await merger.mergeForTask(task(), null); + expect(r.merged).toBe(false); + expect(r.detail).toMatch(/not in a mergeable state/); + }); + + it('treats a task with no PR as approval-only, not a failure', async () => { + const { merger, merge } = harness(); + const r = await merger.mergeForTask(task({ prNumber: null }), null); + expect(r.merged).toBe(true); + expect(r.detail).toMatch(/no pull request attached/); + expect(merge).not.toHaveBeenCalled(); + }); + + it('explains rather than merges when the repo has no forge connection', async () => { + const { merger } = harness({ repoRow: repo({ gitProviderId: null }) }); + const r = await merger.mergeForTask(task(), null); + expect(r.merged).toBe(false); + expect(r.detail).toMatch(/not connected to a git provider/); + }); + + it('explains when the repo is gone', async () => { + const { merger } = harness({ repoRow: null }); + const r = await merger.mergeForTask(task(), null); + expect(r.merged).toBe(false); + expect(r.detail).toMatch(/no longer exists/); + }); +}); + +describe('ownerRepoOf', () => { + it('prefers the name discovery recorded verbatim', () => { + expect(ownerRepoOf('michal/mcpctl', 'https://x/y/z.git')).toEqual({ owner: 'michal', repo: 'mcpctl' }); + }); + + it('parses https, ssh and scp-style clone urls for hand-added repos', () => { + expect(ownerRepoOf(null, 'https://mysources.co.uk/michal/mcpctl.git')).toEqual({ owner: 'michal', repo: 'mcpctl' }); + expect(ownerRepoOf(null, 'git@mysources.co.uk:michal/mcpctl.git')).toEqual({ owner: 'michal', repo: 'mcpctl' }); + expect(ownerRepoOf(null, 'ssh://git@mysources.co.uk/michal/mcpctl')).toEqual({ owner: 'michal', repo: 'mcpctl' }); + }); + + it('returns null rather than guessing at an unparseable url', () => { + expect(ownerRepoOf(null, 'https://mysources.co.uk/')).toBeNull(); + }); +}); diff --git a/src/mcpd/tests/services/task-service.test.ts b/src/mcpd/tests/services/task-service.test.ts index 419c0b8..aac6b54 100644 --- a/src/mcpd/tests/services/task-service.test.ts +++ b/src/mcpd/tests/services/task-service.test.ts @@ -288,3 +288,71 @@ describe('TaskService verdicts and creation', () => { ).rejects.toThrow(/already exists in epictask 'chassis'/); }); }); + +describe('TaskService.accept — the merge must actually happen', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + function withMerger(result: { merged: boolean; detail: string }, taskOverrides = {}) { + const mergeForTask = vi.fn(async () => result); + const m = mocks(makeTask({ state: 'awaiting_review', prNumber: 7, ...taskOverrides })); + return { + ...m, + mergeForTask, + service: new TaskService(m.repo, m.epicTaskRepo, { mergeForTask }), + }; + } + + it('lands the PR before moving the task', async () => { + const { service, mergeForTask } = withMerger({ merged: true, detail: 'merged michal/mcpctl#7' }); + const { task } = await service.accept('task-1', {}, ACTOR); + + expect(mergeForTask).toHaveBeenCalled(); + expect(task.state).toBe('merged'); + }); + + it('leaves the task in awaiting_review when the forge refuses', async () => { + // The single worst thing this gate could do is report landed while main + // does not have the code. + const { service, repo } = withMerger({ merged: false, detail: 'conflicts on main' }); + + await expect(service.accept('task-1', {}, ACTOR)).rejects.toThrow(/conflicts on main/); + expect(repo.transition).not.toHaveBeenCalled(); + expect(repo.appendEvent).not.toHaveBeenCalled(); + }); + + it('records what actually merged in the transition note', async () => { + const { service, repo } = withMerger({ merged: true, detail: 'merged michal/mcpctl#7 via mysources' }); + await service.accept('task-1', { note: 'ship it' }, ACTOR); + + expect(repo.appendEvent).toHaveBeenCalledWith( + expect.objectContaining({ note: expect.stringContaining('merged michal/mcpctl#7 via mysources') }), + ); + }); + + it('passes the reviewed commit so a moved branch cannot slip through', async () => { + const { service, mergeForTask } = withMerger( + { merged: true, detail: 'ok' }, { headCommit: 'abc1234def' }, + ); + await service.accept('task-1', {}, ACTOR); + expect(mergeForTask).toHaveBeenCalledWith(expect.objectContaining({ headCommit: 'abc1234def' }), 'abc1234def'); + }); + + it('still accepts a task with no PR — approval-only is legitimate', async () => { + const mergeForTask = vi.fn(async () => ({ merged: true, detail: 'x' })); + const m = mocks(makeTask({ state: 'awaiting_review', prNumber: null })); + const service = new TaskService(m.repo, m.epicTaskRepo, { mergeForTask }); + + const { task } = await service.accept('task-1', {}, ACTOR); + expect(task.state).toBe('merged'); + expect(mergeForTask).not.toHaveBeenCalled(); + }); + + it('without a merger wired, accept records approval and says so', async () => { + const { service, repo } = mocks(makeTask({ state: 'awaiting_review', prNumber: 7 })); + const { task } = await service.accept('task-1', {}, ACTOR); + expect(task.state).toBe('merged'); + expect(repo.appendEvent).toHaveBeenCalledWith( + expect.objectContaining({ note: expect.stringContaining('approval recorded only') }), + ); + }); +}); -- 2.49.1 From f0ba6e8096db379c5e909bb35d9e227600a65793 Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 15 Aug 2026 23:10:49 +0100 Subject: [PATCH 28/32] feat(git): reopen a task when CI fails on the commit its merge produced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes 151 — the last loop in the lifecycle. A task reaches `merged`, CI then fails on the merge commit, and the same task comes back as `reopened` instead of the breakage going unnoticed because the board already said done. Two properties make this safe rather than a liability. **It fails closed.** The endpoint is unauthenticated by nature — a forge calls it — so it accepts nothing unless the provider has a configured webhook signing secret AND the HMAC verifies. No secret, no effect. The signature is computed over the RAW body: re-serializing a parsed object changes the bytes, which is the classic way this check silently stops working, and there is a test for it. A 404 for an unknown provider is deliberately vague, because confirming which provider names exist is free reconnaissance on a public endpoint. **It matches precisely.** Accept now records `merge_commit_sha` — the commit the merge actually produced, which is what CI runs against — so a failing status finds exactly the task that caused it. The alternative, reopening "whatever merged most recently", would reopen innocent work. A failure on a commit mcpctl did not merge is somebody else's business and is ignored, as is a task that has already moved on from `merged`. Capturing the merge commit is best-effort by design: the merge already succeeded, so failing to re-read the sha must not fail the accept — it only costs the webhook its precise match later. The route is skipped by the RBAC hook, since the forge has no mcpctl identity. That is only safe because of the fail-closed rule above, and both the skip and the reason are asserted in tests. Tests: 12 covering every refusal path (no secret, unsigned, wrong key, signature over different bytes), the vague 404, the RBAC skip, and the reopen matrix. Local: build clean, workspace 2592 passed (+12), db 128 passed (same 4 pre-existing), lint unchanged at 794 errors. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p --- .taskmaster/tasks/tasks.json | 8 +- .../migration.sql | 6 + src/db/prisma/schema.prisma | 31 ++-- src/mcpd/src/main.ts | 8 + src/mcpd/src/middleware/url-permission.ts | 5 + src/mcpd/src/repositories/task.repository.ts | 7 + src/mcpd/src/routes/git-webhooks.ts | 142 +++++++++++++++++ src/mcpd/src/services/git/gitea.driver.ts | 2 + src/mcpd/src/services/git/types.ts | 2 + src/mcpd/src/services/task-merger.ts | 14 +- src/mcpd/src/services/task.service.ts | 7 +- src/mcpd/tests/git-webhooks.test.ts | 149 ++++++++++++++++++ 12 files changed, 364 insertions(+), 17 deletions(-) create mode 100644 src/db/prisma/migrations/20260815090000_add_merge_commit_and_webhook_secret/migration.sql create mode 100644 src/mcpd/src/routes/git-webhooks.ts create mode 100644 src/mcpd/tests/git-webhooks.test.ts diff --git a/.taskmaster/tasks/tasks.json b/.taskmaster/tasks/tasks.json index bc4452c..1e48a80 100644 --- a/.taskmaster/tasks/tasks.json +++ b/.taskmaster/tasks/tasks.json @@ -3075,9 +3075,9 @@ "130", "148" ], - "status": "pending", + "status": "done", "subtasks": [], - "updatedAt": "2026-08-08T08:55:58.627Z" + "updatedAt": "2026-08-15T22:10:49.721Z" }, { "id": "152", @@ -3461,9 +3461,9 @@ ], "metadata": { "version": "1.0.0", - "lastModified": "2026-08-14T23:05:31.792Z", + "lastModified": "2026-08-15T22:10:49.722Z", "taskCount": 177, - "completedCount": 136, + "completedCount": 137, "tags": [ "master" ] diff --git a/src/db/prisma/migrations/20260815090000_add_merge_commit_and_webhook_secret/migration.sql b/src/db/prisma/migrations/20260815090000_add_merge_commit_and_webhook_secret/migration.sql new file mode 100644 index 0000000..e693b7f --- /dev/null +++ b/src/db/prisma/migrations/20260815090000_add_merge_commit_and_webhook_secret/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable +ALTER TABLE "Task" ADD COLUMN "mergeCommit" TEXT; + +-- AlterTable +ALTER TABLE "GitProvider" ADD COLUMN "webhookSecretKey" TEXT; + diff --git a/src/db/prisma/schema.prisma b/src/db/prisma/schema.prisma index ac46a7c..7e4e636 100644 --- a/src/db/prisma/schema.prisma +++ b/src/db/prisma/schema.prisma @@ -1047,6 +1047,10 @@ model Task { headCommit String? prUrl String? prNumber Int? + // The commit the merge produced. CI runs against this, so a failing status + // webhook can find exactly the task that caused it instead of guessing at + // "whatever merged most recently". + mergeCommit String? // Free-form reviewer comment attached at the accept gate. reviewNote String? @db.Text ownerId String @@ -1256,21 +1260,26 @@ enum SessionState { // is a non-migration change (same reasoning as ResourceProposal.resourceType). model GitProvider { - id String @id @default(cuid()) - name String @unique - description String @default("") + id String @id @default(cuid()) + name String @unique + description String @default("") // Driver key: gitea | (future) github | gitlab - type String @default("gitea") + type String @default("gitea") // Instance base URL, e.g. https://mysources.co.uk — never hardcoded anywhere. - url String + url String // API credential, by reference. Same shape as Llm.apiKeySecret*; the value is // resolved through SecretService and never stored or returned here. - credSecretId String? - credSecretKey String? - ownerId String - version Int @default(1) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + credSecretId String? + credSecretKey String? + // Key (in the same Secret) holding this forge's webhook signing secret. + // Required before the webhook endpoint accepts anything from this provider — + // an unauthenticated webhook that can reopen work is an attack surface, so + // the route fails closed when this is unset. + webhookSecretKey String? + ownerId String + version Int @default(1) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt cred Secret? @relation(fields: [credSecretId], references: [id]) owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index bb62d48..9e948be 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -114,6 +114,7 @@ import { registerEpicTaskRoutes } from './routes/epictasks.js'; import { registerTaskRoutes } from './routes/tasks.js'; import { registerCodeRepoRoutes } from './routes/repos.js'; import { registerGitProviderRoutes } from './routes/gitproviders.js'; +import { registerGitWebhookRoutes } from './routes/git-webhooks.js'; import { registerSkillRoutes } from './routes/skills.js'; import { registerGitBackupRoutes } from './routes/git-backup.js'; import { PromptService } from './services/prompt.service.js'; @@ -643,6 +644,13 @@ async function main(): Promise { registerTaskRoutes(app, workTaskService, epicTaskRepo); registerCodeRepoRoutes(app, codeRepoService, projectRepo); registerGitProviderRoutes(app, gitProviderService, projectRepo); + registerGitWebhookRoutes(app, { + providers: gitProviderRepo, + secretRepo, + secrets: secretService, + tasks: workTaskRepo, + taskService: workTaskService, + }); // ── Git-based backup ── const gitBackup = new GitBackupService(prisma); diff --git a/src/mcpd/src/middleware/url-permission.ts b/src/mcpd/src/middleware/url-permission.ts index dcd684e..1ce5483 100644 --- a/src/mcpd/src/middleware/url-permission.ts +++ b/src/mcpd/src/middleware/url-permission.ts @@ -36,6 +36,11 @@ export function mapUrlToPermission(method: string, url: string): PermissionCheck return { kind: 'operation', operation: SET_OWN_PASSWORD_OPERATION }; } + // Git webhooks authenticate by HMAC signature, not by a user token — the + // forge has no mcpctl identity. Skipping RBAC here is safe only because the + // route itself fails closed without a configured signing secret. + if (url.startsWith('/api/v1/webhooks/git/')) return { kind: 'skip' }; + // Operations (non-resource endpoints) if (segment === 'backup') return { kind: 'operation', operation: 'backup' }; if (segment === 'restore') return { kind: 'operation', operation: 'restore' }; diff --git a/src/mcpd/src/repositories/task.repository.ts b/src/mcpd/src/repositories/task.repository.ts index 14dd9ea..2fd8ea9 100644 --- a/src/mcpd/src/repositories/task.repository.ts +++ b/src/mcpd/src/repositories/task.repository.ts @@ -31,6 +31,7 @@ export interface TaskUpdateInput { headCommit?: string; prUrl?: string; prNumber?: number; + mergeCommit?: string; reviewNote?: string; } @@ -61,6 +62,8 @@ export interface EventCreateInput { export interface ITaskRepository { findAll(filters?: TaskFilters): Promise; + /** Find the task a merge produced this commit for — the webhook's entry point. */ + findByMergeCommit(sha: string): Promise; findById(id: string): Promise; findByName(name: string): Promise; findByNameAndEpicTask(name: string, epicTaskId: string): Promise; @@ -98,6 +101,10 @@ export class TaskRepository implements ITaskRepository { }); } + async findByMergeCommit(sha: string): Promise { + return this.prisma.task.findFirst({ where: { mergeCommit: sha }, include: TASK_INCLUDE }); + } + async findById(id: string): Promise { return this.prisma.task.findUnique({ where: { id }, include: TASK_INCLUDE }); } diff --git a/src/mcpd/src/routes/git-webhooks.ts b/src/mcpd/src/routes/git-webhooks.ts new file mode 100644 index 0000000..4290678 --- /dev/null +++ b/src/mcpd/src/routes/git-webhooks.ts @@ -0,0 +1,142 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; + +import type { FastifyInstance } from 'fastify'; + +import type { IGitProviderRepository } from '../repositories/git-provider.repository.js'; +import type { ISecretRepository } from '../repositories/interfaces.js'; +import type { ITaskRepository } from '../repositories/task.repository.js'; +import type { SecretService } from '../services/secret.service.js'; +import type { TaskService } from '../services/task.service.js'; + +/** + * Post-merge failure → reopen the task that caused it. + * + * The design's last loop: a task reaches `merged`, CI then fails on the merge + * commit, and the same task comes back as `reopened` rather than the breakage + * going unnoticed because the board already said done. + * + * Two things make this safe rather than a liability: + * + * 1. **It fails closed.** The endpoint is unauthenticated by nature — a forge + * calls it — so it accepts nothing unless the provider has a configured + * webhook signing secret and the signature verifies. No secret, no effect. + * 2. **It matches precisely.** A failing status carries the commit sha, and the + * accept path recorded exactly which commit each merge produced. No guessing + * at "whatever merged most recently", which would reopen innocent work. + */ + +interface GiteaStatusEvent { + sha?: string; + state?: string; + context?: string; + description?: string; + target_url?: string; + repository?: { full_name?: string }; +} + +/** Failing states across forges. Anything else is ignored. */ +const FAILED = new Set(['failure', 'error']); + +export interface GitWebhookDeps { + providers: IGitProviderRepository; + secretRepo: ISecretRepository; + secrets: SecretService; + tasks: ITaskRepository; + taskService: TaskService; +} + +export function registerGitWebhookRoutes(app: FastifyInstance, deps: GitWebhookDeps): void { + // Raw body is required: a signature is over the exact bytes the forge sent, + // and re-serializing a parsed object changes them. + app.addContentTypeParser( + 'application/json', + { parseAs: 'string' }, + (req, body, done) => { + (req as unknown as { rawBody?: string }).rawBody = body as string; + try { + done(null, JSON.parse(body as string)); + } catch (err) { + done(err as Error, undefined); + } + }, + ); + + app.post<{ Params: { provider: string } }>( + '/api/v1/webhooks/git/:provider', + async (request, reply) => { + const provider = await deps.providers.findByName(request.params.provider); + if (provider === null) { + // Deliberately vague: this endpoint is public, and confirming which + // provider names exist is free reconnaissance. + reply.code(404); + return { error: 'unknown webhook' }; + } + + if (provider.webhookSecretKey === null || provider.credSecretId === null) { + reply.code(403); + return { + error: 'webhook_not_configured', + message: `GitProvider '${provider.name}' has no webhook signing secret, so its webhooks are refused.`, + }; + } + + const secret = await deps.secretRepo.findById(provider.credSecretId); + if (secret === null) { + reply.code(403); + return { error: 'webhook_not_configured', message: 'Signing secret no longer resolvable.' }; + } + const signingKey = await deps.secrets.resolve(secret.name, provider.webhookSecretKey); + + const raw = (request as unknown as { rawBody?: string }).rawBody ?? ''; + const sent = String(request.headers['x-gitea-signature'] ?? request.headers['x-hub-signature-256'] ?? '') + .replace(/^sha256=/, ''); + if (!verify(raw, signingKey, sent)) { + reply.code(401); + return { error: 'bad_signature' }; + } + + const body = request.body as GiteaStatusEvent; + const state = String(body.state ?? '').toLowerCase(); + if (!FAILED.has(state)) { + // Success and pending are the common case — acknowledge and do nothing. + return { ok: true, action: 'ignored', reason: `state '${state}' is not a failure` }; + } + + const sha = body.sha; + if (sha === undefined || sha === '') { + return { ok: true, action: 'ignored', reason: 'no commit sha in payload' }; + } + + const task = await deps.tasks.findByMergeCommit(sha); + if (task === null) { + // A failing build on a commit mcpctl did not merge is somebody else's + // business — not an error, and not something to guess about. + return { ok: true, action: 'ignored', reason: `no task merged commit ${sha.slice(0, 8)}` }; + } + if (task.state !== 'merged') { + return { ok: true, action: 'ignored', reason: `task '${task.name}' is '${task.state}', not 'merged'` }; + } + + const why = [ + `CI failed on the merge commit ${sha.slice(0, 8)}`, + body.context !== undefined ? ` (${body.context})` : '', + body.description !== undefined ? `: ${body.description}` : '', + ].join(''); + + await deps.taskService.reopen(task.id, why, { type: 'system' }); + + return { ok: true, action: 'reopened', task: task.name, reason: why }; + }, + ); +} + +/** Constant-time compare of the forge's HMAC against ours. */ +function verify(rawBody: string, key: string, sent: string): boolean { + if (sent === '') return false; + const expected = createHmac('sha256', key).update(rawBody).digest('hex'); + const a = Buffer.from(expected, 'utf8'); + const b = Buffer.from(sent, 'utf8'); + // timingSafeEqual throws on length mismatch, which would itself leak length. + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); +} diff --git a/src/mcpd/src/services/git/gitea.driver.ts b/src/mcpd/src/services/git/gitea.driver.ts index 58c6c50..a227ab6 100644 --- a/src/mcpd/src/services/git/gitea.driver.ts +++ b/src/mcpd/src/services/git/gitea.driver.ts @@ -41,6 +41,7 @@ interface GiteaPull { state?: string; merged?: boolean; mergeable?: boolean; + merge_commit_sha?: string; head?: { sha?: string; ref?: string }; base?: { ref?: string }; } @@ -234,6 +235,7 @@ function toPullRequest(p: GiteaPull): PullRequest { head: p.head?.ref ?? '', base: p.base?.ref ?? '', mergeable: p.mergeable ?? null, + mergeCommitSha: p.merge_commit_sha ?? null, }; } diff --git a/src/mcpd/src/services/git/types.ts b/src/mcpd/src/services/git/types.ts index f5111ba..f4879cb 100644 --- a/src/mcpd/src/services/git/types.ts +++ b/src/mcpd/src/services/git/types.ts @@ -69,6 +69,8 @@ export interface PullRequest { base: string; /** Forge's own verdict on whether it can merge cleanly. Null when unknown. */ mergeable: boolean | null; + /** Commit the merge produced. Null until merged — CI runs against this. */ + mergeCommitSha: string | null; } export interface MergeOptions { diff --git a/src/mcpd/src/services/task-merger.ts b/src/mcpd/src/services/task-merger.ts index f966b44..95aa679 100644 --- a/src/mcpd/src/services/task-merger.ts +++ b/src/mcpd/src/services/task-merger.ts @@ -22,7 +22,7 @@ export class GitTaskMerger implements TaskMerger { private readonly mergeMethod: 'merge' | 'squash' | 'rebase' = 'merge', ) {} - async mergeForTask(task: Task, expectHeadSha: string | null): Promise<{ merged: boolean; detail: string }> { + async mergeForTask(task: Task, expectHeadSha: string | null): Promise<{ merged: boolean; detail: string; mergeCommit?: string }> { if (task.prNumber === null) { return { merged: true, detail: 'no pull request attached — approval recorded only' }; } @@ -58,9 +58,21 @@ export class GitTaskMerger implements TaskMerger { ...(expectHeadSha !== null ? { expectHeadSha } : {}), }, ); + // Re-read to capture what the merge produced. Best-effort: the merge + // already succeeded, so failing to read the sha must not fail the accept — + // it only costs the webhook its precise match later. + let mergeCommit: string | undefined; + try { + const after = await driver.getPullRequest(ctx, { owner: slug.owner, repo: slug.repo, number: task.prNumber }); + mergeCommit = after.mergeCommitSha ?? undefined; + } catch { + mergeCommit = undefined; + } + return { merged: true, detail: `merged ${slug.owner}/${slug.repo}#${String(task.prNumber)} via ${provider.name}`, + ...(mergeCommit !== undefined ? { mergeCommit } : {}), }; } catch (err: unknown) { if (err instanceof MergeStaleError) { diff --git a/src/mcpd/src/services/task.service.ts b/src/mcpd/src/services/task.service.ts index b72f29e..361b6fc 100644 --- a/src/mcpd/src/services/task.service.ts +++ b/src/mcpd/src/services/task.service.ts @@ -78,7 +78,7 @@ export interface TaskMerger { * @param expectHeadSha merge only if the PR head is still this commit — * the human approved that commit, not whatever landed after. */ - mergeForTask(task: Task, expectHeadSha: string | null): Promise<{ merged: boolean; detail: string }>; + mergeForTask(task: Task, expectHeadSha: string | null): Promise<{ merged: boolean; detail: string; mergeCommit?: string }>; } export class TaskService { @@ -301,6 +301,11 @@ export class TaskService { throw Object.assign(new Error(result.detail), { statusCode: 409 }); } mergeDetail = result.detail; + // Record what the merge produced: a failing CI webhook arrives with this + // sha, and it is the only precise way back to the task that caused it. + if (result.mergeCommit !== undefined) { + await this.repo.update(id, { mergeCommit: result.mergeCommit }); + } } return this.transition( diff --git a/src/mcpd/tests/git-webhooks.test.ts b/src/mcpd/tests/git-webhooks.test.ts new file mode 100644 index 0000000..4ca81da --- /dev/null +++ b/src/mcpd/tests/git-webhooks.test.ts @@ -0,0 +1,149 @@ +import { createHmac } from 'node:crypto'; + +import type { GitProvider, Secret, Task } from '@prisma/client'; +import Fastify, { type FastifyInstance } from 'fastify'; +import { describe, it, expect, vi, afterEach } from 'vitest'; + +import { errorHandler } from '../src/middleware/error-handler.js'; +import { mapUrlToPermission } from '../src/middleware/url-permission.js'; +import type { IGitProviderRepository } from '../src/repositories/git-provider.repository.js'; +import type { ISecretRepository } from '../src/repositories/interfaces.js'; +import type { ITaskRepository } from '../src/repositories/task.repository.js'; +import { registerGitWebhookRoutes } from '../src/routes/git-webhooks.js'; +import type { SecretService } from '../src/services/secret.service.js'; +import type { TaskService } from '../src/services/task.service.js'; + +const SECRET = 'hunter2'; +const SHA = 'deadbeefcafe1234'; + +let app: FastifyInstance; +afterEach(async () => { if (app) await app.close(); }); + +function build(opts: { + provider?: Partial | null; + task?: Partial | null; +} = {}) { + const reopen = vi.fn(async () => ({})); + const providerRow = opts.provider === null ? null : { + id: 'gp1', name: 'mysources', type: 'gitea', + credSecretId: 'sec1', credSecretKey: 'TOKEN', webhookSecretKey: 'WEBHOOK', + ...opts.provider, + } as GitProvider; + + const taskRow = opts.task === null ? null + : ({ id: 't1', name: 'wire-cli', state: 'merged', mergeCommit: SHA, ...opts.task } as Task); + + const instance = Fastify(); + instance.setErrorHandler(errorHandler); + registerGitWebhookRoutes(instance, { + providers: { findByName: vi.fn(async () => providerRow) } as unknown as IGitProviderRepository, + secretRepo: { findById: vi.fn(async () => ({ id: 'sec1', name: 'gitea-token' } as Secret)) } as unknown as ISecretRepository, + secrets: { resolve: vi.fn(async () => SECRET) } as unknown as SecretService, + tasks: { findByMergeCommit: vi.fn(async () => taskRow) } as unknown as ITaskRepository, + taskService: { reopen } as unknown as TaskService, + }); + return { instance, reopen }; +} + +const sign = (body: string, key = SECRET) => createHmac('sha256', key).update(body).digest('hex'); + +async function post(instance: FastifyInstance, payload: unknown, sig?: string) { + const body = JSON.stringify(payload); + return instance.inject({ + method: 'POST', + url: '/api/v1/webhooks/git/mysources', + headers: { 'content-type': 'application/json', 'x-gitea-signature': sig ?? sign(body) }, + payload: body, + }); +} + +const failure = { sha: SHA, state: 'failure', context: 'ci/build', description: 'tests failed' }; + +describe('git webhook — fails closed', () => { + it('refuses when the provider has no signing secret configured', async () => { + const b = build({ provider: { webhookSecretKey: null } }); app = b.instance; + const res = await post(b.instance, failure); + expect(res.statusCode).toBe(403); + expect(b.reopen).not.toHaveBeenCalled(); + }); + + it('refuses an unsigned request', async () => { + const b = build(); app = b.instance; + const res = await post(b.instance, failure, ''); + expect(res.statusCode).toBe(401); + expect(b.reopen).not.toHaveBeenCalled(); + }); + + it('refuses a wrong signature', async () => { + const b = build(); app = b.instance; + const res = await post(b.instance, failure, sign(JSON.stringify(failure), 'wrong-key')); + expect(res.statusCode).toBe(401); + expect(b.reopen).not.toHaveBeenCalled(); + }); + + it('refuses a signature over different bytes than were sent', async () => { + // Signing a re-serialized object rather than the raw body is the classic + // way this check silently stops working. + const b = build(); app = b.instance; + const res = await post(b.instance, failure, sign('{"sha":"other"}')); + expect(res.statusCode).toBe(401); + }); + + it('does not confirm which provider names exist', async () => { + const b = build({ provider: null }); app = b.instance; + const res = await post(b.instance, failure); + expect(res.statusCode).toBe(404); + expect(JSON.stringify(res.json())).not.toContain('mysources'); + }); + + it('is skipped by the RBAC hook — it authenticates by signature', () => { + expect(mapUrlToPermission('POST', '/api/v1/webhooks/git/mysources')).toEqual({ kind: 'skip' }); + }); +}); + +describe('git webhook — reopening', () => { + it('reopens the task whose merge produced the failing commit', async () => { + const b = build(); app = b.instance; + const res = await post(b.instance, failure); + + expect(res.statusCode).toBe(200); + expect(res.json()).toMatchObject({ action: 'reopened', task: 'wire-cli' }); + expect(b.reopen).toHaveBeenCalledWith('t1', expect.stringContaining('deadbeef'), { type: 'system' }); + expect(b.reopen.mock.calls[0]?.[1]).toContain('ci/build'); + }); + + it('ignores a passing status', async () => { + const b = build(); app = b.instance; + const res = await post(b.instance, { ...failure, state: 'success' }); + expect(res.json()).toMatchObject({ action: 'ignored' }); + expect(b.reopen).not.toHaveBeenCalled(); + }); + + it('ignores a failure on a commit mcpctl did not merge', async () => { + // Somebody else's build breaking is not mcpctl's business, and guessing at + // a task would reopen innocent work. + const b = build({ task: null }); app = b.instance; + const res = await post(b.instance, failure); + expect(res.json().reason).toMatch(/no task merged commit deadbeef/); + expect(b.reopen).not.toHaveBeenCalled(); + }); + + it('ignores a task that has already moved on from merged', async () => { + const b = build({ task: { state: 'reopened' } }); app = b.instance; + const res = await post(b.instance, failure); + expect(res.json().reason).toMatch(/is 'reopened', not 'merged'/); + expect(b.reopen).not.toHaveBeenCalled(); + }); + + it('ignores a payload with no sha', async () => { + const b = build(); app = b.instance; + const res = await post(b.instance, { state: 'failure' }); + expect(res.json().reason).toMatch(/no commit sha/); + }); + + it('treats error the same as failure', async () => { + const b = build(); app = b.instance; + await post(b.instance, { ...failure, state: 'error' }); + expect(b.reopen).toHaveBeenCalled(); + }); +}); -- 2.49.1 From 2a85447fac2117d4fe3612bbb53064afbf445dd5 Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 15 Aug 2026 23:54:53 +0100 Subject: [PATCH 29/32] feat(rbac): bound what an agent may DO by whoever invoked it Closes 177. An agent carries a project's prompts and its MCP tools, and the only check on a chat turn was run:agents: - so anyone who could talk to an agent inherited tool access to every project that agent had joined. Membership was the capability. AgentProject widened it from one pinned project to many. The rule now: knowledge travels with the agent, action is bounded by the authority of the run. Prompts and skills still load regardless of who is asking - a QA reviewer that forgets the project's conventions when a different person asks is not a reviewer. Every MCP tool call is gated. Authority is resolved once per run (services/run-authority.ts) and is per-RUN, not per-agent: the same agent answers a human under that human's authority and, once a scheduler exists, runs unattended under its own. `caller` intersects the agent's memberships with the caller's run:projects - reusing the exact grant the project-chat route already demands, rather than inventing a parallel permission. `agent` takes every membership without consulting a user. Both are implemented; `service-account` throws 501 rather than honouring a forgeable x-service-account header, and task 179 records what it needs first. Gated in BOTH listTools and callTool, deliberately: withholding a tool from the list is presentation, not enforcement - a model can invent a tool name it was never shown, and callTool is what would run it. The accepted consequence is stated plainly rather than papered over: a user can ask about a project they cannot reach and the agent will answer from its knowledge. The system block tells the model when it is holding such context so a persona can apply it without reciting it. That is guidance to a model, not a boundary - the boundary is that the tools are absent. Two sibling holes found while exploring and closed here: - Thread hijack. resolveThreadId on the agent path had no owner check, so anyone with run:agents: could append to another user's thread by id and read its history back. The project path already did this correctly; mirrored, 404 not 403 so ids cannot be probed. - McpToken project escape. `project` travels in the request BODY, so the URL-based token scope check in main.ts never saw it - a token bound to project A could drive an agent into B's tools. The binding is now a ceiling. Tests: 19 authority tests (intersection, fail-closed with no user and with no RBAC wired, token ceiling, agent authority bypassing RBAC entirely, the 501) and 4 through the real chat path asserting prompts load while tools do not. Three existing assertions updated for the new dispatcher signature - a deliberate interface change, not tests bent to fit. Local: build clean, workspace 2608 passed (+16), lint unchanged at 794 errors. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p --- .taskmaster/tasks/tasks.json | 36 ++++- src/mcpd/src/main.ts | 4 + src/mcpd/src/routes/agent-chat.ts | 8 + src/mcpd/src/services/chat-tool-dispatcher.ts | 16 +- src/mcpd/src/services/chat.service.ts | 71 ++++++++- src/mcpd/src/services/run-authority.ts | 144 ++++++++++++++++++ .../tests/chat-service-membership.test.ts | 88 ++++++++++- src/mcpd/tests/chat-service-project.test.ts | 2 +- src/mcpd/tests/chat-service.test.ts | 4 +- src/mcpd/tests/services/run-authority.test.ts | 133 ++++++++++++++++ 10 files changed, 488 insertions(+), 18 deletions(-) create mode 100644 src/mcpd/src/services/run-authority.ts create mode 100644 src/mcpd/tests/services/run-authority.test.ts diff --git a/.taskmaster/tasks/tasks.json b/.taskmaster/tasks/tasks.json index 1e48a80..d525bf0 100644 --- a/.taskmaster/tasks/tasks.json +++ b/.taskmaster/tasks/tasks.json @@ -3454,19 +3454,47 @@ "dependencies": [ "166" ], + "status": "done", + "subtasks": [], + "updatedAt": "2026-08-15T22:54:16.432Z" + }, + { + "id": "178", + "title": "Agent authority: run an agent unattended on a schedule", + "description": "Mode 2. The agent acts under its OWN authority - every project it is a member of - with no human in the loop.", + "details": "The authority resolver already implements this (kind: agent returns all memberships without consulting any user, unit-tested). What is missing is anything that can INVOKE it.\n\nBlockers, all verified:\n- ChatService.chat has exactly two call sites, both HTTP handlers. No cron, no job runner, no queue that can re-enter prepareContext (InferenceTask carries raw completion bodies: no agent, no project, no tools).\n- AgentSession is schema-only: no repository, no service, no route, no credential column. Its own comment promises a GC sweep that does not exist.\n- The mcpctl toolset fails closed without a caller token by design, so a scheduled run of a system agent would throw.\n\nNeeds a scheduler, a run record, and a decision on what a scheduled run may do that a caller-authority run may not.", + "testStrategy": "See details.", + "priority": "medium", + "dependencies": [ + "166" + ], "status": "pending", "subtasks": [], - "updatedAt": "2026-08-09T22:34:45.085Z" + "updatedAt": "2026-08-15T22:54:36.185Z" + }, + { + "id": "179", + "title": "Service-account authority: an authenticated non-human identity", + "description": "Mode 3. An agent runs under an explicit service account whose permission set may be smaller OR larger than any human's.", + "details": "The resolver refuses this with a 501 rather than honouring it, deliberately. Two things must exist first, both verified absent:\n\n1. A ServiceAccount is currently a forgeable header. main.ts reads x-service-account with no lookup, signature or credential; there is no ServiceAccount table. tests/security.test.ts already documents this as an escalation hole.\n2. Smaller-than-a-human is not expressible at all. middleware/auth.ts sets request.userId = ownerId unconditionally, so getPermissions always unions the owner's grants in. An McpToken with --rbac=empty is the owner at full privilege; docs/mcptoken-implementation.md claims the opposite and is wrong. A reduced principal needs that inheritance broken, not a restriction layered on top.\n\nAlso fix while there: middleware/rbac.ts route-level guards check the user only, never the SA or token, so enforcement differs by which path a route uses.", + "testStrategy": "See details.", + "priority": "medium", + "dependencies": [ + "177" + ], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-15T22:54:36.185Z" } ], "metadata": { "version": "1.0.0", - "lastModified": "2026-08-15T22:10:49.722Z", + "lastModified": "2026-08-15T22:54:16.433Z", "taskCount": 177, - "completedCount": 137, + "completedCount": 138, "tags": [ "master" ] } } -} \ No newline at end of file +} diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index 9e948be..3e7b470 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -136,6 +136,7 @@ import { GitTaskMerger } from './services/task-merger.js'; import { GitProviderService } from './services/git-provider.service.js'; import { GitProviderRegistry } from './services/git/registry.js'; import { McpctlToolProvider } from './services/mcpctl-tools.js'; +import { RunAuthorityResolver } from './services/run-authority.js'; import { SkillService } from './services/skill.service.js'; import { GitBackupService } from './services/backup/git-backup.service.js'; import type { BackupKind } from './services/backup/yaml-serializer.js'; @@ -591,6 +592,9 @@ async function main(): Promise { // Loopback to this same mcpd, so the mcpctl system agent's calls go through // the normal auth + RBAC path carrying the caller's own token. new McpctlToolProvider(`http://127.0.0.1:${String(config.port)}`), + // Decides which projects' TOOLS a run may call. Prompts are never gated by + // it — knowledge travels with the agent, action does not. + new RunAuthorityResolver(rbacService), ); registerAgentChatRoutes(app, chatService); registerProjectChatRoutes(app, chatService, rbacService); diff --git a/src/mcpd/src/routes/agent-chat.ts b/src/mcpd/src/routes/agent-chat.ts index 39cdbb3..fc4f861 100644 --- a/src/mcpd/src/routes/agent-chat.ts +++ b/src/mcpd/src/routes/agent-chat.ts @@ -48,10 +48,18 @@ export function registerAgentChatRoutes( ? authHeader.slice(7) : undefined; + // A token bound to one project must not drive an agent into another's + // tools. `project` arrives in the BODY, so the URL-based token scope + // check in main.ts never sees it — the ceiling has to travel with the call. + const mcpTokenProjectName = request.mcpToken?.projectName; + const args = { agentName: request.params.name, ownerId, ...(callerToken !== undefined ? { callerToken } : {}), + ...(mcpTokenProjectName !== undefined && mcpTokenProjectName !== null + ? { mcpTokenProjectName } + : {}), ...(threadId !== undefined ? { threadId } : {}), ...(message !== undefined ? { userMessage: message } : {}), ...(messagesOverride !== undefined diff --git a/src/mcpd/src/services/chat-tool-dispatcher.ts b/src/mcpd/src/services/chat-tool-dispatcher.ts index 2602998..d6716b3 100644 --- a/src/mcpd/src/services/chat-tool-dispatcher.ts +++ b/src/mcpd/src/services/chat-tool-dispatcher.ts @@ -15,6 +15,7 @@ */ import type { McpProxyService } from './mcp-proxy-service.js'; import type { IProjectRepository } from '../repositories/project.repository.js'; +import type { RunAuthority } from './run-authority.js'; import type { ChatTool, ChatToolDispatcher } from './chat.service.js'; import { TOOL_NAME_SEPARATOR } from './chat.service.js'; @@ -38,8 +39,11 @@ export interface ChatToolDispatcherDeps { export class ChatToolDispatcherImpl implements ChatToolDispatcher { constructor(private readonly deps: ChatToolDispatcherDeps) {} - async listTools(projectId: string | null): Promise { + async listTools(projectId: string | null, authority?: RunAuthority): Promise { if (projectId === null) return []; + // Not authorised for this project's tools: hand back nothing. `callTool` + // enforces the same rule independently — see the note there. + if (authority !== undefined && !authority.toolProjects.has(projectId)) return []; const project = await this.deps.projects.findById(projectId); if (project === null) return []; const out: ChatTool[] = []; @@ -77,7 +81,17 @@ export class ChatToolDispatcherImpl implements ChatToolDispatcher { serverName: string; toolName: string; args: Record; + authority?: RunAuthority; }): Promise { + // Checked here as well as in listTools, deliberately. Withholding a tool + // from the list is presentation, not enforcement — a model can invent a + // tool name it was never shown, and this is the call that would run it. + if (args.authority !== undefined && !args.authority.toolProjects.has(args.projectId)) { + throw new Error( + `Not authorised to use tools in this project. The agent has knowledge of it, but acting there ` + + `needs run:projects on it.`, + ); + } const project = await this.deps.projects.findById(args.projectId); if (project === null) { throw new Error(`Project ${args.projectId} not found`); diff --git a/src/mcpd/src/services/chat.service.ts b/src/mcpd/src/services/chat.service.ts index 45a7e4d..3251c4f 100644 --- a/src/mcpd/src/services/chat.service.ts +++ b/src/mcpd/src/services/chat.service.ts @@ -38,6 +38,7 @@ import type { RbacService } from './rbac.service.js'; import type { OpenAiChatRequest, OpenAiMessage } from './llm/types.js'; import type { AgentChatParams } from '../validation/agent.schema.js'; import { NotFoundError } from './mcp-server.service.js'; +import { restrictedContextNotice, unscopedAuthority, type RunAuthority, type RunAuthorityResolver } from './run-authority.js'; import type { McpctlToolProvider } from './mcpctl-tools.js'; /** Minimal secret-read surface the project-chat `get_secret` tool needs. */ @@ -82,13 +83,14 @@ export interface ChatTool { export interface ChatToolDispatcher { /** List tools available to an agent's project. Empty if no project. */ - listTools(projectId: string | null): Promise; + listTools(projectId: string | null, authority?: RunAuthority): Promise; /** Execute a tool call. Throws on error. */ callTool(args: { projectId: string; serverName: string; toolName: string; args: Record; + authority?: RunAuthority; }): Promise; } @@ -149,6 +151,14 @@ export interface ChatRequestArgs { * AS the caller — see services/mcpctl-tools.ts. Absent for every other agent. */ callerToken?: string; + /** + * When the request arrived on an McpToken bound to a project, that project's + * name. It is a hard ceiling on which projects' tools this run may use: + * `project` travels in the request BODY, so the URL-based token scope check + * in main.ts cannot see it, and a token for project A could otherwise drive + * an agent into project B's tools. + */ + mcpTokenProjectName?: string; threadId?: string; userMessage?: string; /** Optional full-history override; if set, threadId history is ignored. */ @@ -211,6 +221,8 @@ interface PreparedChatContext { allowSecrets: boolean; /** 'project' (MCP servers) or 'mcpctl' (operate mcpctl as the caller). */ toolset: string; + /** Whose authority this run acts under; decides which projects' tools it may call. */ + authority: RunAuthority; /** Caller's bearer token, forwarded only for the 'mcpctl' toolset. */ callerToken?: string; } @@ -241,6 +253,8 @@ export class ChatService { * compile; without it a system agent simply gets no tools. */ private readonly mcpctlTools?: McpctlToolProvider, + /** Decides which projects' tools a run may call. Absent → knowledge only. */ + private readonly authorities?: RunAuthorityResolver, ) {} async createThread(agentName: string, ownerId: string, title?: string): Promise<{ id: string }> { @@ -321,7 +335,7 @@ export class ChatService { tool_calls: choice.tool_calls, }); for (const call of choice.tool_calls) { - const toolResult = await this.dispatchTool(call.function.name, call.function.arguments, ctx.projectId, ctx.allowSecrets, { toolset: ctx.toolset, ...(ctx.callerToken !== undefined ? { callerToken: ctx.callerToken } : {}) }); + const toolResult = await this.dispatchTool(call.function.name, call.function.arguments, ctx.projectId, ctx.allowSecrets, { toolset: ctx.toolset, authority: ctx.authority, ...(ctx.callerToken !== undefined ? { callerToken: ctx.callerToken } : {}) }); const resultMsg = await this.chatRepo.appendMessage({ threadId: ctx.threadId, role: 'tool', @@ -449,7 +463,7 @@ export class ChatService { for (const call of accumulated.toolCalls) { yield { type: 'tool_call', toolName: call.name, args: safeParseJson(call.argumentsJson) as Record }; try { - const result = await this.dispatchTool(call.name, call.argumentsJson, ctx.projectId, ctx.allowSecrets, { toolset: ctx.toolset, ...(ctx.callerToken !== undefined ? { callerToken: ctx.callerToken } : {}) }); + const result = await this.dispatchTool(call.name, call.argumentsJson, ctx.projectId, ctx.allowSecrets, { toolset: ctx.toolset, authority: ctx.authority, ...(ctx.callerToken !== undefined ? { callerToken: ctx.callerToken } : {}) }); const resultStr = typeof result === 'string' ? result : JSON.stringify(result); await this.chatRepo.appendMessage({ threadId: ctx.threadId, @@ -777,22 +791,43 @@ export class ChatService { history.push({ role: 'user', content: args.userMessage }); } + // Whose authority this run acts under. Knowledge (the prompts assembled + // above) is NOT gated by it — a QA reviewer that forgets the project's + // conventions when a different person asks is not a reviewer. Acting is. + const authority = this.authorities !== undefined && projectId !== null && membership !== null + ? await this.authorities.resolve({ + kind: 'caller', + projects: [{ id: projectId, name: membership.projectName }], + ...(args.ownerId !== undefined ? { userId: args.ownerId } : {}), + ...(args.mcpTokenProjectName !== undefined + ? { mcpTokenProjectName: args.mcpTokenProjectName } + : {}), + }) + : unscopedAuthority(args.ownerId); + // A system agent operating mcpctl gets ONLY the mcpctl tools: no project // MCP servers, and no project prompts in the system block above. Managing // mcpctl is the whole job, and an unrelated project's context would just // be noise it might act on. const toolList = agent.toolset === 'mcpctl' && this.mcpctlTools !== undefined ? this.mcpctlTools.listTools() - : await this.tools.listTools(projectId); + : await this.tools.listTools(projectId, authority); const allowed = mergedParams.tools_allowlist; const filteredTools = allowed === undefined ? toolList : toolList.filter((t) => allowed.includes(t.name)); + // Tell the model when it is carrying context the caller cannot reach on + // their own. This is guidance to a persona, not an enforced boundary — the + // enforcement is that the tools simply are not there. + const finalSystemBlock = authority.restrictedProjects.length > 0 + ? `${systemBlock}\n\n${restrictedContextNotice(authority.restrictedProjects)}` + : systemBlock; + return { threadId, history, - systemBlock, + systemBlock: finalSystemBlock, poolCandidates, primaryLlm: pinned.name, mergedParams, @@ -802,6 +837,7 @@ export class ChatService { maxIterations: resolveMaxIterations(agent.extras), allowSecrets: false, toolset: agent.toolset, + authority, ...(args.callerToken !== undefined ? { callerToken: args.callerToken } : {}), }; } @@ -918,6 +954,14 @@ export class ChatService { // Project chat never gets the mcpctl toolset — that surface belongs to // the system agent alone. toolset: 'project', + // The route gate for this path is already `run:projects:`, so the + // caller is authorised for exactly this project and nothing else. + authority: { + kind: 'caller' as const, + userId: args.ownerId, + toolProjects: new Set([project.id]), + restrictedProjects: [], + }, }; } @@ -1042,6 +1086,13 @@ export class ChatService { if (args.threadId !== undefined) { const existing = await this.chatRepo.findThread(args.threadId); if (existing === null) throw new NotFoundError(`Thread not found: ${args.threadId}`); + // Ownership check, mirroring resolveProjectThreadId. Without it anyone + // holding `run:agents:` could append to another user's thread by + // guessing its id — and read the history back in the reply. 404 rather + // than 403 so an id cannot be probed for existence. + if (existing.ownerId !== args.ownerId) { + throw new NotFoundError(`Thread not found: ${args.threadId}`); + } return existing.id; } const created = await this.chatRepo.createThread({ agentId, ownerId: args.ownerId }); @@ -1113,7 +1164,7 @@ export class ChatService { argsJson: string, projectId: string | null, allowSecrets: boolean, - ctx?: { toolset: string; callerToken?: string }, + ctx?: { toolset: string; callerToken?: string; authority?: RunAuthority }, ): Promise { // mcpctl system tools are checked FIRST: they need no project, and they // execute as the caller rather than through an MCP server. @@ -1147,7 +1198,13 @@ export class ChatService { const serverName = toolWireName.slice(0, sep); const toolName = toolWireName.slice(sep + TOOL_NAME_SEPARATOR.length); const parsed = safeParseJson(argsJson) as Record; - return this.tools.callTool({ projectId, serverName, toolName, args: parsed }); + return this.tools.callTool({ + projectId, + serverName, + toolName, + args: parsed, + ...(ctx?.authority !== undefined ? { authority: ctx.authority } : {}), + }); } } diff --git a/src/mcpd/src/services/run-authority.ts b/src/mcpd/src/services/run-authority.ts new file mode 100644 index 0000000..b3968d3 --- /dev/null +++ b/src/mcpd/src/services/run-authority.ts @@ -0,0 +1,144 @@ +import type { RbacService } from './rbac.service.js'; + +/** + * Whose authority a chat turn runs under. + * + * An agent carries a project's prompts AND its MCP tools. Those two are not the + * same kind of thing, and this splits them: + * + * - **Knowledge travels with the agent.** Prompts and skills load regardless of + * who is asking — that is what makes an agent a QA reviewer rather than a + * generic bot. A reviewer that forgets the project's conventions when a + * different person asks is not a reviewer. + * - **Action is bounded by the authority.** Every MCP tool call is gated. The + * agent's membership is no longer, by itself, a capability. + * + * Authority is per-RUN, not per-agent: the same agent answers a human under that + * human's authority and (once a scheduler exists) runs unattended under its own. + */ +export type AuthorityKind = 'caller' | 'agent' | 'service-account'; + +export interface RunAuthority { + kind: AuthorityKind; + /** The human this run acts as. Present only for `caller`. */ + userId?: string; + /** Project ids whose TOOLS this run may call. Prompt loading ignores this. */ + toolProjects: Set; + /** + * Names of projects contributing context the caller could not reach on their + * own. Surfaced to the model so a persona can apply the knowledge without + * reciting it — guidance, not an enforced boundary, and described as such. + */ + restrictedProjects: string[]; +} + +/** One project the agent belongs to. */ +export interface AuthorityProject { + id: string; + name: string; +} + +export interface ResolveAuthorityInput { + kind: AuthorityKind; + /** Projects the agent is a member of (plus any legacy pinned project). */ + projects: AuthorityProject[]; + /** Required for `caller`. */ + userId?: string; + /** + * When the request arrived on an McpToken bound to a project, that project's + * name. The token's binding is a ceiling: a token for project A must not + * drive an agent into project B's tools, which the URL-based scope check in + * main.ts cannot see because `project` travels in the request body. + */ + mcpTokenProjectName?: string; +} + +export class RunAuthorityResolver { + constructor(private readonly rbac?: RbacService) {} + + async resolve(input: ResolveAuthorityInput): Promise { + switch (input.kind) { + case 'agent': + // The agent's own memberships ARE the grant. No human is consulted + // because there is no human — this is the scheduled-work authority. + return { + kind: 'agent', + toolProjects: new Set(input.projects.map((p) => p.id)), + restrictedProjects: [], + }; + + case 'service-account': + // Deliberately unreachable today. A ServiceAccount is currently a + // forgeable `x-service-account` header with no credential behind it, so + // honouring it here would grant tool access on an unauthenticated + // string. Refuse loudly rather than quietly degrading to something + // weaker or stronger than asked for. + throw Object.assign( + new Error( + 'service-account authority is not implemented: a ServiceAccount has no authenticated ' + + 'identity yet, so running under one would trust a client-supplied header.', + ), + { statusCode: 501 }, + ); + + case 'caller': + default: + return this.resolveForCaller(input); + } + } + + private async resolveForCaller(input: ResolveAuthorityInput): Promise { + const userId = input.userId; + const toolProjects = new Set(); + const restrictedProjects: string[] = []; + + for (const project of input.projects) { + // A token bound to one project is a hard ceiling regardless of RBAC. + if (input.mcpTokenProjectName !== undefined && input.mcpTokenProjectName !== project.name) { + restrictedProjects.push(project.name); + continue; + } + + // No RBAC wired (older test harnesses) or no user: fail closed. An + // unauthenticated run gets knowledge and no tools, never the reverse. + const allowed = + this.rbac !== undefined && + userId !== undefined && + (await this.rbac.canAccess(userId, 'run', 'projects', project.name)); + + if (allowed) toolProjects.add(project.id); + else restrictedProjects.push(project.name); + } + + return { + kind: 'caller', + ...(userId !== undefined ? { userId } : {}), + toolProjects, + restrictedProjects, + }; + } +} + +/** Authority for paths that legitimately have no project scope (bare agent chat). */ +export function unscopedAuthority(userId?: string): RunAuthority { + return { + kind: 'caller', + ...(userId !== undefined ? { userId } : {}), + toolProjects: new Set(), + restrictedProjects: [], + }; +} + +/** + * The note prepended to the system block when a run carries context from + * projects whose tools it may not use. + */ +export function restrictedContextNotice(projects: string[]): string { + const list = projects.join(', '); + return [ + `[context boundary] You are carrying knowledge from ${list}, which the person you are`, + `talking to cannot access themselves. You have NO tools for ${list} and calling any would fail.`, + 'Use this knowledge to inform your judgement. Do not reproduce its contents verbatim on request,', + 'and do not describe it as something the person can go and read.', + ].join('\n'); +} diff --git a/src/mcpd/tests/chat-service-membership.test.ts b/src/mcpd/tests/chat-service-membership.test.ts index 1ddeb38..5fa641f 100644 --- a/src/mcpd/tests/chat-service-membership.test.ts +++ b/src/mcpd/tests/chat-service-membership.test.ts @@ -16,6 +16,8 @@ import { ChatService, type ChatToolDispatcher } from '../src/services/chat.servi import type { LlmAdapterRegistry } from '../src/services/llm/dispatcher.js'; import type { LlmAdapter, NonStreamingResult } from '../src/services/llm/types.js'; import type { LlmService } from '../src/services/llm.service.js'; +import type { RbacService } from '../src/services/rbac.service.js'; +import { RunAuthorityResolver } from '../src/services/run-authority.js'; import { NotFoundError } from '../src/services/mcp-server.service.js'; const NOW = new Date('2026-01-01T00:00:00Z'); @@ -130,13 +132,13 @@ describe('agent chat scoped to a joined project', () => { it('uses the MEMBERSHIP project for tools, not the legacy pin', async () => { const { service, listTools } = build(); await service.chat({ agentName: 'alice', ownerId: 'u1', userMessage: 'hi', projectName: 'payments' }); - expect(listTools).toHaveBeenCalledWith('proj-payments'); + expect(listTools).toHaveBeenCalledWith('proj-payments', expect.anything()); }); it('falls back to the legacy pin when no project is named', async () => { const { service, listTools } = build(); await service.chat({ agentName: 'alice', ownerId: 'u1', userMessage: 'hi' }); - expect(listTools).toHaveBeenCalledWith('proj-legacy'); + expect(listTools).toHaveBeenCalledWith('proj-legacy', expect.anything()); }); it('the per-membership brain wins over the agent\'s pinned Llm', async () => { @@ -175,6 +177,86 @@ describe('agent chat scoped to a joined project', () => { for await (const c of service.chatStream({ agentName: 'alice', ownerId: 'u1', userMessage: 'hi', projectName: 'sre' })) { chunks.push(c); } - expect(listTools).toHaveBeenCalledWith('proj-sre'); + expect(listTools).toHaveBeenCalledWith('proj-sre', expect.anything()); + }); +}); + +describe('tool authority — knowledge travels, action is bounded', () => { + /** Same wiring as build(), plus an authority resolver with a real RBAC stub. */ + function buildWithAuthority(canReach: string[]) { + const listTools = vi.fn(async () => []); + const tools = { listTools, callTool: vi.fn(async () => ({})) } as unknown as ChatToolDispatcher; + const adapter: LlmAdapter = { + kind: 'scripted', + infer: vi.fn(async () => reply('ok')), + stream: async function*() { yield { data: '[DONE]', done: true }; }, + } as unknown as LlmAdapter; + const rbac = { + canAccess: vi.fn(async (_u: string, action: string, resource: string, name?: string) => + action === 'run' && resource === 'projects' && name !== undefined && canReach.includes(name), + ), + } as unknown as RbacService; + + const service = new ChatService( + mockAgents(), mockLlms(), { get: () => adapter } as unknown as LlmAdapterRegistry, + mockChatRepo(), mockPromptRepo(), tools, + undefined, undefined, undefined, undefined, undefined, undefined, + new RunAuthorityResolver(rbac), + ); + + const systemSent = (): string => { + const body = (adapter.infer as ReturnType).mock.calls[0]?.[0].body as + { messages: Array<{ role: string; content: string }> }; + return body.messages.find((m) => m.role === 'system')?.content ?? ''; + }; + return { service, listTools, systemSent }; + } + + it('withholds the tools when the caller cannot reach the project', async () => { + // The motivating hole: run:agents: was the whole check, so talking to + // an agent inherited tool access to every project it had joined. + const { service, listTools } = buildWithAuthority([]); + await service.chat({ agentName: 'alice', ownerId: 'u1', userMessage: 'hi', projectName: 'payments' }); + + expect(listTools).toHaveBeenCalledWith( + 'proj-payments', + expect.objectContaining({ toolProjects: new Set() }), + ); + }); + + it('tells the model it is carrying context the caller cannot reach', async () => { + const { service, systemSent } = buildWithAuthority([]); + await service.chat({ agentName: 'alice', ownerId: 'u1', userMessage: 'hi', projectName: 'payments' }); + + const system = systemSent(); + expect(system).toContain('[context boundary]'); + expect(system).toContain('payments'); + expect(system).toMatch(/NO tools/); + }); + + it('grants the tools when the caller can reach the project', async () => { + const { service, listTools, systemSent } = buildWithAuthority(['payments']); + await service.chat({ agentName: 'alice', ownerId: 'u1', userMessage: 'hi', projectName: 'payments' }); + + expect(listTools).toHaveBeenCalledWith( + 'proj-payments', + expect.objectContaining({ toolProjects: new Set(['proj-payments']) }), + ); + expect(systemSent()).not.toContain('[context boundary]'); + }); + + it('honours an McpToken binding as a ceiling over RBAC', async () => { + // `project` arrives in the body, so main.ts's URL-based token scope check + // cannot see it. + const { service, listTools } = buildWithAuthority(['payments']); + await service.chat({ + agentName: 'alice', ownerId: 'u1', userMessage: 'hi', + projectName: 'payments', mcpTokenProjectName: 'sre', + }); + + expect(listTools).toHaveBeenCalledWith( + 'proj-payments', + expect.objectContaining({ toolProjects: new Set() }), + ); }); }); diff --git a/src/mcpd/tests/chat-service-project.test.ts b/src/mcpd/tests/chat-service-project.test.ts index 886a0e5..6bb21ec 100644 --- a/src/mcpd/tests/chat-service-project.test.ts +++ b/src/mcpd/tests/chat-service-project.test.ts @@ -168,7 +168,7 @@ describe('ChatService — project chat', () => { adapter: scriptedAdapter([toolCall('srv__do', { x: 1 }), text('done')]), }); await service.chatProject({ projectName: 'sre', userMessage: 'go', ownerId: 'owner-1' }); - expect(callTool).toHaveBeenCalledWith({ projectId: 'proj-1', serverName: 'srv', toolName: 'do', args: { x: 1 } }); + expect(callTool).toHaveBeenCalledWith(expect.objectContaining({ projectId: 'proj-1', serverName: 'srv', toolName: 'do', args: { x: 1 } })); }); it('does NOT offer get_secret without --allow-secrets', async () => { diff --git a/src/mcpd/tests/chat-service.test.ts b/src/mcpd/tests/chat-service.test.ts index ef5f9aa..9d02bf4 100644 --- a/src/mcpd/tests/chat-service.test.ts +++ b/src/mcpd/tests/chat-service.test.ts @@ -258,12 +258,12 @@ describe('ChatService', () => { }); expect(result.assistant).toBe('the answer is 42'); - expect(tools.callTool).toHaveBeenCalledWith({ + expect(tools.callTool).toHaveBeenCalledWith(expect.objectContaining({ projectId: 'proj-1', serverName: 'grafana', toolName: 'query', args: { q: 'cpu' }, - }); + })); const stored = chatRepo._msgs.filter((m) => m.threadId === result.threadId); expect(stored.map((m) => m.role)).toEqual(['user', 'assistant', 'tool', 'assistant']); // No `pending` rows leaked. diff --git a/src/mcpd/tests/services/run-authority.test.ts b/src/mcpd/tests/services/run-authority.test.ts new file mode 100644 index 0000000..0074c4c --- /dev/null +++ b/src/mcpd/tests/services/run-authority.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, vi } from 'vitest'; + +import type { RbacService } from '../../src/services/rbac.service.js'; +import { + RunAuthorityResolver, + restrictedContextNotice, + unscopedAuthority, +} from '../../src/services/run-authority.js'; + +/** + * An agent carries a project's prompts AND its MCP tools. Before this, the only + * check on a chat turn was `run:agents:` — so anyone who could talk to an + * agent inherited tool access to every project that agent had joined. + * + * The rule these pin: knowledge travels with the agent, action is bounded by + * whoever invoked it. + */ +function rbac(allowed: string[]): RbacService { + return { + canAccess: vi.fn(async (_u: string, action: string, resource: string, name?: string) => + action === 'run' && resource === 'projects' && name !== undefined && allowed.includes(name), + ), + } as unknown as RbacService; +} + +const SRE = { id: 'p-sre', name: 'sre' }; +const PAY = { id: 'p-pay', name: 'payments' }; + +describe('caller authority', () => { + it('grants tools only for projects the caller can reach', async () => { + const r = new RunAuthorityResolver(rbac(['sre'])); + const a = await r.resolve({ kind: 'caller', userId: 'u1', projects: [SRE, PAY] }); + + expect([...a.toolProjects]).toEqual(['p-sre']); + expect(a.restrictedProjects).toEqual(['payments']); + }); + + it('still lists the unreachable project as restricted, not absent', async () => { + // The distinction matters: the prompts DO load, so the model has to be told + // it is holding context it cannot act on. + const r = new RunAuthorityResolver(rbac([])); + const a = await r.resolve({ kind: 'caller', userId: 'u1', projects: [SRE, PAY] }); + + expect(a.toolProjects.size).toBe(0); + expect(a.restrictedProjects).toEqual(['sre', 'payments']); + }); + + it('gives full tool access when the caller can reach everything', async () => { + const r = new RunAuthorityResolver(rbac(['sre', 'payments'])); + const a = await r.resolve({ kind: 'caller', userId: 'u1', projects: [SRE, PAY] }); + expect(a.toolProjects.size).toBe(2); + expect(a.restrictedProjects).toEqual([]); + }); + + it('asks RBAC the same question the project-chat route asks', async () => { + // Reusing `run:projects:` rather than inventing a parallel permission + // is the point — one grant means the same thing on both paths. + const svc = rbac(['sre']); + await new RunAuthorityResolver(svc).resolve({ kind: 'caller', userId: 'u1', projects: [SRE] }); + expect(svc.canAccess).toHaveBeenCalledWith('u1', 'run', 'projects', 'sre'); + }); + + it('fails closed with no user', async () => { + const r = new RunAuthorityResolver(rbac(['sre'])); + const a = await r.resolve({ kind: 'caller', projects: [SRE] }); + expect(a.toolProjects.size).toBe(0); + }); + + it('fails closed when RBAC is not wired', async () => { + // Older harnesses construct ChatService without it. Knowledge and no tools + // is the safe degradation; the reverse would be a silent grant. + const a = await new RunAuthorityResolver().resolve({ kind: 'caller', userId: 'u1', projects: [SRE] }); + expect(a.toolProjects.size).toBe(0); + }); +}); + +describe('McpToken project ceiling', () => { + it('refuses projects outside the token binding even when RBAC would allow', async () => { + // `project` travels in the request BODY, so the URL-based token scope check + // in main.ts never sees it. Without this a token for sre could drive an + // agent into payments' tools. + const r = new RunAuthorityResolver(rbac(['sre', 'payments'])); + const a = await r.resolve({ + kind: 'caller', userId: 'u1', projects: [SRE, PAY], mcpTokenProjectName: 'sre', + }); + + expect([...a.toolProjects]).toEqual(['p-sre']); + expect(a.restrictedProjects).toEqual(['payments']); + }); + + it('does not widen a token beyond what its owner may reach', async () => { + const r = new RunAuthorityResolver(rbac([])); + const a = await r.resolve({ + kind: 'caller', userId: 'u1', projects: [SRE], mcpTokenProjectName: 'sre', + }); + expect(a.toolProjects.size).toBe(0); + }); +}); + +describe('agent authority', () => { + it('takes every membership without consulting any user', async () => { + const svc = rbac([]); + const a = await new RunAuthorityResolver(svc).resolve({ kind: 'agent', projects: [SRE, PAY] }); + + expect(a.toolProjects.size).toBe(2); + expect(a.restrictedProjects).toEqual([]); + expect(svc.canAccess).not.toHaveBeenCalled(); + }); +}); + +describe('service-account authority', () => { + it('refuses rather than running on a forgeable identity', async () => { + // A ServiceAccount today is an unauthenticated `x-service-account` header. + // Honouring it would grant tool access on a client-supplied string, so this + // fails loudly until there is a real credential behind it. + await expect( + new RunAuthorityResolver(rbac(['sre'])).resolve({ kind: 'service-account', projects: [SRE] }), + ).rejects.toThrow(/not implemented|authenticated identity/); + }); +}); + +describe('the boundary notice', () => { + it('names the projects and says the tools are absent', () => { + const n = restrictedContextNotice(['payments', 'sre']); + expect(n).toContain('payments, sre'); + expect(n).toMatch(/NO tools/); + expect(n).toMatch(/not reproduce its contents verbatim/i); + }); + + it('unscoped authority grants nothing', () => { + expect(unscopedAuthority('u1').toolProjects.size).toBe(0); + }); +}); -- 2.49.1 From 6d38953a96d8e8d8d2fe28b97694d6e61c629976 Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 16 Aug 2026 00:14:58 +0100 Subject: [PATCH 30/32] docs(agentic-teams): document the new resources, and make the webhook reachable Five resources shipped with no documentation and one documented lie: docs/inference-tasks.md still told people `mcpctl get tasks` lists the inference queue. Work items own that name now. Adds docs/epics.md (the Epic -> EpicTask -> Task hierarchy, the state machine, risk-scaled review gates, the accept-merges gate, and reconciliation), docs/gitproviders.md (forge registration, discovery/import, merge-on-accept, post-merge reopen) and docs/agent-authority.md (the three run modes, and plainly which two are not implemented). Two things found while writing them, both fixed rather than filed: - `webhookSecretKey` was read by the webhook route but writable from NOWHERE -- absent from the create schema, update schema, service, CLI and apply spec. Since the endpoint fails closed on a null key, post-merge CI failures could never reopen their task; the board would keep claiming `merged` for work that broke the build. Adds --webhook-secret-key, the schema fields and the apply key, with tests covering persist / omit / explicit-null / reject-empty. Omission deliberately does not clear the column, so an unrelated edit cannot silently disarm the webhook. - src/cli/src/commands/create.ts contained a literal NUL byte inside a string literal (a binary-file check written with the raw character rather than an escape). Identical behaviour, but it made the whole file read as binary, so `grep -I` skipped it entirely and 1120 lines of CLI were invisible to every text search. Replaced with the escape. The Epic write path (criteria/status/tradeoffs) is API-only -- documented as such rather than papered over, and filed as task 180. Full suite 2612 green; lint at baseline 794/77. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p --- .taskmaster/tasks/tasks.json | 13 +- CLAUDE.md | 9 + README.md | 11 + completions/mcpctl.bash | 2 +- completions/mcpctl.fish | 1 + docs/agent-authority.md | 118 +++++++++++ docs/epics.md | 190 ++++++++++++++++++ docs/gitproviders.md | 149 ++++++++++++++ docs/inference-tasks.md | 17 +- src/cli/src/commands/apply.ts | 4 + src/cli/src/commands/create.ts | 4 +- src/mcpd/src/services/git-provider.service.ts | 2 + .../src/validation/git-provider.schema.ts | 12 ++ src/mcpd/tests/services/git-provider.test.ts | 61 ++++++ 14 files changed, 587 insertions(+), 6 deletions(-) create mode 100644 docs/agent-authority.md create mode 100644 docs/epics.md create mode 100644 docs/gitproviders.md diff --git a/.taskmaster/tasks/tasks.json b/.taskmaster/tasks/tasks.json index d525bf0..eab3dd9 100644 --- a/.taskmaster/tasks/tasks.json +++ b/.taskmaster/tasks/tasks.json @@ -3485,6 +3485,17 @@ "status": "pending", "subtasks": [], "updatedAt": "2026-08-15T22:54:36.185Z" + }, + { + "id": 180, + "title": "CLI for the Epic write path (status, criteria, tradeoffs)", + "description": "Marking a done-criterion met, setting Epic status and recording tradeoffs are POST /api/v1/epics/:id/{criteria,status,tradeoffs}. describe epic READS all of it — completeness bar, unmet criteria, reconciliation — but the CLI cannot WRITE any of it, so completeness is permanently 0% for anyone not driving the API by hand. That makes the Epic layer read-only in practice and the reconciliation feature unusable, since it exists to contrast claimed vs delivered and nothing can ever claim.", + "details": "Add an `epics` command group mirroring `tasks`:\n mcpctl epics criterion --met|--unmet [--note]\n mcpctl epics status [--note]\n mcpctl epics tradeoff --criterion --why \nRoutes already exist and are RBAC-mapped to edit:epics (url-permission.ts). Closing an Epic with unmet criteria must keep going through the tradeoffs path — the CLI must not offer a way to mark it delivered while criteria are unmet.\nPer the project rule, these are project-scoped commands: keep them out of the root --help (see task 174).\nReferenced from docs/epics.md as \"task 180\" — update that note when this lands.", + "testStrategy": "Unit: criterion --met flips only the named id and leaves others untouched; status delivered with unmet criteria is refused and names them; tradeoff records the reason. Verify describe epic completeness moves off 0% after a criterion is marked, and that reconciliation then has something to contrast.", + "status": "pending", + "priority": "medium", + "dependencies": [], + "subtasks": [] } ], "metadata": { @@ -3497,4 +3508,4 @@ ] } } -} +} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index c7fd973..aaea9f3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,9 +35,18 @@ Key routing rules: - `project` — workspace grouping servers, prompts, agents - `llm` — server-managed LLM provider (api key + endpoint) - `agent` — LLM persona pinned to one Llm; project attach surfaces project Prompts as system context, project MCP servers as tools, and exposes the agent itself as an MCP virtual server (`agent-/chat`). See `docs/agents.md`, `docs/chat.md`. + Tool access is bounded by **whoever invoked the run**, not by the agent's + memberships — knowledge travels, action is gated. See `docs/agent-authority.md`. - `prompt` / `promptrequest` — curated content / legacy pending proposal (use `proposal` for new work). - `skill` — Claude Code skill bundle (SKILL.md + files + typed metadata). Materialised onto disk by `mcpctl skills sync`. See `docs/skills.md`. - `proposal` — generic pending proposal queue, replaces `promptrequest`. Covers both prompts and skills. See `docs/proposals.md`. Triage via `mcpctl review`. - `revision` — append-only audit + diff log shared by prompts and skills. Auto-bumps semver on save. See `docs/revisions.md`. +- `epic` / `epictask` / `task` — the work tracker: vision → roadmap node → work item. + Tasks carry a risk level that decides which review gates they must pass, and + accepting one **merges its PR**. See `docs/epics.md`. + **`tasks` = work items**; the inference queue is `inference-tasks` / `itasks`. +- `gitprovider` / `repo` — git forge connection + attached repositories. Discovery + and import via `mcpctl repos discover|import`; a forge connection is what makes + merge-on-accept possible. See `docs/gitproviders.md`. - `rbac` — access control bindings - `mcptoken` — bearer credentials for HTTP-mode mcplocal diff --git a/README.md b/README.md index e12537e..1ccf887 100644 --- a/README.md +++ b/README.md @@ -500,6 +500,17 @@ new FileCache('ns', { maxSize: '10%' }) // 10% of partition | **promptrequest** | Pending prompt proposal | LLM-submitted, needs approval | | **rbac** | Access control bindings | Who can do what | | **serverattachment** | Server-to-project link | Virtual resource for `apply` | +| **epic** | The vision work is measured against | Done-criteria, completeness, tradeoffs | +| **epictask** | A roadmap node under an Epic | Owns a subset of the Epic's criteria | +| **task** | A work item with a review lifecycle | `todo → … → awaiting_review → merged` | +| **gitprovider** | A connection to a git forge | Gitea instance + API token | +| **repo** | A repository attached to a project | Imported from a forge, or added by URL | + +> **`tasks` changed meaning.** It used to list the durable inference queue, +> which now answers only to `inference-tasks` / `itasks`. Bare `tasks` lists +> work items. Existing RBAC bindings are rewritten once at startup, so an old +> `view:tasks` grant keeps authorizing the queue and does not silently become +> authority over work items. See [docs/inference-tasks.md](docs/inference-tasks.md). ## Agents diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index 131a4b6..4aaae54 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -224,7 +224,7 @@ _mcpctl() { else case "$create_sub" in gitprovider) - COMPREPLY=($(compgen -W "--url --type --description --secret --key --skip-probe -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "--url --type --description --secret --key --webhook-secret-key --skip-probe -h --help" -- "$cur")) ;; epic) COMPREPLY=($(compgen -W "--project --content --content-file --description --criterion -h --help" -- "$cur")) diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index 30a055e..9876f3d 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -420,6 +420,7 @@ complete -c mcpctl -n "__mcpctl_subcmd_active create gitprovider" -l type -d 'Dr complete -c mcpctl -n "__mcpctl_subcmd_active create gitprovider" -l description -d 'Description' -x complete -c mcpctl -n "__mcpctl_subcmd_active create gitprovider" -l secret -d 'Secret holding the API token' -x complete -c mcpctl -n "__mcpctl_subcmd_active create gitprovider" -l key -d 'Key within that secret' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create gitprovider" -l webhook-secret-key -d 'Key in that secret holding the webhook signing secret; without it incoming webhooks are refused' -x complete -c mcpctl -n "__mcpctl_subcmd_active create gitprovider" -l skip-probe -d 'Skip the credential check at registration time' # create epic options diff --git a/docs/agent-authority.md b/docs/agent-authority.md new file mode 100644 index 0000000..64b42c3 --- /dev/null +++ b/docs/agent-authority.md @@ -0,0 +1,118 @@ +# Agent run authority + +An agent carries two different things from the projects it joins: + +- **knowledge** — prompts, skills, project context +- **capability** — the projects' MCP tools + +Treating those as one thing is what makes agent membership dangerous. `sre-bot` +belongs to `payments` so it understands payments; that should not mean everyone +who can talk to `sre-bot` can call payments' tools through it. + +The rule: + +> **Knowledge travels with the agent. Action is bounded by the authority of the +> run.** + +Prompts load regardless of who is asking — that is what makes an agent a QA +reviewer rather than a generic bot. Every MCP tool call is gated. + +## Authority is per-run, not per-agent + +The same agent runs under different authority depending on how it was invoked. +One `sre-bot`, three modes: + +| Mode | Who decides what it may do | Effective tool scope | Status | +|---|---|---|---| +| **caller** | the human who invoked it | agent's memberships **∩** caller's `run:projects` | **implemented** | +| **agent** | the agent itself, on a schedule | every project it is a member of | not implemented | +| **service-account** | an explicit non-human identity | exactly the SA's own bindings — may be smaller *or* larger than any human's | not implemented | + +### caller + +The interactive path, and the only one live today. + +For each project the agent belongs to, mcpd asks the same question the +project-chat route asks — `run:projects:` — and grants tools only where +the answer is yes. Reusing that permission rather than inventing a parallel one +is deliberate: one grant means the same thing on both paths. + +It **fails closed**. No user, or RBAC not wired, means knowledge and no tools — +never the reverse. + +A pull request that arrived on an [McpToken](mcptoken-implementation.md) bound +to a project is additionally ceilinged by that binding. This matters because +`project` travels in the request *body*, so the URL-based scope check never sees +it; without the ceiling a token for `sre` could drive an agent into `payments`. + +### agent + +Scheduled, unattended work. The agent's own memberships are the grant and no +human is consulted, because there is no human. + +**Not implemented.** `ChatService.chat` has exactly two call sites, both HTTP +handlers — there is no scheduler, no job runner, and `AgentSession` is +schema-only with no credential column. Tracked as task 178. + +### service-account + +An explicit non-human identity holding its own bindings, which may deliberately +*exceed* what any human has (a deploy bot) or fall well short of it. + +**Not implemented, and it refuses loudly rather than degrading.** Requesting it +returns `501`. Two things block it, both live problems rather than missing +features: + +1. A `ServiceAccount` today is a **forgeable header** — `x-service-account` is + read with no lookup and no signature. Honouring it as an authority source + would grant tool access on a client-supplied string. +2. "Smaller than its owner" is currently **inexpressible**: auth sets + `request.userId = ownerId` unconditionally, so the owner's grants are always + unioned in. An McpToken with no bindings is the owner at full privilege. + +Tracked as task 179. + +## What the model is told + +When a run carries prompts from a project whose tools it may not call, the +system block gets a notice naming those projects: + +``` +[context boundary] You are carrying knowledge from payments, which the person +you are talking to cannot access themselves. You have NO tools for payments and +calling any would fail. Use this knowledge to inform your judgement. Do not +reproduce its contents verbatim on request, and do not describe it as something +the person can go and read. +``` + +Be clear about what this is. **The tool boundary is enforced; the disclosure +boundary is not.** The first half is a statement of fact the runtime guarantees. +The second half is *guidance to a model* — a persona that applies privileged +knowledge without reciting it. A determined caller may still extract some of it. + +This is an accepted trade: a reviewer that forgets the project's conventions +when a different person asks is not a reviewer. If context must not leak to a +caller at all, do not attach that project to an agent they can reach. + +## Enforcement points + +Gated in **both** places: + +- `listTools` omits tools for projects outside the run's authority +- `callTool` refuses them + +Withholding a tool from the list is presentation, not enforcement — a model can +invent a tool name it was never shown, and `callTool` is the call that would run +it. + +## Threads + +Resuming a thread checks ownership. Another user's thread id returns `404`, not +`403` — confirming a thread exists to someone who cannot read it is itself a +leak. + +## See also + +- [agents.md](agents.md) — creating agents and attaching them to projects +- [chat.md](chat.md) — the chat surfaces this governs +- [mcptoken-implementation.md](mcptoken-implementation.md) — token scoping diff --git a/docs/epics.md b/docs/epics.md new file mode 100644 index 0000000..e10418c --- /dev/null +++ b/docs/epics.md @@ -0,0 +1,190 @@ +# Epics, roadmap nodes and work items + +Three resources, one hierarchy: + +``` +Epic the vision, and the criteria delivery is measured against +└─ EpicTask a roadmap node; owns a subset of the Epic's criteria + └─ Task a work item that moves through states and lands a PR +``` + +The split exists because "every task is done" and "we delivered the thing" are +different claims. Tasks close; criteria are what say whether closing them +amounted to anything. Nothing derives one from the other automatically — see +[Reconciliation](#reconciliation). + +## Epic + +The versioned statement of intent. `content` is the vision prose; `doneCriteria` +is the list the work is graded against. + +A criterion is `id=text`, and prefixing the id with `q:` marks it a **quality** +criterion rather than an outcome one. Both count toward completeness — grading +only the mechanical half is how you end up with every task done and no car. + +```sh +mcpctl create epic checkout-rewrite \ + --project payments \ + --content-file vision.md \ + --criterion 'cart=A cart survives a session drop' \ + --criterion 'q:latency=p99 checkout under 400ms' + +mcpctl get epics +mcpctl describe epic checkout-rewrite # vision, criteria, completeness, reconciliation, roadmap +``` + +`status` is `open`, `delivered`, `delivered_with_tradeoffs` or `cancelled`. +Closing an Epic with unmet criteria requires the tradeoffs path — you may ship +something short of the vision, but not while calling it whole. + +> **API only, no CLI yet.** Marking a criterion `met`, setting status and +> recording tradeoffs are `POST /api/v1/epics/:id/{criteria,status,tradeoffs}`. +> The CLI reads all of it (`describe epic`) but cannot write it. Tracked as +> task 180. + +## EpicTask + +A roadmap node. Its `--criterion` ids must match ids on the parent Epic: the +node is claiming *"these parts of the vision are mine"*. + +```sh +mcpctl create epictask cart-persistence \ + --epic checkout-rewrite \ + --criterion 'cart=A cart survives a session drop' \ + --depends-on session-store + +mcpctl get epictasks +mcpctl describe epictask cart-persistence +``` + +`--depends-on` builds the dependency graph `describe epic` renders as a roadmap. + +## Task + +The unit an agent actually works on. It carries a `risk` level, which decides +how much review it must survive, and optionally a repo and a pull request. + +```sh +mcpctl create task cart-cookie-ttl \ + --epictask cart-persistence \ + --title 'Extend cart cookie TTL to 30 days' \ + --risk normal \ + --repo + +mcpctl get tasks +mcpctl describe task cart-cookie-ttl # state, legal moves, verdicts, git, history +``` + +> **`tasks` means work items.** It used to mean the durable inference queue, +> which now answers only to `inference-tasks` / `itasks`. See +> [inference-tasks.md](inference-tasks.md). + +### States + +``` +todo → in_progress → building → self_testing → qa_review + ↓ ↓ + done ← security_review → sre_review + ↓ + awaiting_review ⇄ (reject → in_progress) + ↓ accept + merged → reopened → in_progress +``` + +Every state can go back to `in_progress` and out to `cancelled`; a failed build +goes *back* to work rather than forward. `merged` has exactly one exit, +`reopened`, and only CI drives it (see [gitproviders.md](gitproviders.md#post-merge-failure)). + +```sh +mcpctl tasks advance cart-cookie-ttl building --note 'kicked off CI' +mcpctl tasks history cart-cookie-ttl +``` + +Transitions are compare-and-swap: two agents racing the same task, one wins and +the other gets a 409 naming the state it lost to. + +### Risk decides the gates + +| Risk | Must pass before `done` | +|---|---| +| `trivial` | nothing — docs, formatting | +| `normal` | `qa_review` | +| `high` | `qa_review`, `security_review`, `sre_review` | + +Verification effort scales with risk instead of running one fixed pipeline over +everything. A verdict is pinned to the commit it judged, so a later push +invalidates it rather than silently carrying forward: + +```sh +mcpctl tasks verdict cart-cookie-ttl \ + --stage qa_review --commit 4f2a91c --pass \ + --summary 'covers the session-drop path; added a regression test' +``` + +Reaching `done` without the required passing verdicts is refused, and the error +names which stage is missing. + +### The human gate + +`done` means it left the developer's loop, not that it shipped. `awaiting_review` +is where a human decides: + +```sh +mcpctl tasks accept cart-cookie-ttl --note 'ships with the friday release' +mcpctl tasks reject cart-cookie-ttl --note 'cookie is set before consent — move it after' +``` + +**Accept lands the code.** If the task has a PR, accept merges it through the +repo's gitprovider and records the merge commit. If the merge fails — conflict, +or someone pushed after the review — the task stays un-merged and says why. A +stale head is refused rather than merged, because accepting a commit nobody +reviewed is the one thing the gate exists to prevent. + +Reject requires its comment. That comment is what the agent works from, so an +empty rejection is a bug report with the contents removed. + +## Reconciliation + +`describe epic` prints claimed completeness and, directly under it, what the +delivered work actually says. Those two disagree by design: + +- **Completeness** counts `met` flags. Nothing flips them automatically, so an + Epic whose every Task is merged still reads 0% until someone asserts the + vision was met. +- **Reconciliation** walks the roadmap and reports which criteria have merged + work behind them, which are claimed but undelivered, and which no roadmap node + owns at all (`unowned`). + +Attribution is by criterion id, which is a *convention* — nothing enforces that +an EpicTask's criteria ids exist on the parent. Reconciliation reports where the +convention doesn't hold rather than guessing an owner. It never writes `met`: +deriving it from task state would assert that finishing the tasks delivered the +vision, which is exactly the assumption under audit. + +## RBAC + +| Resource | Verbs | +|---|---| +| `epics` | `view`, `create`, `edit`, `delete` — `edit` also covers status/criteria/tradeoffs | +| `epictasks` | `view`, `create`, `edit`, `delete` — `edit` also covers dependency edges | +| `tasks` | `view`, `create`, `edit`, `delete`, **`run`** | + +`run:tasks` is the gated one: advancing state, recording verdicts, accepting and +rejecting are `run`, not `create`. A reviewer who may drive tasks through the +board should not need permission to invent them, and — more to the point — +anyone who can *file* a task should not thereby be able to *accept* one. + +```sh +mcpctl create rbac task-reviewer --role run --resource tasks +mcpctl create rbac epic-reader --role view --resource epics +``` + +Bindings written before the `tasks` / `inference-tasks` split are rewritten once +at startup, so an old `view:tasks` grant keeps authorizing the inference queue +and does not silently become authority over work items. + +## See also + +- [gitproviders.md](gitproviders.md) — forges, repo import, merge and post-merge reopen +- [agent-authority.md](agent-authority.md) — whose authority an agent acts under +- [inference-tasks.md](inference-tasks.md) — the durable inference queue that used to own this name diff --git a/docs/gitproviders.md b/docs/gitproviders.md new file mode 100644 index 0000000..dc8a6a5 --- /dev/null +++ b/docs/gitproviders.md @@ -0,0 +1,149 @@ +# Git providers and repositories + +Two resources: + +- **`gitprovider`** — a connection to a git forge (Gitea today). One per + instance/account. Owns the credential. +- **`repo`** (CodeRepo) — a repository attached to a project. Either imported + from a provider or added by URL. + +Registering a provider is what makes discovery, import and **merge-on-accept** +possible. A repo added by bare URL can be worked in, but nothing can land its +pull requests, because there is no API to land them through. + +## Registering a forge + +The token lives in a Secret; the provider references it by name and key. + +```sh +mcpctl create secret gitea-token --data TOKEN= + +mcpctl create gitprovider mysources \ + --url https://mysources.co.uk \ + --secret gitea-token --key TOKEN +``` + +Registration **probes the credential** — a wrong URL or an under-scoped token +fails here with a clear message, rather than later at discovery where it looks +like "the forge has no repos". Use `--skip-probe` to wire config before the +infrastructure exists. + +The token needs repository scope. Discovery deliberately calls `/repos/search` +rather than `/user/repos`, because the latter requires `read:user`, which the +repo-scoped tokens people actually create for automation do not carry. + +## Discover and import + +```sh +mcpctl repos discover mysources # everything visible +mcpctl repos discover mysources -q mcp --limit 20 # filter +mcpctl repos discover mysources --project mcpctl # mark what's already imported + +mcpctl repos import mysources michal/mcpctl michal/lab --project mcpctl +mcpctl repos import mysources michal/mcpctl --project mcpctl --ssh +``` + +Discovery stores nothing — it is a live read. Import creates CodeRepo rows. + +Import is partial by design: it takes what it can and reports what it skipped +with reasons, rather than failing the batch because one repo is empty or +archived. An empty repo is flagged unavailable — there is nothing to clone. + +Imported repos carry **denormalized provenance** (`sourceProvider`, +`sourceFullName`) and store no credential of their own — they inherit the +forge's. Deleting a provider leaves its repos in place with their provenance +intact, so where a repo came from outlives the connection. + +To attach a repo without a provider: + +```sh +mcpctl create repo mcpctl --project mcpctl \ + --url https://mysources.co.uk/michal/mcpctl.git --branch main +``` + +```sh +mcpctl get repos +mcpctl get gitproviders +mcpctl describe repo mcpctl +``` + +## Merge on accept + +When a task carrying a PR is accepted, mcpctl merges it through the repo's +provider — see [epics.md](epics.md#the-human-gate). Two refusals matter: + +- **Stale head.** The accept records which commit was reviewed. If someone + pushed after that, the merge is refused rather than landing code nobody + approved. Re-review the current head and accept again. +- **Conflict.** Reported as-is; the task stays un-merged. + +A failure always leaves the task un-merged and says why. Nothing reports success +the board cannot back up. + +Repos with no `gitProviderId` cannot be merged through — the accept says so and +tells you to import from a provider or merge in the forge. + +## Post-merge failure + +CI can fail *after* a merge lands. Without a way to hear about it, the board +keeps claiming `merged` for work that broke the build. The webhook closes that +loop by moving the task to `reopened`. + +Add the signing secret to the provider's Secret and point the provider at it: + +```sh +mcpctl create secret gitea-token \ + --data TOKEN= --data WEBHOOK= + +mcpctl create gitprovider mysources \ + --url https://mysources.co.uk \ + --secret gitea-token --key TOKEN \ + --webhook-secret-key WEBHOOK +``` + +Then in the forge, add a webhook on **commit status** events: + +``` +URL: https:///api/v1/webhooks/git/mysources +Content: application/json +Secret: +``` + +> **It fails closed.** The endpoint is unauthenticated by nature — a forge calls +> it — so it accepts nothing unless the provider has a signing key configured +> *and* the signature verifies. **A provider without `--webhook-secret-key` +> refuses every delivery**, which means post-merge failures silently never +> reopen anything. If you want this loop, you must set the key. + +Accepted signature headers are `X-Gitea-Signature` and `X-Hub-Signature-256` +(HMAC-SHA256 over the raw body, `sha256=` prefix optional), compared in constant +time. + +Matching is precise, not approximate: a failing status carries a commit sha, and +the accept path recorded exactly which commit each merge produced. A failure on +a commit mcpctl did not merge is ignored — it is somebody else's business, and +guessing at "whatever merged most recently" would reopen innocent work. + +Only `failure` and `error` states do anything. Success and pending are +acknowledged and ignored. + +## RBAC + +| Resource | Verbs | +|---|---| +| `gitproviders` | `view`, `create`, `edit`, `delete`, **`run`** | +| `repos` | `view`, `create`, `edit`, `delete` | + +`run:gitproviders` covers discovery, import and probe. Those reach out to a +third-party system with a stored credential, so they are `run`, not `view` — +holding `view:gitproviders` should not let you drive the forge's API. + +```sh +mcpctl create rbac forge-user --role run --resource gitproviders +``` + +## See also + +- [epics.md](epics.md) — the work items whose PRs get merged here +- [agent-authority.md](agent-authority.md) — whose authority an agent acts under +- [secret-backends.md](secret-backends.md) — where the forge credential lives diff --git a/docs/inference-tasks.md b/docs/inference-tasks.md index 6304d91..c288211 100644 --- a/docs/inference-tasks.md +++ b/docs/inference-tasks.md @@ -136,10 +136,21 @@ result is just discarded server-side. ## CLI +> **Renamed.** This queue used to answer to `mcpctl get tasks`. Agentic-teams +> **work items** own that name now, so the queue keeps only its explicit +> spellings: `inference-tasks`, `inference-task`, `itasks`, `itask`. Bare +> `tasks`/`task` list work items — see [epics.md](epics.md). +> +> The same split applies to RBAC: this queue is authorized by +> `view:inference-tasks`, work items by `view:tasks`. Bindings written before +> the split are rewritten once at startup, so an existing `view:tasks` grant +> keeps working here and does **not** silently become a grant over work items. + ```sh -mcpctl get tasks # table view: ID, STATUS, POOL, LLM, MODEL, STREAM, AGE, WORKER -mcpctl get task # describe one -mcpctl get task -o yaml # full row, yaml format +mcpctl get inference-tasks # table view: ID, STATUS, POOL, LLM, MODEL, STREAM, AGE, WORKER +mcpctl get itasks # short alias +mcpctl get inference-task # describe one +mcpctl get inference-task -o yaml # full row, yaml format # Enqueue async via chat-llm: mcpctl chat-llm vllm-local-qwen3 --async -m "summarize the changelog" diff --git a/src/cli/src/commands/apply.ts b/src/cli/src/commands/apply.ts index 36fd16a..4c906a2 100644 --- a/src/cli/src/commands/apply.ts +++ b/src/cli/src/commands/apply.ts @@ -232,6 +232,10 @@ const GitProviderSpecSchema = z.object({ type: z.string().optional(), description: z.string().optional(), credRef: z.object({ name: z.string(), key: z.string() }).nullable().optional(), + // Key in the credential Secret holding the webhook signing secret. The + // webhook endpoint fails closed without it, so a provider that omits this + // silently never reopens tasks whose merge broke CI. + webhookSecretKey: z.string().nullable().optional(), skipProbe: z.boolean().optional(), }); diff --git a/src/cli/src/commands/create.ts b/src/cli/src/commands/create.ts index ed9ab5c..23417d6 100644 --- a/src/cli/src/commands/create.ts +++ b/src/cli/src/commands/create.ts @@ -131,11 +131,13 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { .option('--description ', 'Description') .option('--secret ', 'Secret holding the API token') .option('--key ', 'Key within that secret', 'TOKEN') + .option('--webhook-secret-key ', 'Key in that secret holding the webhook signing secret; without it incoming webhooks are refused') .option('--skip-probe', 'Skip the credential check at registration time') .action(async (name: string, opts) => { const body: Record = { name, url: opts.url, type: opts.type }; if (opts.description !== undefined) body.description = opts.description; if (opts.secret !== undefined) body.credRef = { name: opts.secret, key: opts.key }; + if (opts.webhookSecretKey !== undefined) body.webhookSecretKey = opts.webhookSecretKey; if (opts.skipProbe === true) body.skipProbe = true; const created = await client.post<{ name: string; type: string; url: string }>('/api/v1/gitproviders', body); log(`Created gitprovider '${created.name}' (${created.type} @ ${created.url})`); @@ -1019,7 +1021,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { const buf = await fs.readFile(full); // Reject non-UTF8 — v1 is text-only. const text = buf.toString('utf-8'); - if (text.includes('')) { + if (text.includes('\u0000')) { throw new Error(`File ${rel} contains a null byte; binaries aren't supported in v1`); } files[rel] = text; diff --git a/src/mcpd/src/services/git-provider.service.ts b/src/mcpd/src/services/git-provider.service.ts index 038df36..235820a 100644 --- a/src/mcpd/src/services/git-provider.service.ts +++ b/src/mcpd/src/services/git-provider.service.ts @@ -86,6 +86,7 @@ export class GitProviderService { ownerId, type, ...(data.description !== undefined ? { description: data.description } : {}), + ...(data.webhookSecretKey !== undefined ? { webhookSecretKey: data.webhookSecretKey } : {}), ...cred, }); } @@ -114,6 +115,7 @@ export class GitProviderService { ...(data.url !== undefined ? { url: data.url } : {}), ...(data.type !== undefined ? { type: data.type } : {}), ...(data.description !== undefined ? { description: data.description } : {}), + ...(data.webhookSecretKey !== undefined ? { webhookSecretKey: data.webhookSecretKey } : {}), ...cred, }); } diff --git a/src/mcpd/src/validation/git-provider.schema.ts b/src/mcpd/src/validation/git-provider.schema.ts index 38c3df1..da79706 100644 --- a/src/mcpd/src/validation/git-provider.schema.ts +++ b/src/mcpd/src/validation/git-provider.schema.ts @@ -11,12 +11,21 @@ const InstanceUrlSchema = z.string().url().refine( { message: 'url must be http:// or https://' }, ); +/** + * Key within the provider's credential Secret holding the webhook signing + * secret. Without it the webhook endpoint refuses every delivery (it fails + * closed), so post-merge CI failures never reopen their task — leaving the + * board claiming `merged` for work that broke the build. + */ +const WebhookSecretKeySchema = z.string().min(1).max(200); + export const CreateGitProviderSchema = z.object({ name: z.string().min(1).max(100).regex(NAME_RE, 'Name must be lowercase alphanumeric with hyphens'), url: InstanceUrlSchema, type: z.enum(GIT_PROVIDER_TYPES).optional(), description: z.string().max(500).optional(), credRef: CredRefSchema.optional(), + webhookSecretKey: WebhookSecretKeySchema.optional(), /** Skip the registration-time credential probe (wiring config before infra exists). */ skipProbe: z.boolean().optional(), }); @@ -26,6 +35,9 @@ export const UpdateGitProviderSchema = z.object({ type: z.enum(GIT_PROVIDER_TYPES).optional(), description: z.string().max(500).optional(), credRef: CredRefSchema.nullable().optional(), + // Nullable so webhook verification can be turned back off without deleting + // the provider. + webhookSecretKey: WebhookSecretKeySchema.nullable().optional(), skipProbe: z.boolean().optional(), }); diff --git a/src/mcpd/tests/services/git-provider.test.ts b/src/mcpd/tests/services/git-provider.test.ts index f7cc410..7fbd0a4 100644 --- a/src/mcpd/tests/services/git-provider.test.ts +++ b/src/mcpd/tests/services/git-provider.test.ts @@ -441,3 +441,64 @@ describe('GiteaDriver — pull requests', () => { await expect(new GiteaDriver().getPullRequest(CTX, REF)).rejects.toThrow(GitProviderAuthError); }); }); + +describe('GitProviderService — webhook signing key', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + /** + * `webhookSecretKey` existed on the schema and was read by the webhook route, + * but nothing could ever write it: it was absent from the create schema, the + * update schema, the service, the CLI and the apply spec. Since the endpoint + * fails closed on a null key, post-merge CI failures could never reopen their + * task — the board kept claiming `merged` for work that broke the build. + * + * These pin the write path that makes the feature reachable at all. + */ + it('persists the key given at registration', async () => { + const { service, providerRepo } = svc(); + await service.create( + { + name: 'mysources', + url: 'https://mysources.co.uk', + credRef: { name: 'gitea-token', key: 'TOKEN' }, + webhookSecretKey: 'WEBHOOK', + skipProbe: true, + }, + 'user-1', + ); + + expect(providerRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ webhookSecretKey: 'WEBHOOK' }), + ); + }); + + it('leaves the column untouched when the flag is omitted', async () => { + // Omission must not be read as "clear it" — that would silently disarm the + // webhook on any unrelated edit. + const { service, providerRepo } = svc(); + await service.update('gp-1', { description: 'renamed', skipProbe: true }); + + const patch = (providerRepo.update as ReturnType).mock.calls[0]?.[1] as Record; + expect(patch).not.toHaveProperty('webhookSecretKey'); + }); + + it('turns verification back off when explicitly nulled', async () => { + const { service, providerRepo } = svc(); + await service.update('gp-1', { webhookSecretKey: null, skipProbe: true }); + + expect(providerRepo.update).toHaveBeenCalledWith( + 'gp-1', + expect.objectContaining({ webhookSecretKey: null }), + ); + }); + + it('rejects an empty key rather than storing a falsy signing secret', async () => { + const { service } = svc(); + await expect( + service.create( + { name: 'mysources', url: 'https://mysources.co.uk', webhookSecretKey: '', skipProbe: true }, + 'user-1', + ), + ).rejects.toThrow(); + }); +}); -- 2.49.1 From 553834dbb10fd5565d4922183d71f4e3684765d7 Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 16 Aug 2026 00:16:07 +0100 Subject: [PATCH 31/32] docs(epics): document linking a task to its pull request Accept only merges if the task carries a prNumber, and nothing said how a human sets one. It goes through the generic patch command; without this the merge gate reads as unreachable from the CLI. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p --- docs/epics.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/epics.md b/docs/epics.md index e10418c..6b766cd 100644 --- a/docs/epics.md +++ b/docs/epics.md @@ -124,6 +124,25 @@ mcpctl tasks verdict cart-cookie-ttl \ Reaching `done` without the required passing verdicts is refused, and the error names which stage is missing. +### Linking the pull request + +Accept only merges something if the task knows what its pull request is. An +agent writes these when it pushes; from the CLI they go through `patch`: + +```sh +mcpctl patch task cart-cookie-ttl \ + branch=feat/cart-cookie-ttl \ + headCommit=4f2a91c \ + prNumber=214 \ + prUrl=https://mysources.co.uk/michal/mcpctl/pulls/214 +``` + +`headCommit` is what the review is pinned to. Accept refuses if the PR's head +has moved past it — see below. + +A task with no `prNumber` can still be accepted; it records the approval and +merges nothing, and says so rather than implying code landed. + ### The human gate `done` means it left the developer's loop, not that it shipped. `awaiting_review` -- 2.49.1 From 527983d87ee960caf5f2916c148df9d24ac17dbe Mon Sep 17 00:00:00 2001 From: Michal Date: Fri, 9 Oct 2026 15:40:11 +0100 Subject: [PATCH 32/32] fix(db-tests): seed a SecretBackend for Secret model tests The four Secret tests in models.test.ts created rows via raw prisma.secret.create without a backendId, so the schema default '' violated Secret_backendId_fkey (that default only exists so 'db push' can add the column to pre-existing rows; real writes always carry a valid FK). Seed a plaintext backend per test, the same shape bootstrapSecretBackends creates, and clear SecretBackend rows in clearAllTables so nothing leaks between tests. Co-Authored-By: Claude Fable 5 --- src/db/tests/helpers.ts | 1 + src/db/tests/models.test.ts | 24 ++++++++++++++++++++---- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/db/tests/helpers.ts b/src/db/tests/helpers.ts index 7d80678..03ffd97 100644 --- a/src/db/tests/helpers.ts +++ b/src/db/tests/helpers.ts @@ -61,6 +61,7 @@ export async function clearAllTables(client: PrismaClient): Promise { await client.prompt.deleteMany(); await client.projectServer.deleteMany(); await client.secret.deleteMany(); + await client.secretBackend.deleteMany(); await client.session.deleteMany(); await client.project.deleteMany(); await client.mcpServer.deleteMany(); diff --git a/src/db/tests/models.test.ts b/src/db/tests/models.test.ts index dbedd36..37ea825 100644 --- a/src/db/tests/models.test.ts +++ b/src/db/tests/models.test.ts @@ -184,9 +184,22 @@ describe('McpServer', () => { // ── Secret model ── describe('Secret', () => { + // New Secret rows always carry a valid backend FK (SecretService guarantees + // this; the schema-level `backendId @default("")` only exists so `db push` + // can add the column to pre-existing rows). Seed the plaintext backend each + // test the same way bootstrapSecretBackends does. + let backendId: string; + + beforeEach(async () => { + const backend = await prisma.secretBackend.create({ + data: { name: 'default', type: 'plaintext', isDefault: true }, + }); + backendId = backend.id; + }); + it('creates a secret with defaults', async () => { const secret = await prisma.secret.create({ - data: { name: 'my-secret' }, + data: { name: 'my-secret', backendId }, }); expect(secret.name).toBe('my-secret'); expect(secret.data).toEqual({}); @@ -197,6 +210,7 @@ describe('Secret', () => { const secret = await prisma.secret.create({ data: { name: 'api-keys', + backendId, data: { API_KEY: 'test-key', API_SECRET: 'test-secret' }, }, }); @@ -206,13 +220,15 @@ describe('Secret', () => { }); it('enforces unique name', async () => { - await prisma.secret.create({ data: { name: 'dup-secret' } }); - await expect(prisma.secret.create({ data: { name: 'dup-secret' } })).rejects.toThrow(); + await prisma.secret.create({ data: { name: 'dup-secret', backendId } }); + await expect( + prisma.secret.create({ data: { name: 'dup-secret', backendId } }), + ).rejects.toThrow(); }); it('updates data', async () => { const secret = await prisma.secret.create({ - data: { name: 'updatable', data: { KEY: 'old' } }, + data: { name: 'updatable', backendId, data: { KEY: 'old' } }, }); const updated = await prisma.secret.update({ where: { id: secret.id }, -- 2.49.1