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/task_025.md b/.taskmaster/tasks/task_025.md new file mode 100644 index 0000000..9e26fc5 --- /dev/null +++ b/.taskmaster/tasks/task_025.md @@ -0,0 +1,35 @@ +# Task ID: 25 + +**Title:** Rename local-proxy to mcplocal + +**Status:** done + +**Dependencies:** None + +**Priority:** high + +**Description:** Rename the src/local-proxy directory to src/mcplocal and update all package references, imports, and build configurations throughout the monorepo. + +**Details:** + +1. Rename directory: `mv src/local-proxy src/mcplocal` +2. Update package.json name from `@mcpctl/local-proxy` to `@mcpctl/mcplocal` +3. Update pnpm-workspace.yaml if needed +4. Update all imports in other packages that reference local-proxy: + - Search for `@mcpctl/local-proxy` and replace with `@mcpctl/mcplocal` + - Check tsconfig references and path mappings +5. Update any scripts in package.json root that reference local-proxy +6. Update docker-compose files in deploy/ if they reference local-proxy +7. Update documentation and README references +8. Run `pnpm install` to regenerate lockfile with new package name +9. Verify TypeScript compilation succeeds: `pnpm build` +10. Run existing tests to ensure nothing broke: `pnpm test` + +**Test Strategy:** + +1. Verify directory rename completed: `ls src/mcplocal` +2. Verify package.json has correct name +3. Run `pnpm install` - should complete without errors +4. Run `pnpm build` - all packages should compile +5. Run `pnpm test` - all existing tests should pass +6. Grep codebase for 'local-proxy' - should find no stale references except git history diff --git a/.taskmaster/tasks/task_026.md b/.taskmaster/tasks/task_026.md new file mode 100644 index 0000000..17ada91 --- /dev/null +++ b/.taskmaster/tasks/task_026.md @@ -0,0 +1,46 @@ +# Task ID: 26 + +**Title:** Add HTTP REST server to mcplocal + +**Status:** done + +**Dependencies:** 25 ✓ + +**Priority:** high + +**Description:** Add a Fastify HTTP server to mcplocal that runs alongside the existing stdio server, providing REST endpoints for mcpctl management commands. + +**Details:** + +1. Add Fastify dependency to mcplocal package.json: `@fastify/cors`, `fastify` +2. Create `src/mcplocal/src/http/server.ts` with Fastify app setup: + ```typescript + import Fastify from 'fastify'; + import cors from '@fastify/cors'; + + export async function createHttpServer(config: HttpServerConfig) { + const app = Fastify({ logger: true }); + await app.register(cors, { origin: true }); + // Register routes + return app; + } + ``` +3. Create `src/mcplocal/src/http/routes/` directory structure +4. Create health check endpoint: `GET /health` +5. Create config types in `src/mcplocal/src/config.ts`: + - `httpPort`: number (default 3200) + - `httpHost`: string (default '127.0.0.1') + - `mcpdUrl`: string (default 'http://localhost:3100') +6. Update mcplocal entry point to start both servers: + - stdio server for Claude MCP protocol + - HTTP server for mcpctl REST API +7. Add graceful shutdown handling for both servers + +**Test Strategy:** + +1. Unit test: HTTP server starts on configured port +2. Unit test: Health endpoint returns 200 OK +3. Integration test: Both stdio and HTTP servers can run simultaneously +4. Test graceful shutdown stops both servers cleanly +5. Test CORS headers are present on responses +6. Manual test: curl http://localhost:3200/health diff --git a/.taskmaster/tasks/task_027.md b/.taskmaster/tasks/task_027.md new file mode 100644 index 0000000..f20e25b --- /dev/null +++ b/.taskmaster/tasks/task_027.md @@ -0,0 +1,130 @@ +# Task ID: 27 + +**Title:** Implement mcplocal management proxy routes + +**Status:** done + +**Dependencies:** 26 ✓ + +**Priority:** high + +**Description:** Add REST endpoints to mcplocal that mirror mcpd's API and proxy management requests to mcpd without LLM processing. All requests must include proper authentication to mcpd using a Bearer token read from mcplocal config. + +**Details:** + +1. Create HTTP client for mcpd communication with auth: `src/local-proxy/src/http/mcpd-client.ts` + ```typescript + export class McpdClient { + private token: string; + + constructor(private baseUrl: string, token: string) { + this.token = token; + } + + private getHeaders(): Record { + return { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.token}` + }; + } + + async get(path: string): Promise { + const response = await fetch(`${this.baseUrl}${path}`, { + method: 'GET', + headers: this.getHeaders() + }); + await this.handleAuthError(response); + return response.json(); + } + + async post(path: string, body: unknown): Promise { + const response = await fetch(`${this.baseUrl}${path}`, { + method: 'POST', + headers: this.getHeaders(), + body: JSON.stringify(body) + }); + await this.handleAuthError(response); + return response.json(); + } + + async put(path: string, body: unknown): Promise { + const response = await fetch(`${this.baseUrl}${path}`, { + method: 'PUT', + headers: this.getHeaders(), + body: JSON.stringify(body) + }); + await this.handleAuthError(response); + return response.json(); + } + + async delete(path: string): Promise { + const response = await fetch(`${this.baseUrl}${path}`, { + method: 'DELETE', + headers: this.getHeaders() + }); + await this.handleAuthError(response); + return response.json(); + } + + private async handleAuthError(response: Response): Promise { + if (response.status === 401) { + throw new AuthenticationError('Invalid or expired token. Please check mcplocal config.'); + } + } + } + + export class AuthenticationError extends Error { + constructor(message: string) { + super(message); + this.name = 'AuthenticationError'; + } + } + ``` +2. Add token to mcplocal config type (extend ProxyConfig or similar): + ```typescript + export interface McpdAuthConfig { + /** Bearer token for mcpd API authentication */ + mcpdToken: string; + } + ``` +3. Create proxy routes in `src/local-proxy/src/http/routes/`: + - `servers.ts`: GET/POST /api/v1/servers, GET/PUT/DELETE /api/v1/servers/:id + - `profiles.ts`: GET/POST /api/v1/profiles, GET/PUT/DELETE /api/v1/profiles/:id + - `instances.ts`: GET/POST /api/v1/instances, GET/POST/DELETE /api/v1/instances/:id, etc. + - `projects.ts`: GET/POST /api/v1/projects, etc. + - `audit.ts`: GET /api/v1/audit-logs + - `backup.ts`: POST /api/v1/backup, POST /api/v1/restore +4. Each route handler forwards to mcpd with auth: + ```typescript + app.get('/api/v1/servers', async (req, reply) => { + try { + const result = await mcpdClient.get('/api/v1/servers'); + return result; + } catch (error) { + if (error instanceof AuthenticationError) { + return reply.status(401).send({ error: error.message }); + } + throw error; + } + }); + ``` +5. Add comprehensive error handling: + - If mcpd is unreachable, return 503 Service Unavailable + - If mcpd returns 401, return 401 with clear message about token configuration + - Forward other HTTP errors from mcpd with appropriate status codes +6. Add request/response logging for debugging + +**Test Strategy:** + +1. Unit test: McpdClient attaches Authorization header to all request methods (GET, POST, PUT, DELETE) +2. Unit test: McpdClient throws AuthenticationError on 401 response from mcpd +3. Unit test: Each proxy route forwards requests correctly with auth headers +4. Unit test: Error handling when mcpd is unreachable (503 response) +5. Unit test: Error handling when mcpd returns 401 (clear error message returned) +6. Integration test: Full request flow mcpctl -> mcplocal -> mcpd with valid token +7. Integration test: Full request flow with invalid token returns 401 +8. Test query parameters are forwarded correctly +9. Test request body is forwarded correctly for POST/PUT +10. Test path parameters (:id) are passed through correctly +11. Mock mcpd responses and verify mcplocal returns them unchanged +12. Test token is read correctly from mcplocal config diff --git a/.taskmaster/tasks/task_028.md b/.taskmaster/tasks/task_028.md new file mode 100644 index 0000000..c50fd00 --- /dev/null +++ b/.taskmaster/tasks/task_028.md @@ -0,0 +1,149 @@ +# Task ID: 28 + +**Title:** Add MCP proxy endpoint to mcpd + +**Status:** done + +**Dependencies:** None + +**Priority:** high + +**Description:** Create a new endpoint in mcpd at /api/v1/mcp/proxy that accepts MCP tool call requests and executes them on managed MCP server instances. Also add authentication endpoints (login/logout) that mcpctl will use to authenticate users. + +**Details:** + +## MCP Proxy Endpoint + +1. Create new route file: `src/mcpd/src/routes/mcp-proxy.ts` +2. Define request schema: + ```typescript + interface McpProxyRequest { + serverId: string; // or instanceId + method: string; // e.g., 'tools/call', 'resources/read' + params: Record; + } + ``` +3. Create McpProxyService in `src/mcpd/src/services/mcp-proxy-service.ts`: + - Look up instance by serverId (auto-start if profile allows) + - Connect to the container via stdio or HTTP (depending on transport type) + - Execute the MCP JSON-RPC call + - Return the result +4. Handle MCP JSON-RPC protocol: + ```typescript + async executeCall(instanceId: string, method: string, params: unknown) { + const instance = await this.instanceService.getInstance(instanceId); + const connection = await this.getOrCreateConnection(instance); + const result = await connection.call(method, params); + return result; + } + ``` +5. Connection pooling: maintain persistent connections to running instances +6. Add route: `POST /api/v1/mcp/proxy` (must be behind auth middleware) +7. Add audit logging for all MCP proxy calls - include authenticated userId from request.userId +8. Handle errors: instance not found, instance not running, MCP call failed + +## Authentication Endpoints + +9. Create auth routes file: `src/mcpd/src/routes/auth.ts` +10. Implement `POST /api/v1/auth/login`: + - Request body: `{ username: string, password: string }` + - Validate credentials against User table (use bcrypt for password comparison) + - Create new Session record with token (use crypto.randomUUID or similar) + - Response: `{ token: string, expiresAt: string }` +11. Implement `POST /api/v1/auth/logout`: + - Requires Bearer token in Authorization header + - Delete/invalidate the Session record + - Response: `{ success: true }` + +## Auth Integration Notes + +- Existing auth middleware in `src/mcpd/src/middleware/auth.ts` validates Bearer tokens against Session table +- It sets `request.userId` on successful authentication +- MCP proxy endpoint MUST use this auth middleware +- Auth endpoints (login) should NOT require auth middleware +- Logout endpoint SHOULD require auth middleware to validate the session being invalidated + +**Test Strategy:** + +1. Unit test: Proxy service looks up correct instance +2. Unit test: JSON-RPC call is formatted correctly +3. Integration test: Full flow with a mock MCP server container +4. Test error handling: non-existent server returns 404 +5. Test error handling: stopped instance returns appropriate error +6. Test audit log entries include authenticated userId +7. Test connection reuse for multiple calls to same instance +8. Test login endpoint: valid credentials return session token +9. Test login endpoint: invalid credentials return 401 +10. Test logout endpoint: valid session is invalidated +11. Test logout endpoint: invalid/missing token returns 401 +12. Test MCP proxy endpoint without auth token returns 401 +13. Test MCP proxy endpoint with expired token returns 401 +14. Test MCP proxy endpoint with valid token succeeds and logs userId in audit + +## Subtasks + +### 28.1. Create auth routes with login/logout endpoints + +**Status:** pending +**Dependencies:** None + +Create src/mcpd/src/routes/auth.ts with POST /api/v1/auth/login and POST /api/v1/auth/logout endpoints for mcpctl authentication. + +**Details:** + +Implement login endpoint: validate username/password against User table using bcrypt, create Session record with generated token and expiry. Implement logout endpoint: require auth middleware, delete/invalidate Session record. Login does NOT require auth, logout DOES require auth. Export registerAuthRoutes function and update routes/index.ts. + +### 28.2. Create MCP proxy route file with auth middleware + +**Status:** pending +**Dependencies:** 28.1 + +Create src/mcpd/src/routes/mcp-proxy.ts with POST /api/v1/mcp/proxy endpoint protected by auth middleware. + +**Details:** + +Define McpProxyRequest interface (serverId, method, params). Register route handler that extracts userId from request.userId (set by auth middleware). Apply auth middleware using preHandler hook. Validate request body schema. + +### 28.3. Create McpProxyService for instance lookup and connection + +**Status:** pending +**Dependencies:** None + +Create src/mcpd/src/services/mcp-proxy-service.ts to handle instance lookup, connection management, and MCP call execution. + +**Details:** + +Implement getInstance to look up by serverId, auto-start if profile allows. Implement getOrCreateConnection for connection pooling. Handle both stdio and HTTP transports. Implement executeCall method that formats JSON-RPC call and returns result. + +### 28.4. Implement MCP JSON-RPC call execution + +**Status:** pending +**Dependencies:** 28.3 + +Implement the core JSON-RPC call logic in McpProxyService to execute tool calls on MCP server instances. + +**Details:** + +Format JSON-RPC 2.0 request with method and params. Send request over established connection (stdio/HTTP). Parse JSON-RPC response and handle errors. Return result or throw appropriate error for failed calls. + +### 28.5. Add audit logging with userId for MCP proxy calls + +**Status:** pending +**Dependencies:** 28.2, 28.4 + +Ensure all MCP proxy calls are logged to audit log including the authenticated userId from the session. + +**Details:** + +Use existing audit middleware/service. Include userId from request.userId in audit log entry. Log serverId, method, and outcome (success/failure). Log any errors that occur during MCP call execution. + +### 28.6. Integrate auth and proxy routes into server.ts + +**Status:** pending +**Dependencies:** 28.1, 28.2, 28.5 + +Register the new auth and mcp-proxy routes in the Fastify server with proper auth middleware wiring. + +**Details:** + +Update server.ts to register auth routes (no auth required for login). Register mcp-proxy routes with auth middleware. Ensure auth middleware is wired with findSession dependency from Prisma. Update routes/index.ts exports. diff --git a/.taskmaster/tasks/task_029.md b/.taskmaster/tasks/task_029.md new file mode 100644 index 0000000..542a805 --- /dev/null +++ b/.taskmaster/tasks/task_029.md @@ -0,0 +1,74 @@ +# Task ID: 29 + +**Title:** Implement LLM pre-processing pipeline in mcplocal + +**Status:** done + +**Dependencies:** 25 ✓, 27 ✓, 28 ✓ + +**Priority:** high + +**Description:** Create the core LLM pre-processing pipeline that intercepts MCP tool calls, uses a local LLM to optimize requests before sending to mcpd, and filters responses before returning to Claude. + +**Details:** + +1. Create `src/mcplocal/src/llm/processor.ts` - the core pipeline: + ```typescript + export class LlmProcessor { + constructor( + private providerRegistry: ProviderRegistry, + private config: LlmProcessorConfig + ) {} + + async preprocessRequest(toolName: string, params: unknown): Promise { + // Use LLM to interpret and optimize the request + const prompt = this.buildRequestPrompt(toolName, params); + const result = await this.providerRegistry.getActiveProvider().complete({ + systemPrompt: REQUEST_OPTIMIZATION_SYSTEM_PROMPT, + userPrompt: prompt + }); + return this.parseOptimizedRequest(result); + } + + async filterResponse(toolName: string, originalRequest: unknown, rawResponse: unknown): Promise { + // Use LLM to filter/summarize the response + const prompt = this.buildFilterPrompt(toolName, originalRequest, rawResponse); + const result = await this.providerRegistry.getActiveProvider().complete({ + systemPrompt: RESPONSE_FILTER_SYSTEM_PROMPT, + userPrompt: prompt + }); + return this.parseFilteredResponse(result); + } + } + ``` +2. Create system prompts in `src/mcplocal/src/llm/prompts.ts`: + - REQUEST_OPTIMIZATION_SYSTEM_PROMPT: instruct LLM to generate optimal queries + - RESPONSE_FILTER_SYSTEM_PROMPT: instruct LLM to extract relevant information +3. Integrate into router.ts - wrap tools/call handler: + ```typescript + async handleToolsCall(request: JsonRpcRequest) { + if (this.shouldPreprocess(request.params.name)) { + const processed = await this.llmProcessor.preprocessRequest(...); + // Call mcpd with processed request + const rawResponse = await this.callMcpd(processed); + const filtered = await this.llmProcessor.filterResponse(...); + return filtered; + } + return this.callMcpd(request.params); + } + ``` +4. Add configuration options: + - `enablePreprocessing`: boolean + - `preprocessingExclude`: string[] (tool names to skip) + - `preferredProvider`: string (ollama, gemini, deepseek, etc.) +5. Add bypass logic for simple operations (list, create, delete) + +**Test Strategy:** + +1. Unit test: Request preprocessing generates optimized queries +2. Unit test: Response filtering reduces data volume +3. Unit test: Bypass logic works for excluded tools +4. Integration test: Full pipeline with mock LLM provider +5. Test error handling: LLM failure falls back to unfiltered pass-through +6. Test configuration options are respected +7. Measure: response size reduction percentage diff --git a/.taskmaster/tasks/task_030.md b/.taskmaster/tasks/task_030.md new file mode 100644 index 0000000..fe90d4d --- /dev/null +++ b/.taskmaster/tasks/task_030.md @@ -0,0 +1,61 @@ +# Task ID: 30 + +**Title:** Add Gemini CLI LLM provider + +**Status:** done + +**Dependencies:** 25 ✓ + +**Priority:** medium + +**Description:** Implement a new LLM provider that uses the Gemini CLI binary for local, free LLM inference as the preferred provider for pre-processing. + +**Details:** + +1. Create `src/mcplocal/src/providers/gemini-cli.ts`: + ```typescript + import { spawn } from 'child_process'; + + export class GeminiCliProvider implements LlmProvider { + readonly name = 'gemini-cli'; + private binaryPath: string; + + constructor(config: GeminiCliConfig) { + this.binaryPath = config.binaryPath || 'gemini'; + } + + async isAvailable(): Promise { + // Check if gemini binary exists and is executable + try { + await this.runCommand(['--version']); + return true; + } catch { + return false; + } + } + + async complete(options: CompletionOptions): Promise { + const input = this.formatPrompt(options); + const output = await this.runCommand(['--prompt', input]); + return { content: output, model: 'gemini-cli' }; + } + + private async runCommand(args: string[]): Promise { + // Spawn gemini CLI process and capture output + } + } + ``` +2. Research actual Gemini CLI interface and adjust implementation +3. Add to provider registry with high priority (prefer over API providers) +4. Add configuration: `geminiCliBinaryPath` +5. Handle timeout for slow inference +6. Add fallback to next provider if Gemini CLI fails + +**Test Strategy:** + +1. Unit test: Provider correctly detects CLI availability +2. Unit test: Prompt formatting is correct +3. Unit test: Output parsing handles various formats +4. Integration test: Full completion with actual Gemini CLI (if available) +5. Test timeout handling for slow responses +6. Test fallback when CLI is not installed diff --git a/.taskmaster/tasks/task_031.md b/.taskmaster/tasks/task_031.md new file mode 100644 index 0000000..42ab8cd --- /dev/null +++ b/.taskmaster/tasks/task_031.md @@ -0,0 +1,59 @@ +# Task ID: 31 + +**Title:** Add DeepSeek API LLM provider + +**Status:** done + +**Dependencies:** 25 ✓ + +**Priority:** medium + +**Description:** Implement DeepSeek API provider as a cheap cloud-based fallback when local LLMs are unavailable. + +**Details:** + +1. Create `src/mcplocal/src/providers/deepseek.ts`: + ```typescript + export class DeepSeekProvider implements LlmProvider { + readonly name = 'deepseek'; + private apiKey: string; + private baseUrl = 'https://api.deepseek.com/v1'; + + constructor(config: DeepSeekConfig) { + this.apiKey = config.apiKey || process.env.DEEPSEEK_API_KEY; + } + + async isAvailable(): Promise { + return !!this.apiKey; + } + + async complete(options: CompletionOptions): Promise { + // DeepSeek uses OpenAI-compatible API + const response = await fetch(`${this.baseUrl}/chat/completions`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + model: 'deepseek-chat', + messages: [{ role: 'user', content: options.userPrompt }] + }) + }); + // Parse and return + } + } + ``` +2. Add DEEPSEEK_API_KEY to configuration +3. Register in provider registry with medium priority +4. Support both deepseek-chat and deepseek-coder models +5. Add rate limiting handling + +**Test Strategy:** + +1. Unit test: Provider correctly checks API key availability +2. Unit test: Request formatting matches DeepSeek API spec +3. Unit test: Response parsing handles all fields +4. Integration test: Full completion with actual API (with valid key) +5. Test error handling for rate limits +6. Test error handling for invalid API key diff --git a/.taskmaster/tasks/task_032.md b/.taskmaster/tasks/task_032.md new file mode 100644 index 0000000..7609b13 --- /dev/null +++ b/.taskmaster/tasks/task_032.md @@ -0,0 +1,70 @@ +# Task ID: 32 + +**Title:** Implement smart context optimization + +**Status:** done + +**Dependencies:** 29 ✓ + +**Priority:** medium + +**Description:** Add token counting and decision logic to intelligently skip LLM filtering when responses are small enough, and cache filtering decisions for repeated queries. + +**Details:** + +1. Create `src/mcplocal/src/llm/token-counter.ts`: + ```typescript + export function estimateTokens(text: string): number { + // Simple estimation: ~4 chars per token for English + // More accurate: use tiktoken or similar library + return Math.ceil(text.length / 4); + } + ``` +2. Create `src/mcplocal/src/llm/filter-cache.ts`: + ```typescript + export class FilterCache { + private cache: LRUCache; + + shouldFilter(toolName: string, params: unknown, responseSize: number): boolean { + const key = this.computeKey(toolName, params); + const cached = this.cache.get(key); + if (cached) return cached.shouldFilter; + // No cache hit - use default threshold logic + return responseSize > this.tokenThreshold; + } + + recordDecision(toolName: string, params: unknown, decision: FilterDecision): void { + const key = this.computeKey(toolName, params); + this.cache.set(key, decision); + } + } + ``` +3. Add configuration options: + - `tokenThreshold`: number (default 1000 tokens) + - `filterCacheSize`: number (default 1000 entries) + - `filterCacheTtl`: number (default 3600 seconds) +4. Integrate into LlmProcessor: + ```typescript + async filterResponse(...) { + const tokens = estimateTokens(JSON.stringify(rawResponse)); + if (tokens < this.config.tokenThreshold) { + // Not worth filtering - return as-is + return { filtered: false, response: rawResponse }; + } + // Proceed with LLM filtering + } + ``` +5. Add metrics tracking: + - Total tokens processed + - Tokens saved by filtering + - Filter cache hit rate + - Average latency added by filtering + +**Test Strategy:** + +1. Unit test: Token estimation is reasonably accurate +2. Unit test: Cache correctly stores and retrieves decisions +3. Unit test: Threshold logic skips filtering for small responses +4. Unit test: Cache TTL expiration works correctly +5. Integration test: Metrics are recorded accurately +6. Performance test: Cache improves latency for repeated queries diff --git a/.taskmaster/tasks/task_033.md b/.taskmaster/tasks/task_033.md new file mode 100644 index 0000000..a9315b2 --- /dev/null +++ b/.taskmaster/tasks/task_033.md @@ -0,0 +1,171 @@ +# Task ID: 33 + +**Title:** Update mcpctl to use mcplocal as daemon + +**Status:** done + +**Dependencies:** 27 ✓ + +**Priority:** high + +**Description:** Modify mcpctl CLI to connect to mcplocal instead of mcpd directly, update configuration options, add dual connectivity status checking, and implement authentication commands (login/logout) with secure credential storage. + +**Details:** + +1. Update `src/cli/src/config/schema.ts`: + ```typescript + export interface McpctlConfig { + mcplocalUrl: string; // NEW: default 'http://localhost:3200' + mcpdUrl: string; // Keep for reference/direct access if needed + // ... other fields + } + ``` +2. Update `src/cli/src/config/defaults.ts`: + - Change default daemonUrl to http://localhost:3200 (mcplocal) +3. Update `src/cli/src/api-client.ts`: + - Default baseUrl now points to mcplocal +4. Add new config commands in `src/cli/src/commands/config.ts`: + ```typescript + .command('set-mcplocal-url ') + .command('set-mcpd-url ') + .command('get-mcplocal-url') + .command('get-mcpd-url') + ``` +5. Update `src/cli/src/commands/status.ts` to show both connections and auth status: + ``` + $ mcpctl status + mcplocal: connected (localhost:3200) + mcpd: connected (nas.local:3100) via mcplocal + Auth: logged in as user@example.com + LLM Provider: ollama (llama3.2) + Token savings: 45% (last 24h) + ``` +6. Update CLI --daemon-url flag to point to mcplocal +7. Add --direct flag to bypass mcplocal and talk to mcpd directly (for debugging) +8. Create `src/cli/src/commands/auth.ts` with login/logout commands: + - `mcpctl login`: Prompt for mcpd URL (if not configured) and credentials + - Call POST /api/v1/auth/login with { email, password } + - Store session token in ~/.mcpctl/credentials with 0600 permissions + - `mcpctl logout`: Invalidate session and delete stored token +9. Create `src/cli/src/auth/credentials.ts` for secure token storage: + - Use fs.chmod to set 0600 permissions on credentials file + - Token format: { token: string, mcpdUrl: string, user: string, expiresAt?: string } +10. Update api-client.ts to include stored token in requests to mcplocal + - mcplocal passes this token to mcpd for authentication + +**Test Strategy:** + +1. Unit test: Default config points to mcplocal URL +2. Unit test: Config commands update correct fields +3. Integration test: CLI commands work through mcplocal proxy +4. Test status command shows both mcplocal and mcpd status +5. Test --direct flag bypasses mcplocal +6. Test backward compatibility with existing config files +7. Unit test: login command stores token with correct permissions (0600) +8. Unit test: logout command removes credentials file +9. Integration test: login flow with POST /api/v1/auth/login +10. Test status command shows auth status (logged in as user) +11. Test token is passed to mcplocal in API requests +12. Test invalid credentials return appropriate error message +13. Test expired token handling + +## Subtasks + +### 33.1. Update config schema for mcplocal and mcpd URLs + +**Status:** pending +**Dependencies:** None + +Modify McpctlConfigSchema in src/cli/src/config/schema.ts to include separate mcplocalUrl and mcpdUrl fields with appropriate defaults. + +**Details:** + +Update the Zod schema to add mcplocalUrl (default: http://localhost:3200) and mcpdUrl (default: http://localhost:3100). Update DEFAULT_CONFIG and ensure backward compatibility with existing daemonUrl field by mapping it to mcplocalUrl. + +### 33.2. Create auth credentials storage module + +**Status:** pending +**Dependencies:** None + +Create src/cli/src/auth/credentials.ts to handle secure storage and retrieval of session tokens in ~/.mcpctl/credentials. + +**Details:** + +Implement saveCredentials(token, mcpdUrl, user), loadCredentials(), and deleteCredentials() functions. Use fs.chmod to set 0600 permissions. Store JSON format: { token, mcpdUrl, user, expiresAt }. Handle file not found gracefully in loadCredentials. + +### 33.3. Implement login command + +**Status:** pending +**Dependencies:** 33.2 + +Create src/cli/src/commands/auth.ts with mcpctl login command that prompts for mcpd URL and credentials, calls POST /api/v1/auth/login, and stores the session token. + +**Details:** + +Use inquirer or prompts library for interactive credential input (email, password). If mcpdUrl not configured, prompt for it. Call POST /api/v1/auth/login with credentials. On success, save token using credentials module. Display 'Logged in as {user}' on success. Handle errors (invalid credentials, network errors) with clear messages. + +### 33.4. Implement logout command + +**Status:** pending +**Dependencies:** 33.2 + +Add mcpctl logout command to auth.ts that invalidates the session and removes stored credentials. + +**Details:** + +Load stored credentials, optionally call a logout endpoint on mcpd to invalidate server-side session, then delete the local credentials file. Display 'Logged out successfully' or 'Not logged in' as appropriate. + +### 33.5. Update api-client to include auth token + +**Status:** pending +**Dependencies:** 33.2 + +Modify src/cli/src/api-client.ts to load and include stored session token in Authorization header for requests to mcplocal. + +**Details:** + +Import loadCredentials from auth module. Add Authorization: Bearer {token} header to requests when credentials exist. Handle expired token by returning appropriate error suggesting re-login. + +### 33.6. Update status command to show auth status + +**Status:** pending +**Dependencies:** 33.2, 33.5 + +Modify src/cli/src/commands/status.ts to display authentication status (logged in as user X or not logged in) along with mcplocal and mcpd connectivity. + +**Details:** + +Load credentials and display auth status line: 'Auth: logged in as {user}' or 'Auth: not logged in'. Update status output format to show mcplocal and mcpd status separately with the auth info. + +### 33.7. Add config commands for mcplocal and mcpd URLs + +**Status:** pending +**Dependencies:** 33.1 + +Add set-mcplocal-url, set-mcpd-url, get-mcplocal-url, and get-mcpd-url commands to src/cli/src/commands/config.ts. + +**Details:** + +Add four new subcommands to the config command for setting and getting the mcplocal and mcpd URLs independently. Update the generic 'set' command to handle these new schema fields. + +### 33.8. Add --direct flag for mcpd bypass + +**Status:** pending +**Dependencies:** 33.1, 33.5 + +Add --direct flag to CLI commands that bypasses mcplocal and connects directly to mcpd for debugging purposes. + +**Details:** + +Add global --direct option to the main CLI. When set, api-client uses mcpdUrl instead of mcplocalUrl. Useful for debugging connectivity issues between mcplocal and mcpd. + +### 33.9. Register auth commands in CLI entry point + +**Status:** pending +**Dependencies:** 33.3, 33.4 + +Import and register the login and logout commands in src/cli/src/index.ts. + +**Details:** + +Import createAuthCommand from commands/auth.ts and add it to the main program with program.addCommand(createAuthCommand()). diff --git a/.taskmaster/tasks/task_034.md b/.taskmaster/tasks/task_034.md new file mode 100644 index 0000000..14e0216 --- /dev/null +++ b/.taskmaster/tasks/task_034.md @@ -0,0 +1,63 @@ +# Task ID: 34 + +**Title:** Connect mcplocal MCP router to mcpd proxy endpoint + +**Status:** done + +**Dependencies:** 28 ✓, 29 ✓ + +**Priority:** high + +**Description:** Update mcplocal's MCP router to forward tool calls to mcpd's new /api/v1/mcp/proxy endpoint instead of connecting to MCP servers directly. + +**Details:** + +1. Update `src/mcplocal/src/router.ts` to use mcpd proxy: + ```typescript + class Router { + private mcpdClient: McpdClient; + + async handleToolsCall(request: JsonRpcRequest) { + const { name, arguments: args } = request.params; + const [serverName, toolName] = name.split('/'); + + // Pre-process with LLM if enabled + const processedArgs = this.config.enablePreprocessing + ? await this.llmProcessor.preprocessRequest(toolName, args) + : args; + + // Call mcpd proxy endpoint + const result = await this.mcpdClient.post('/api/v1/mcp/proxy', { + serverId: serverName, + method: 'tools/call', + params: { name: toolName, arguments: processedArgs } + }); + + // Post-process response with LLM if enabled + return this.config.enablePreprocessing + ? await this.llmProcessor.filterResponse(toolName, args, result) + : result; + } + } + ``` +2. Update upstream configuration: + - Remove direct upstream connections for managed servers + - Keep option for local/unmanaged upstreams +3. Add server discovery from mcpd: + ```typescript + async refreshServerList() { + const servers = await this.mcpdClient.get('/api/v1/servers'); + this.updateAvailableTools(servers); + } + ``` +4. Handle tools/list by aggregating from mcpd servers +5. Handle resources/list and prompts/list similarly + +**Test Strategy:** + +1. Unit test: Tool calls are forwarded to mcpd proxy correctly +2. Unit test: Server name is extracted from namespaced tool name +3. Integration test: Full flow Claude -> mcplocal -> mcpd -> container +4. Test tools/list aggregates from all mcpd servers +5. Test error handling when mcpd is unreachable +6. Test LLM preprocessing is applied when enabled diff --git a/.taskmaster/tasks/task_035.md b/.taskmaster/tasks/task_035.md new file mode 100644 index 0000000..0513976 --- /dev/null +++ b/.taskmaster/tasks/task_035.md @@ -0,0 +1,64 @@ +# Task ID: 35 + +**Title:** Implement health monitoring across all tiers + +**Status:** done + +**Dependencies:** 33 ✓, 34 ✓ + +**Priority:** medium + +**Description:** Extend health monitoring to track connectivity and status across mcplocal, mcpd, and individual MCP server instances. + +**Details:** + +1. Update mcplocal health monitor in `src/mcplocal/src/health.ts`: + ```typescript + export class TieredHealthMonitor { + async checkHealth(): Promise { + return { + mcplocal: { + status: 'healthy', + llmProvider: await this.checkLlmProvider(), + uptime: process.uptime() + }, + mcpd: await this.checkMcpdHealth(), + instances: await this.checkInstancesHealth() + }; + } + + private async checkMcpdHealth(): Promise { + try { + const health = await this.mcpdClient.get('/api/v1/health'); + return { status: 'connected', ...health }; + } catch { + return { status: 'disconnected' }; + } + } + + private async checkInstancesHealth(): Promise { + const instances = await this.mcpdClient.get('/api/v1/instances'); + return instances.map(i => ({ + name: i.name, + status: i.status, + lastHealthCheck: i.lastHealthCheck + })); + } + } + ``` +2. Add health endpoint to mcplocal HTTP server: `GET /health` +3. Update mcpctl status command to display tiered health +4. Add degraded state detection: + - LLM provider unavailable but mcpd reachable + - Some instances down but others healthy +5. Add health event notifications for state transitions +6. Add configurable health check intervals + +**Test Strategy:** + +1. Unit test: Health check correctly identifies all states +2. Unit test: Degraded state is detected correctly +3. Integration test: Full health check across all tiers +4. Test health endpoint returns correct format +5. Test mcpctl status displays health correctly +6. Test state transition events are emitted diff --git a/.taskmaster/tasks/task_036.md b/.taskmaster/tasks/task_036.md new file mode 100644 index 0000000..de3ebcf --- /dev/null +++ b/.taskmaster/tasks/task_036.md @@ -0,0 +1,58 @@ +# Task ID: 36 + +**Title:** End-to-end integration testing + +**Status:** done + +**Dependencies:** 29 ✓, 33 ✓, 34 ✓, 35 ✓ + +**Priority:** high + +**Description:** Create comprehensive integration tests that validate the full data flow from mcpctl through mcplocal to mcpd to MCP server containers and back. + +**Details:** + +1. Create test fixtures in `src/mcplocal/test/fixtures/`: + - Mock MCP server that returns predictable responses + - Test configuration files + - Sample tool call payloads +2. Create integration test suite in `src/mcplocal/test/integration/`: + ```typescript + describe('End-to-end flow', () => { + it('mcpctl -> mcplocal -> mcpd -> mcp_server', async () => { + // Start mock MCP server + // Start mcpd with test config + // Start mcplocal pointing to mcpd + // Execute mcpctl command + // Verify response flows back correctly + }); + + it('LLM pre-processing reduces response size', async () => { + // Send query that returns large dataset + // Verify LLM filtering reduces token count + // Verify relevant data is preserved + }); + + it('credentials never leave mcpd', async () => { + // Monitor all traffic from mcplocal + // Verify no credentials appear in requests/responses + }); + }); + ``` +3. Test scenarios: + - Management commands (get servers, instances, etc.) + - MCP tool calls with LLM preprocessing + - MCP tool calls without preprocessing + - Error handling (mcpd down, instance down, LLM failure) + - Health monitoring accuracy +4. Add CI integration test workflow +5. Create docker-compose.test.yml for test environment + +**Test Strategy:** + +1. All integration tests pass in CI environment +2. Test coverage includes happy path and error scenarios +3. Performance benchmarks: measure latency at each tier +4. Security test: verify credential isolation +5. Load test: multiple concurrent requests +6. Chaos test: random component failures diff --git a/.taskmaster/tasks/task_037.md b/.taskmaster/tasks/task_037.md new file mode 100644 index 0000000..3f81b4a --- /dev/null +++ b/.taskmaster/tasks/task_037.md @@ -0,0 +1,36 @@ +# Task ID: 37 + +**Title:** Add priority, summary, chapters, and linkTarget fields to Prompt schema + +**Status:** done + +**Dependencies:** None + +**Priority:** high + +**Description:** Extend the Prisma schema for the Prompt model to include priority (integer 1-10, default 5), summary (nullable string), chapters (nullable JSON array), and linkTarget (nullable string for prompt links). + +**Details:** + +1. Update `/src/db/prisma/schema.prisma` to add fields to the Prompt model: + - `priority Int @default(5)` with check constraint 1-10 + - `summary String? @db.Text` + - `chapters Json?` (stored as JSON array of strings) + - `linkTarget String?` (format: `project/server:resource-uri`) + +2. Create Prisma migration: + ```bash + pnpm --filter db exec prisma migrate dev --name add-prompt-priority-summary-chapters-link + ``` + +3. Update TypeScript types in shared package to reflect new fields + +4. Add validation for priority range (1-10) at the database level if possible, otherwise enforce in application layer + +**Test Strategy:** + +- Unit test: Verify migration creates columns with correct types and defaults +- Unit test: Verify priority default is 5 +- Unit test: Verify nullable fields accept null +- Unit test: Verify chapters stores/retrieves JSON arrays correctly +- Integration test: Create prompt with all new fields, retrieve and verify values diff --git a/.taskmaster/tasks/task_038.md b/.taskmaster/tasks/task_038.md new file mode 100644 index 0000000..5b2703c --- /dev/null +++ b/.taskmaster/tasks/task_038.md @@ -0,0 +1,35 @@ +# Task ID: 38 + +**Title:** Add priority field to PromptRequest schema + +**Status:** done + +**Dependencies:** 37 ✓ + +**Priority:** high + +**Description:** Extend the Prisma schema for the PromptRequest model to include the priority field (integer 1-10, default 5) to match the Prompt model. + +**Details:** + +1. Update `/src/db/prisma/schema.prisma` to add to PromptRequest: + - `priority Int @default(5)` + +2. Create Prisma migration: + ```bash + pnpm --filter db exec prisma migrate dev --name add-promptrequest-priority + ``` + +3. Update the `CreatePromptRequestSchema` in `/src/mcpd/src/validation/prompt.schema.ts` to include priority validation: + ```typescript + priority: z.number().int().min(1).max(10).default(5).optional(), + ``` + +4. Update TypeScript types in shared package + +**Test Strategy:** + +- Unit test: Migration creates priority column with default 5 +- Unit test: PromptRequest creation with explicit priority +- Unit test: PromptRequest creation uses default priority when not specified +- Unit test: Validation rejects priority outside 1-10 range diff --git a/.taskmaster/tasks/task_039.md b/.taskmaster/tasks/task_039.md new file mode 100644 index 0000000..4827a37 --- /dev/null +++ b/.taskmaster/tasks/task_039.md @@ -0,0 +1,38 @@ +# Task ID: 39 + +**Title:** Add gated field to Project schema + +**Status:** done + +**Dependencies:** None + +**Priority:** high + +**Description:** Extend the Prisma schema for the Project model to include the gated boolean field (default true) that controls whether sessions go through the keyword-driven prompt retrieval flow. + +**Details:** + +1. Update `/src/db/prisma/schema.prisma` to add to Project: + - `gated Boolean @default(true)` + +2. Create Prisma migration: + ```bash + pnpm --filter db exec prisma migrate dev --name add-project-gated + ``` + +3. Update project-related TypeScript types + +4. Update project validation schemas to include gated field: + ```typescript + gated: z.boolean().default(true).optional(), + ``` + +5. Update project API routes to accept and return the gated field + +**Test Strategy:** + +- Unit test: Migration creates gated column with default true +- Unit test: Project creation with gated=false +- Unit test: Project creation uses default gated=true when not specified +- Unit test: Project update can toggle gated field +- Integration test: GET /api/v1/projects/:name returns gated field diff --git a/.taskmaster/tasks/task_040.md b/.taskmaster/tasks/task_040.md new file mode 100644 index 0000000..bfb3570 --- /dev/null +++ b/.taskmaster/tasks/task_040.md @@ -0,0 +1,49 @@ +# Task ID: 40 + +**Title:** Update Prompt CRUD API to handle priority and linkTarget + +**Status:** done + +**Dependencies:** 37 ✓ + +**Priority:** high + +**Description:** Modify prompt API endpoints to accept, validate, and return the priority and linkTarget fields. LinkTarget should be immutable after creation. + +**Details:** + +1. Update `/src/mcpd/src/validation/prompt.schema.ts`: + ```typescript + export const CreatePromptSchema = z.object({ + name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/), + content: z.string().min(1).max(50000), + projectId: z.string().optional(), + priority: z.number().int().min(1).max(10).default(5).optional(), + linkTarget: z.string().regex(/^[a-z0-9-]+\/[a-z0-9-]+:[\S]+$/).optional(), + }); + + export const UpdatePromptSchema = z.object({ + content: z.string().min(1).max(50000).optional(), + priority: z.number().int().min(1).max(10).optional(), + // Note: linkTarget is NOT included - links are immutable + }); + ``` + +2. Update `/src/mcpd/src/routes/prompts.ts`: + - POST /api/v1/prompts: Accept priority, linkTarget + - PUT /api/v1/prompts/:id: Accept priority only (not linkTarget) + - GET endpoints: Return priority, linkTarget in response + +3. Update repository layer to handle new fields + +4. Add linkTarget format validation: `project/server:resource-uri` + +**Test Strategy:** + +- Unit test: POST /api/v1/prompts with priority creates prompt with correct priority +- Unit test: POST /api/v1/prompts with linkTarget creates linked prompt +- Unit test: PUT /api/v1/prompts/:id with priority updates priority +- Unit test: PUT /api/v1/prompts/:id rejects linkTarget (immutable) +- Unit test: GET /api/v1/prompts returns priority and linkTarget fields +- Unit test: Invalid linkTarget format rejected (validation error) +- Unit test: Priority outside 1-10 range rejected diff --git a/.taskmaster/tasks/task_041.md b/.taskmaster/tasks/task_041.md new file mode 100644 index 0000000..f6f1dce --- /dev/null +++ b/.taskmaster/tasks/task_041.md @@ -0,0 +1,42 @@ +# Task ID: 41 + +**Title:** Update PromptRequest API to handle priority + +**Status:** done + +**Dependencies:** 38 ✓ + +**Priority:** high + +**Description:** Modify prompt request API endpoints to accept, validate, and return the priority field for proposed prompts. + +**Details:** + +1. Update validation in `/src/mcpd/src/validation/prompt.schema.ts`: + ```typescript + export const CreatePromptRequestSchema = z.object({ + name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/), + content: z.string().min(1).max(50000), + projectId: z.string().optional(), + createdBySession: z.string().optional(), + createdByUserId: z.string().optional(), + priority: z.number().int().min(1).max(10).default(5).optional(), + }); + ``` + +2. Update `/src/mcpd/src/routes/prompts.ts` for PromptRequest endpoints: + - POST /api/v1/promptrequests: Accept priority + - GET /api/v1/promptrequests: Return priority + - POST /api/v1/promptrequests/:id/approve: Preserve priority when creating Prompt + +3. Update PromptService.approve() to copy priority from request to prompt + +4. Update repository layer + +**Test Strategy:** + +- Unit test: POST /api/v1/promptrequests with priority creates request with correct priority +- Unit test: POST /api/v1/promptrequests uses default priority 5 when not specified +- Unit test: GET /api/v1/promptrequests returns priority field +- Unit test: Approve preserves priority from request to created prompt +- Unit test: Priority validation (1-10 range) diff --git a/.taskmaster/tasks/task_042.md b/.taskmaster/tasks/task_042.md new file mode 100644 index 0000000..f860d9b --- /dev/null +++ b/.taskmaster/tasks/task_042.md @@ -0,0 +1,58 @@ +# Task ID: 42 + +**Title:** Implement prompt summary generation service + +**Status:** done + +**Dependencies:** 37 ✓ + +**Priority:** high + +**Description:** Create a service that auto-generates summary (20 words) and chapters (key sections) for prompts, using fast LLM when available or regex fallback. + +**Details:** + +1. Create `/src/mcpd/src/services/prompt-summary.service.ts`: + ```typescript + export class PromptSummaryService { + constructor( + private llmClient: LlmClient | null, + private promptRepo: IPromptRepository + ) {} + + async generateSummary(content: string): Promise<{ summary: string; chapters: string[] }> { + if (this.llmClient) { + return this.generateWithLlm(content); + } + return this.generateWithRegex(content); + } + + private async generateWithLlm(content: string): Promise<...> { + // Send content to fast LLM with prompt: + // "Generate a 20-word summary and extract key section topics..." + } + + private generateWithRegex(content: string): { summary: string; chapters: string[] } { + // summary: first sentence of content (truncated to ~20 words) + // chapters: extract markdown headings via regex /^#+\s+(.+)$/gm + } + } + ``` + +2. Integrate with PromptService: + - Call generateSummary on prompt create + - Call generateSummary on prompt update (when content changes) + - Cache results on the prompt record + +3. Handle LLM availability check via existing LlmConfig patterns + +**Test Strategy:** + +- Unit test: generateWithRegex extracts first sentence as summary +- Unit test: generateWithRegex extracts markdown headings as chapters +- Unit test: generateWithLlm calls LLM with correct prompt (mock LLM) +- Unit test: generateSummary uses LLM when available +- Unit test: generateSummary falls back to regex when no LLM +- Unit test: Empty content handled gracefully +- Unit test: Content without headings returns empty chapters array +- Integration test: Creating prompt triggers summary generation diff --git a/.taskmaster/tasks/task_043.md b/.taskmaster/tasks/task_043.md new file mode 100644 index 0000000..3ef95b9 --- /dev/null +++ b/.taskmaster/tasks/task_043.md @@ -0,0 +1,41 @@ +# Task ID: 43 + +**Title:** Add regenerate-summary API endpoint + +**Status:** done + +**Dependencies:** 42 ✓ + +**Priority:** medium + +**Description:** Create POST /api/v1/prompts/:id/regenerate-summary endpoint to force re-generation of summary and chapters for a prompt. + +**Details:** + +1. Add route in `/src/mcpd/src/routes/prompts.ts`: + ```typescript + fastify.post('/api/v1/prompts/:id/regenerate-summary', async (request, reply) => { + const { id } = request.params as { id: string }; + const prompt = await promptService.findById(id); + if (!prompt) { + return reply.status(404).send({ error: 'Prompt not found' }); + } + + const { summary, chapters } = await summaryService.generateSummary(prompt.content); + const updated = await promptService.updateSummary(id, summary, chapters); + + return reply.send(updated); + }); + ``` + +2. Add `updateSummary(id, summary, chapters)` method to PromptRepository and PromptService + +3. Return the updated prompt with new summary/chapters in response + +**Test Strategy:** + +- Unit test: POST to valid prompt ID regenerates summary +- Unit test: Returns updated prompt with new summary/chapters +- Unit test: 404 for non-existent prompt ID +- Unit test: Uses LLM when available, regex fallback otherwise +- Integration test: End-to-end regeneration updates database diff --git a/.taskmaster/tasks/task_044.md b/.taskmaster/tasks/task_044.md new file mode 100644 index 0000000..d2c9482 --- /dev/null +++ b/.taskmaster/tasks/task_044.md @@ -0,0 +1,48 @@ +# Task ID: 44 + +**Title:** Create prompt-index API endpoint + +**Status:** done + +**Dependencies:** 42 ✓ + +**Priority:** medium + +**Description:** Create GET /api/v1/projects/:name/prompt-index endpoint that returns a compact index of prompts (name, priority, summary, chapters) for a project. + +**Details:** + +1. Add route in `/src/mcpd/src/routes/prompts.ts`: + ```typescript + fastify.get('/api/v1/projects/:name/prompt-index', async (request, reply) => { + const { name } = request.params as { name: string }; + const project = await projectService.findByName(name); + if (!project) { + return reply.status(404).send({ error: 'Project not found' }); + } + + const prompts = await promptService.findByProject(project.id); + const index = prompts.map(p => ({ + name: p.name, + priority: p.priority, + summary: p.summary, + chapters: p.chapters, + linkTarget: p.linkTarget, + })); + + return reply.send({ prompts: index }); + }); + ``` + +2. Consider adding global prompts to the index (inherited by all projects) + +3. Sort by priority descending in response + +**Test Strategy:** + +- Unit test: Returns compact index for valid project +- Unit test: Index contains name, priority, summary, chapters, linkTarget +- Unit test: 404 for non-existent project +- Unit test: Empty array for project with no prompts +- Unit test: Results sorted by priority descending +- Integration test: End-to-end retrieval matches database state diff --git a/.taskmaster/tasks/task_045.md b/.taskmaster/tasks/task_045.md new file mode 100644 index 0000000..dad88a5 --- /dev/null +++ b/.taskmaster/tasks/task_045.md @@ -0,0 +1,73 @@ +# Task ID: 45 + +**Title:** Implement tag-matching algorithm for prompt selection + +**Status:** done + +**Dependencies:** 44 ✓ + +**Priority:** high + +**Description:** Create a deterministic keyword-based tag matching algorithm as the no-LLM fallback for prompt selection, with byte-budget allocation and priority weighting. + +**Details:** + +1. Create `/src/mcplocal/src/services/tag-matcher.service.ts`: + ```typescript + interface MatchedPrompt { + prompt: PromptIndex; + score: number; + matchedTags: string[]; + } + + export class TagMatcherService { + constructor(private byteBudget: number = 8192) {} + + matchPrompts(tags: string[], promptIndex: PromptIndex[]): { + fullContent: PromptIndex[]; // Prompts to include in full + indexOnly: PromptIndex[]; // Prompts to include as index entries + remaining: PromptIndex[]; // Non-matched prompts (names only) + } { + // 1. Priority 10 prompts: always included (score = Infinity) + // 2. For each prompt, compute score: + // - Check tags against summary + chapters (case-insensitive substring) + // - score = matching_tags_count * priority + // 3. Sort by score descending + // 4. Fill byte budget from top: + // - Include full content until budget exhausted + // - Remaining matched: include as index entries + // - Non-matched: names only + } + + private computeScore(tags: string[], prompt: PromptIndex): number { + if (prompt.priority === 10) return Infinity; + const matchingTags = tags.filter(tag => + this.matchesPrompt(tag.toLowerCase(), prompt) + ); + return matchingTags.length * prompt.priority; + } + + private matchesPrompt(tag: string, prompt: PromptIndex): boolean { + const searchText = [ + prompt.summary || '', + ...(prompt.chapters || []) + ].join(' ').toLowerCase(); + return searchText.includes(tag); + } + } + ``` + +2. Handle edge cases: empty tags, no prompts, all priority 10, etc. + +**Test Strategy:** + +- Unit test: Priority 10 prompts always included regardless of tags +- Unit test: Score calculation: matching_tags * priority +- Unit test: Case-insensitive matching +- Unit test: Substring matching in summary and chapters +- Unit test: Byte budget exhaustion stops full content inclusion +- Unit test: Matched prompts beyond budget become index entries +- Unit test: Non-matched prompts listed as names only +- Unit test: Sorting by score descending +- Unit test: Empty tags returns priority 10 only +- Unit test: No prompts returns empty result diff --git a/.taskmaster/tasks/task_046.md b/.taskmaster/tasks/task_046.md new file mode 100644 index 0000000..6887b6e --- /dev/null +++ b/.taskmaster/tasks/task_046.md @@ -0,0 +1,59 @@ +# Task ID: 46 + +**Title:** Implement LLM-based prompt selection service + +**Status:** done + +**Dependencies:** 45 ✓ + +**Priority:** high + +**Description:** Create a service that uses the heavy LLM to intelligently select relevant prompts based on tags and the full prompt index, understanding synonyms and context. + +**Details:** + +1. Create `/src/mcplocal/src/services/llm-prompt-selector.service.ts`: + ```typescript + export class LlmPromptSelectorService { + constructor( + private llmClient: LlmClient, + private fastLlmClient: LlmClient | null, + private tagMatcher: TagMatcherService // fallback + ) {} + + async selectPrompts(tags: string[], promptIndex: PromptIndex[]): Promise<{ + selected: Array<{ name: string; reason: string }>; + priority10: PromptIndex[]; // Always included + }> { + // 1. Extract priority 10 prompts (always included) + // 2. Generate missing summaries using fast LLM if needed + // 3. Send to heavy LLM: + const prompt = ` + Given these keywords: ${tags.join(', ')} + And this prompt index: + ${promptIndex.map(p => `- ${p.name}: ${p.summary}`).join('\n')} + + Select the most relevant prompts for someone working on tasks + related to these keywords. Consider synonyms and related concepts. + Return a ranked JSON array: [{name: string, reason: string}] + `; + // 4. Parse LLM response + // 5. On LLM error, fall back to tag matcher + } + } + ``` + +2. Handle LLM timeouts and errors gracefully with fallback + +3. Validate LLM response format + +**Test Strategy:** + +- Unit test: Priority 10 prompts always returned regardless of LLM selection +- Unit test: LLM called with correct prompt format (mock) +- Unit test: LLM response parsed correctly +- Unit test: Invalid LLM response falls back to tag matcher +- Unit test: LLM timeout falls back to tag matcher +- Unit test: Missing summaries trigger fast LLM generation +- Unit test: No LLM available uses tag matcher directly +- Integration test: End-to-end selection with mock LLM diff --git a/.taskmaster/tasks/task_047.md b/.taskmaster/tasks/task_047.md new file mode 100644 index 0000000..d277ed1 --- /dev/null +++ b/.taskmaster/tasks/task_047.md @@ -0,0 +1,61 @@ +# Task ID: 47 + +**Title:** Implement session state management for gating + +**Status:** done + +**Dependencies:** 39 ✓ + +**Priority:** high + +**Description:** Extend the McpRouter to track per-session gating state including gated status, accumulated tags, and retrieved prompts set. + +**Details:** + +1. Update `/src/mcplocal/src/router.ts` to add session state: + ```typescript + interface SessionState { + gated: boolean; // starts true if project is gated + tags: string[]; // accumulated from begin_session + read_prompts + retrievedPrompts: Set; // prompts already sent (avoid duplicates) + } + + export class McpRouter { + private sessionStates: Map = new Map(); + + getSessionState(sessionId: string): SessionState { + if (!this.sessionStates.has(sessionId)) { + this.sessionStates.set(sessionId, { + gated: this.projectConfig?.gated ?? true, + tags: [], + retrievedPrompts: new Set(), + }); + } + return this.sessionStates.get(sessionId)!; + } + + ungateSession(sessionId: string): void { + const state = this.getSessionState(sessionId); + state.gated = false; + } + + addRetrievedPrompts(sessionId: string, names: string[]): void { + const state = this.getSessionState(sessionId); + names.forEach(n => state.retrievedPrompts.add(n)); + } + } + ``` + +2. Clean up session state when session closes + +3. Handle session state for non-gated projects (gated=false from start) + +**Test Strategy:** + +- Unit test: New session starts with gated=true for gated project +- Unit test: New session starts with gated=false for non-gated project +- Unit test: ungateSession changes gated to false +- Unit test: addRetrievedPrompts adds to set +- Unit test: retrievedPrompts prevents duplicates +- Unit test: Session state isolated per sessionId +- Unit test: Session cleanup removes state diff --git a/.taskmaster/tasks/task_048.md b/.taskmaster/tasks/task_048.md new file mode 100644 index 0000000..5d77fec --- /dev/null +++ b/.taskmaster/tasks/task_048.md @@ -0,0 +1,67 @@ +# Task ID: 48 + +**Title:** Implement begin_session tool for gated sessions + +**Status:** done + +**Dependencies:** 46 ✓, 47 ✓ + +**Priority:** high + +**Description:** Create the begin_session MCP tool that accepts 5 keywords, triggers prompt selection, returns matched content with encouragement, and ungates the session. + +**Details:** + +1. Add begin_session tool definition in `/src/mcplocal/src/router.ts`: + ```typescript + private getBeginSessionTool(): Tool { + return { + name: 'begin_session', + description: 'Start your session by providing 5 keywords that describe your current task. You\'ll receive relevant project context, policies, and guidelines. Required before using other tools.', + inputSchema: { + type: 'object', + properties: { + tags: { + type: 'array', + items: { type: 'string' }, + maxItems: 10, + description: '5 keywords describing your current task' + } + }, + required: ['tags'] + } + }; + } + ``` + +2. Implement begin_session handler: + - Validate tags array (1-10 items) + - Call LlmPromptSelector or TagMatcher based on LLM availability + - Fetch full content for selected prompts + - Build response with matched content + index + encouragement + - Ungate session + - Send `notifications/tools/list_changed` + +3. Response format: + ``` + [Priority 10 prompts - full content] + + [Tag-matched prompts - full content, priority-ordered] + + Other prompts available that may become relevant... + - name: summary + ... + If any seem related, request them with read_prompts({ tags: [...] }). + ``` + +**Test Strategy:** + +- Unit test: begin_session with valid tags returns matched prompts +- Unit test: begin_session includes priority 10 prompts always +- Unit test: begin_session response includes encouragement text +- Unit test: begin_session response includes prompt index +- Unit test: Session ungated after successful begin_session +- Unit test: notifications/tools/list_changed sent after ungating +- Unit test: Empty tags handled (returns priority 10 only) +- Unit test: Invalid tags rejected with error +- Unit test: begin_session while already ungated returns error diff --git a/.taskmaster/tasks/task_049.md b/.taskmaster/tasks/task_049.md new file mode 100644 index 0000000..ccafc04 --- /dev/null +++ b/.taskmaster/tasks/task_049.md @@ -0,0 +1,58 @@ +# Task ID: 49 + +**Title:** Implement read_prompts tool for ongoing retrieval + +**Status:** done + +**Dependencies:** 48 ✓ + +**Priority:** high + +**Description:** Create the read_prompts MCP tool that allows clients to request additional context by keywords after the session is ungated. + +**Details:** + +1. Add read_prompts tool definition: + ```typescript + private getReadPromptsTool(): Tool { + return { + name: 'read_prompts', + description: 'Request additional project context by keywords. Use this whenever you need guidelines, policies, or conventions related to your current work.', + inputSchema: { + type: 'object', + properties: { + tags: { + type: 'array', + items: { type: 'string' }, + description: 'Keywords describing what context you need' + } + }, + required: ['tags'] + } + }; + } + ``` + +2. Implement read_prompts handler: + - Always use keyword matching (not LLM) for precision + - Exclude already-retrieved prompts from response + - Add newly retrieved prompts to session state + - Include reminder about more prompts available + +3. Response format: + ``` + [Matched prompt content - deduplicated] + + Remember: you can request more prompts at any time with read_prompts({ tags: [...] }). + The project may have additional guidelines relevant to your current approach. + ``` + +**Test Strategy:** + +- Unit test: read_prompts returns matched prompts by keyword +- Unit test: Already retrieved prompts excluded from response +- Unit test: Newly retrieved prompts added to session state +- Unit test: Response includes reminder text +- Unit test: read_prompts while gated returns error +- Unit test: Empty tags returns empty response +- Unit test: Uses keyword matching not LLM diff --git a/.taskmaster/tasks/task_050.md b/.taskmaster/tasks/task_050.md new file mode 100644 index 0000000..2a0c13e --- /dev/null +++ b/.taskmaster/tasks/task_050.md @@ -0,0 +1,55 @@ +# Task ID: 50 + +**Title:** Implement progressive tool exposure for gated sessions + +**Status:** done + +**Dependencies:** 48 ✓, 49 ✓ + +**Priority:** high + +**Description:** Modify tools/list behavior to only expose begin_session while gated, and expose all tools plus read_prompts after ungating. + +**Details:** + +1. Update tools/list handling in `/src/mcplocal/src/router.ts`: + ```typescript + async handleToolsList(sessionId: string): Promise { + const state = this.getSessionState(sessionId); + + if (state.gated) { + // Only show begin_session while gated + return [this.getBeginSessionTool()]; + } + + // After ungating: all upstream tools + read_prompts + const upstreamTools = await this.discoverTools(); + return [...upstreamTools, this.getReadPromptsTool()]; + } + ``` + +2. Block direct tool calls while gated: + ```typescript + async handleToolCall(sessionId: string, toolName: string, args: any): Promise { + const state = this.getSessionState(sessionId); + + if (state.gated && toolName !== 'begin_session') { + // Intercept: extract keywords, match prompts, inject briefing + return this.handleInterceptedCall(sessionId, toolName, args); + } + + // Normal routing + return this.routeToolCall(toolName, args); + } + ``` + +3. Ensure notifications/tools/list_changed is sent after ungating + +**Test Strategy:** + +- Unit test: tools/list while gated returns only begin_session +- Unit test: tools/list after ungating returns all tools + read_prompts +- Unit test: begin_session not visible after ungating +- Unit test: Tool call while gated (not begin_session) triggers intercept +- Unit test: Tool call after ungating routes normally +- Unit test: notifications/tools/list_changed sent on ungate diff --git a/.taskmaster/tasks/task_051.md b/.taskmaster/tasks/task_051.md new file mode 100644 index 0000000..957bca5 --- /dev/null +++ b/.taskmaster/tasks/task_051.md @@ -0,0 +1,54 @@ +# Task ID: 51 + +**Title:** Implement keyword extraction from tool calls + +**Status:** done + +**Dependencies:** None + +**Priority:** medium + +**Description:** Create a service that extracts keywords from tool names and arguments for the intercept fallback path when clients skip begin_session. + +**Details:** + +1. Create `/src/mcplocal/src/services/keyword-extractor.service.ts`: + ```typescript + export class KeywordExtractorService { + extractKeywords(toolName: string, args: Record): string[] { + const keywords: string[] = []; + + // Extract from tool name (split on / and -) + // e.g., "home-assistant/get_entities" -> ["home", "assistant", "get", "entities"] + keywords.push(...this.extractFromName(toolName)); + + // Extract from argument values + // e.g., { domain: "light", entity_id: "light.kitchen" } -> ["light", "kitchen"] + keywords.push(...this.extractFromArgs(args)); + + // Deduplicate and sanitize + return [...new Set(keywords.map(k => this.sanitize(k)))]; + } + + private sanitize(keyword: string): string { + // Remove special characters, lowercase, limit length + return keyword.toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 50); + } + } + ``` + +2. Handle various argument types: strings, arrays, nested objects + +3. Prevent injection by sanitizing extracted keywords + +**Test Strategy:** + +- Unit test: Extracts keywords from tool name with / +- Unit test: Extracts keywords from tool name with - +- Unit test: Extracts keywords from string argument values +- Unit test: Extracts keywords from array argument values +- Unit test: Handles nested object arguments +- Unit test: Sanitizes special characters +- Unit test: Deduplicates keywords +- Unit test: Handles empty arguments +- Unit test: Limits keyword length to prevent abuse diff --git a/.taskmaster/tasks/task_052.md b/.taskmaster/tasks/task_052.md new file mode 100644 index 0000000..511f46d --- /dev/null +++ b/.taskmaster/tasks/task_052.md @@ -0,0 +1,63 @@ +# Task ID: 52 + +**Title:** Implement tool call intercept with briefing injection + +**Status:** done + +**Dependencies:** 50 ✓, 51 ✓ + +**Priority:** high + +**Description:** When a gated session calls a tool without first calling begin_session, intercept the call, extract keywords, match prompts, and inject the briefing alongside the real tool result. + +**Details:** + +1. Implement handleInterceptedCall in `/src/mcplocal/src/router.ts`: + ```typescript + async handleInterceptedCall( + sessionId: string, + toolName: string, + args: any + ): Promise { + // 1. Extract keywords from tool call + const keywords = this.keywordExtractor.extractKeywords(toolName, args); + + // 2. Match prompts using keywords + const { fullContent, indexOnly, remaining } = + await this.promptSelector.selectPrompts(keywords, this.promptIndex); + + // 3. Execute the actual tool call + const actualResult = await this.routeToolCall(toolName, args); + + // 4. Build briefing with intercept preamble + const briefing = this.buildBriefing(fullContent, indexOnly, remaining, 'intercept'); + + // 5. Ungate session + this.ungateSession(sessionId); + + // 6. Send notifications/tools/list_changed + await this.sendToolsListChanged(); + + // 7. Return combined result + return { + content: [{ + type: 'text', + text: `${briefing}\n\n---\n\n${actualResult.content[0].text}` + }] + }; + } + ``` + +2. Use gate-intercept-preamble system prompt for the briefing prefix + +**Test Strategy:** + +- Unit test: Tool call while gated triggers intercept +- Unit test: Keywords extracted from tool name and args +- Unit test: Prompts matched using extracted keywords +- Unit test: Actual tool still executes and returns result +- Unit test: Briefing prepended to tool result +- Unit test: Session ungated after intercept +- Unit test: notifications/tools/list_changed sent +- Unit test: Intercept preamble included in briefing +- Integration test: End-to-end intercept flow diff --git a/.taskmaster/tasks/task_053.md b/.taskmaster/tasks/task_053.md new file mode 100644 index 0000000..5b24c7a --- /dev/null +++ b/.taskmaster/tasks/task_053.md @@ -0,0 +1,54 @@ +# Task ID: 53 + +**Title:** Add prompt index to initialize instructions + +**Status:** done + +**Dependencies:** 47 ✓, 44 ✓ + +**Priority:** high + +**Description:** Modify the initialize handler to include the compact prompt index and gate message in instructions for gated projects. + +**Details:** + +1. Update initialize handling in `/src/mcplocal/src/router.ts`: + ```typescript + async handleInitialize(sessionId: string): Promise { + const state = this.getSessionState(sessionId); + + let instructions = this.projectConfig.prompt || ''; + + if (state.gated) { + // Add gate instructions + const gateInstructions = await this.getSystemPrompt('gate-instructions'); + + // Build prompt index (cap at 50, priority 7+ if over) + const index = this.buildPromptIndex(); + + instructions += `\n\n${gateInstructions.replace('{{prompt_index}}', index)}`; + } + + return { + protocolVersion: '2024-11-05', + capabilities: { ... }, + serverInfo: { ... }, + instructions, + }; + } + ``` + +2. Build prompt index with cap: + - If <= 50 prompts: include all + - If > 50 prompts: include only priority 7+ + - Format: `- : ` (~100 chars per entry) + +**Test Strategy:** + +- Unit test: Gated project includes gate instructions in initialize +- Unit test: Prompt index included in instructions +- Unit test: Index capped at 50 entries +- Unit test: Over 50 prompts shows priority 7+ only +- Unit test: Non-gated project skips gate instructions +- Unit test: {{prompt_index}} template replaced +- Integration test: End-to-end initialize with gated project diff --git a/.taskmaster/tasks/task_054.md b/.taskmaster/tasks/task_054.md new file mode 100644 index 0000000..f58de01 --- /dev/null +++ b/.taskmaster/tasks/task_054.md @@ -0,0 +1,58 @@ +# Task ID: 54 + +**Title:** Create mcpctl-system project with system prompts + +**Status:** done + +**Dependencies:** 40 ✓, 39 ✓ + +**Priority:** high + +**Description:** Implement bootstrap logic to create the mcpctl-system project and its required system prompts on first startup, with protection against deletion. + +**Details:** + +1. Create seed migration or startup hook: + ```typescript + async function bootstrapSystemProject() { + const systemProject = await projectRepo.findByName('mcpctl-system'); + if (systemProject) return; // Already exists + + // Create mcpctl-system project + const project = await projectRepo.create({ + name: 'mcpctl-system', + description: 'System prompts for mcpctl gating and encouragement', + gated: false, // System project is not gated + ownerId: SYSTEM_USER_ID, + }); + + // Create required system prompts + const systemPrompts = [ + { name: 'gate-instructions', priority: 10, content: GATE_INSTRUCTIONS }, + { name: 'gate-encouragement', priority: 10, content: GATE_ENCOURAGEMENT }, + { name: 'read-prompts-reminder', priority: 10, content: READ_PROMPTS_REMINDER }, + { name: 'gate-intercept-preamble', priority: 10, content: GATE_INTERCEPT_PREAMBLE }, + { name: 'session-greeting', priority: 10, content: SESSION_GREETING }, + ]; + + for (const p of systemPrompts) { + await promptRepo.create({ ...p, projectId: project.id }); + } + } + ``` + +2. Add delete protection in prompt delete endpoint: + - Check if prompt belongs to mcpctl-system + - Return 403 error if attempting to delete system prompt + +3. Define default content for each system prompt per PRD + +**Test Strategy:** + +- Unit test: System project created on first startup +- Unit test: All 5 system prompts created +- Unit test: Subsequent startups don't duplicate +- Unit test: Delete system prompt returns 403 +- Unit test: System prompts have priority 10 +- Unit test: mcpctl-system project has gated=false +- Integration test: End-to-end bootstrap flow diff --git a/.taskmaster/tasks/task_055.md b/.taskmaster/tasks/task_055.md new file mode 100644 index 0000000..e4d32ec --- /dev/null +++ b/.taskmaster/tasks/task_055.md @@ -0,0 +1,61 @@ +# Task ID: 55 + +**Title:** Implement system prompt fetching and caching in mcplocal + +**Status:** done + +**Dependencies:** 54 ✓ + +**Priority:** high + +**Description:** Add functionality to mcplocal router to fetch system prompts from mcpd and cache them with 60s TTL, supporting template variable replacement. + +**Details:** + +1. Add system prompt fetching in `/src/mcplocal/src/router.ts`: + ```typescript + private systemPromptCache: Map = new Map(); + + async getSystemPrompt(name: string): Promise { + const cached = this.systemPromptCache.get(name); + if (cached && cached.expiresAt > Date.now()) { + return cached.content; + } + + const prompts = await this.mcpdClient.fetch( + '/api/v1/projects/mcpctl-system/prompts/visible' + ); + const prompt = prompts.find(p => p.name === name); + if (!prompt) { + throw new Error(`System prompt not found: ${name}`); + } + + this.systemPromptCache.set(name, { + content: prompt.content, + expiresAt: Date.now() + 60000, // 60s TTL + }); + + return prompt.content; + } + ``` + +2. Add template variable replacement: + ```typescript + replaceTemplateVariables(content: string, vars: Record): string { + return content + .replace(/\{\{prompt_index\}\}/g, vars.prompt_index || '') + .replace(/\{\{project_name\}\}/g, vars.project_name || '') + .replace(/\{\{matched_prompts\}\}/g, vars.matched_prompts || '') + .replace(/\{\{remaining_prompts\}\}/g, vars.remaining_prompts || ''); + } + ``` + +**Test Strategy:** + +- Unit test: System prompt fetched from mcpd +- Unit test: Cached prompt returned within TTL +- Unit test: Cache miss triggers fresh fetch +- Unit test: Missing system prompt throws error +- Unit test: Template variables replaced correctly +- Unit test: Unknown template variables left as-is +- Integration test: End-to-end fetch and cache diff --git a/.taskmaster/tasks/task_056.md b/.taskmaster/tasks/task_056.md new file mode 100644 index 0000000..629b83a --- /dev/null +++ b/.taskmaster/tasks/task_056.md @@ -0,0 +1,67 @@ +# Task ID: 56 + +**Title:** Implement prompt link resolution service + +**Status:** done + +**Dependencies:** 40 ✓ + +**Priority:** medium + +**Description:** Create a service that fetches linked prompt content from source MCP servers using the project's service account, with dead link detection. + +**Details:** + +1. Create `/src/mcplocal/src/services/link-resolver.service.ts`: + ```typescript + export class LinkResolverService { + constructor(private mcpdClient: McpdClient) {} + + async resolveLink(linkTarget: string): Promise<{ + content: string | null; + status: 'alive' | 'dead' | 'unknown'; + error?: string; + }> { + // Parse linkTarget: project/server:resource-uri + const { project, server, uri } = this.parseLink(linkTarget); + + try { + // Use service account for source project + const content = await this.fetchResource(project, server, uri); + return { content, status: 'alive' }; + } catch (error) { + this.logDeadLink(linkTarget, error); + return { + content: null, + status: 'dead', + error: error.message + }; + } + } + + private parseLink(linkTarget: string): { project: string; server: string; uri: string } { + const match = linkTarget.match(/^([^/]+)\/([^:]+):(.+)$/); + if (!match) throw new Error('Invalid link format'); + return { project: match[1], server: match[2], uri: match[3] }; + } + + private async fetchResource(project: string, server: string, uri: string): Promise { + // Call mcpd to fetch resource via service account + // mcpd routes to the source project's MCP server + } + } + ``` + +2. Log dead links as errors + +3. Cache resolution results + +**Test Strategy:** + +- Unit test: Valid link parsed correctly +- Unit test: Invalid link format throws error +- Unit test: Successful resolution returns content and status='alive' +- Unit test: Failed resolution returns status='dead' with error +- Unit test: Dead link logged as error +- Unit test: Service account header included in request +- Integration test: End-to-end link resolution diff --git a/.taskmaster/tasks/task_057.md b/.taskmaster/tasks/task_057.md new file mode 100644 index 0000000..1e28dec --- /dev/null +++ b/.taskmaster/tasks/task_057.md @@ -0,0 +1,50 @@ +# Task ID: 57 + +**Title:** Add linkStatus to prompt GET responses + +**Status:** done + +**Dependencies:** 56 ✓ + +**Priority:** medium + +**Description:** Modify the GET /api/v1/prompts endpoint to include linkStatus (alive/dead/unknown) for linked prompts by checking link health. + +**Details:** + +1. Update `/src/mcpd/src/routes/prompts.ts` GET endpoint: + ```typescript + fastify.get('/api/v1/prompts', async (request, reply) => { + const prompts = await promptService.findAll(filter); + + // Check link status for linked prompts + const promptsWithStatus = await Promise.all( + prompts.map(async (p) => { + if (!p.linkTarget) { + return { ...p, linkStatus: null }; + } + const status = await linkResolver.checkLinkHealth(p.linkTarget); + return { ...p, linkStatus: status }; + }) + ); + + return reply.send(promptsWithStatus); + }); + ``` + +2. Consider caching link health to avoid repeated checks + +3. Add `linkStatus` field to prompt response schema: + - `null` for non-linked prompts + - `'alive'` for working links + - `'dead'` for broken links + - `'unknown'` for unchecked links + +**Test Strategy:** + +- Unit test: Non-linked prompt has linkStatus=null +- Unit test: Linked prompt with working link has linkStatus='alive' +- Unit test: Linked prompt with broken link has linkStatus='dead' +- Unit test: Link health cached to avoid repeated checks +- Unit test: All prompts in response have linkStatus field +- Integration test: End-to-end GET with linked prompts diff --git a/.taskmaster/tasks/task_058.md b/.taskmaster/tasks/task_058.md new file mode 100644 index 0000000..0ec4462 --- /dev/null +++ b/.taskmaster/tasks/task_058.md @@ -0,0 +1,55 @@ +# Task ID: 58 + +**Title:** Add RBAC for prompt link creation + +**Status:** done + +**Dependencies:** 56 ✓ + +**Priority:** medium + +**Description:** Implement RBAC checks requiring edit permission on the target project to create prompt links, and auto-create service account permission on the source project. + +**Details:** + +1. Update prompt creation in `/src/mcpd/src/services/prompt.service.ts`: + ```typescript + async createPrompt(data: CreatePromptInput, userId: string): Promise { + if (data.linkTarget) { + // Verify user has edit permission on target project RBAC + const hasPermission = await this.rbacService.checkPermission( + userId, data.projectId, 'edit' + ); + if (!hasPermission) { + throw new ForbiddenError('Edit permission required to create prompt links'); + } + + // Parse link target + const { project: sourceProject, server, uri } = this.parseLink(data.linkTarget); + + // Create service account permission on source project + await this.rbacService.createServiceAccountPermission( + data.projectId, // target project + sourceProject, // source project + server, + uri, + 'read' + ); + } + + return this.promptRepo.create(data); + } + ``` + +2. Clean up service account permission when link is deleted + +3. Handle permission denied from source project + +**Test Strategy:** + +- Unit test: Link creation requires edit permission +- Unit test: Link creation without permission throws 403 +- Unit test: Service account permission created on source project +- Unit test: Service account permission deleted when link deleted +- Unit test: Non-link prompts skip RBAC checks +- Integration test: End-to-end link creation with RBAC diff --git a/.taskmaster/tasks/task_059.md b/.taskmaster/tasks/task_059.md new file mode 100644 index 0000000..9f60d05 --- /dev/null +++ b/.taskmaster/tasks/task_059.md @@ -0,0 +1,64 @@ +# Task ID: 59 + +**Title:** Update CLI create prompt command for priority and link + +**Status:** done + +**Dependencies:** 40 ✓ + +**Priority:** medium + +**Description:** Extend the mcpctl create prompt command to accept --priority (1-10) and --link (project/server:uri) flags. + +**Details:** + +1. Update `/src/cli/src/commands/create.ts` for prompt: + ```typescript + .command('prompt ') + .description('Create a new prompt') + .option('-p, --project ', 'Project to create prompt in') + .option('--priority ', 'Priority level (1-10, default: 5)', '5') + .option('--link ', 'Link to MCP resource (project/server:uri)') + .option('-f, --file ', 'Read content from file') + .action(async (name, options) => { + const priority = parseInt(options.priority, 10); + if (priority < 1 || priority > 10) { + console.error('Priority must be between 1 and 10'); + process.exit(1); + } + + let content = ''; + if (options.link) { + // Linked prompts don't need content (fetched from source) + content = `[Link: ${options.link}]`; + } else if (options.file) { + content = await fs.readFile(options.file, 'utf-8'); + } else { + content = await promptForContent(); + } + + const body = { + name, + content, + projectId: options.project, + priority, + linkTarget: options.link, + }; + + await api.post('/api/v1/prompts', body); + }); + ``` + +2. Validate link format: `project/server:resource-uri` + +3. Add shell completions for new flags + +**Test Strategy:** + +- Unit test: --priority flag sets prompt priority +- Unit test: --priority validation (1-10 range) +- Unit test: --link flag sets linkTarget +- Unit test: --link validation (format check) +- Unit test: Linked prompt skips content prompt +- Unit test: Default priority is 5 +- Integration test: End-to-end create with flags diff --git a/.taskmaster/tasks/task_060.md b/.taskmaster/tasks/task_060.md new file mode 100644 index 0000000..2395639 --- /dev/null +++ b/.taskmaster/tasks/task_060.md @@ -0,0 +1,54 @@ +# Task ID: 60 + +**Title:** Update CLI get prompt command for -A flag and link columns + +**Status:** done + +**Dependencies:** 57 ✓, 59 ✓ + +**Priority:** medium + +**Description:** Extend the mcpctl get prompt command with -A (all projects) flag and add link target and status columns to output. + +**Details:** + +1. Update `/src/cli/src/commands/get.ts` for prompt: + ```typescript + .command('prompt [name]') + .option('-A, --all-projects', 'Show prompts from all projects') + .option('-p, --project ', 'Filter by project') + .action(async (name, options) => { + let url = '/api/v1/prompts'; + if (options.allProjects) { + url += '?all=true'; + } else if (options.project) { + url += `?project=${options.project}`; + } + + const prompts = await api.get(url); + + // Format table with new columns + formatPromptsTable(prompts, { + columns: ['PROJECT', 'NAME', 'PRIORITY', 'LINK', 'STATUS'] + }); + }); + ``` + +2. Update table formatter to handle link columns: + ``` + PROJECT NAME PRIORITY LINK STATUS + homeautomation security-policies 8 - - + homeautomation architecture-adr 6 system-public/docmost-mcp:docmost://pages/a1 alive + ``` + +3. Add shell completions for -A flag + +**Test Strategy:** + +- Unit test: -A flag shows all projects +- Unit test: --project flag filters by project +- Unit test: PRIORITY column displayed +- Unit test: LINK column shows linkTarget or - +- Unit test: STATUS column shows linkStatus or - +- Unit test: Table formatted correctly +- Integration test: End-to-end get with flags diff --git a/.taskmaster/tasks/task_061.md b/.taskmaster/tasks/task_061.md new file mode 100644 index 0000000..1afdd68 --- /dev/null +++ b/.taskmaster/tasks/task_061.md @@ -0,0 +1,49 @@ +# Task ID: 61 + +**Title:** Update CLI describe project command for gated status + +**Status:** done + +**Dependencies:** 44 ✓, 54 ✓ + +**Priority:** medium + +**Description:** Extend mcpctl describe project to show gated status, session greeting, and prompt table with priority and link information. + +**Details:** + +1. Update `/src/cli/src/commands/get.ts` describe project: + ```typescript + async function describeProject(name: string) { + const project = await api.get(`/api/v1/projects/${name}`); + const prompts = await api.get(`/api/v1/projects/${name}/prompt-index`); + const greeting = await getSessionGreeting(name); + + console.log(`Name: ${project.name}`); + console.log(`Gated: ${project.gated}`); + console.log(`LLM Provider: ${project.llmProvider || '-'}`); + console.log(`...`); + console.log(); + console.log(`Session greeting:`); + console.log(` ${greeting}`); + console.log(); + console.log(`Prompts:`); + console.log(` NAME PRIORITY TYPE LINK`); + for (const p of prompts) { + const type = p.linkTarget ? 'link' : 'local'; + const link = p.linkTarget || '-'; + console.log(` ${p.name.padEnd(20)} ${p.priority.toString().padEnd(9)} ${type.padEnd(7)} ${link}`); + } + } + ``` + +2. Fetch session greeting from system prompts or project config + +**Test Strategy:** + +- Unit test: Gated status displayed +- Unit test: Session greeting displayed +- Unit test: Prompt table with PRIORITY, TYPE, LINK columns +- Unit test: TYPE shows 'local' or 'link' +- Unit test: LINK shows target or - +- Integration test: End-to-end describe project diff --git a/.taskmaster/tasks/task_062.md b/.taskmaster/tasks/task_062.md new file mode 100644 index 0000000..4fd4641 --- /dev/null +++ b/.taskmaster/tasks/task_062.md @@ -0,0 +1,51 @@ +# Task ID: 62 + +**Title:** Update CLI edit project command for gated field + +**Status:** done + +**Dependencies:** 39 ✓ + +**Priority:** medium + +**Description:** Extend mcpctl edit project to allow editing the gated boolean field. + +**Details:** + +1. Update `/src/cli/src/commands/edit.ts` for project: + ```typescript + async function editProject(name: string) { + const project = await api.get(`/api/v1/projects/${name}`); + + // Add gated to editable fields + const yaml = ` + name: ${project.name} + description: ${project.description} + gated: ${project.gated} + llmProvider: ${project.llmProvider || ''} + ...`; + + const edited = await openEditor(yaml); + const parsed = YAML.parse(edited); + + // Validate gated is boolean + if (typeof parsed.gated !== 'boolean') { + console.error('gated must be true or false'); + process.exit(1); + } + + await api.put(`/api/v1/projects/${name}`, parsed); + } + ``` + +2. Update project validation schema to accept gated + +3. Handle conversion from string 'true'/'false' to boolean + +**Test Strategy:** + +- Unit test: Gated field appears in editor YAML +- Unit test: Gated field saved on edit +- Unit test: Boolean validation (true/false only) +- Unit test: String 'true'/'false' converted to boolean +- Integration test: End-to-end edit project gated diff --git a/.taskmaster/tasks/task_063.md b/.taskmaster/tasks/task_063.md new file mode 100644 index 0000000..bd2d804 --- /dev/null +++ b/.taskmaster/tasks/task_063.md @@ -0,0 +1,68 @@ +# Task ID: 63 + +**Title:** Add unit tests for prompt priority and link CRUD + +**Status:** done + +**Dependencies:** 40 ✓, 41 ✓ + +**Priority:** high + +**Description:** Create comprehensive unit tests for all prompt CRUD operations with the new priority and linkTarget fields. + +**Details:** + +1. Add tests in `/src/mcpd/tests/services/prompt-service.test.ts`: + ```typescript + describe('Prompt Priority', () => { + it('creates prompt with explicit priority', async () => { + const prompt = await service.createPrompt({ ...data, priority: 8 }); + expect(prompt.priority).toBe(8); + }); + + it('uses default priority 5 when not specified', async () => { + const prompt = await service.createPrompt(data); + expect(prompt.priority).toBe(5); + }); + + it('validates priority range 1-10', async () => { + await expect(service.createPrompt({ ...data, priority: 11 })) + .rejects.toThrow(); + }); + + it('updates priority', async () => { + const updated = await service.updatePrompt(id, { priority: 3 }); + expect(updated.priority).toBe(3); + }); + }); + + describe('Prompt Links', () => { + it('creates linked prompt', async () => { + const prompt = await service.createPrompt({ + ...data, + linkTarget: 'project/server:uri' + }); + expect(prompt.linkTarget).toBe('project/server:uri'); + }); + + it('rejects invalid link format', async () => { + await expect(service.createPrompt({ + ...data, + linkTarget: 'invalid' + })).rejects.toThrow(); + }); + + it('linkTarget is immutable on update', async () => { + // linkTarget not accepted in update schema + }); + }); + ``` + +**Test Strategy:** + +This task IS the test implementation. Verify: +- All priority CRUD tests pass +- All link CRUD tests pass +- Validation tests cover edge cases +- Tests use proper mocking patterns +- Coverage meets project standards diff --git a/.taskmaster/tasks/task_064.md b/.taskmaster/tasks/task_064.md new file mode 100644 index 0000000..48c38a9 --- /dev/null +++ b/.taskmaster/tasks/task_064.md @@ -0,0 +1,58 @@ +# Task ID: 64 + +**Title:** Add unit tests for tag matching algorithm + +**Status:** done + +**Dependencies:** 45 ✓ + +**Priority:** high + +**Description:** Create comprehensive unit tests for the deterministic tag matching algorithm covering score calculation, byte budget, and priority handling. + +**Details:** + +1. Add tests in `/src/mcplocal/tests/services/tag-matcher.test.ts`: + ```typescript + describe('TagMatcherService', () => { + describe('score calculation', () => { + it('priority 10 prompts have infinite score', () => { + const score = matcher.computeScore(['any'], { priority: 10, ... }); + expect(score).toBe(Infinity); + }); + + it('score = matching_tags * priority', () => { + const score = matcher.computeScore( + ['tag1', 'tag2'], + { priority: 5, summary: 'tag1 tag2', chapters: [] } + ); + expect(score).toBe(10); // 2 tags * 5 priority + }); + }); + + describe('matching', () => { + it('matches case-insensitively', () => { + const matches = matcher.matchesPrompt('ZIGBEE', { summary: 'zigbee setup' }); + expect(matches).toBe(true); + }); + + it('matches substring in summary', () => { ... }); + it('matches substring in chapters', () => { ... }); + }); + + describe('byte budget', () => { + it('includes full content until budget exhausted', () => { ... }); + it('matched prompts beyond budget become index entries', () => { ... }); + it('non-matched prompts listed as names only', () => { ... }); + }); + }); + ``` + +**Test Strategy:** + +This task IS the test implementation. Verify: +- Score calculation tests pass +- Matching tests cover all cases +- Byte budget tests verify allocation +- Edge cases handled (empty tags, no prompts, etc.) +- Tests are deterministic diff --git a/.taskmaster/tasks/task_065.md b/.taskmaster/tasks/task_065.md new file mode 100644 index 0000000..b6cfc1c --- /dev/null +++ b/.taskmaster/tasks/task_065.md @@ -0,0 +1,63 @@ +# Task ID: 65 + +**Title:** Add unit tests for gating state machine + +**Status:** done + +**Dependencies:** 50 ✓, 52 ✓ + +**Priority:** high + +**Description:** Create comprehensive unit tests for the session gating state machine covering all transitions and edge cases. + +**Details:** + +1. Add tests in `/src/mcplocal/tests/router-gating.test.ts`: + ```typescript + describe('Gating State Machine', () => { + describe('initial state', () => { + it('starts gated for gated project', () => { + const router = createRouter({ gated: true }); + const state = router.getSessionState('session1'); + expect(state.gated).toBe(true); + }); + + it('starts ungated for non-gated project', () => { + const router = createRouter({ gated: false }); + const state = router.getSessionState('session1'); + expect(state.gated).toBe(false); + }); + }); + + describe('begin_session transition', () => { + it('ungates session on successful begin_session', async () => { + const router = createGatedRouter(); + await router.handleBeginSession('session1', { tags: ['test'] }); + expect(router.getSessionState('session1').gated).toBe(false); + }); + + it('returns matched prompts', async () => { ... }); + it('sends notifications/tools/list_changed', async () => { ... }); + }); + + describe('intercept transition', () => { + it('ungates session on tool call intercept', async () => { ... }); + it('extracts keywords from tool call', async () => { ... }); + it('injects briefing with tool result', async () => { ... }); + }); + + describe('tools/list behavior', () => { + it('returns only begin_session while gated', async () => { ... }); + it('returns all tools + read_prompts after ungating', async () => { ... }); + }); + }); + ``` + +**Test Strategy:** + +This task IS the test implementation. Verify: +- Initial state tests pass +- Transition tests cover happy paths +- Edge case tests (already ungated, etc.) +- Notification tests verify signals sent +- Tests use proper mocking diff --git a/.taskmaster/tasks/task_066.md b/.taskmaster/tasks/task_066.md new file mode 100644 index 0000000..cb846e9 --- /dev/null +++ b/.taskmaster/tasks/task_066.md @@ -0,0 +1,61 @@ +# Task ID: 66 + +**Title:** Add unit tests for LLM prompt selection + +**Status:** done + +**Dependencies:** 46 ✓ + +**Priority:** high + +**Description:** Create unit tests for the LLM-based prompt selection service covering LLM interactions, fallback behavior, and priority 10 handling. + +**Details:** + +1. Add tests in `/src/mcplocal/tests/services/llm-prompt-selector.test.ts`: + ```typescript + describe('LlmPromptSelectorService', () => { + describe('priority 10 handling', () => { + it('always includes priority 10 prompts', async () => { + const result = await selector.selectPrompts(['unrelated'], promptIndex); + expect(result.priority10).toContain(priority10Prompt); + }); + }); + + describe('LLM selection', () => { + it('sends tags and index to heavy LLM', async () => { + await selector.selectPrompts(['zigbee', 'mqtt'], promptIndex); + expect(mockLlm.complete).toHaveBeenCalledWith( + expect.stringContaining('zigbee') + ); + }); + + it('parses LLM response correctly', async () => { + mockLlm.complete.mockResolvedValue( + '[{"name": "prompt1", "reason": "relevant"}]' + ); + const result = await selector.selectPrompts(['test'], promptIndex); + expect(result.selected[0].name).toBe('prompt1'); + }); + }); + + describe('fallback behavior', () => { + it('falls back to tag matcher on LLM error', async () => { ... }); + it('falls back on LLM timeout', async () => { ... }); + it('falls back when no LLM available', async () => { ... }); + }); + + describe('summary generation', () => { + it('generates missing summaries with fast LLM', async () => { ... }); + }); + }); + ``` + +**Test Strategy:** + +This task IS the test implementation. Verify: +- Priority 10 tests pass +- LLM interaction tests use proper mocks +- Fallback tests cover all error scenarios +- Summary generation tests pass +- Response parsing handles edge cases diff --git a/.taskmaster/tasks/task_067.md b/.taskmaster/tasks/task_067.md new file mode 100644 index 0000000..89c09a7 --- /dev/null +++ b/.taskmaster/tasks/task_067.md @@ -0,0 +1,64 @@ +# Task ID: 67 + +**Title:** Add integration tests for gated session flow + +**Status:** done + +**Dependencies:** 65 ✓ + +**Priority:** high + +**Description:** Create end-to-end integration tests for the complete gated session flow including connect, begin_session, tool calls, and read_prompts. + +**Details:** + +1. Add tests in `/src/mcplocal/tests/integration/gated-flow.test.ts`: + ```typescript + describe('Gated Session Flow Integration', () => { + let app: FastifyInstance; + let mcpClient: McpClient; + + beforeAll(async () => { + app = await createTestApp(); + // Seed test project with gated=true and test prompts + }); + + describe('end-to-end gated flow', () => { + it('connect → begin_session with tags → tools available → correct prompts', async () => { + // 1. Connect to MCP endpoint + const session = await mcpClient.connect(app, 'test-project'); + + // 2. Verify only begin_session available + const toolsBefore = await session.listTools(); + expect(toolsBefore.map(t => t.name)).toEqual(['begin_session']); + + // 3. Call begin_session + const briefing = await session.callTool('begin_session', { + tags: ['test', 'integration'] + }); + expect(briefing).toContain('matched prompt content'); + + // 4. Verify all tools now available + const toolsAfter = await session.listTools(); + expect(toolsAfter.map(t => t.name)).toContain('read_prompts'); + }); + }); + + describe('end-to-end intercept flow', () => { + it('connect → skip begin_session → call tool → keywords extracted → briefing injected', async () => { ... }); + }); + + describe('end-to-end read_prompts', () => { + it('after ungating → request more context → additional prompts → no duplicates', async () => { ... }); + }); + }); + ``` + +**Test Strategy:** + +This task IS the test implementation. Verify: +- Happy path tests pass +- Intercept path tests pass +- read_prompts deduplication works +- Tests use realistic data +- Tests clean up properly diff --git a/.taskmaster/tasks/task_068.md b/.taskmaster/tasks/task_068.md new file mode 100644 index 0000000..90cce57 --- /dev/null +++ b/.taskmaster/tasks/task_068.md @@ -0,0 +1,58 @@ +# Task ID: 68 + +**Title:** Add integration tests for prompt links + +**Status:** done + +**Dependencies:** 57 ✓, 58 ✓ + +**Priority:** medium + +**Description:** Create end-to-end integration tests for prompt link creation, resolution, and dead link detection. + +**Details:** + +1. Add tests in `/src/mcplocal/tests/integration/prompt-links.test.ts`: + ```typescript + describe('Prompt Links Integration', () => { + describe('link creation', () => { + it('creates link with RBAC permission', async () => { + // Setup: user with edit permission on target project + const prompt = await api.post('/api/v1/prompts', { + name: 'linked-prompt', + content: '[Link]', + projectId: targetProject.id, + linkTarget: 'source-project/server:uri' + }); + expect(prompt.linkTarget).toBe('source-project/server:uri'); + }); + + it('rejects link creation without RBAC permission', async () => { ... }); + }); + + describe('link resolution', () => { + it('fetches content from source server', async () => { ... }); + it('uses service account for RBAC', async () => { ... }); + }); + + describe('dead link lifecycle', () => { + it('detects dead link when source unavailable', async () => { + // Kill source server + const prompts = await api.get('/api/v1/prompts'); + const linked = prompts.find(p => p.linkTarget); + expect(linked.linkStatus).toBe('dead'); + }); + + it('recovers when source restored', async () => { ... }); + }); + }); + ``` + +**Test Strategy:** + +This task IS the test implementation. Verify: +- RBAC tests cover permission scenarios +- Resolution tests verify content fetched +- Dead link tests cover full lifecycle +- Tests properly mock/control source servers +- Tests clean up resources diff --git a/.taskmaster/tasks/task_069.md b/.taskmaster/tasks/task_069.md new file mode 100644 index 0000000..2d405b1 --- /dev/null +++ b/.taskmaster/tasks/task_069.md @@ -0,0 +1,59 @@ +# Task ID: 69 + +**Title:** Add CLI unit tests for new prompt and project flags + +**Status:** done + +**Dependencies:** 59 ✓, 60 ✓, 61 ✓, 62 ✓ + +**Priority:** medium + +**Description:** Create unit tests for the new CLI flags: --priority, --link for prompts, -A for get, and gated field for projects. + +**Details:** + +1. Add tests in `/src/cli/tests/commands/prompt.test.ts`: + ```typescript + describe('create prompt command', () => { + it('--priority sets prompt priority', async () => { + await cli('create prompt test --priority 8'); + expect(mockApi.post).toHaveBeenCalledWith( + '/api/v1/prompts', + expect.objectContaining({ priority: 8 }) + ); + }); + + it('--priority validates range 1-10', async () => { + await expect(cli('create prompt test --priority 15')) + .rejects.toThrow('Priority must be between 1 and 10'); + }); + + it('--link sets linkTarget', async () => { + await cli('create prompt test --link proj/srv:uri'); + expect(mockApi.post).toHaveBeenCalledWith( + '/api/v1/prompts', + expect.objectContaining({ linkTarget: 'proj/srv:uri' }) + ); + }); + }); + + describe('get prompt command', () => { + it('-A shows all projects', async () => { + await cli('get prompt -A'); + expect(mockApi.get).toHaveBeenCalledWith('/api/v1/prompts?all=true'); + }); + }); + ``` + +2. Add tests for project gated field editing + +3. Add tests for describe project output + +**Test Strategy:** + +This task IS the test implementation. Verify: +- Flag parsing tests pass +- Validation tests cover edge cases +- API call tests verify correct parameters +- Output formatting tests verify columns +- Tests mock API properly diff --git a/.taskmaster/tasks/task_070.md b/.taskmaster/tasks/task_070.md new file mode 100644 index 0000000..9e2f994 --- /dev/null +++ b/.taskmaster/tasks/task_070.md @@ -0,0 +1,37 @@ +# Task ID: 70 + +**Title:** Add shell completions for new CLI flags + +**Status:** done + +**Dependencies:** 59 ✓, 60 ✓ + +**Priority:** low + +**Description:** Update shell completion scripts (bash, zsh, fish) to include completions for new flags: --priority, --link, -A, and gated values. + +**Details:** + +1. Update `/completions/mcpctl.fish`: + ```fish + # create prompt completions + complete -c mcpctl -n '__fish_seen_subcommand_from create; and __fish_seen_subcommand_from prompt' -l priority -d 'Priority level (1-10)' -a '(seq 1 10)' + complete -c mcpctl -n '__fish_seen_subcommand_from create; and __fish_seen_subcommand_from prompt' -l link -d 'Link to MCP resource (project/server:uri)' + + # get prompt completions + complete -c mcpctl -n '__fish_seen_subcommand_from get; and __fish_seen_subcommand_from prompt' -s A -l all-projects -d 'Show prompts from all projects' + ``` + +2. Update bash completions similarly + +3. Update zsh completions similarly + +4. Add dynamic completion for priority values (1-10) + +**Test Strategy:** + +- Manual test: Fish completions suggest --priority with values 1-10 +- Manual test: Fish completions suggest --link flag +- Manual test: Fish completions suggest -A/--all-projects +- Manual test: Bash completions work similarly +- Manual test: Zsh completions work similarly diff --git a/.taskmaster/tasks/task_071.md b/.taskmaster/tasks/task_071.md new file mode 100644 index 0000000..bc88435 --- /dev/null +++ b/.taskmaster/tasks/task_071.md @@ -0,0 +1,70 @@ +# Task ID: 71 + +**Title:** Define ProxyModel Public Type Contract + +**Status:** done + +**Dependencies:** None + +**Priority:** high + +**Description:** Create the core TypeScript types for the ProxyModel framework that stages will import from `mcpctl/proxymodel`. This establishes the public API contract that stage authors write against. + +**Details:** + +Create `src/mcplocal/src/proxymodel/types.ts` with: + +```typescript +export interface StageHandler { + (content: string, ctx: StageContext): Promise; +} + +export interface StageContext { + contentType: 'prompt' | 'toolResult' | 'resource'; + sourceName: string; + projectName: string; + sessionId: string; + originalContent: string; + llm: LLMProvider; + cache: CacheProvider; + log: Logger; + config: Record; +} + +export interface StageResult { + content: string; + sections?: Section[]; + metadata?: Record; +} + +export interface Section { + id: string; + title: string; + content: string; +} + +export interface LLMProvider { + complete(prompt: string, options?: { system?: string; maxTokens?: number }): Promise; + available(): boolean; +} + +export interface CacheProvider { + getOrCompute(key: string, compute: () => Promise): Promise; + hash(content: string): string; + get(key: string): Promise; + set(key: string, value: string): Promise; +} + +export interface Logger { + debug(msg: string): void; + info(msg: string): void; + warn(msg: string): void; + error(msg: string): void; +} +``` + +Also create `src/mcplocal/src/proxymodel/index.ts` as the public entrypoint that re-exports these types. Update `package.json` exports to expose `mcpctl/proxymodel`. + +**Test Strategy:** + +Unit tests verifying type exports are accessible from the public entrypoint. Create a sample stage file that imports from `mcpctl/proxymodel` and verify it compiles without errors. diff --git a/.taskmaster/tasks/task_072.md b/.taskmaster/tasks/task_072.md new file mode 100644 index 0000000..780fc2e --- /dev/null +++ b/.taskmaster/tasks/task_072.md @@ -0,0 +1,49 @@ +# Task ID: 72 + +**Title:** Implement LLMProvider Adapter + +**Status:** done + +**Dependencies:** 71 ✓ + +**Priority:** high + +**Description:** Create an adapter that wraps the existing ProviderRegistry to implement the StageContext.llm interface, providing stages with a simplified LLM access API. + +**Details:** + +Create `src/mcplocal/src/proxymodel/llm-adapter.ts`: + +```typescript +import type { LLMProvider } from './types'; +import type { ProviderRegistry } from '../providers/registry'; + +export function createLLMAdapter(registry: ProviderRegistry, projectName: string): LLMProvider { + return { + async complete(prompt: string, options?: { system?: string; maxTokens?: number }): Promise { + const provider = registry.getProvider('heavy'); + if (!provider) throw new Error('No LLM provider configured'); + + const messages = options?.system + ? [{ role: 'system', content: options.system }, { role: 'user', content: prompt }] + : [{ role: 'user', content: prompt }]; + + const result = await provider.complete({ + messages, + maxTokens: options?.maxTokens ?? 1000, + }); + return result.content; + }, + + available(): boolean { + return registry.getProvider('heavy') !== null; + } + }; +} +``` + +This adapter uses the 'heavy' tier from the existing registry, preserving the project-level LLM configuration. + +**Test Strategy:** + +Unit test with mocked ProviderRegistry verifying complete() calls are delegated correctly. Test available() returns false when no provider is configured. Integration test with a real provider. diff --git a/.taskmaster/tasks/task_073.md b/.taskmaster/tasks/task_073.md new file mode 100644 index 0000000..17c3104 --- /dev/null +++ b/.taskmaster/tasks/task_073.md @@ -0,0 +1,67 @@ +# Task ID: 73 + +**Title:** Implement In-Memory CacheProvider + +**Status:** done + +**Dependencies:** 71 ✓ + +**Priority:** high + +**Description:** Create the CacheProvider implementation that stages use for caching expensive computations. Start with in-memory cache for Phase 1, with content-addressed keys. + +**Details:** + +Create `src/mcplocal/src/proxymodel/cache-provider.ts`: + +```typescript +import { createHash } from 'crypto'; +import type { CacheProvider } from './types'; + +export class InMemoryCacheProvider implements CacheProvider { + private cache = new Map(); + private maxSize: number; + private ttlMs: number; + + constructor(options: { maxSize?: number; ttlMs?: number } = {}) { + this.maxSize = options.maxSize ?? 1000; + this.ttlMs = options.ttlMs ?? 3600000; // 1 hour default + } + + hash(content: string): string { + return createHash('sha256').update(content).digest('hex').slice(0, 16); + } + + async get(key: string): Promise { + const entry = this.cache.get(key); + if (!entry) return null; + if (Date.now() - entry.timestamp > this.ttlMs) { + this.cache.delete(key); + return null; + } + return entry.value; + } + + async set(key: string, value: string): Promise { + if (this.cache.size >= this.maxSize) this.evictOldest(); + this.cache.set(key, { value, timestamp: Date.now() }); + } + + async getOrCompute(key: string, compute: () => Promise): Promise { + const cached = await this.get(key); + if (cached !== null) return cached; + const value = await compute(); + await this.set(key, value); + return value; + } + + private evictOldest(): void { + const oldest = [...this.cache.entries()].sort((a, b) => a[1].timestamp - b[1].timestamp)[0]; + if (oldest) this.cache.delete(oldest[0]); + } +} +``` + +**Test Strategy:** + +Unit tests for: hash() produces consistent output, get() returns null for missing keys, set()/get() round-trip works, TTL expiration works, LRU eviction triggers at maxSize, getOrCompute() caches and returns cached values. diff --git a/.taskmaster/tasks/task_074.md b/.taskmaster/tasks/task_074.md new file mode 100644 index 0000000..82d9faf --- /dev/null +++ b/.taskmaster/tasks/task_074.md @@ -0,0 +1,57 @@ +# Task ID: 74 + +**Title:** Implement Content Type Detection + +**Status:** done + +**Dependencies:** None + +**Priority:** high + +**Description:** Create a utility that detects content type (JSON, YAML, XML, code, prose) for structural splitting in the section-split stage. + +**Details:** + +Create `src/mcplocal/src/proxymodel/content-detection.ts`: + +```typescript +export type ContentType = 'json' | 'yaml' | 'xml' | 'code' | 'prose'; + +export function detectContentType(content: string): ContentType { + const trimmed = content.trimStart(); + + // JSON detection + if (trimmed.startsWith('{') || trimmed.startsWith('[')) { + try { + JSON.parse(content); + return 'json'; + } catch { /* not valid JSON, continue */ } + } + + // XML detection + if (trimmed.startsWith(']*>/.test(trimmed)) { + return 'xml'; + } + + // YAML detection (key: value at start of lines) + if (/^[a-zA-Z_][a-zA-Z0-9_]*:\s/m.test(trimmed) && !trimmed.includes('{')) { + return 'yaml'; + } + + // Code detection (common patterns) + const codePatterns = [ + /^(function |class |def |const |let |var |import |export |package |pub fn |fn |impl )/m, + /^#include\s+[<"]/m, + /^(public |private |protected )?(static )?(void |int |string |bool )/m, + ]; + if (codePatterns.some(p => p.test(trimmed))) { + return 'code'; + } + + return 'prose'; +} +``` + +**Test Strategy:** + +Unit tests with sample content for each type: valid JSON objects/arrays, XML documents, YAML configs, code snippets in multiple languages (JS, Python, Rust, Go, Java), and prose markdown. Edge cases: JSON-like strings that aren't valid JSON, mixed content. diff --git a/.taskmaster/tasks/task_075.md b/.taskmaster/tasks/task_075.md new file mode 100644 index 0000000..b228d52 --- /dev/null +++ b/.taskmaster/tasks/task_075.md @@ -0,0 +1,77 @@ +# Task ID: 75 + +**Title:** Implement section-split Stage + +**Status:** done + +**Dependencies:** 71 ✓, 74 ✓ + +**Priority:** high + +**Description:** Create the built-in section-split stage that splits content based on detected content type, using structural boundaries for JSON/YAML/XML and headers for prose. + +**Details:** + +Create `src/mcplocal/src/proxymodel/stages/section-split.ts`: + +```typescript +import type { StageHandler, Section } from '../types'; +import { detectContentType } from '../content-detection'; + +const handler: StageHandler = async (content, ctx) => { + const minSize = (ctx.config.minSectionSize as number) ?? 2000; + const maxSize = (ctx.config.maxSectionSize as number) ?? 15000; + const contentType = detectContentType(content); + + let sections: Section[]; + + switch (contentType) { + case 'json': + sections = splitJson(content, minSize, maxSize); + break; + case 'yaml': + sections = splitYaml(content, minSize, maxSize); + break; + case 'xml': + sections = splitXml(content, minSize, maxSize); + break; + case 'code': + sections = splitCode(content, minSize); + break; + default: + sections = splitProse(content, minSize); + } + + if (sections.length === 0) { + return { content, sections: [{ id: 'main', title: 'Content', content }] }; + } + + const toc = sections.map((s, i) => `[${s.id}] ${s.title}`).join('\n'); + return { + content: `${sections.length} sections (${contentType}):\n${toc}`, + sections, + }; +}; + +function splitJson(content: string, minSize: number, maxSize: number): Section[] { + const parsed = JSON.parse(content); + if (Array.isArray(parsed)) { + return parsed.map((item, i) => ({ + id: item.id ?? item.name ?? `item-${i}`, + title: item.label ?? item.title ?? item.name ?? `Item ${i}`, + content: JSON.stringify(item, null, 2), + })); + } + return Object.entries(parsed).map(([key, value]) => ({ + id: key, + title: key, + content: JSON.stringify(value, null, 2), + })); +} + +// Similar implementations for splitYaml, splitXml, splitCode, splitProse +``` + +**Test Strategy:** + +Unit tests for each content type: JSON arrays split by element, JSON objects split by key, YAML split by top-level keys, XML split by elements, prose split by markdown headers. Test minSize/maxSize thresholds. Test fallback when content can't be parsed. diff --git a/.taskmaster/tasks/task_076.md b/.taskmaster/tasks/task_076.md new file mode 100644 index 0000000..250d9e2 --- /dev/null +++ b/.taskmaster/tasks/task_076.md @@ -0,0 +1,84 @@ +# Task ID: 76 + +**Title:** Implement summarize-tree Stage + +**Status:** done + +**Dependencies:** 71 ✓, 72 ✓, 73 ✓, 74 ✓ + +**Priority:** high + +**Description:** Create the built-in summarize-tree stage that recursively summarizes sections, using structural summaries for programmatic content and LLM summaries for prose. + +**Details:** + +Create `src/mcplocal/src/proxymodel/stages/summarize-tree.ts`: + +```typescript +import type { StageHandler, Section, StageContext } from '../types'; +import { detectContentType } from '../content-detection'; + +const handler: StageHandler = async (content, ctx) => { + const maxTokens = (ctx.config.maxSummaryTokens as number) ?? 200; + const maxGroup = (ctx.config.maxGroupSize as number) ?? 5; + const maxDepth = (ctx.config.maxDepth as number) ?? 3; + + // Parse sections from previous stage or create single section + const inputSections = parseSectionsFromContent(content); + + const tree = await buildTree(inputSections, ctx, { maxTokens, maxGroup, maxDepth, depth: 0 }); + + const toc = tree.map(s => + `[${s.id}] ${s.title} — ${s.metadata?.summary ?? ''}` + + (s.sections?.length ? `\n → ${s.sections.length} sub-sections` : '') + ).join('\n'); + + return { + content: `${tree.length} sections:\n${toc}\n\nUse section parameter to read details.`, + sections: tree, + }; +}; + +async function buildTree( + sections: Section[], + ctx: StageContext, + opts: { maxTokens: number; maxGroup: number; maxDepth: number; depth: number } +): Promise { + for (const section of sections) { + const contentType = detectContentType(section.content); + + // Structural summary for programmatic content (no LLM needed) + if (contentType !== 'prose') { + section.metadata = { summary: generateStructuralSummary(section.content, contentType) }; + } else { + // LLM summary for prose (cached) + const cacheKey = `summary:${ctx.cache.hash(section.content)}:${opts.maxTokens}`; + const summary = await ctx.cache.getOrCompute(cacheKey, () => + ctx.llm.complete( + `Summarize in ${opts.maxTokens} tokens, preserve MUST/REQUIRED items:\n\n${section.content}` + ) + ); + section.metadata = { summary }; + } + + // Recurse if large and not at max depth + if (section.content.length > 5000 && opts.depth < opts.maxDepth) { + section.sections = await buildTree( + splitContent(section.content), + ctx, + { ...opts, depth: opts.depth + 1 } + ); + } + } + return sections; +} + +function generateStructuralSummary(content: string, type: string): string { + // Generate summary from structure: key names, array lengths, types + // No LLM needed for JSON/YAML/XML/code +} +``` + +**Test Strategy:** + +Unit tests: prose content gets LLM summary (mock LLM), JSON content gets structural summary without LLM call, recursive splitting triggers at 5000 chars, maxDepth is respected, cache is used for repeated content. Integration test with real LLM provider. diff --git a/.taskmaster/tasks/task_077.md b/.taskmaster/tasks/task_077.md new file mode 100644 index 0000000..4ea7301 --- /dev/null +++ b/.taskmaster/tasks/task_077.md @@ -0,0 +1,63 @@ +# Task ID: 77 + +**Title:** Implement passthrough and paginate Stages + +**Status:** done + +**Dependencies:** 71 ✓ + +**Priority:** high + +**Description:** Create the built-in passthrough (no-op) and paginate (large response splitting) stages that form the default proxymodel. + +**Details:** + +Create `src/mcplocal/src/proxymodel/stages/passthrough.ts`: + +```typescript +import type { StageHandler } from '../types'; + +const handler: StageHandler = async (content, ctx) => { + return { content }; +}; +export default handler; +``` + +Create `src/mcplocal/src/proxymodel/stages/paginate.ts`: + +```typescript +import type { StageHandler, Section } from '../types'; + +const handler: StageHandler = async (content, ctx) => { + const pageSize = (ctx.config.pageSize as number) ?? 8000; + + if (content.length <= pageSize) { + return { content }; + } + + const pages: Section[] = []; + let offset = 0; + let pageNum = 1; + + while (offset < content.length) { + const pageContent = content.slice(offset, offset + pageSize); + pages.push({ + id: `page-${pageNum}`, + title: `Page ${pageNum}`, + content: pageContent, + }); + offset += pageSize; + pageNum++; + } + + return { + content: `Content split into ${pages.length} pages (${content.length} chars total). Use section parameter to read specific pages.`, + sections: pages, + }; +}; +export default handler; +``` + +**Test Strategy:** + +passthrough: verify content returned unchanged. paginate: verify content under threshold returns unchanged, content over threshold splits correctly, page boundaries are correct, section IDs are sequential. diff --git a/.taskmaster/tasks/task_078.md b/.taskmaster/tasks/task_078.md new file mode 100644 index 0000000..2397e6b --- /dev/null +++ b/.taskmaster/tasks/task_078.md @@ -0,0 +1,78 @@ +# Task ID: 78 + +**Title:** Create ProxyModel YAML Schema and Loader + +**Status:** done + +**Dependencies:** 71 ✓ + +**Priority:** high + +**Description:** Define the YAML schema for proxymodel definitions and implement the loader that reads from ~/.mcpctl/proxymodels/ and merges with built-ins. + +**Details:** + +Create `src/mcplocal/src/proxymodel/schema.ts`: + +```typescript +import { z } from 'zod'; + +export const ProxyModelSchema = z.object({ + kind: z.literal('ProxyModel'), + metadata: z.object({ + name: z.string(), + }), + spec: z.object({ + controller: z.string().optional().default('gate'), + controllerConfig: z.record(z.unknown()).optional(), + stages: z.array(z.object({ + type: z.string(), + config: z.record(z.unknown()).optional(), + })), + appliesTo: z.array(z.enum(['prompts', 'toolResults', 'resource'])).optional(), + cacheable: z.boolean().optional().default(true), + }), +}); + +export type ProxyModelDefinition = z.infer; +``` + +Create `src/mcplocal/src/proxymodel/loader.ts`: + +```typescript +import { readdir, readFile } from 'fs/promises'; +import { join } from 'path'; +import { parse as parseYaml } from 'yaml'; +import { ProxyModelSchema, type ProxyModelDefinition } from './schema'; +import { getBuiltInProxyModels } from './built-in-models'; + +const PROXYMODELS_DIR = join(process.env.HOME ?? '', '.mcpctl', 'proxymodels'); + +export async function loadProxyModels(): Promise> { + const models = new Map(); + + // Load built-ins first + for (const [name, model] of getBuiltInProxyModels()) { + models.set(name, model); + } + + // Load local (overrides built-ins) + try { + const files = await readdir(PROXYMODELS_DIR); + for (const file of files.filter(f => f.endsWith('.yaml') || f.endsWith('.yml'))) { + const content = await readFile(join(PROXYMODELS_DIR, file), 'utf-8'); + const parsed = parseYaml(content); + const validated = ProxyModelSchema.parse(parsed); + models.set(validated.metadata.name, validated); + } + } catch (e) { + // Directory doesn't exist or can't be read - use built-ins only + } + + return models; +} +``` + +**Test Strategy:** + +Unit tests: valid YAML parses correctly, invalid YAML throws validation error, local models override built-ins with same name, missing directory doesn't throw. Create test fixtures for various YAML configurations. diff --git a/.taskmaster/tasks/task_079.md b/.taskmaster/tasks/task_079.md new file mode 100644 index 0000000..a0367d8 --- /dev/null +++ b/.taskmaster/tasks/task_079.md @@ -0,0 +1,68 @@ +# Task ID: 79 + +**Title:** Implement Stage Registry and Dynamic Loader + +**Status:** done + +**Dependencies:** 71 ✓, 75 ✓, 76 ✓, 77 ✓ + +**Priority:** high + +**Description:** Create the stage registry that resolves stage names to handlers, loading from ~/.mcpctl/stages/ for custom stages and falling back to built-ins. + +**Details:** + +Create `src/mcplocal/src/proxymodel/stage-registry.ts`: + +```typescript +import { readdir, stat } from 'fs/promises'; +import { join } from 'path'; +import type { StageHandler } from './types'; + +const STAGES_DIR = join(process.env.HOME ?? '', '.mcpctl', 'stages'); + +const builtInStages: Map = new Map(); +const customStages: Map = new Map(); + +// Register built-ins at module load +import passthrough from './stages/passthrough'; +import paginate from './stages/paginate'; +import sectionSplit from './stages/section-split'; +import summarizeTree from './stages/summarize-tree'; + +builtInStages.set('passthrough', passthrough); +builtInStages.set('paginate', paginate); +builtInStages.set('section-split', sectionSplit); +builtInStages.set('summarize-tree', summarizeTree); + +export async function loadCustomStages(): Promise { + customStages.clear(); + try { + const files = await readdir(STAGES_DIR); + for (const file of files.filter(f => f.endsWith('.ts') || f.endsWith('.js'))) { + const name = file.replace(/\.(ts|js)$/, ''); + const module = await import(join(STAGES_DIR, file)); + customStages.set(name, module.default); + } + } catch { /* directory doesn't exist */ } +} + +export function getStage(name: string): StageHandler | null { + return customStages.get(name) ?? builtInStages.get(name) ?? null; +} + +export function listStages(): { name: string; source: 'built-in' | 'local' }[] { + const result: { name: string; source: 'built-in' | 'local' }[] = []; + for (const name of builtInStages.keys()) { + result.push({ name, source: customStages.has(name) ? 'local' : 'built-in' }); + } + for (const name of customStages.keys()) { + if (!builtInStages.has(name)) result.push({ name, source: 'local' }); + } + return result; +} +``` + +**Test Strategy:** + +Unit tests: built-in stages are registered, getStage() returns correct handler, custom stages override built-ins, listStages() shows correct sources, missing stages return null. Integration test with actual stage files in temp directory. diff --git a/.taskmaster/tasks/task_080.md b/.taskmaster/tasks/task_080.md new file mode 100644 index 0000000..5699745 --- /dev/null +++ b/.taskmaster/tasks/task_080.md @@ -0,0 +1,90 @@ +# Task ID: 80 + +**Title:** Implement Pipeline Executor + +**Status:** done + +**Dependencies:** 71 ✓, 72 ✓, 73 ✓, 79 ✓ + +**Priority:** high + +**Description:** Create the pipeline executor that runs content through a sequence of stages, managing context, caching, and error handling. + +**Details:** + +Create `src/mcplocal/src/proxymodel/executor.ts`: + +```typescript +import type { StageContext, StageResult, Section } from './types'; +import type { ProxyModelDefinition } from './schema'; +import { getStage } from './stage-registry'; +import { createLLMAdapter } from './llm-adapter'; +import { InMemoryCacheProvider } from './cache-provider'; +import type { ProviderRegistry } from '../providers/registry'; + +export interface ExecuteOptions { + content: string; + contentType: 'prompt' | 'toolResult' | 'resource'; + sourceName: string; + projectName: string; + sessionId: string; + proxyModel: ProxyModelDefinition; + providerRegistry: ProviderRegistry; + cache?: InMemoryCacheProvider; +} + +export async function executePipeline(opts: ExecuteOptions): Promise { + const { content, proxyModel, providerRegistry } = opts; + const cache = opts.cache ?? new InMemoryCacheProvider(); + const llm = createLLMAdapter(providerRegistry, opts.projectName); + + let currentContent = content; + let sections: Section[] | undefined; + let metadata: Record = {}; + + for (const stageConfig of proxyModel.spec.stages) { + const handler = getStage(stageConfig.type); + if (!handler) { + console.warn(`Stage '${stageConfig.type}' not found, skipping`); + continue; + } + + const ctx: StageContext = { + contentType: opts.contentType, + sourceName: opts.sourceName, + projectName: opts.projectName, + sessionId: opts.sessionId, + originalContent: content, + llm, + cache, + log: createLogger(stageConfig.type), + config: stageConfig.config ?? {}, + }; + + try { + const result = await handler(currentContent, ctx); + currentContent = result.content; + if (result.sections) sections = result.sections; + if (result.metadata) metadata = { ...metadata, ...result.metadata }; + } catch (err) { + console.error(`Stage '${stageConfig.type}' failed:`, err); + // Continue with previous content on error + } + } + + return { content: currentContent, sections, metadata }; +} + +function createLogger(stageName: string) { + return { + debug: (msg: string) => console.debug(`[${stageName}] ${msg}`), + info: (msg: string) => console.info(`[${stageName}] ${msg}`), + warn: (msg: string) => console.warn(`[${stageName}] ${msg}`), + error: (msg: string) => console.error(`[${stageName}] ${msg}`), + }; +} +``` + +**Test Strategy:** + +Unit tests: single stage executes correctly, multiple stages chain output to input, originalContent preserved across stages, missing stage logs warning and continues, stage error doesn't break pipeline, sections/metadata accumulate correctly. diff --git a/.taskmaster/tasks/task_081.md b/.taskmaster/tasks/task_081.md new file mode 100644 index 0000000..c90debd --- /dev/null +++ b/.taskmaster/tasks/task_081.md @@ -0,0 +1,59 @@ +# Task ID: 81 + +**Title:** Define Built-in ProxyModels (default, subindex) + +**Status:** done + +**Dependencies:** 78 ✓ + +**Priority:** high + +**Description:** Create the built-in proxymodel definitions for 'default' (current behavior) and 'subindex' (hierarchical navigation). + +**Details:** + +Create `src/mcplocal/src/proxymodel/built-in-models.ts`: + +```typescript +import type { ProxyModelDefinition } from './schema'; + +export function getBuiltInProxyModels(): Map { + const models = new Map(); + + models.set('default', { + kind: 'ProxyModel', + metadata: { name: 'default' }, + spec: { + controller: 'gate', + controllerConfig: { byteBudget: 8192 }, + stages: [ + { type: 'passthrough' }, + { type: 'paginate', config: { pageSize: 8000 } }, + ], + appliesTo: ['prompts', 'toolResults'], + cacheable: false, + }, + }); + + models.set('subindex', { + kind: 'ProxyModel', + metadata: { name: 'subindex' }, + spec: { + controller: 'gate', + controllerConfig: { byteBudget: 8192 }, + stages: [ + { type: 'section-split', config: { minSectionSize: 2000, maxSectionSize: 15000 } }, + { type: 'summarize-tree', config: { maxSummaryTokens: 200, maxGroupSize: 5, maxDepth: 3 } }, + ], + appliesTo: ['prompts', 'toolResults'], + cacheable: true, + }, + }); + + return models; +} +``` + +**Test Strategy:** + +Unit tests: both models are returned by getBuiltInProxyModels(), 'default' has passthrough+paginate stages, 'subindex' has section-split+summarize-tree stages, both schemas validate correctly. diff --git a/.taskmaster/tasks/task_082.md b/.taskmaster/tasks/task_082.md new file mode 100644 index 0000000..4dd3939 --- /dev/null +++ b/.taskmaster/tasks/task_082.md @@ -0,0 +1,66 @@ +# Task ID: 82 + +**Title:** Integrate Pipeline Executor into Router + +**Status:** done + +**Dependencies:** 80 ✓, 81 ✓ + +**Priority:** high + +**Description:** Modify the McpRouter to route content through the proxymodel pipeline, keeping the gating logic cleanly separated from content processing. + +**Details:** + +Modify `src/mcplocal/src/router.ts` to: + +1. Add proxyModel resolution during router creation: +```typescript +import { loadProxyModels } from './proxymodel/loader'; +import { executePipeline } from './proxymodel/executor'; + +interface RouterOptions { + proxyModelName?: string; + // ... existing options +} + +async function createRouter(opts: RouterOptions): Promise { + const proxyModels = await loadProxyModels(); + const proxyModel = proxyModels.get(opts.proxyModelName ?? 'default'); + // ... +} +``` + +2. Add content processing method: +```typescript +async processContent( + content: string, + type: 'prompt' | 'toolResult', + sourceName: string, + sessionId: string +): Promise { + if (!this.proxyModel) return { content }; + + const appliesTo = this.proxyModel.spec.appliesTo ?? ['prompts', 'toolResults']; + if (!appliesTo.includes(type === 'prompt' ? 'prompts' : 'toolResults')) { + return { content }; + } + + return executePipeline({ + content, + contentType: type, + sourceName, + projectName: this.projectName, + sessionId, + proxyModel: this.proxyModel, + providerRegistry: this.providerRegistry, + cache: this.cache, + }); +} +``` + +3. Call processContent at the appropriate points in the request flow (prompt serving, tool result handling) WITHOUT interweaving with gating logic. + +**Test Strategy:** + +Integration tests: default proxymodel passes content through unchanged, subindex proxymodel produces summaries, appliesTo filtering works correctly, gating still works as before with proxymodel processing happening at the right stage. diff --git a/.taskmaster/tasks/task_083.md b/.taskmaster/tasks/task_083.md new file mode 100644 index 0000000..748652a --- /dev/null +++ b/.taskmaster/tasks/task_083.md @@ -0,0 +1,49 @@ +# Task ID: 83 + +**Title:** Implement Section Drill-Down for Prompts + +**Status:** cancelled + +**Dependencies:** 82 ✓ + +**Priority:** high + +**Description:** Extend read_prompts to support section parameter for drilling into specific sections produced by proxymodel stages. + +**Details:** + +Modify the read_prompts handler in `src/mcplocal/src/router.ts`: + +```typescript +// In the read_prompts tool handler +if (args.section) { + // Look up section in the processed result + const sectionId = args.section; + const cachedResult = this.sectionCache.get(promptName); + if (cachedResult?.sections) { + const section = findSection(cachedResult.sections, sectionId); + if (section) { + return { content: [{ type: 'text', text: section.content }] }; + } + return { content: [{ type: 'text', text: `Section '${sectionId}' not found` }], isError: true }; + } +} + +// Helper to find section by ID (supports nested sections) +function findSection(sections: Section[], id: string): Section | null { + for (const s of sections) { + if (s.id === id) return s; + if (s.sections) { + const nested = findSection(s.sections, id); + if (nested) return nested; + } + } + return null; +} +``` + +Also add a sectionCache Map to store processed results with their sections for drill-down. + +**Test Strategy:** + +Integration tests: read_prompts with section parameter returns correct section content, nested section lookup works, missing section returns error, section cache populated after initial processing. diff --git a/.taskmaster/tasks/task_084.md b/.taskmaster/tasks/task_084.md new file mode 100644 index 0000000..e3993aa --- /dev/null +++ b/.taskmaster/tasks/task_084.md @@ -0,0 +1,47 @@ +# Task ID: 84 + +**Title:** Implement Section Drill-Down for Tool Results + +**Status:** done + +**Dependencies:** 82 ✓ + +**Priority:** medium + +**Description:** Extend tool result handling to support _section parameter for drilling into specific sections of large tool responses. + +**Details:** + +Modify tool call handling in `src/mcplocal/src/router.ts`: + +```typescript +// When processing tool calls +if (args._section) { + const sectionId = args._section; + delete args._section; // Don't pass to upstream + + // Check cache for previous full result + const cacheKey = `tool:${serverName}/${toolName}:${JSON.stringify(args)}`; + const cachedResult = this.toolResultCache.get(cacheKey); + + if (cachedResult?.sections) { + const section = findSection(cachedResult.sections, sectionId); + if (section) { + return { content: [{ type: 'text', text: section.content }] }; + } + } + // If no cache, make the full call and process, then serve section +} + +// After receiving tool result, process through pipeline +const processed = await this.processContent(result, 'toolResult', `${serverName}/${toolName}`, sessionId); +if (processed.sections) { + this.toolResultCache.set(cacheKey, processed); +} +``` + +Add a toolResultCache Map with appropriate TTL. + +**Test Strategy:** + +Integration tests: large tool result gets processed into sections, _section parameter returns specific section, _section removed before upstream call, cache hit serves from cache, cache miss processes and caches. diff --git a/.taskmaster/tasks/task_085.md b/.taskmaster/tasks/task_085.md new file mode 100644 index 0000000..b93f40f --- /dev/null +++ b/.taskmaster/tasks/task_085.md @@ -0,0 +1,65 @@ +# Task ID: 85 + +**Title:** Implement Hot-Reload for Stages + +**Status:** done + +**Dependencies:** 79 ✓ + +**Priority:** medium + +**Description:** Add file watching for ~/.mcpctl/stages/ to automatically reload custom stages when they change without restarting mcplocal. + +**Details:** + +Modify `src/mcplocal/src/proxymodel/stage-registry.ts`: + +```typescript +import { watch, FSWatcher } from 'fs'; +import { join, basename } from 'path'; + +let watcher: FSWatcher | null = null; +const stageFileHashes: Map = new Map(); + +export function startStageWatcher(): void { + if (watcher) return; + + try { + watcher = watch(STAGES_DIR, async (eventType, filename) => { + if (!filename || (!filename.endsWith('.ts') && !filename.endsWith('.js'))) return; + + const name = filename.replace(/\.(ts|js)$/, ''); + const fullPath = join(STAGES_DIR, filename); + + if (eventType === 'rename') { + // File added or removed + await loadCustomStages(); + console.info(`[proxymodel] Stages reloaded due to ${filename} change`); + } else if (eventType === 'change') { + // File modified - invalidate module cache and reload + delete require.cache[require.resolve(fullPath)]; + try { + const module = await import(fullPath + '?t=' + Date.now()); + customStages.set(name, module.default); + console.info(`[proxymodel] Stage '${name}' hot-reloaded`); + } catch (err) { + console.error(`[proxymodel] Failed to reload stage '${name}':`, err); + } + } + }); + } catch { + // Directory doesn't exist - no watching needed + } +} + +export function stopStageWatcher(): void { + watcher?.close(); + watcher = null; +} +``` + +Call startStageWatcher() during mcplocal initialization. + +**Test Strategy:** + +Integration tests: modify a stage file and verify the new version is loaded without restart, add a new stage file and verify it becomes available, remove a stage file and verify it's no longer available, syntax errors in stage file don't crash the watcher. diff --git a/.taskmaster/tasks/task_086.md b/.taskmaster/tasks/task_086.md new file mode 100644 index 0000000..19c8191 --- /dev/null +++ b/.taskmaster/tasks/task_086.md @@ -0,0 +1,57 @@ +# Task ID: 86 + +**Title:** Implement Hot-Reload for ProxyModels + +**Status:** done + +**Dependencies:** 78 ✓ + +**Priority:** medium + +**Description:** Add file watching for ~/.mcpctl/proxymodels/ to automatically reload proxymodel definitions when they change. + +**Details:** + +Create `src/mcplocal/src/proxymodel/model-watcher.ts`: + +```typescript +import { watch, FSWatcher } from 'fs'; +import { join } from 'path'; +import { readFile } from 'fs/promises'; +import { parse as parseYaml } from 'yaml'; +import { ProxyModelSchema } from './schema'; + +const PROXYMODELS_DIR = join(process.env.HOME ?? '', '.mcpctl', 'proxymodels'); +let watcher: FSWatcher | null = null; +const modelUpdateCallbacks: Set<() => void> = new Set(); + +export function onModelUpdate(callback: () => void): () => void { + modelUpdateCallbacks.add(callback); + return () => modelUpdateCallbacks.delete(callback); +} + +export function startModelWatcher(): void { + if (watcher) return; + + try { + watcher = watch(PROXYMODELS_DIR, async (eventType, filename) => { + if (!filename || (!filename.endsWith('.yaml') && !filename.endsWith('.yml'))) return; + + console.info(`[proxymodel] Model file ${filename} changed, reloading...`); + + // Notify all subscribers to reload their models + for (const cb of modelUpdateCallbacks) { + try { cb(); } catch (err) { console.error('Model update callback failed:', err); } + } + }); + } catch { + // Directory doesn't exist + } +} +``` + +Integrate with router to reload proxymodels when files change. + +**Test Strategy:** + +Integration tests: modify a proxymodel YAML and verify changes take effect, add a new proxymodel and verify it becomes available, invalid YAML logs error but doesn't crash. diff --git a/.taskmaster/tasks/task_087.md b/.taskmaster/tasks/task_087.md new file mode 100644 index 0000000..0d7414b --- /dev/null +++ b/.taskmaster/tasks/task_087.md @@ -0,0 +1,48 @@ +# Task ID: 87 + +**Title:** Add proxyModel Field to Project Schema + +**Status:** cancelled + +**Dependencies:** None + +**Priority:** high + +**Description:** Extend the Project database schema and API to support proxyModel field and proxyModelOverrides for per-content-type configuration. + +**Details:** + +Update `src/db/prisma/schema.prisma`: + +```prisma +model Project { + // ... existing fields + proxyModel String? @default("default") + proxyModelOverrides Json? // { prompts: { "prompt-name": "model" }, toolResults: { "server/tool": "model" } } +} +``` + +Run `npx prisma migrate dev --name add_proxymodel_field`. + +Update `src/mcpd/src/routes/projects.ts` to include the new fields in CRUD operations. + +Update `src/cli/src/commands/get.ts` and `describe.ts` to display proxyModel. + +Update `src/cli/src/commands/patch.ts` to support `--set proxyModel=`. + +**Test Strategy:** + +Database migration test: verify migration applies cleanly. API tests: verify proxyModel field is returned in project GET, can be updated via PATCH. CLI tests: verify `mcpctl describe project ` shows proxyModel. + +## Subtasks + +### 87.1. Minimal placeholder subtask + +**Status:** pending +**Dependencies:** None + +This task requires complete rewrite before expansion. + +**Details:** + +Task 87 has been marked as DO NOT EXPAND and needs to be completely rewritten first. No subtasks should be generated until the task is properly redefined. diff --git a/.taskmaster/tasks/task_088.md b/.taskmaster/tasks/task_088.md new file mode 100644 index 0000000..68c68c1 --- /dev/null +++ b/.taskmaster/tasks/task_088.md @@ -0,0 +1,38 @@ +# Task ID: 88 + +**Title:** Rename proxyMode: filtered to proxyMode: proxy + +**Status:** cancelled + +**Dependencies:** 87 ✗ + +**Priority:** low + +**Description:** Rename the existing proxyMode value 'filtered' to 'proxy' for clarity, with backwards compatibility for existing configs. + +**Details:** + +Update `src/db/prisma/schema.prisma`: + +```prisma +enum ProxyMode { + direct + proxy // renamed from 'filtered' +} +``` + +Create migration that updates existing 'filtered' values to 'proxy': +```sql +UPDATE Project SET proxyMode = 'proxy' WHERE proxyMode = 'filtered'; +``` + +Update all code references from 'filtered' to 'proxy': +- `src/mcplocal/src/http/project-mcp-endpoint.ts` +- `src/cli/src/commands/create.ts` +- Documentation and help text + +For backwards compatibility in config files, add a normalization step that treats 'filtered' as 'proxy'. + +**Test Strategy:** + +Migration test: existing projects with proxyMode='filtered' are updated to 'proxy'. Config parsing test: both 'filtered' and 'proxy' values work. CLI test: help text shows 'proxy' not 'filtered'. diff --git a/.taskmaster/tasks/task_089.md b/.taskmaster/tasks/task_089.md new file mode 100644 index 0000000..d0e97d9 --- /dev/null +++ b/.taskmaster/tasks/task_089.md @@ -0,0 +1,53 @@ +# Task ID: 89 + +**Title:** Implement mcpctl get proxymodels Command + +**Status:** cancelled + +**Dependencies:** 78 ✓, 79 ✓ + +**Priority:** medium + +**Description:** Add CLI command to list all available proxymodels (built-in + local) with source, stages, and requirements. + +**Details:** + +Create `src/cli/src/commands/get-proxymodels.ts`: + +```typescript +import { Command } from 'commander'; +import { loadProxyModels } from 'mcplocal/proxymodel/loader'; +import { listStages } from 'mcplocal/proxymodel/stage-registry'; +import Table from 'cli-table3'; + +export function registerGetProxymodels(program: Command): void { + program + .command('get proxymodels') + .description('List all available proxymodels') + .action(async () => { + const models = await loadProxyModels(); + const stageInfo = new Map(listStages().map(s => [s.name, s])); + + const table = new Table({ + head: ['NAME', 'SOURCE', 'STAGES', 'REQUIRES-LLM', 'CACHEABLE'], + }); + + for (const [name, model] of models) { + const source = isBuiltIn(name) ? 'built-in' : 'local'; + const stages = model.spec.stages.map(s => s.type).join(','); + const requiresLlm = model.spec.stages.some(s => stageRequiresLlm(s.type)); + const cacheable = model.spec.cacheable ? 'yes' : 'no'; + + table.push([name, source, stages, requiresLlm ? 'yes' : 'no', cacheable]); + } + + console.log(table.toString()); + }); +} +``` + +Register in `src/cli/src/commands/get.ts` as a subcommand. + +**Test Strategy:** + +CLI test: `mcpctl get proxymodels` outputs table with expected columns. Test with only built-ins, test with local overrides, verify correct source detection. diff --git a/.taskmaster/tasks/task_090.md b/.taskmaster/tasks/task_090.md new file mode 100644 index 0000000..f102374 --- /dev/null +++ b/.taskmaster/tasks/task_090.md @@ -0,0 +1,47 @@ +# Task ID: 90 + +**Title:** Implement mcpctl get stages Command + +**Status:** cancelled + +**Dependencies:** 79 ✓ + +**Priority:** medium + +**Description:** Add CLI command to list all available stages (built-in + custom) with source and LLM requirements. + +**Details:** + +Create `src/cli/src/commands/get-stages.ts`: + +```typescript +import { Command } from 'commander'; +import { listStages } from 'mcplocal/proxymodel/stage-registry'; +import Table from 'cli-table3'; + +const LLM_REQUIRING_STAGES = ['summarize', 'summarize-tree', 'enhance', 'compress']; + +export function registerGetStages(program: Command): void { + program + .command('get stages') + .description('List all available stages') + .action(async () => { + const stages = listStages(); + + const table = new Table({ + head: ['NAME', 'SOURCE', 'REQUIRES-LLM'], + }); + + for (const stage of stages) { + const requiresLlm = LLM_REQUIRING_STAGES.includes(stage.name); + table.push([stage.name, stage.source, requiresLlm ? 'yes' : 'no']); + } + + console.log(table.toString()); + }); +} +``` + +**Test Strategy:** + +CLI test: `mcpctl get stages` outputs table with expected columns. Test with only built-ins, test with custom stages in ~/.mcpctl/stages/, verify custom overrides show 'local' source. diff --git a/.taskmaster/tasks/task_091.md b/.taskmaster/tasks/task_091.md new file mode 100644 index 0000000..4bb5d6c --- /dev/null +++ b/.taskmaster/tasks/task_091.md @@ -0,0 +1,57 @@ +# Task ID: 91 + +**Title:** Implement mcpctl describe proxymodel Command + +**Status:** cancelled + +**Dependencies:** 78 ✓ + +**Priority:** medium + +**Description:** Add CLI command to show detailed information about a specific proxymodel including full stage configuration. + +**Details:** + +Create `src/cli/src/commands/describe-proxymodel.ts`: + +```typescript +import { Command } from 'commander'; +import { loadProxyModels } from 'mcplocal/proxymodel/loader'; +import { stringify as yamlStringify } from 'yaml'; + +export function registerDescribeProxymodel(program: Command): void { + program + .command('describe proxymodel ') + .description('Show detailed information about a proxymodel') + .action(async (name: string) => { + const models = await loadProxyModels(); + const model = models.get(name); + + if (!model) { + console.error(`Proxymodel '${name}' not found`); + process.exit(1); + } + + console.log(`Name: ${model.metadata.name}`); + console.log(`Source: ${isBuiltIn(name) ? 'built-in' : 'local'}`); + console.log(`Controller: ${model.spec.controller ?? 'gate'}`); + console.log(`Cacheable: ${model.spec.cacheable ? 'yes' : 'no'}`); + console.log(`Applies to: ${(model.spec.appliesTo ?? ['prompts', 'toolResults']).join(', ')}`); + console.log(''); + console.log('Stages:'); + for (const stage of model.spec.stages) { + console.log(` - ${stage.type}`); + if (stage.config) { + console.log(` config:`); + for (const [k, v] of Object.entries(stage.config)) { + console.log(` ${k}: ${JSON.stringify(v)}`); + } + } + } + }); +} +``` + +**Test Strategy:** + +CLI test: `mcpctl describe proxymodel default` shows expected output. Test with proxymodel that has stage configs, verify all fields displayed correctly. diff --git a/.taskmaster/tasks/task_092.md b/.taskmaster/tasks/task_092.md new file mode 100644 index 0000000..35a486f --- /dev/null +++ b/.taskmaster/tasks/task_092.md @@ -0,0 +1,57 @@ +# Task ID: 92 + +**Title:** Implement mcpctl describe stage Command + +**Status:** cancelled + +**Dependencies:** 79 ✓ + +**Priority:** medium + +**Description:** Add CLI command to show detailed information about a specific stage including its source location. + +**Details:** + +Create `src/cli/src/commands/describe-stage.ts`: + +```typescript +import { Command } from 'commander'; +import { listStages, getStage } from 'mcplocal/proxymodel/stage-registry'; +import { join } from 'path'; + +const STAGES_DIR = join(process.env.HOME ?? '', '.mcpctl', 'stages'); + +const STAGE_DESCRIPTIONS: Record = { + 'passthrough': 'Returns content unchanged. No processing.', + 'paginate': 'Splits large content into pages with navigation.', + 'section-split': 'Splits content on structural boundaries (headers, JSON keys, etc.).', + 'summarize-tree': 'Recursively summarizes sections with hierarchical navigation.', +}; + +export function registerDescribeStage(program: Command): void { + program + .command('describe stage ') + .description('Show detailed information about a stage') + .action(async (name: string) => { + const stages = listStages(); + const stageInfo = stages.find(s => s.name === name); + + if (!stageInfo) { + console.error(`Stage '${name}' not found`); + process.exit(1); + } + + console.log(`Name: ${name}`); + console.log(`Source: ${stageInfo.source}`); + if (stageInfo.source === 'local') { + console.log(`Path: ${join(STAGES_DIR, name + '.ts')}`); + } + console.log(`Description: ${STAGE_DESCRIPTIONS[name] ?? 'Custom stage'}`); + console.log(`Requires LLM: ${requiresLlm(name) ? 'yes' : 'no'}`); + }); +} +``` + +**Test Strategy:** + +CLI test: `mcpctl describe stage passthrough` shows expected output. Test with custom stage, verify path is shown correctly. diff --git a/.taskmaster/tasks/task_093.md b/.taskmaster/tasks/task_093.md new file mode 100644 index 0000000..d64c94d --- /dev/null +++ b/.taskmaster/tasks/task_093.md @@ -0,0 +1,73 @@ +# Task ID: 93 + +**Title:** Implement mcpctl create stage Command + +**Status:** cancelled + +**Dependencies:** 71 ✓ + +**Priority:** medium + +**Description:** Add CLI command to scaffold a new custom stage with boilerplate TypeScript code. + +**Details:** + +Create `src/cli/src/commands/create-stage.ts`: + +```typescript +import { Command } from 'commander'; +import { mkdir, writeFile, access } from 'fs/promises'; +import { join } from 'path'; + +const STAGES_DIR = join(process.env.HOME ?? '', '.mcpctl', 'stages'); + +const STAGE_TEMPLATE = `import type { StageHandler } from 'mcpctl/proxymodel'; + +/** + * Custom stage: {{name}} + * + * Modify this handler to transform content as needed. + * Available in ctx: + * - ctx.llm.complete(prompt) - call the configured LLM + * - ctx.cache.getOrCompute(key, fn) - cache expensive computations + * - ctx.config - stage configuration from proxymodel YAML + * - ctx.originalContent - raw content before any stage processing + * - ctx.log - structured logging + */ +const handler: StageHandler = async (content, ctx) => { + // TODO: Implement your transformation + return { content }; +}; + +export default handler; +`; + +export function registerCreateStage(program: Command): void { + program + .command('create stage ') + .description('Create a new custom stage') + .action(async (name: string) => { + await mkdir(STAGES_DIR, { recursive: true }); + + const filePath = join(STAGES_DIR, `${name}.ts`); + + try { + await access(filePath); + console.error(`Stage '${name}' already exists at ${filePath}`); + process.exit(1); + } catch { + // File doesn't exist, good + } + + const code = STAGE_TEMPLATE.replace(/\{\{name\}\}/g, name); + await writeFile(filePath, code); + + console.log(`Created ${filePath}`); + console.log('Edit the file to implement your stage logic.'); + }); +} +``` + +**Test Strategy:** + +CLI test: `mcpctl create stage my-filter` creates file at expected path with correct template. Test error when stage already exists. Verify generated code compiles. diff --git a/.taskmaster/tasks/task_094.md b/.taskmaster/tasks/task_094.md new file mode 100644 index 0000000..717e9bd --- /dev/null +++ b/.taskmaster/tasks/task_094.md @@ -0,0 +1,66 @@ +# Task ID: 94 + +**Title:** Implement mcpctl create proxymodel Command + +**Status:** cancelled + +**Dependencies:** 78 ✓ + +**Priority:** medium + +**Description:** Add CLI command to scaffold a new proxymodel YAML file with specified stages. + +**Details:** + +Create `src/cli/src/commands/create-proxymodel.ts`: + +```typescript +import { Command } from 'commander'; +import { mkdir, writeFile, access } from 'fs/promises'; +import { join } from 'path'; +import { stringify as yamlStringify } from 'yaml'; + +const PROXYMODELS_DIR = join(process.env.HOME ?? '', '.mcpctl', 'proxymodels'); + +export function registerCreateProxymodel(program: Command): void { + program + .command('create proxymodel ') + .description('Create a new proxymodel') + .option('--stages ', 'Comma-separated list of stage names', 'passthrough') + .option('--controller ', 'Session controller (gate or none)', 'gate') + .action(async (name: string, opts) => { + await mkdir(PROXYMODELS_DIR, { recursive: true }); + + const filePath = join(PROXYMODELS_DIR, `${name}.yaml`); + + try { + await access(filePath); + console.error(`Proxymodel '${name}' already exists at ${filePath}`); + process.exit(1); + } catch { + // File doesn't exist, good + } + + const stages = opts.stages.split(',').map((s: string) => ({ type: s.trim() })); + + const model = { + kind: 'ProxyModel', + metadata: { name }, + spec: { + controller: opts.controller, + stages, + appliesTo: ['prompts', 'toolResults'], + cacheable: true, + }, + }; + + await writeFile(filePath, yamlStringify(model)); + + console.log(`Created ${filePath}`); + }); +} +``` + +**Test Strategy:** + +CLI test: `mcpctl create proxymodel my-pipeline --stages summarize,compress` creates valid YAML. Test default values. Verify generated YAML validates against schema. diff --git a/.taskmaster/tasks/task_095.md b/.taskmaster/tasks/task_095.md new file mode 100644 index 0000000..1a51125 --- /dev/null +++ b/.taskmaster/tasks/task_095.md @@ -0,0 +1,70 @@ +# Task ID: 95 + +**Title:** Implement mcpctl proxymodel validate Command + +**Status:** cancelled + +**Dependencies:** 78 ✓, 79 ✓ + +**Priority:** medium + +**Description:** Add CLI command to validate a proxymodel definition, checking that all stages resolve and config is valid. + +**Details:** + +Create `src/cli/src/commands/proxymodel-validate.ts`: + +```typescript +import { Command } from 'commander'; +import { loadProxyModels } from 'mcplocal/proxymodel/loader'; +import { getStage, loadCustomStages } from 'mcplocal/proxymodel/stage-registry'; + +export function registerProxymodelValidate(program: Command): void { + program + .command('proxymodel validate ') + .description('Validate a proxymodel definition') + .action(async (name: string) => { + await loadCustomStages(); + const models = await loadProxyModels(); + const model = models.get(name); + + if (!model) { + console.error(`Proxymodel '${name}' not found`); + process.exit(1); + } + + let valid = true; + const errors: string[] = []; + + // Check all stages resolve + for (const stageConfig of model.spec.stages) { + const stage = getStage(stageConfig.type); + if (!stage) { + errors.push(`Stage '${stageConfig.type}' not found`); + valid = false; + } + } + + // Check controller is valid + const validControllers = ['gate', 'none']; + if (model.spec.controller && !validControllers.includes(model.spec.controller)) { + errors.push(`Unknown controller '${model.spec.controller}'`); + valid = false; + } + + if (valid) { + console.log(`✓ Proxymodel '${name}' is valid`); + } else { + console.error(`✗ Proxymodel '${name}' has errors:`); + for (const err of errors) { + console.error(` - ${err}`); + } + process.exit(1); + } + }); +} +``` + +**Test Strategy:** + +CLI test: valid proxymodel passes, proxymodel with unknown stage fails with clear error, proxymodel with unknown controller fails. Test with both built-in and custom stages. diff --git a/.taskmaster/tasks/task_096.md b/.taskmaster/tasks/task_096.md new file mode 100644 index 0000000..61c8d01 --- /dev/null +++ b/.taskmaster/tasks/task_096.md @@ -0,0 +1,53 @@ +# Task ID: 96 + +**Title:** Implement mcpctl delete stage Command + +**Status:** cancelled + +**Dependencies:** 79 ✓ + +**Priority:** low + +**Description:** Add CLI command to delete a custom stage file (cannot delete built-ins). + +**Details:** + +Create `src/cli/src/commands/delete-stage.ts`: + +```typescript +import { Command } from 'commander'; +import { unlink, access } from 'fs/promises'; +import { join } from 'path'; +import { listStages } from 'mcplocal/proxymodel/stage-registry'; + +const STAGES_DIR = join(process.env.HOME ?? '', '.mcpctl', 'stages'); + +export function registerDeleteStage(program: Command): void { + program + .command('delete stage ') + .description('Delete a custom stage') + .action(async (name: string) => { + const stages = listStages(); + const stageInfo = stages.find(s => s.name === name); + + if (!stageInfo) { + console.error(`Stage '${name}' not found`); + process.exit(1); + } + + if (stageInfo.source === 'built-in') { + console.error(`Cannot delete built-in stage '${name}'`); + process.exit(1); + } + + const filePath = join(STAGES_DIR, `${name}.ts`); + await unlink(filePath); + + console.log(`Deleted ${filePath}`); + }); +} +``` + +**Test Strategy:** + +CLI test: can delete custom stage, cannot delete built-in stage (error message), deleting non-existent stage shows error. diff --git a/.taskmaster/tasks/task_097.md b/.taskmaster/tasks/task_097.md new file mode 100644 index 0000000..b66a0b6 --- /dev/null +++ b/.taskmaster/tasks/task_097.md @@ -0,0 +1,64 @@ +# Task ID: 97 + +**Title:** Implement mcpctl delete proxymodel Command + +**Status:** cancelled + +**Dependencies:** 78 ✓ + +**Priority:** low + +**Description:** Add CLI command to delete a local proxymodel YAML file (cannot delete built-ins). + +**Details:** + +Create `src/cli/src/commands/delete-proxymodel.ts`: + +```typescript +import { Command } from 'commander'; +import { unlink, access } from 'fs/promises'; +import { join } from 'path'; +import { loadProxyModels } from 'mcplocal/proxymodel/loader'; +import { getBuiltInProxyModels } from 'mcplocal/proxymodel/built-in-models'; + +const PROXYMODELS_DIR = join(process.env.HOME ?? '', '.mcpctl', 'proxymodels'); + +export function registerDeleteProxymodel(program: Command): void { + program + .command('delete proxymodel ') + .description('Delete a local proxymodel') + .action(async (name: string) => { + const models = await loadProxyModels(); + const builtIns = getBuiltInProxyModels(); + + if (!models.has(name)) { + console.error(`Proxymodel '${name}' not found`); + process.exit(1); + } + + const filePath = join(PROXYMODELS_DIR, `${name}.yaml`); + + try { + await access(filePath); + } catch { + if (builtIns.has(name)) { + console.error(`Cannot delete built-in proxymodel '${name}'`); + } else { + console.error(`Proxymodel '${name}' file not found at ${filePath}`); + } + process.exit(1); + } + + await unlink(filePath); + console.log(`Deleted ${filePath}`); + + if (builtIns.has(name)) { + console.log(`Note: Built-in '${name}' will still be available`); + } + }); +} +``` + +**Test Strategy:** + +CLI test: can delete local proxymodel, cannot delete built-in (error message), deleting local override shows note about built-in fallback. diff --git a/.taskmaster/tasks/task_098.md b/.taskmaster/tasks/task_098.md new file mode 100644 index 0000000..4447f4e --- /dev/null +++ b/.taskmaster/tasks/task_098.md @@ -0,0 +1,79 @@ +# Task ID: 98 + +**Title:** Implement Persistent File Cache for Stages + +**Status:** done + +**Dependencies:** 73 ✓ + +**Priority:** high + +**Description:** Extend CacheProvider with file-based persistence in ~/.mcpctl/cache/proxymodel/ for cross-session caching. + +**Details:** + +Create `src/mcplocal/src/proxymodel/file-cache.ts`: + +```typescript +import { mkdir, readFile, writeFile, readdir, stat, unlink } from 'fs/promises'; +import { join } from 'path'; +import { createHash } from 'crypto'; +import type { CacheProvider } from './types'; + +const CACHE_DIR = join(process.env.HOME ?? '', '.mcpctl', 'cache', 'proxymodel'); + +export class FileCacheProvider implements CacheProvider { + private memCache = new Map(); + private maxSizeBytes: number; + + constructor(options: { maxSizeBytes?: number } = {}) { + this.maxSizeBytes = options.maxSizeBytes ?? 100 * 1024 * 1024; // 100MB default + } + + hash(content: string): string { + return createHash('sha256').update(content).digest('hex').slice(0, 16); + } + + private keyToPath(key: string): string { + const safeKey = key.replace(/[^a-zA-Z0-9-_]/g, '_'); + return join(CACHE_DIR, safeKey); + } + + async get(key: string): Promise { + // Check memory first + if (this.memCache.has(key)) return this.memCache.get(key)!; + + // Check file + try { + const content = await readFile(this.keyToPath(key), 'utf-8'); + this.memCache.set(key, content); // Warm memory cache + return content; + } catch { + return null; + } + } + + async set(key: string, value: string): Promise { + await mkdir(CACHE_DIR, { recursive: true }); + this.memCache.set(key, value); + await writeFile(this.keyToPath(key), value); + await this.enforceMaxSize(); + } + + async getOrCompute(key: string, compute: () => Promise): Promise { + const cached = await this.get(key); + if (cached !== null) return cached; + const value = await compute(); + await this.set(key, value); + return value; + } + + private async enforceMaxSize(): Promise { + // LRU eviction based on file mtime when cache exceeds maxSizeBytes + } +} +``` + +**Test Strategy:** + +Unit tests: file-based persistence survives process restart, memory cache is warmed on file read, LRU eviction works when size exceeded, concurrent access is safe. Integration test with real filesystem. diff --git a/.taskmaster/tasks/task_099.md b/.taskmaster/tasks/task_099.md new file mode 100644 index 0000000..ae28c7c --- /dev/null +++ b/.taskmaster/tasks/task_099.md @@ -0,0 +1,61 @@ +# Task ID: 99 + +**Title:** Add Cache Key with Stage File Hash + +**Status:** done + +**Dependencies:** 98 ✓ + +**Priority:** medium + +**Description:** Include the stage file hash in cache keys so cached artifacts are automatically invalidated when stage code changes. + +**Details:** + +Modify `src/mcplocal/src/proxymodel/executor.ts`: + +```typescript +import { readFile, stat } from 'fs/promises'; +import { createHash } from 'crypto'; +import { join } from 'path'; + +const STAGES_DIR = join(process.env.HOME ?? '', '.mcpctl', 'stages'); +const stageFileHashes: Map = new Map(); + +async function getStageFileHash(stageName: string): Promise { + // Check if custom stage file exists + const filePath = join(STAGES_DIR, `${stageName}.ts`); + try { + const content = await readFile(filePath, 'utf-8'); + const hash = createHash('sha256').update(content).digest('hex').slice(0, 8); + stageFileHashes.set(stageName, hash); + return hash; + } catch { + // Built-in stage, use version-based hash or fixed value + return 'builtin-v1'; + } +} + +// In executePipeline, compute cache key: +const stageHash = await getStageFileHash(stageConfig.type); +const cacheKey = [ + 'stage', + stageConfig.type, + stageHash, + cache.hash(currentContent), + cache.hash(JSON.stringify(stageConfig.config ?? {})), +].join(':'); + +// Use pipeline-level cache wrapping: +if (proxyModel.spec.cacheable) { + const cached = await cache.get(cacheKey); + if (cached) { + currentContent = cached; + continue; // Skip stage execution + } +} +``` + +**Test Strategy:** + +Unit tests: changing stage file content changes the hash, built-in stages have stable hash, cache miss when stage file changes, cache hit when stage file unchanged. diff --git a/.taskmaster/tasks/task_100.md b/.taskmaster/tasks/task_100.md new file mode 100644 index 0000000..9c67924 --- /dev/null +++ b/.taskmaster/tasks/task_100.md @@ -0,0 +1,77 @@ +# Task ID: 100 + +**Title:** Implement mcpctl cache list Command + +**Status:** done + +**Dependencies:** 98 ✓ + +**Priority:** low + +**Description:** Add CLI command to list cached proxymodel artifacts with size and age information. + +**Details:** + +Create `src/cli/src/commands/cache-list.ts`: + +```typescript +import { Command } from 'commander'; +import { readdir, stat } from 'fs/promises'; +import { join } from 'path'; +import Table from 'cli-table3'; + +const CACHE_DIR = join(process.env.HOME ?? '', '.mcpctl', 'cache', 'proxymodel'); + +export function registerCacheList(program: Command): void { + program + .command('cache list') + .description('List cached proxymodel artifacts') + .option('--project ', 'Filter by project') + .action(async (opts) => { + try { + const files = await readdir(CACHE_DIR); + + const table = new Table({ + head: ['KEY', 'SIZE', 'AGE'], + }); + + let totalSize = 0; + + for (const file of files) { + const filePath = join(CACHE_DIR, file); + const stats = await stat(filePath); + const age = formatAge(Date.now() - stats.mtimeMs); + const size = formatSize(stats.size); + totalSize += stats.size; + + if (opts.project && !file.includes(opts.project)) continue; + + table.push([file, size, age]); + } + + console.log(table.toString()); + console.log(`Total: ${formatSize(totalSize)}`); + } catch { + console.log('No cache entries found'); + } + }); +} + +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes}B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`; + return `${(bytes / 1024 / 1024).toFixed(1)}MB`; +} + +function formatAge(ms: number): string { + const mins = Math.floor(ms / 60000); + if (mins < 60) return `${mins}m`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h`; + return `${Math.floor(hours / 24)}d`; +} +``` + +**Test Strategy:** + +CLI test: list shows cache entries with correct format, --project filter works, empty cache shows appropriate message, size/age formatting is correct. diff --git a/.taskmaster/tasks/task_101.md b/.taskmaster/tasks/task_101.md new file mode 100644 index 0000000..44f8d02 --- /dev/null +++ b/.taskmaster/tasks/task_101.md @@ -0,0 +1,61 @@ +# Task ID: 101 + +**Title:** Implement mcpctl cache clear Command + +**Status:** done + +**Dependencies:** 98 ✓ + +**Priority:** low + +**Description:** Add CLI command to clear the proxymodel cache, optionally filtered by project. + +**Details:** + +Create `src/cli/src/commands/cache-clear.ts`: + +```typescript +import { Command } from 'commander'; +import { readdir, unlink, rmdir } from 'fs/promises'; +import { join } from 'path'; + +const CACHE_DIR = join(process.env.HOME ?? '', '.mcpctl', 'cache', 'proxymodel'); + +export function registerCacheClear(program: Command): void { + program + .command('cache clear') + .description('Clear the proxymodel cache') + .option('--project ', 'Clear only cache for a specific project') + .option('--force', 'Skip confirmation', false) + .action(async (opts) => { + try { + const files = await readdir(CACHE_DIR); + const toDelete = opts.project + ? files.filter(f => f.includes(opts.project)) + : files; + + if (toDelete.length === 0) { + console.log('No cache entries to clear'); + return; + } + + if (!opts.force) { + console.log(`This will delete ${toDelete.length} cache entries.`); + // Add confirmation prompt + } + + for (const file of toDelete) { + await unlink(join(CACHE_DIR, file)); + } + + console.log(`Cleared ${toDelete.length} cache entries`); + } catch { + console.log('Cache directory does not exist'); + } + }); +} +``` + +**Test Strategy:** + +CLI test: clears all entries without --project, clears filtered entries with --project, confirmation required without --force, --force skips confirmation. diff --git a/.taskmaster/tasks/task_102.md b/.taskmaster/tasks/task_102.md new file mode 100644 index 0000000..41405ef --- /dev/null +++ b/.taskmaster/tasks/task_102.md @@ -0,0 +1,60 @@ +# Task ID: 102 + +**Title:** Implement mcpctl cache stats Command + +**Status:** done + +**Dependencies:** 98 ✓ + +**Priority:** low + +**Description:** Add CLI command to show cache statistics including hit rates, total size, and entry counts. + +**Details:** + +Create `src/cli/src/commands/cache-stats.ts`: + +```typescript +import { Command } from 'commander'; +import { readdir, stat } from 'fs/promises'; +import { join } from 'path'; + +const CACHE_DIR = join(process.env.HOME ?? '', '.mcpctl', 'cache', 'proxymodel'); + +export function registerCacheStats(program: Command): void { + program + .command('cache stats') + .description('Show cache statistics') + .action(async () => { + try { + const files = await readdir(CACHE_DIR); + + let totalSize = 0; + let oldest = Date.now(); + let newest = 0; + + for (const file of files) { + const filePath = join(CACHE_DIR, file); + const stats = await stat(filePath); + totalSize += stats.size; + oldest = Math.min(oldest, stats.mtimeMs); + newest = Math.max(newest, stats.mtimeMs); + } + + console.log(`Entries: ${files.length}`); + console.log(`Total size: ${formatSize(totalSize)}`); + console.log(`Oldest entry: ${formatAge(Date.now() - oldest)} ago`); + console.log(`Newest entry: ${formatAge(Date.now() - newest)} ago`); + + // Note: hit rate tracking would require runtime instrumentation + console.log('\nNote: Hit rate statistics require runtime instrumentation.'); + } catch { + console.log('No cache data available'); + } + }); +} +``` + +**Test Strategy:** + +CLI test: shows correct stats for populated cache, handles empty cache gracefully, size formatting is correct. diff --git a/.taskmaster/tasks/task_103.md b/.taskmaster/tasks/task_103.md new file mode 100644 index 0000000..70c0924 --- /dev/null +++ b/.taskmaster/tasks/task_103.md @@ -0,0 +1,47 @@ +# Task ID: 103 + +**Title:** Add Shell Completions for ProxyModel Commands + +**Status:** cancelled + +**Dependencies:** 89 ✗, 90 ✗, 91 ✗, 92 ✗, 93 ✗, 94 ✗, 95 ✗, 96 ✗, 97 ✗, 100 ✓, 101 ✓, 102 ✓ + +**Priority:** low + +**Description:** Extend shell completions to include all new proxymodel-related commands, resources, and flags. + +**Details:** + +Update `src/cli/src/completions.ts` to add completions for: + +```typescript +// Resource types +const RESOURCE_TYPES = [...existing, 'proxymodels', 'stages']; + +// Command completions +const COMMANDS = { + 'get': ['proxymodels', 'stages', ...existing], + 'describe': ['proxymodel', 'stage', ...existing], + 'create': ['proxymodel', 'stage', ...existing], + 'delete': ['proxymodel', 'stage', ...existing], + 'proxymodel': ['validate'], + 'cache': ['list', 'clear', 'stats'], +}; + +// Dynamic completions for proxymodel/stage names +async function completeProxymodelName(partial: string): Promise { + const models = await loadProxyModels(); + return [...models.keys()].filter(n => n.startsWith(partial)); +} + +async function completeStageName(partial: string): Promise { + const stages = listStages(); + return stages.map(s => s.name).filter(n => n.startsWith(partial)); +} +``` + +Generate completion scripts for bash, zsh, and fish. + +**Test Strategy:** + +Manual test: completions work in bash/zsh/fish for all new commands. Test proxymodel name completion, stage name completion, subcommand completion. diff --git a/.taskmaster/tasks/task_104.md b/.taskmaster/tasks/task_104.md new file mode 100644 index 0000000..30d4267 --- /dev/null +++ b/.taskmaster/tasks/task_104.md @@ -0,0 +1,67 @@ +# Task ID: 104 + +**Title:** Extend Traffic Events for ProxyModel Processing + +**Status:** cancelled + +**Dependencies:** 80 ✓ + +**Priority:** medium + +**Description:** Add new traffic event types for proxymodel processing: content_original, content_transformed, stage timing, cache hits/misses. + +**Details:** + +Modify `src/mcplocal/src/http/traffic.ts`: + +```typescript +export type TrafficEventType = + | 'client_request' + | 'client_response' + | 'upstream_request' + | 'upstream_response' + | 'client_notification' + // New proxymodel events: + | 'content_original' + | 'content_transformed' + | 'stage_executed' + | 'stage_cache_hit' + | 'stage_cache_miss'; + +export interface ContentOriginalEvent { + eventType: 'content_original'; + sessionId: string; + contentType: 'prompt' | 'toolResult'; + sourceName: string; + content: string; + charCount: number; +} + +export interface ContentTransformedEvent { + eventType: 'content_transformed'; + sessionId: string; + contentType: 'prompt' | 'toolResult'; + sourceName: string; + content: string; + charCount: number; + proxyModel: string; + stages: string[]; + durationMs: number; +} + +export interface StageExecutedEvent { + eventType: 'stage_executed'; + sessionId: string; + stageName: string; + inputChars: number; + outputChars: number; + durationMs: number; + cacheHit: boolean; +} +``` + +Emit these events from the pipeline executor. + +**Test Strategy:** + +Unit tests: events emitted at correct points in pipeline execution, event payloads contain correct data, cache hit/miss events distinguish correctly. Integration test with inspector showing new events. diff --git a/.taskmaster/tasks/task_105.md b/.taskmaster/tasks/task_105.md new file mode 100644 index 0000000..7ccb0a8 --- /dev/null +++ b/.taskmaster/tasks/task_105.md @@ -0,0 +1,83 @@ +# Task ID: 105 + +**Title:** Implement Model Studio TUI Base + +**Status:** done + +**Dependencies:** 104 ✗ + +**Priority:** medium + +**Description:** Create the base TUI for mcpctl console --model-studio that extends --inspect with original vs transformed view. + +**Details:** + +Create `src/cli/src/commands/console/model-studio.tsx`: + +```typescript +import React, { useState, useEffect } from 'react'; +import { Box, Text, useInput } from 'ink'; +import { TrafficEvent } from './types'; + +interface ModelStudioProps { + projectName: string; + events: TrafficEvent[]; +} + +export function ModelStudio({ projectName, events }: ModelStudioProps) { + const [selectedIdx, setSelectedIdx] = useState(0); + const [viewMode, setViewMode] = useState<'original' | 'transformed' | 'diff'>('transformed'); + const [pauseMode, setPauseMode] = useState(false); + + useInput((input, key) => { + if (input === 'j') setSelectedIdx(i => Math.min(i + 1, events.length - 1)); + if (input === 'k') setSelectedIdx(i => Math.max(i - 1, 0)); + if (input === 'o') setViewMode(m => m === 'original' ? 'transformed' : m === 'transformed' ? 'diff' : 'original'); + if (input === 'p') setPauseMode(p => !p); + if (input === 'G') setSelectedIdx(events.length - 1); + }); + + const selected = events[selectedIdx]; + const isContentEvent = selected?.eventType === 'content_original' || selected?.eventType === 'content_transformed'; + + return ( + + + Model Studio: {projectName} + | + View: {viewMode} + | + {pauseMode ? '⏸ PAUSED' : '▶ LIVE'} + + + + {/* Event list sidebar */} + + {events.map((e, i) => ( + + {formatEventLine(e)} + + ))} + + + {/* Content view */} + + {isContentEvent && ( + + )} + + + + + [o] toggle view [p] pause [j/k] navigate [G] latest [q] quit + + + ); +} +``` + +Add --model-studio flag to console command. + +**Test Strategy:** + +Manual test: TUI renders correctly, keyboard navigation works, original/transformed/diff views switch correctly, pause indicator shows correctly. diff --git a/.taskmaster/tasks/task_106.md b/.taskmaster/tasks/task_106.md new file mode 100644 index 0000000..d0a1f1c --- /dev/null +++ b/.taskmaster/tasks/task_106.md @@ -0,0 +1,97 @@ +# Task ID: 106 + +**Title:** Implement Pause Queue for Model Studio + +**Status:** done + +**Dependencies:** 105 ✓ + +**Priority:** medium + +**Description:** Add a pause queue in mcplocal that holds outgoing responses when model studio pause mode is active. + +**Details:** + +Create `src/mcplocal/src/proxymodel/pause-queue.ts`: + +```typescript +interface PausedResponse { + id: string; + sessionId: string; + contentType: 'prompt' | 'toolResult'; + sourceName: string; + original: string; + transformed: string; + resolve: (content: string) => void; + timestamp: number; +} + +class PauseQueue { + private paused = false; + private queue: PausedResponse[] = []; + private listeners = new Set<(items: PausedResponse[]) => void>(); + + setPaused(paused: boolean): void { + this.paused = paused; + if (!paused) { + // Release all paused items with their transformed content + for (const item of this.queue) { + item.resolve(item.transformed); + } + this.queue = []; + } + this.notifyListeners(); + } + + isPaused(): boolean { + return this.paused; + } + + async enqueue(item: Omit): Promise { + if (!this.paused) return item.transformed; + + return new Promise(resolve => { + this.queue.push({ + ...item, + id: crypto.randomUUID(), + timestamp: Date.now(), + resolve, + }); + this.notifyListeners(); + }); + } + + editAndRelease(id: string, editedContent: string): void { + const idx = this.queue.findIndex(q => q.id === id); + if (idx >= 0) { + const item = this.queue.splice(idx, 1)[0]; + item.resolve(editedContent); + this.notifyListeners(); + } + } + + releaseOne(id: string): void { + const idx = this.queue.findIndex(q => q.id === id); + if (idx >= 0) { + const item = this.queue.splice(idx, 1)[0]; + item.resolve(item.transformed); + this.notifyListeners(); + } + } + + dropOne(id: string): void { + const idx = this.queue.findIndex(q => q.id === id); + if (idx >= 0) { + const item = this.queue.splice(idx, 1)[0]; + item.resolve(''); // Empty response + this.notifyListeners(); + } + } +} + +export const pauseQueue = new PauseQueue(); +``` + +**Test Strategy:** + +Unit tests: enqueue returns immediately when not paused, enqueue blocks when paused, releaseOne/editAndRelease/dropOne work correctly, setPaused(false) releases all. diff --git a/.taskmaster/tasks/task_107.md b/.taskmaster/tasks/task_107.md new file mode 100644 index 0000000..ea6667b --- /dev/null +++ b/.taskmaster/tasks/task_107.md @@ -0,0 +1,70 @@ +# Task ID: 107 + +**Title:** Implement Edit Mode for Model Studio + +**Status:** done + +**Dependencies:** 106 ✓ + +**Priority:** medium + +**Description:** Add inline editing capability to model studio for modifying paused responses before release. + +**Details:** + +Extend `src/cli/src/commands/console/model-studio.tsx`: + +```typescript +import { spawn } from 'child_process'; +import { writeFileSync, readFileSync, unlinkSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +async function editContent(original: string): Promise { + const editor = process.env.EDITOR ?? 'vim'; + const tmpFile = join(tmpdir(), `mcpctl-edit-${Date.now()}.txt`); + + writeFileSync(tmpFile, original); + + return new Promise((resolve, reject) => { + const proc = spawn(editor, [tmpFile], { + stdio: 'inherit', + }); + + proc.on('close', (code) => { + if (code === 0) { + const edited = readFileSync(tmpFile, 'utf-8'); + unlinkSync(tmpFile); + resolve(edited); + } else { + unlinkSync(tmpFile); + reject(new Error(`Editor exited with code ${code}`)); + } + }); + }); +} + +// In the TUI component: +useInput(async (input, key) => { + if (input === 'e' && pauseMode && selectedPausedItem) { + const edited = await editContent(selectedPausedItem.transformed); + pauseQueue.editAndRelease(selectedPausedItem.id, edited); + + // Emit correction event + trafficCapture.emit({ + eventType: 'content_edited', + sessionId: selectedPausedItem.sessionId, + contentType: selectedPausedItem.contentType, + sourceName: selectedPausedItem.sourceName, + original: selectedPausedItem.original, + transformed: selectedPausedItem.transformed, + edited, + timestamp: Date.now(), + }); + } +}); +``` + +**Test Strategy:** + +Integration test: pressing 'e' opens editor with content, saving and closing applies edit, edit event is emitted with correct before/after content. diff --git a/.taskmaster/tasks/task_108.md b/.taskmaster/tasks/task_108.md new file mode 100644 index 0000000..ced395a --- /dev/null +++ b/.taskmaster/tasks/task_108.md @@ -0,0 +1,75 @@ +# Task ID: 108 + +**Title:** Implement Model Switch for Model Studio + +**Status:** done + +**Dependencies:** 105 ✓, 82 ✓ + +**Priority:** medium + +**Description:** Add ability to switch the active proxymodel for a project mid-session from model studio. + +**Details:** + +Extend `src/cli/src/commands/console/model-studio.tsx`: + +```typescript +function ModelPicker({ models, current, onSelect }: { + models: string[]; + current: string; + onSelect: (name: string) => void; +}) { + const [selectedIdx, setSelectedIdx] = useState(models.indexOf(current)); + + useInput((input, key) => { + if (key.upArrow) setSelectedIdx(i => Math.max(0, i - 1)); + if (key.downArrow) setSelectedIdx(i => Math.min(models.length - 1, i + 1)); + if (key.return) onSelect(models[selectedIdx]); + }); + + return ( + + Select ProxyModel: + {models.map((m, i) => ( + + {m === current ? '✓ ' : ' '}{m} + + ))} + + ); +} + +// Add to main component: +const [showModelPicker, setShowModelPicker] = useState(false); +const [activeModel, setActiveModel] = useState('default'); + +useInput((input) => { + if (input === 'm') setShowModelPicker(true); +}); + +async function switchModel(name: string) { + // Call mcplocal API to switch model + await fetch(`http://localhost:${port}/projects/${projectName}/proxymodel`, { + method: 'PUT', + body: JSON.stringify({ proxyModel: name }), + }); + setActiveModel(name); + setShowModelPicker(false); + + // Emit model_switched event + trafficCapture.emit({ + eventType: 'model_switched', + projectName, + previousModel: activeModel, + newModel: name, + timestamp: Date.now(), + }); +} +``` + +Add PUT endpoint to mcplocal for switching proxymodel. + +**Test Strategy:** + +Integration test: 'm' opens model picker, selecting a model updates the active model, subsequent content flows through new model, model_switched event is emitted. diff --git a/.taskmaster/tasks/task_109.md b/.taskmaster/tasks/task_109.md new file mode 100644 index 0000000..b207fa9 --- /dev/null +++ b/.taskmaster/tasks/task_109.md @@ -0,0 +1,94 @@ +# Task ID: 109 + +**Title:** Implement Studio MCP Server Tools + +**Status:** done + +**Dependencies:** 104 ✗, 106 ✓, 107 ✓, 108 ✓ + +**Priority:** medium + +**Description:** Create MCP tools for Claude Monitor to observe traffic, get corrections, switch models, and modify stages. + +**Details:** + +Extend `src/cli/src/commands/console/inspect-mcp.ts` with studio tools: + +```typescript +const studioTools: Tool[] = [ + { + name: 'get_content_diff', + description: 'Get original vs transformed vs edited content for a specific event', + inputSchema: { + type: 'object', + properties: { + eventId: { type: 'string', description: 'Event ID' }, + }, + required: ['eventId'], + }, + }, + { + name: 'get_corrections', + description: 'Get all user corrections (edits) in a session', + inputSchema: { + type: 'object', + properties: { + sessionId: { type: 'string', description: 'Optional session filter' }, + }, + }, + }, + { + name: 'get_active_model', + description: 'Get current proxymodel name and stage list for a project', + inputSchema: { + type: 'object', + properties: { + project: { type: 'string' }, + }, + required: ['project'], + }, + }, + { + name: 'switch_model', + description: 'Hot-swap the active proxymodel on a project', + inputSchema: { + type: 'object', + properties: { + project: { type: 'string' }, + model: { type: 'string' }, + }, + required: ['project', 'model'], + }, + }, + { + name: 'reload_stages', + description: 'Force reload all stages from ~/.mcpctl/stages/', + inputSchema: { type: 'object', properties: {} }, + }, + { + name: 'get_stage_source', + description: 'Read the source code of a stage file', + inputSchema: { + type: 'object', + properties: { + name: { type: 'string' }, + }, + required: ['name'], + }, + }, + { + name: 'list_models', + description: 'List available proxymodels', + inputSchema: { type: 'object', properties: {} }, + }, + { + name: 'list_stages', + description: 'List available stages', + inputSchema: { type: 'object', properties: {} }, + }, +]; +``` + +**Test Strategy:** + +Integration test with MCP client: each tool returns expected data format, switch_model actually changes the model, reload_stages picks up file changes, get_corrections returns user edits. diff --git a/.taskmaster/tasks/task_110.md b/.taskmaster/tasks/task_110.md new file mode 100644 index 0000000..2f8c570 --- /dev/null +++ b/.taskmaster/tasks/task_110.md @@ -0,0 +1,62 @@ +# Task ID: 110 + +**Title:** Implement RBAC for ProxyModels + +**Status:** deferred + +**Dependencies:** 87 ✗ + +**Priority:** low + +**Description:** Add 'run' permission on proxymodels resource controlling which proxymodels users can activate on projects. + +**Details:** + +Update RBAC schema and enforcement: + +1. Add to `src/db/prisma/schema.prisma`: +```prisma +// Extend existing RbacBinding or Permission model +enum RbacResource { + // ... existing + proxymodels +} + +enum RbacPermission { + // ... existing + run // permission to use a proxymodel + cache // permission to push to shared cache +} +``` + +2. Add enforcement in `src/mcplocal/src/router.ts`: +```typescript +async function resolveProxyModel( + requestedModel: string, + projectName: string, + userId: string +): Promise { + const models = await loadProxyModels(); + const model = models.get(requestedModel); + + if (!model) { + console.warn(`Proxymodel '${requestedModel}' not found, using default`); + return models.get('default')!; + } + + // Check RBAC permission + const hasPermission = await checkPermission(userId, 'run', 'proxymodels', requestedModel); + if (!hasPermission) { + console.warn(`User lacks 'run' permission for proxymodel '${requestedModel}', using default`); + return models.get('default')!; + } + + return model; +} +``` + +3. 'default' proxymodel requires no permission (always allowed). + +**Test Strategy:** + +Integration test: user with 'run' permission can use proxymodel, user without permission falls back to default, 'default' always works, permission check logs reason for fallback. diff --git a/.taskmaster/tasks/task_111.md b/.taskmaster/tasks/task_111.md new file mode 100644 index 0000000..c6e0536 --- /dev/null +++ b/.taskmaster/tasks/task_111.md @@ -0,0 +1,76 @@ +# Task ID: 111 + +**Title:** Write Integration Tests for subindex Model + +**Status:** done + +**Dependencies:** 75 ✓, 76 ✓, 82 ✓, 83 ✗ + +**Priority:** high + +**Description:** Create comprehensive integration tests for the subindex proxymodel processing real content through section-split and summarize-tree. + +**Details:** + +Create `src/mcplocal/tests/proxymodel/subindex.test.ts`: + +```typescript +import { describe, it, expect, beforeAll } from 'vitest'; +import { executePipeline } from '../../src/proxymodel/executor'; +import { loadProxyModels } from '../../src/proxymodel/loader'; +import { createMockProviderRegistry } from '../mocks/providers'; + +describe('subindex proxymodel', () => { + let proxyModel; + let mockRegistry; + + beforeAll(async () => { + const models = await loadProxyModels(); + proxyModel = models.get('subindex'); + mockRegistry = createMockProviderRegistry({ + complete: async (prompt) => 'Mock summary of the content', + }); + }); + + it('splits JSON array into sections', async () => { + const content = JSON.stringify([ + { id: 'flow1', label: 'Thermostat', nodes: [] }, + { id: 'flow2', label: 'Lighting', nodes: [] }, + ]); + + const result = await executePipeline({ + content, + contentType: 'toolResult', + sourceName: 'test/get_flows', + projectName: 'test', + sessionId: 'test-session', + proxyModel, + providerRegistry: mockRegistry, + }); + + expect(result.sections).toHaveLength(2); + expect(result.sections[0].id).toBe('flow1'); + expect(result.content).toContain('2 sections'); + }); + + it('provides drill-down to exact JSON content', async () => { + // Test that drilling into a section returns exact original JSON + }); + + it('uses structural summaries for JSON (no LLM call)', async () => { + // Verify LLM not called for JSON content + }); + + it('uses LLM summaries for prose content', async () => { + // Verify LLM called for markdown content + }); + + it('caches summaries across requests', async () => { + // Verify cache hit on second request with same content + }); +}); +``` + +**Test Strategy:** + +Run with vitest, verify all test cases pass, check LLM call counts are as expected (structural vs prose), verify cache behavior. diff --git a/.taskmaster/tasks/task_112.md b/.taskmaster/tasks/task_112.md new file mode 100644 index 0000000..fe9f3e2 --- /dev/null +++ b/.taskmaster/tasks/task_112.md @@ -0,0 +1,47 @@ +# Task ID: 112 + +**Title:** Write Documentation for ProxyModel Authoring + +**Status:** done + +**Dependencies:** 71 ✓, 78 ✓, 93 ✗, 94 ✗ + +**Priority:** low + +**Description:** Create comprehensive documentation for users wanting to create custom stages and proxymodels. + +**Details:** + +Create documentation covering: + +1. `docs/proxymodels/authoring-guide.md` - Complete guide from PRD's "Authoring Guide" section: + - Concepts: stages, proxymodels, framework + - File locations + - Step-by-step stage creation + - Step-by-step proxymodel creation + - Testing with mcpctl proxymodel validate + - Section drill-down + - Cache usage + - Error handling + - Full example + +2. `docs/proxymodels/built-in-stages.md` - Reference for all built-in stages: + - passthrough + - paginate + - section-split + - summarize-tree + - Config options for each + +3. `docs/proxymodels/api-reference.md` - Type reference: + - StageHandler + - StageContext + - StageResult + - Section + - LLMProvider + - CacheProvider + +4. Update main README with proxymodels overview. + +**Test Strategy:** + +Review documentation for completeness, verify all code examples compile, test example stage from documentation works end-to-end. diff --git a/.taskmaster/tasks/task_113.md b/.taskmaster/tasks/task_113.md new file mode 100644 index 0000000..b4e9e10 --- /dev/null +++ b/.taskmaster/tasks/task_113.md @@ -0,0 +1,31 @@ +# Task ID: 113 + +**Title:** Write Documentation for Model Studio + +**Status:** done + +**Dependencies:** 105 ✓, 106 ✓, 107 ✓, 108 ✓, 109 ✓ + +**Priority:** low + +**Description:** Create documentation for using model studio for live proxymodel development and debugging. + +**Details:** + +Create `docs/proxymodels/model-studio.md` covering: + +1. Overview: Three-window setup (Claude Client, Model Studio, Claude Monitor) +2. Starting Model Studio: `mcpctl console --model-studio ` +3. Keyboard shortcuts reference +4. Viewing original vs transformed content +5. Pause mode: when and why to use it +6. Editing paused responses +7. Switching proxymodels mid-session +8. Using Claude Monitor to observe and modify stages +9. The correction workflow: edit → observe → adjust stage → verify +10. MCP tools available to Claude Monitor +11. Troubleshooting common issues + +**Test Strategy:** + +Review documentation for completeness, verify all described features work as documented. diff --git a/.taskmaster/tasks/task_114.md b/.taskmaster/tasks/task_114.md new file mode 100644 index 0000000..83cb871 --- /dev/null +++ b/.taskmaster/tasks/task_114.md @@ -0,0 +1,74 @@ +# Task ID: 114 + +**Title:** ProxyModel v2: Code-based MCP middleware plugin system + +**Status:** done + +**Dependencies:** None + +**Priority:** high + +**Description:** Redesign the ProxyModel framework from a YAML-configured content transformation pipeline into a full code-based MCP middleware plugin system. Proxy models become TypeScript files that can intercept any MCP request/response, create synthetic tools, maintain per-session state, and compose via multiple inheritance with compile-time conflict detection. The existing gate functionality (begin_session, tools/list filtering, prompt scoring, ungating) becomes the first proxy model implementation, proving the framework works by implementing gate entirely as a plugin with zero gate-specific code in router.ts. + +**Details:** + +## Vision + +A proxy model is a TypeScript code file (not YAML) that acts as full MCP middleware. It can: +- Intercept any MCP request (initialize, tools/list, tools/call, resources/*, prompts/*) +- Modify any response before it reaches the client +- Create synthetic tools (e.g. begin_session doesn't exist upstream) +- Maintain per-session state (gated/ungated, accumulated tags, etc.) +- Access project resources (prompts, servers, config) +- Transform content (what stages do today: paginate, section-split, etc.) + +## Key design decisions + +1. Code not YAML: Proxy models live as .ts files in a known directory (e.g. ~/.mcpctl/proxymodels/). File exists = model exists. No create/delete via CLI. +2. Stages deprecated: No separate stage resource. Content transformation is just code inside the proxy model. +3. Multiple inheritance: A model can extend [gate, subindex] to compose behaviors from multiple parents. Conflicts (two parents intercepting the same method incompatibly) detected at load/compile time, not runtime. +4. Gate is just a proxy model: The ~300 lines of gate logic in router.ts move into a gate.ts proxy model file. Router becomes thin plumbing (~100 lines). +5. gated:true replaced by proxyModel field: Projects get a proxyModel: gate field. If the assigned model implements gating, the project is gated. No separate boolean. +6. Discoverable as resources: mcpctl get proxymodels lists available models (discovered from files). mcpctl describe proxymodel gate shows details. But no create/delete commands. +7. Attached to projects: mcpctl edit project foo --proxyModel gate or via apply YAML. + +## Framework interface (sketch) + +export interface ProxyModelContext { + session: SessionState; + project: ProjectConfig; + upstream: UpstreamClient; + llm?: LLMProvider; + cache?: CacheProvider; +} + +export interface ProxyModel { + name: string; + extends?: string[]; + onInitialize?(ctx, request): Promise; + onToolsList?(ctx): Promise; + onToolCall?(ctx, name, args): Promise; + onResourceRead?(ctx, uri): Promise; + transformContent?(ctx, content, contentType): Promise; + createSessionState?(): Record; +} + +## Migration path + +1. Define the ProxyModel TypeScript interface +2. Implement the plugin loader (discover .ts files, compile, validate inheritance, detect conflicts) +3. Implement the router integration (router delegates to loaded proxy model) +4. Extract gate logic from router.ts into gate.ts proxy model +5. Extract content pipeline (passthrough, paginate, section-split) into proxy model code +6. Add proxyModel field to Project schema (replaces gated boolean) +7. Add CLI: get proxymodels, describe proxymodel, edit project --proxyModel +8. Add smoke tests: gate proxy model produces identical behavior to current hardcoded gate +9. Deprecate gated field (backward compat: gated:true maps to proxyModel:gate) + +## Supersedes + +This task supersedes deferred tasks 83, 85-97, 98-99, 103, 104, 110, 111-112 which assumed the old YAML/stage architecture. + +**Test Strategy:** + +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. diff --git a/.taskmaster/tasks/task_115.md b/.taskmaster/tasks/task_115.md new file mode 100644 index 0000000..da882aa --- /dev/null +++ b/.taskmaster/tasks/task_115.md @@ -0,0 +1,29 @@ +# Task ID: 115 + +**Title:** RBAC tasks namespace split and alias repoint + +**Status:** pending + +**Dependencies:** None + +**Priority:** high + +**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: +1. Update RBAC_RESOURCES array to include: 'epics', 'epictasks', 'tasks', 'repos', 'sessions', 'inference-tasks' +2. Update RESOURCE_ALIASES: + - task → tasks (work items) + - epic → epics + - epictask → epictasks, et → epictasks + - repo → repos, repository → repos, repositories → repos + - session → sessions + - inference-task → inference-tasks +3. REMOVE the entry 'inference-tasks': 'tasks' +4. Export normalizeResource unchanged (it uses the alias map) + +**Test Strategy:** + +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. diff --git a/.taskmaster/tasks/task_116.md b/.taskmaster/tasks/task_116.md new file mode 100644 index 0000000..61e2316 --- /dev/null +++ b/.taskmaster/tasks/task_116.md @@ -0,0 +1,26 @@ +# Task ID: 116 + +**Title:** One-shot RBAC binding backfill migration + +**Status:** pending + +**Dependencies:** 115 + +**Priority:** high + +**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: +1. Read system settings from SYSTEM_SETTINGS_SECRET (see src/mcpd/src/bootstrap/system-settings.ts pattern) +2. Check for boolean key 'rbacTasksBackfillComplete' +3. If false/missing: iterate rbacRepo.findAll(), for each RbacDefinition row, rewrite roleBindings where resource === 'tasks' to resource: 'inference-tasks' +4. Use rbacRepo.update() to persist changes +5. Set 'rbacTasksBackfillComplete': true in system settings after completion +6. Wildcard bindings (resource: '*') are untouched +7. Model on migrateAdminRole pattern in main.ts + +**Test Strategy:** + +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. diff --git a/.taskmaster/tasks/task_117.md b/.taskmaster/tasks/task_117.md new file mode 100644 index 0000000..19fa45f --- /dev/null +++ b/.taskmaster/tasks/task_117.md @@ -0,0 +1,25 @@ +# Task ID: 117 + +**Title:** Add Epic, EpicTask, EpicTaskEdge Prisma models + +**Status:** pending + +**Dependencies:** None + +**Priority:** high + +**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: +1. enum EpicStatus { open, delivered, delivered_with_tradeoffs, cancelled } +2. enum EpicTaskStatus { todo, in_progress, done, cancelled } +3. 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]) +4. 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]) +5. model EpicTaskEdge with: id, fromId, toId, createdAt. Relations using @relation names. @@unique([fromId, toId]), @@index([fromId]), @@index([toId]) +6. Add reverse relations to User and Project models + +**Test Strategy:** + +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. diff --git a/.taskmaster/tasks/task_118.md b/.taskmaster/tasks/task_118.md new file mode 100644 index 0000000..30b59bc --- /dev/null +++ b/.taskmaster/tasks/task_118.md @@ -0,0 +1,25 @@ +# Task ID: 118 + +**Title:** Add Task, TaskVerdict, TaskEvent Prisma models + +**Status:** pending + +**Dependencies:** 117 + +**Priority:** high + +**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: +1. enum TaskState { todo, in_progress, building, self_testing, qa_review, security_review, sre_review, done, awaiting_review, merged, reopened, cancelled } +2. enum TaskRisk { trivial, normal, high } +3. 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 +4. model TaskVerdict with: id, taskId, stage (TaskState), agentId?, commit, passed, summary (@db.Text), findings (Json), createdAt. Relations: task, agent. @@index([taskId]), @@index([taskId, stage]) +5. model TaskEvent with: id, taskId, fromState?, toState, actorType, actorId?, note? (@db.Text), createdAt. @@index([taskId, createdAt]) +6. Add reverse relations to Agent model (assignedTasks[], verdicts[]) + +**Test Strategy:** + +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. diff --git a/.taskmaster/tasks/task_119.md b/.taskmaster/tasks/task_119.md new file mode 100644 index 0000000..e708b58 --- /dev/null +++ b/.taskmaster/tasks/task_119.md @@ -0,0 +1,25 @@ +# Task ID: 119 + +**Title:** Add CodeRepo Prisma model + +**Status:** pending + +**Dependencies:** None + +**Priority:** high + +**Description:** Add CodeRepo model to schema.prisma for git repository management within projects + +**Details:** + +In src/db/prisma/schema.prisma add: +1. model CodeRepo with: id, name, projectId, url, defaultBranch (@default("main")), path (@default("")), provider (@default("gitea")), credSecretId?, credSecretKey?, ownerId, version, createdAt, updatedAt +2. Relations: project (Project), cred (Secret?), owner (User), tasks (Task[]) +3. @@unique([name, projectId]), @@index([projectId]) +4. Add repos CodeRepo[] relation to Project model +5. Add codeRepos CodeRepo[] relation to Secret model +6. Add ownedRepos CodeRepo[] relation to User model + +**Test Strategy:** + +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. diff --git a/.taskmaster/tasks/task_120.md b/.taskmaster/tasks/task_120.md new file mode 100644 index 0000000..a56e72b --- /dev/null +++ b/.taskmaster/tasks/task_120.md @@ -0,0 +1,26 @@ +# Task ID: 120 + +**Title:** Add AgentProject junction model + +**Status:** pending + +**Dependencies:** None + +**Priority:** high + +**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: +1. model AgentProject with: id, agentId, projectId, role (@default("developer")), llmId?, createdAt +2. Relations: agent (Agent), project (Project), llm (Llm?) +3. @@unique([agentId, projectId]), @@index([agentId]), @@index([projectId]) +4. Add projectMemberships AgentProject[] to Agent model +5. Add agentMemberships AgentProject[] to Project model +6. Add agentProjects AgentProject[] to Llm model +7. Keep Agent.projectId for backward compatibility (one release) + +**Test Strategy:** + +Schema tests: create AgentProject junction, verify unique constraint prevents duplicate membership, test cascade on both Agent and Project delete, test SetNull on Llm delete. diff --git a/.taskmaster/tasks/task_121.md b/.taskmaster/tasks/task_121.md new file mode 100644 index 0000000..8442be1 --- /dev/null +++ b/.taskmaster/tasks/task_121.md @@ -0,0 +1,24 @@ +# Task ID: 121 + +**Title:** Add AgentSession Prisma model + +**Status:** pending + +**Dependencies:** 118, 120 + +**Priority:** high + +**Description:** Add AgentSession model with SessionState enum for ephemeral per-session pod tracking + +**Details:** + +In src/db/prisma/schema.prisma add: +1. enum SessionState { pending, provisioning, running, ended, failed } +2. model AgentSession with: id, agentId, projectId, taskId?, containerId?, state, image, expiresAt, lastError? (@db.Text), ownerId, createdAt, updatedAt, endedAt? +3. Relations: agent (Agent), project (Project), task (Task?), owner (User) +4. @@index([state]), @@index([taskId]), @@index([agentId]) +5. Add sessions AgentSession[] to Agent, Project, Task, User models + +**Test Strategy:** + +Schema tests: create AgentSession with all relations, test cascade on Agent/Project delete, test SetNull on Task delete, verify state enum transitions. diff --git a/.taskmaster/tasks/task_122.md b/.taskmaster/tasks/task_122.md new file mode 100644 index 0000000..33c34cd --- /dev/null +++ b/.taskmaster/tasks/task_122.md @@ -0,0 +1,30 @@ +# Task ID: 122 + +**Title:** Update clearAllTables test helper + +**Status:** pending + +**Dependencies:** 117, 118, 119, 120, 121 + +**Priority:** high + +**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: +1. Add deleteMany for TaskEvent (child first) +2. Add deleteMany for TaskVerdict +3. Add deleteMany for AgentSession +4. Add deleteMany for Task +5. Add deleteMany for EpicTaskEdge +6. Add deleteMany for EpicTask +7. Add deleteMany for Epic +8. Add deleteMany for CodeRepo +9. Add deleteMany for AgentProject +10. Order matters: delete children before parents to avoid FK violations +11. Follow existing pattern in the file + +**Test Strategy:** + +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. diff --git a/.taskmaster/tasks/task_123.md b/.taskmaster/tasks/task_123.md new file mode 100644 index 0000000..30ac011 --- /dev/null +++ b/.taskmaster/tasks/task_123.md @@ -0,0 +1,25 @@ +# Task ID: 123 + +**Title:** Epic repository layer + +**Status:** pending + +**Dependencies:** 117 + +**Priority:** medium + +**Description:** Create EpicRepository in src/mcpd/src/repositories/ following existing repository patterns + +**Details:** + +Create src/mcpd/src/repositories/epic.repository.ts: +1. Export EpicRepository class following agent.repository.ts pattern +2. Methods: create, findById, findByName, findByProjectId, findAll, update, delete +3. update must increment version (optimistic concurrency) +4. Include relations in queries where needed (epicTasks, project, owner) +5. Add type exports for CreateEpicData, UpdateEpicData +6. Register in repository index file + +**Test Strategy:** + +Unit tests in src/mcpd/tests/repositories/: test CRUD operations, test optimistic concurrency version increment, test findByProjectId filtering, test cascade behavior. diff --git a/.taskmaster/tasks/task_124.md b/.taskmaster/tasks/task_124.md new file mode 100644 index 0000000..5a017fa --- /dev/null +++ b/.taskmaster/tasks/task_124.md @@ -0,0 +1,27 @@ +# Task ID: 124 + +**Title:** Epic service layer with revision integration + +**Status:** pending + +**Dependencies:** 123 + +**Priority:** medium + +**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: +1. Inject EpicRepository, ResourceRevisionRepository, ProjectRepository +2. create(): validate project exists, create Epic, create initial ResourceRevision with resourceType:'epic' +3. update(): bump semver, create ResourceRevision, update currentRevisionId +4. getById(), getByName(), listByProject() +5. addTradeoff(): append to acceptedTradeoffs JSON array +6. computeStatus(): roll up EpicTask completion to determine if Epic is delivered/delivered_with_tradeoffs +7. getRoadmap(): return epictask graph with per-node completeness percentages +8. Follow PromptService pattern for revision integration + +**Test Strategy:** + +Unit tests: test create writes revision, test update bumps semver and writes revision, test addTradeoff appends correctly, test computeStatus logic for all EpicStatus values. diff --git a/.taskmaster/tasks/task_125.md b/.taskmaster/tasks/task_125.md new file mode 100644 index 0000000..087922b --- /dev/null +++ b/.taskmaster/tasks/task_125.md @@ -0,0 +1,25 @@ +# Task ID: 125 + +**Title:** Epic zod validation schemas + +**Status:** pending + +**Dependencies:** 117 + +**Priority:** medium + +**Description:** Create Epic validation schemas in src/mcpd/src/validation/ for request/response validation + +**Details:** + +Create src/mcpd/src/validation/epic.schema.ts: +1. DoneCriterionSchema: z.object({ id: z.string(), text: z.string(), kind: z.enum(['outcome', 'quality']), met: z.boolean() }) +2. AcceptedTradeoffSchema: z.object({ what: z.string(), why: z.string(), who: z.string(), deScopedBy: z.string().optional(), at: z.string() }) +3. CreateEpicSchema: name, content, doneCriteria (array), projectId or project (name), description optional +4. UpdateEpicSchema: partial of create fields plus status +5. EpicResponseSchema for API responses +6. Follow prompt.schema.ts patterns + +**Test Strategy:** + +Unit tests: validate CreateEpicSchema accepts valid input, rejects missing required fields, validates doneCriteria array structure, validates status enum values. diff --git a/.taskmaster/tasks/task_126.md b/.taskmaster/tasks/task_126.md new file mode 100644 index 0000000..cc2b8de --- /dev/null +++ b/.taskmaster/tasks/task_126.md @@ -0,0 +1,28 @@ +# Task ID: 126 + +**Title:** Epic routes and API endpoints + +**Status:** pending + +**Dependencies:** 124, 125 + +**Priority:** medium + +**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: +1. GET /api/v1/epics - list with ?project= filter +2. POST /api/v1/epics - create, resolve project name to id +3. GET /api/v1/epics/:id - get by id +4. PUT /api/v1/epics/:id - update +5. DELETE /api/v1/epics/:id - delete +6. GET /api/v1/epics/:id/roadmap - return epictask graph with completeness +7. POST /api/v1/epics/:id/tradeoffs - append accepted tradeoff +8. Register routes in main.ts +9. Follow agent-routes.ts pattern for auth and validation + +**Test Strategy:** + +Route tests in src/mcpd/tests/routes/: test CRUD endpoints, test project name resolution, test roadmap returns graph structure, test tradeoffs append correctly. diff --git a/.taskmaster/tasks/task_127.md b/.taskmaster/tasks/task_127.md new file mode 100644 index 0000000..5a8a978 --- /dev/null +++ b/.taskmaster/tasks/task_127.md @@ -0,0 +1,25 @@ +# Task ID: 127 + +**Title:** EpicTask repository and service layers + +**Status:** pending + +**Dependencies:** 117, 123 + +**Priority:** medium + +**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: +1. Repository: CRUD for EpicTask, edge CRUD for EpicTaskEdge, findByEpicId +2. Service.addEdge(): before creating edge, walk toId's transitive dependencies (via dependsOn edges), reject if fromId is reachable (cycle detection) +3. Service.removeEdge(): simple delete +4. Service.getGraph(): return nodes with edges for visualization +5. Service.computeCompleteness(): calculate percentage of doneCriteria met per node +6. Follow existing repository/service patterns + +**Test Strategy:** + +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. diff --git a/.taskmaster/tasks/task_128.md b/.taskmaster/tasks/task_128.md new file mode 100644 index 0000000..bb4da22 --- /dev/null +++ b/.taskmaster/tasks/task_128.md @@ -0,0 +1,27 @@ +# Task ID: 128 + +**Title:** EpicTask routes and API endpoints + +**Status:** pending + +**Dependencies:** 127 + +**Priority:** medium + +**Description:** Create EpicTask routes with CRUD and edge management endpoints + +**Details:** + +Create src/mcpd/src/routes/epic-task.routes.ts: +1. GET /api/v1/epictasks - list with ?epic=&project= filters +2. POST /api/v1/epictasks - create +3. GET /api/v1/epictasks/:id - get +4. PUT /api/v1/epictasks/:id - update +5. DELETE /api/v1/epictasks/:id - delete +6. POST /api/v1/epictasks/:id/edges - add dependency edge { toId } +7. DELETE /api/v1/epictasks/:id/edges - remove edge { toId } +8. Register in main.ts + +**Test Strategy:** + +Route tests: test CRUD, test edge creation returns error on cycle, test edge deletion, test filters work correctly. diff --git a/.taskmaster/tasks/task_129.md b/.taskmaster/tasks/task_129.md new file mode 100644 index 0000000..ed05816 --- /dev/null +++ b/.taskmaster/tasks/task_129.md @@ -0,0 +1,24 @@ +# Task ID: 129 + +**Title:** Task repository with compare-and-swap transitions + +**Status:** pending + +**Dependencies:** 118 + +**Priority:** medium + +**Description:** Create TaskRepository with optimistic locking state transitions using updateMany with where clause + +**Details:** + +Create src/mcpd/src/repositories/task.repository.ts: +1. Standard CRUD methods following existing patterns +2. transition(id, fromState, toState): use prisma.task.updateMany({ where: { id, state: fromState }, data: { state: toState, version: { increment: 1 } } }), return affected count +3. If affected count is 0, the transition failed (race or wrong state) +4. findByFilters(): support epicTaskId, state, assigneeId filters +5. Include verdict and event relations where appropriate + +**Test Strategy:** + +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. diff --git a/.taskmaster/tasks/task_130.md b/.taskmaster/tasks/task_130.md new file mode 100644 index 0000000..ebd2a90 --- /dev/null +++ b/.taskmaster/tasks/task_130.md @@ -0,0 +1,26 @@ +# Task ID: 130 + +**Title:** Task service with lifecycle state machine + +**Status:** pending + +**Dependencies:** 129 + +**Priority:** medium + +**Description:** Create TaskService implementing the full task lifecycle with transition guards and event logging + +**Details:** + +Create src/mcpd/src/services/task.service.ts: +1. Define VALID_TRANSITIONS map: { [fromState]: [allowedToStates] } based on PRD lifecycle diagram +2. transition(id, toState, actorType, actorId, note): check transition is valid, call repo.transition, if success create TaskEvent, return result +3. assign(id, agentId): set assigneeId +4. recordVerdict(id, stage, agentId, commit, passed, summary, findings): create TaskVerdict, if failed transition back to in_progress +5. accept(id, userId, note): transition to merged, trigger merge via GitProvider +6. reject(id, userId, note): transition to in_progress +7. reopenOnPostMergeFailure(id): transition merged→reopened + +**Test Strategy:** + +Unit tests: test all valid transitions succeed, test invalid transitions throw, test TaskEvent created on every transition, test verdict creation, test accept triggers merge. diff --git a/.taskmaster/tasks/task_131.md b/.taskmaster/tasks/task_131.md new file mode 100644 index 0000000..02074f1 --- /dev/null +++ b/.taskmaster/tasks/task_131.md @@ -0,0 +1,30 @@ +# Task ID: 131 + +**Title:** Task routes and API endpoints + +**Status:** pending + +**Dependencies:** 130 + +**Priority:** medium + +**Description:** Create Task routes with CRUD, transition, verdict, accept/reject, and events endpoints + +**Details:** + +Create src/mcpd/src/routes/task.routes.ts: +1. GET /api/v1/tasks - list with ?project=&epictask=&state=&assignee= filters +2. POST /api/v1/tasks - create +3. GET /api/v1/tasks/:id - get +4. PUT /api/v1/tasks/:id - update +5. DELETE /api/v1/tasks/:id - delete +6. POST /api/v1/tasks/:id/transition - { toState, note? } +7. POST /api/v1/tasks/:id/verdict - record QA/Security/SRE verdict +8. POST /api/v1/tasks/:id/accept - { note? } human gate → merge +9. POST /api/v1/tasks/:id/reject - { note } → back to in_progress +10. GET /api/v1/tasks/:id/events - list TaskEvents +11. Register in main.ts + +**Test Strategy:** + +Route tests: test CRUD, test transition endpoint, test verdict recording, test accept triggers merge, test reject changes state, test events returns ordered list. diff --git a/.taskmaster/tasks/task_132.md b/.taskmaster/tasks/task_132.md new file mode 100644 index 0000000..635669d --- /dev/null +++ b/.taskmaster/tasks/task_132.md @@ -0,0 +1,25 @@ +# Task ID: 132 + +**Title:** CodeRepo repository and service layers + +**Status:** pending + +**Dependencies:** 119 + +**Priority:** medium + +**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: +1. Repository: standard CRUD, findByProjectId +2. Service.create(): validate project exists, validate credSecretId/Key if provided +3. Service.resolveCredentials(repoId): fetch Secret via SecretService, extract the key, return credential (never store plaintext) +4. Service.update(): validate changes +5. Service.delete(): check no active sessions reference this repo +6. Follow existing service patterns for validation + +**Test Strategy:** + +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. diff --git a/.taskmaster/tasks/task_133.md b/.taskmaster/tasks/task_133.md new file mode 100644 index 0000000..ed89352 --- /dev/null +++ b/.taskmaster/tasks/task_133.md @@ -0,0 +1,26 @@ +# Task ID: 133 + +**Title:** CodeRepo routes and API endpoints + +**Status:** pending + +**Dependencies:** 132 + +**Priority:** medium + +**Description:** Create CodeRepo routes with CRUD endpoints + +**Details:** + +Create src/mcpd/src/routes/code-repo.routes.ts: +1. GET /api/v1/repos - list with ?project= filter +2. POST /api/v1/repos - create, resolve project name +3. GET /api/v1/repos/:id - get (credential fields return references, not values) +4. PUT /api/v1/repos/:id - update +5. DELETE /api/v1/repos/:id - delete +6. Register in main.ts +7. Ensure credentials are never exposed in responses + +**Test Strategy:** + +Route tests: test CRUD, test project filter, test response does not include credential values, test delete succeeds when no sessions reference repo. diff --git a/.taskmaster/tasks/task_134.md b/.taskmaster/tasks/task_134.md new file mode 100644 index 0000000..3b3f2dc --- /dev/null +++ b/.taskmaster/tasks/task_134.md @@ -0,0 +1,26 @@ +# Task ID: 134 + +**Title:** RBAC wiring for new resources + +**Status:** pending + +**Dependencies:** 115, 126, 128, 131, 133 + +**Priority:** medium + +**Description:** Update mapUrlToPermission and nameResolvers in main.ts for epics, epictasks, tasks, repos, sessions + +**Details:** + +In src/mcpd/src/main.ts: +1. Add to mapUrlToPermission cases: epics, epictasks, tasks, repos, sessions +2. Map /api/v1/inference-tasks to 'inference-tasks' resource +3. Add non-CRUD verb cases above the map: + - POST /tasks/:id/accept|reject|transition → run:tasks: + - POST /sessions/:id/exec, GET /sessions/:id/attach → run:sessions: +4. Add nameResolvers for epics, epictasks, tasks, repos, sessions (fetch by id, return name for scoped grants) +5. Follow existing patterns for permission mapping + +**Test Strategy:** + +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. diff --git a/.taskmaster/tasks/task_135.md b/.taskmaster/tasks/task_135.md new file mode 100644 index 0000000..ce3c4ba --- /dev/null +++ b/.taskmaster/tasks/task_135.md @@ -0,0 +1,27 @@ +# Task ID: 135 + +**Title:** Backup and YAML kinds for new resources + +**Status:** pending + +**Dependencies:** 126, 128, 131, 133 + +**Priority:** medium + +**Description:** Add new resources to BACKUP_KINDS, APPLY_ORDER, kindMap, and export/import loops + +**Details:** + +Update backup/apply infrastructure: +1. Add 'Epic', 'EpicTask', 'Task', 'CodeRepo' to BACKUP_KINDS array +2. Update APPLY_ORDER for correct dependency ordering (Epic before EpicTask before Task) +3. Add to kindMap: { Epic: 'epics', EpicTask: 'epictasks', Task: 'tasks', CodeRepo: 'repos' } +4. Update serializeResource for each new kind +5. Update kindFromSegment in main.ts import handler +6. Update importResource for create/update logic per kind +7. Update deleteResource for each kind +8. Fix existing gap: add 'skill' to importResource/deleteResource if missing + +**Test Strategy:** + +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. diff --git a/.taskmaster/tasks/task_136.md b/.taskmaster/tasks/task_136.md new file mode 100644 index 0000000..d3021b5 --- /dev/null +++ b/.taskmaster/tasks/task_136.md @@ -0,0 +1,28 @@ +# Task ID: 136 + +**Title:** CLI get columns and describe formatters for new resources + +**Status:** pending + +**Dependencies:** 126, 128, 131, 133 + +**Priority:** medium + +**Description:** Add column definitions and describe formatters for epics, epictasks, tasks, repos, sessions in CLI + +**Details:** + +In src/cli/src/commands/get.ts: +1. Add row interfaces: EpicRow, EpicTaskRow, TaskRow, CodeRepoRow, AgentSessionRow +2. Add to getColumnsForResource for each: define column names, widths, formatters +3. Add to RESOURCE_KIND map: epics→Epic, epictasks→EpicTask, tasks→Task, repos→CodeRepo, sessions→AgentSession +4. Add to toApplyDocs strip blocks + +In src/cli/src/commands/describe.ts: +5. Add describe handlers with clean sections and tables (not key-value dumps) +6. For Epic: show roadmap section with epictask names and completeness +7. For Task: show lifecycle state, assignee, PR link if present + +**Test Strategy:** + +Unit tests: test getColumnsForResource returns correct columns for each resource, test describe output formatting matches expected structure. diff --git a/.taskmaster/tasks/task_137.md b/.taskmaster/tasks/task_137.md new file mode 100644 index 0000000..b1da932 --- /dev/null +++ b/.taskmaster/tasks/task_137.md @@ -0,0 +1,25 @@ +# Task ID: 137 + +**Title:** CLI create subcommands for new resources + +**Status:** pending + +**Dependencies:** 136 + +**Priority:** medium + +**Description:** Add create subcommands for epic, epictask, task, repo with all settable fields as flags + +**Details:** + +In src/cli/src/commands/create.ts: +1. Add 'epic' subcommand: --name, --project, --content, --done-criteria (JSON), --description +2. Add 'epictask' subcommand: --name, --epic, --project, --done-criteria, --description, --depends-on (comma-separated names) +3. Add 'task' subcommand: --name, --title, --epictask, --epic, --project, --description, --risk, --repo +4. Add 'repo' subcommand: --name, --project, --url, --default-branch, --path, --provider, --cred-secret, --cred-key +5. Every field settable in apply must have a corresponding flag +6. Follow existing create subcommand patterns + +**Test Strategy:** + +Unit tests: test each create subcommand parses all flags correctly, test required flags are enforced, test API calls are made with correct payload. diff --git a/.taskmaster/tasks/task_138.md b/.taskmaster/tasks/task_138.md new file mode 100644 index 0000000..8da2eea --- /dev/null +++ b/.taskmaster/tasks/task_138.md @@ -0,0 +1,27 @@ +# Task ID: 138 + +**Title:** CLI apply and edit support for new resources + +**Status:** pending + +**Dependencies:** 137 + +**Priority:** medium + +**Description:** Add new resources to apply -f spec schemas and edit validResources + +**Details:** + +In src/cli/src/commands/apply.ts: +1. Add spec schemas: EpicSpecSchema, EpicTaskSpecSchema, TaskSpecSchema, CodeRepoSpecSchema +2. Add to ApplyConfigSchema discriminated union +3. Add to KIND_TO_RESOURCE map +4. Update dry-run counts for new resources +5. Add apply loops handling create/update for each resource + +In src/cli/src/commands/edit.ts: +6. Add 'epic', 'epictask', 'task', 'repo' to validResources array + +**Test Strategy:** + +Unit tests: test apply parses valid YAML for each new kind, test get -o yaml → apply -f round-trip for each resource type. diff --git a/.taskmaster/tasks/task_139.md b/.taskmaster/tasks/task_139.md new file mode 100644 index 0000000..febf77b --- /dev/null +++ b/.taskmaster/tasks/task_139.md @@ -0,0 +1,26 @@ +# Task ID: 139 + +**Title:** CLI shell completions for new resources + +**Status:** pending + +**Dependencies:** 138 + +**Priority:** low + +**Description:** Update completion generation for new resource names and aliases + +**Details:** + +In scripts/generate-completions.ts: +1. Add to CANONICAL_RESOURCES: 'epics', 'epictasks', 'tasks', 'repos', 'sessions' +2. Add to ALIAS_ENTRIES: epic→epics, epictask→epictasks, et→epictasks, task→tasks, repo→repos, repository→repos, repositories→repos, session→sessions +3. Run pnpm completions:generate to regenerate completion scripts +4. Completions are generated, never hand-edited + +In src/cli/tests/completions.test.ts: +5. Test assertions already run generator with --check, will fail on drift automatically + +**Test Strategy:** + +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. diff --git a/.taskmaster/tasks/task_140.md b/.taskmaster/tasks/task_140.md new file mode 100644 index 0000000..e56247c --- /dev/null +++ b/.taskmaster/tasks/task_140.md @@ -0,0 +1,25 @@ +# Task ID: 140 + +**Title:** Extend ContainerSpec for ephemeral pods + +**Status:** pending + +**Dependencies:** None + +**Priority:** high + +**Description:** Add restartPolicy, workdir, volumes, and initContainers to ContainerSpec interface + +**Details:** + +In src/mcpd/src/services/orchestrator.ts: +1. Add to ContainerSpec interface: + - restartPolicy?: 'Always' | 'Never' | 'OnFailure' + - workdir?: string + - volumes?: Array<{ name: string; mountPath: string; kind: 'emptyDir' }> + - initContainers?: Array<{ name: string; image: string; command: string[]; env?: Record; volumeMounts?: Array<{ name: string; mountPath: string }> }> +2. These enable ephemeral session pods with workspace volumes and repo-clone init containers + +**Test Strategy:** + +Type tests: verify ContainerSpec accepts all new optional fields, verify existing code still compiles without providing new fields. diff --git a/.taskmaster/tasks/task_141.md b/.taskmaster/tasks/task_141.md new file mode 100644 index 0000000..acc77fa --- /dev/null +++ b/.taskmaster/tasks/task_141.md @@ -0,0 +1,25 @@ +# Task ID: 141 + +**Title:** Kubernetes orchestrator ephemeral pod support + +**Status:** pending + +**Dependencies:** 140 + +**Priority:** high + +**Description:** Update KubernetesOrchestrator to handle new ContainerSpec fields in generatePodSpec + +**Details:** + +In src/mcpd/src/services/k8s/manifest-generator.ts and KubernetesOrchestrator: +1. Update generatePodSpec to use spec.restartPolicy if provided (default 'Always' unchanged) +2. Add workingDir to container spec if workdir provided +3. Add volumes array for emptyDir volumes +4. Add volumeMounts to main container +5. Add initContainers array with their own volumeMounts +6. Ensure init containers complete before main container starts (Kubernetes handles this) + +**Test Strategy:** + +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. diff --git a/.taskmaster/tasks/task_142.md b/.taskmaster/tasks/task_142.md new file mode 100644 index 0000000..630c6b4 --- /dev/null +++ b/.taskmaster/tasks/task_142.md @@ -0,0 +1,24 @@ +# Task ID: 142 + +**Title:** Docker orchestrator ephemeral container support + +**Status:** pending + +**Dependencies:** 140 + +**Priority:** medium + +**Description:** Update DockerContainerManager to handle new ContainerSpec fields + +**Details:** + +In DockerContainerManager (src/mcpd/src/services/docker/): +1. Map restartPolicy to Docker restart policy +2. Set WorkingDir in container config +3. Create volumes as Docker volumes, add Binds/Mounts +4. For initContainers: run each sequentially before main container, fail if any init fails +5. Docker doesn't have native init container support, so implement as sequential container runs that populate shared volume + +**Test Strategy:** + +Unit tests: test container spec translation. Integration test: create container with init, verify volume populated before main container runs. diff --git a/.taskmaster/tasks/task_143.md b/.taskmaster/tasks/task_143.md new file mode 100644 index 0000000..e6e5301 --- /dev/null +++ b/.taskmaster/tasks/task_143.md @@ -0,0 +1,25 @@ +# Task ID: 143 + +**Title:** SessionService for pod provisioning and lifecycle + +**Status:** pending + +**Dependencies:** 121, 132, 141, 142 + +**Priority:** high + +**Description:** Create SessionService handling AgentSession creation, repo-clone init container, credential resolution, and GC sweep + +**Details:** + +Create src/mcpd/src/services/session.service.ts: +1. create(agentId, projectId, taskId?, image, expiresAt): create AgentSession row in pending state +2. 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 +3. end(sessionId): stop container, mark ended +4. getById(), listByFilters() +5. GC sweep: find sessions where now() > expiresAt, stop containers, mark ended. Run in same style as inference-task sweep in main.ts +6. Note: assumes single mcpd replica (no leader election) + +**Test Strategy:** + +Unit tests: test provision builds correct init container command, test credential resolution failure aborts provision, test GC sweep marks expired sessions ended. diff --git a/.taskmaster/tasks/task_144.md b/.taskmaster/tasks/task_144.md new file mode 100644 index 0000000..349e29a --- /dev/null +++ b/.taskmaster/tasks/task_144.md @@ -0,0 +1,27 @@ +# Task ID: 144 + +**Title:** Session routes and exec/attach endpoints + +**Status:** pending + +**Dependencies:** 143 + +**Priority:** high + +**Description:** Create Session routes with CRUD, one-shot exec, and WebSocket attach endpoints + +**Details:** + +Create src/mcpd/src/routes/session.routes.ts: +1. GET /api/v1/sessions - list with ?project=&agent=&task= filters +2. POST /api/v1/sessions - create and provision +3. GET /api/v1/sessions/:id - get +4. DELETE /api/v1/sessions/:id - end session +5. POST /api/v1/sessions/:id/exec - one-shot exec, body: { command: string[] }, returns { exitCode, stdout, stderr } +6. GET /api/v1/sessions/:id/attach - WebSocket upgrade, bidirectional stdio with terminal resize and exit code propagation +7. Use existing execInContainer, attachInteractive from orchestrator +8. Register in main.ts + +**Test Strategy:** + +Route tests: test CRUD, test exec returns command output, test attach upgrades to WebSocket. Integration: exec 'echo hello' in session pod, verify output. diff --git a/.taskmaster/tasks/task_145.md b/.taskmaster/tasks/task_145.md new file mode 100644 index 0000000..53bb3d7 --- /dev/null +++ b/.taskmaster/tasks/task_145.md @@ -0,0 +1,26 @@ +# Task ID: 145 + +**Title:** CLI exec command + +**Status:** pending + +**Dependencies:** 144 + +**Priority:** medium + +**Description:** Add mcpctl exec --project NAME AGENT -- command for interactive session access + +**Details:** + +In src/cli/src/commands/ add exec.ts or extend existing: +1. Parse: mcpctl exec --project NAME AGENT -- command args +2. Find or create AgentSession for agent in project +3. If command provided: call POST /sessions/:id/exec, print output +4. If no command (or -- bash): attach via WebSocket to /sessions/:id/attach +5. Handle terminal resize (SIGWINCH), pass to WebSocket +6. Propagate exit code from session +7. Per design doc: exec drops human into working environment (cloned repos), NOT the agent's reasoning loop + +**Test Strategy:** + +Manual testing: mcpctl exec --project test myagent -- ls should list repo contents. mcpctl exec --project test myagent -- bash should give interactive shell. diff --git a/.taskmaster/tasks/task_146.md b/.taskmaster/tasks/task_146.md new file mode 100644 index 0000000..a32e321 --- /dev/null +++ b/.taskmaster/tasks/task_146.md @@ -0,0 +1,29 @@ +# Task ID: 146 + +**Title:** AgentRuntime interface definition + +**Status:** pending + +**Dependencies:** None + +**Priority:** medium + +**Description:** Define AgentRuntime adapter interface for swappable agent implementations + +**Details:** + +Create src/mcpd/src/services/agent-runtime.ts: +1. Define RuntimeSessionOpts: { agentId, projectId, taskId?, image, persona (prompt files) } +2. Define RuntimeStatus: 'starting' | 'running' | 'completed' | 'failed' +3. Define RuntimeResult: { branch: string, headCommit: string, summary: string, prUrl?: string } +4. Define AgentRuntime interface: + - startSession(opts: RuntimeSessionOpts): Promise<{ sessionId: string }> + - send(sessionId: string, message: string): Promise + - status(sessionId: string): Promise + - result(sessionId: string): Promise + - endSession(sessionId: string): Promise +5. This is an interface, not an implementation - allows swapping runtimes later + +**Test Strategy:** + +Type tests: verify interface can be implemented, verify all methods have correct signatures. diff --git a/.taskmaster/tasks/task_147.md b/.taskmaster/tasks/task_147.md new file mode 100644 index 0000000..da20a43 --- /dev/null +++ b/.taskmaster/tasks/task_147.md @@ -0,0 +1,27 @@ +# Task ID: 147 + +**Title:** Headless coding agent runtime adapter + +**Status:** pending + +**Dependencies:** 143, 146 + +**Priority:** medium + +**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: +1. Implement AgentRuntime interface +2. startSession: create AgentSession, provision pod with coding agent image, inject persona as prompt files in container +3. send: exec into container to send message to agent process (implementation depends on chosen agent's IPC) +4. status: check agent process state via exec or container status +5. result: read agent output (branch, commit, summary) from known output location +6. endSession: stop container, update session state +7. The coding agent handles git, branching, MCP tools, streaming internally +8. Persona delivered via mounted files the agent reads + +**Test Strategy:** + +Integration tests against test cluster: start session, send simple task, verify branch created and pushed. diff --git a/.taskmaster/tasks/task_148.md b/.taskmaster/tasks/task_148.md new file mode 100644 index 0000000..c6d61db --- /dev/null +++ b/.taskmaster/tasks/task_148.md @@ -0,0 +1,27 @@ +# Task ID: 148 + +**Title:** GitProvider interface and Gitea driver + +**Status:** pending + +**Dependencies:** 132 + +**Priority:** high + +**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: +1. GitProvider interface: createPullRequest(repo, branch, title, body), getPullRequest(repo, prNumber), getCombinedStatus(repo, ref), mergePullRequest(repo, prNumber, method?) +2. Gitea driver: implement using Gitea API v1 + - POST /api/v1/repos/:owner/:repo/pulls for createPullRequest + - GET /api/v1/repos/:owner/:repo/pulls/:id for getPullRequest + - GET /api/v1/repos/:owner/:repo/commits/:sha/status for getCombinedStatus + - POST /api/v1/repos/:owner/:repo/pulls/:id/merge for mergePullRequest +3. Credentials resolved from CodeRepo's Secret via SecretService +4. Reference pr.sh script for endpoint shapes + +**Test Strategy:** + +Unit tests with mocked HTTP: test each method sends correct request, handles success/error responses. Integration test against local Gitea instance if available. diff --git a/.taskmaster/tasks/task_149.md b/.taskmaster/tasks/task_149.md new file mode 100644 index 0000000..4b5ff99 --- /dev/null +++ b/.taskmaster/tasks/task_149.md @@ -0,0 +1,28 @@ +# Task ID: 149 + +**Title:** Task PR linkage and human accept gate + +**Status:** pending + +**Dependencies:** 130, 148 + +**Priority:** high + +**Description:** Integrate GitProvider into TaskService for PR creation on push and merge on accept + +**Details:** + +Update src/mcpd/src/services/task.service.ts: +1. linkPR(taskId, branch, headCommit): create PR via GitProvider, update Task with prUrl, prNumber +2. accept(taskId, userId, note): + - Verify task is in awaiting_review state + - Check PR combined status is success (optional gate based on project config) + - Call gitProvider.mergePullRequest + - Transition to merged state + - Create TaskEvent +3. If merge fails, keep in awaiting_review, surface error +4. reject: transition to in_progress, clear PR linkage or keep for reference + +**Test Strategy:** + +Unit tests: test linkPR updates task fields, test accept calls merge API, test accept fails if PR status is failing. diff --git a/.taskmaster/tasks/task_150.md b/.taskmaster/tasks/task_150.md new file mode 100644 index 0000000..cc724f0 --- /dev/null +++ b/.taskmaster/tasks/task_150.md @@ -0,0 +1,28 @@ +# Task ID: 150 + +**Title:** QA/Security/SRE stage verdicts + +**Status:** pending + +**Dependencies:** 130 + +**Priority:** medium + +**Description:** Implement risk-scaled verification stages that record commit-pinned verdicts + +**Details:** + +Update TaskService: +1. recordVerdict(taskId, stage, agentId, commit, passed, summary, findings): create TaskVerdict row +2. If !passed: transition task back to in_progress, create TaskEvent explaining why +3. Determine required stages based on Task.risk: + - trivial: may pass on light self-check (no mandatory QA/Security/SRE) + - normal: requires qa_review + - high: requires qa_review + security_review + sre_review +4. Verdicts are immutable - append only, pinned to commit SHA +5. Each stage persona decides its own depth (not enforced in code, just recorded) +6. Transition to done only when all required stages have passing verdicts for current headCommit + +**Test Strategy:** + +Unit tests: test verdict creation, test failed verdict transitions back to in_progress, test risk-based stage requirements. diff --git a/.taskmaster/tasks/task_151.md b/.taskmaster/tasks/task_151.md new file mode 100644 index 0000000..36b6692 --- /dev/null +++ b/.taskmaster/tasks/task_151.md @@ -0,0 +1,28 @@ +# Task ID: 151 + +**Title:** Git webhook receiver for post-merge failure + +**Status:** pending + +**Dependencies:** 130, 148 + +**Priority:** medium + +**Description:** Create webhook endpoint that reopens tasks when post-merge CI fails + +**Details:** + +Create src/mcpd/src/routes/webhook.routes.ts: +1. POST /api/v1/webhooks/git/:provider - receive webhook payloads +2. Validate webhook signature using provider-specific method (Gitea uses HMAC-SHA256 with secret) +3. Parse payload to identify: repo, commit SHA, status (success/failure/pending) +4. If status is failure for a merged commit: + - Find Task with that headCommit and state=merged + - Call taskService.reopenOnPostMergeFailure(taskId) + - This transitions merged→reopened and creates TaskEvent +5. Register route in main.ts +6. Webhook secret stored in CodeRepo's credential secret + +**Test Strategy:** + +Unit tests: test signature validation, test payload parsing, test reopen triggered on failure status. Integration: simulate Gitea webhook payload. diff --git a/.taskmaster/tasks/task_152.md b/.taskmaster/tasks/task_152.md new file mode 100644 index 0000000..5721aaf --- /dev/null +++ b/.taskmaster/tasks/task_152.md @@ -0,0 +1,25 @@ +# Task ID: 152 + +**Title:** Agent membership routes + +**Status:** pending + +**Dependencies:** 120 + +**Priority:** medium + +**Description:** Create API endpoints for managing agent-project memberships + +**Details:** + +Add to existing agent routes or create new file: +1. GET /api/v1/agents/:name/projects - list projects agent belongs to +2. POST /api/v1/agents/:name/projects/:project - add membership { role?, llm? } +3. DELETE /api/v1/agents/:name/projects/:project - remove membership +4. GET /api/v1/projects/:name/agents - list agents in project +5. Keep Agent.projectId in sync when membership added/removed (backward compat for one release) +6. Follow existing route patterns + +**Test Strategy:** + +Route tests: test add membership creates junction row, test remove deletes it, test list returns correct memberships. diff --git a/.taskmaster/tasks/task_153.md b/.taskmaster/tasks/task_153.md new file mode 100644 index 0000000..83910ce --- /dev/null +++ b/.taskmaster/tasks/task_153.md @@ -0,0 +1,24 @@ +# Task ID: 153 + +**Title:** CLI tasks accept and reject commands + +**Status:** pending + +**Dependencies:** 131, 149 + +**Priority:** medium + +**Description:** Add mcpctl tasks accept and reject commands for human approval gate + +**Details:** + +In src/cli/src/commands/ add or extend: +1. mcpctl tasks accept NAME [--note TEXT] - call POST /tasks/:id/accept, triggers merge +2. mcpctl tasks reject NAME --note TEXT - call POST /tasks/:id/reject, note required +3. Resolve task name to id via API +4. Print result: merge URL on success, error message on failure +5. Accept should not require going to git - the CLI is the approval surface + +**Test Strategy:** + +Unit tests: test accept sends correct API call, test reject requires note flag, test output formatting. diff --git a/.taskmaster/tasks/task_154.md b/.taskmaster/tasks/task_154.md new file mode 100644 index 0000000..b94349a --- /dev/null +++ b/.taskmaster/tasks/task_154.md @@ -0,0 +1,26 @@ +# Task ID: 154 + +**Title:** Documentation for new resources + +**Status:** pending + +**Dependencies:** 126, 128, 131, 133, 144, 152 + +**Priority:** low + +**Description:** Create docs for epics, tasks, repos, sessions and update README and CLAUDE.md + +**Details:** + +Create documentation files: +1. docs/epics.md - Epic/EpicTask concepts, CLI usage, API reference +2. docs/tasks.md - Task lifecycle, states, transitions, verdicts +3. docs/repos.md - CodeRepo configuration, credential setup +4. docs/sessions.md - AgentSession lifecycle, exec usage +5. docs/inference-tasks.md - document the rename from 'tasks' RBAC resource +6. Update README.md resources table with new resources +7. Update CLAUDE.md resource cheatsheet with new resources + +**Test Strategy:** + +Manual review: verify docs are accurate, CLI examples work, API endpoints match implementation. diff --git a/.taskmaster/tasks/task_155.md b/.taskmaster/tasks/task_155.md new file mode 100644 index 0000000..aff25f9 --- /dev/null +++ b/.taskmaster/tasks/task_155.md @@ -0,0 +1,25 @@ +# Task ID: 155 + +**Title:** Smoke tests for Epic/EpicTask/Task CRUD + +**Status:** pending + +**Dependencies:** 126, 128, 131 + +**Priority:** medium + +**Description:** Create smoke tests for vision layer resources against live mcpd + +**Details:** + +Create src/mcplocal/tests/smoke/epic.smoke.test.ts: +1. 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 +2. Test Epic create/list/describe/delete +3. Test EpicTask create with edge dependencies +4. Test Task create linked to EpicTask +5. Verify cascade: delete Epic removes EpicTasks and Tasks +6. When test fails, fix environment not assertion + +**Test Strategy:** + +Run against live mcpd instance. Tests self-document expected behavior. diff --git a/.taskmaster/tasks/task_156.md b/.taskmaster/tasks/task_156.md new file mode 100644 index 0000000..62a3696 --- /dev/null +++ b/.taskmaster/tasks/task_156.md @@ -0,0 +1,23 @@ +# Task ID: 156 + +**Title:** Smoke tests for CodeRepo and Session + +**Status:** pending + +**Dependencies:** 133, 144 + +**Priority:** medium + +**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: +1. repo tests: create CodeRepo with credential reference, list, describe, delete +2. session tests: provision session pod, confirm repo cloned via exec, verify workspace mounted, verify pod reaped after expiry +3. Follow smoke test patterns: preflight skip, cleanup in afterAll +4. Session test requires real cluster access + +**Test Strategy:** + +Run against live mcpd with cluster access. Verify repo credentials resolve, session pod starts and has cloned repos. diff --git a/.taskmaster/tasks/task_157.md b/.taskmaster/tasks/task_157.md new file mode 100644 index 0000000..de17fa5 --- /dev/null +++ b/.taskmaster/tasks/task_157.md @@ -0,0 +1,26 @@ +# Task ID: 157 + +**Title:** Smoke test for task lifecycle transitions + +**Status:** pending + +**Dependencies:** 131, 149, 153 + +**Priority:** medium + +**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: +1. Create Epic → EpicTask → Task +2. Transition task: todo → in_progress → building → self_testing → qa_review → done → awaiting_review +3. Test reject: awaiting_review → in_progress +4. Advance again to awaiting_review +5. Test accept: verify merge called (may need mock git provider for smoke) +6. Verify TaskEvents recorded for all transitions +7. Follow smoke test patterns + +**Test Strategy:** + +Run against live mcpd. Verify each transition succeeds, events logged, reject bounces state correctly. diff --git a/.taskmaster/tasks/task_158.md b/.taskmaster/tasks/task_158.md new file mode 100644 index 0000000..daacb4f --- /dev/null +++ b/.taskmaster/tasks/task_158.md @@ -0,0 +1,25 @@ +# Task ID: 158 + +**Title:** Deferred: Tier-2 vision critic placeholder + +**Status:** pending + +**Dependencies:** 131, 149 + +**Priority:** low + +**Description:** Placeholder task for Tier-2 vision critic and Epic-comparison loop - blocked on design questions + +**Details:** + +DEFERRED - DO NOT IMPLEMENT YET + +Blocked on docs/agentic-teams.md §15 Q13: does this need render/screenshot infrastructure, or can a design-aware persona judge from a structured artifact? + +Concept: 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. + +Expand this task when the blocking design question is answered. + +**Test Strategy:** + +N/A - placeholder only diff --git a/.taskmaster/tasks/task_159.md b/.taskmaster/tasks/task_159.md new file mode 100644 index 0000000..778e67d --- /dev/null +++ b/.taskmaster/tasks/task_159.md @@ -0,0 +1,27 @@ +# Task ID: 159 + +**Title:** Deferred: Collaboration modes placeholder + +**Status:** pending + +**Dependencies:** 144, 145 + +**Priority:** low + +**Description:** Placeholder task for async standup and exec joint sessions - blocked on design questions + +**Details:** + +DEFERRED - DO NOT IMPLEMENT YET + +Blocked on docs/agentic-teams.md §15 Q4 and Q5. + +Concepts: +- mcpctl standup --project NAME: on-demand async standup +- exec as a workflow rather than just a command + +Expand this task when the blocking design questions are answered. + +**Test Strategy:** + +N/A - placeholder only diff --git a/.taskmaster/tasks/task_160.md b/.taskmaster/tasks/task_160.md new file mode 100644 index 0000000..24bcd8c --- /dev/null +++ b/.taskmaster/tasks/task_160.md @@ -0,0 +1,29 @@ +# Task ID: 160 + +**Title:** Deferred: Confidence signal and escalation dashboard placeholder + +**Status:** pending + +**Dependencies:** 131, 150 + +**Priority:** low + +**Description:** Placeholder task for evidence-anchored confidence and human decision queue - blocked on design questions + +**Details:** + +DEFERRED - DO NOT IMPLEMENT YET + +Blocked on docs/agentic-teams.md §15 Q12: web / TUI / CLI? + +Concepts: +- Evidence-anchored confidence: retrieve K similar past tasks with outcomes, assess confidence +- Thresholds driving autonomy level +- Single cross-project human decision queue +- Retrieval quality is the crux - weak retrieval quietly poisons the model + +Expand this task when the blocking design question is answered. + +**Test Strategy:** + +N/A - placeholder only diff --git a/.taskmaster/tasks/task_161.md b/.taskmaster/tasks/task_161.md new file mode 100644 index 0000000..c54e0bd --- /dev/null +++ b/.taskmaster/tasks/task_161.md @@ -0,0 +1,27 @@ +# Task ID: 161 + +**Title:** Deferred: Per-agent memory and coverage ledger placeholder + +**Status:** pending + +**Dependencies:** 147, 150 + +**Priority:** low + +**Description:** Placeholder task for agent memory and commit-pinned coverage ledger - blocked on design questions + +**Details:** + +DEFERRED - DO NOT IMPLEMENT YET + +Concepts: +- Global per-agent memory plus per-project knowledge +- Commit-pinned coverage ledger enabling delta-verification +- Non-negotiable: invalidation when covered region changes (or ledger becomes false confidence) +- Trust boundary: ledger is platform-verified machine fact; agent prose notes are opinion, never the gate + +Expand this task when prerequisite infrastructure exists. + +**Test Strategy:** + +N/A - placeholder only diff --git a/.taskmaster/tasks/task_162.md b/.taskmaster/tasks/task_162.md new file mode 100644 index 0000000..1b12a81 --- /dev/null +++ b/.taskmaster/tasks/task_162.md @@ -0,0 +1,28 @@ +# Task ID: 162 + +**Title:** Deferred: Script artifacts and baseline scorecard placeholder + +**Status:** pending + +**Dependencies:** 147 + +**Priority:** low + +**Description:** Placeholder task for reusable script artifacts and baseline rules scorecard - blocked on design questions + +**Details:** + +DEFERRED - DO NOT IMPLEMENT YET + +Blocked on docs/agentic-teams.md §15 Q15 and Q16. + +Concepts: +- Reusable executable script artifacts at task, project, and agent scope +- Provisioned baseline-rules scorecard +- Watch incentive integrity risk: reward reused tools, not tool proliferation + +Expand this task when the blocking design questions are answered. + +**Test Strategy:** + +N/A - placeholder only diff --git a/.taskmaster/tasks/tasks.json b/.taskmaster/tasks/tasks.json index d897f79..eab3dd9 100644 --- a/.taskmaster/tasks/tasks.json +++ b/.taskmaster/tasks/tasks.json @@ -2549,13 +2549,960 @@ "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": "done", + "subtasks": [], + "updatedAt": "2026-08-08T09:22:12.130Z" + }, + { + "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": "done", + "subtasks": [], + "updatedAt": "2026-08-08T09:22:12.140Z" + }, + { + "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": "done", + "subtasks": [], + "updatedAt": "2026-08-08T09:29:43.358Z" + }, + { + "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": "done", + "subtasks": [], + "updatedAt": "2026-08-08T09:29:43.369Z" + }, + { + "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": "done", + "subtasks": [], + "updatedAt": "2026-08-08T09:29:43.377Z" + }, + { + "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": "done", + "subtasks": [], + "updatedAt": "2026-08-08T09:29:43.384Z" + }, + { + "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": "done", + "subtasks": [], + "updatedAt": "2026-08-08T09:29:43.392Z" + }, + { + "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": "done", + "subtasks": [], + "updatedAt": "2026-08-08T09:29:43.398Z" + }, + { + "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": "done", + "subtasks": [], + "updatedAt": "2026-08-08T10:05:28.155Z" + }, + { + "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": "done", + "subtasks": [], + "updatedAt": "2026-08-08T10:05:28.165Z" + }, + { + "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": "done", + "subtasks": [], + "updatedAt": "2026-08-08T10:05:28.172Z" + }, + { + "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": "done", + "subtasks": [], + "updatedAt": "2026-08-08T10:05:28.178Z" + }, + { + "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": "done", + "subtasks": [], + "updatedAt": "2026-08-08T10:10:39.751Z" + }, + { + "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": "done", + "subtasks": [], + "updatedAt": "2026-08-08T10:10:39.761Z" + }, + { + "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": "done", + "subtasks": [], + "updatedAt": "2026-08-08T15:28:26.687Z" + }, + { + "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": "done", + "subtasks": [], + "updatedAt": "2026-08-08T15:28:26.698Z" + }, + { + "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": "done", + "subtasks": [], + "updatedAt": "2026-08-08T15:28:26.705Z" + }, + { + "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": "done", + "subtasks": [], + "updatedAt": "2026-08-08T15:42:40.442Z" + }, + { + "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": "done", + "subtasks": [], + "updatedAt": "2026-08-08T15:42:40.452Z" + }, + { + "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": "done", + "subtasks": [], + "updatedAt": "2026-08-09T20:55:31.457Z" + }, + { + "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": "done", + "subtasks": [], + "updatedAt": "2026-08-09T19:44:20.519Z" + }, + { + "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": "done", + "subtasks": [], + "updatedAt": "2026-08-08T16:00:25.459Z" + }, + { + "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": "done", + "subtasks": [], + "updatedAt": "2026-08-08T16:00:25.469Z" + }, + { + "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": "done", + "subtasks": [], + "updatedAt": "2026-08-09T18:56:11.740Z" + }, + { + "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": "done", + "subtasks": [], + "updatedAt": "2026-08-08T16:00:25.475Z" + }, + { + "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": "done", + "subtasks": [], + "updatedAt": "2026-08-08T09:36:21.123Z" + }, + { + "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": "done", + "subtasks": [], + "updatedAt": "2026-08-08T09:36:21.132Z" + }, + { + "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": "done", + "subtasks": [], + "updatedAt": "2026-08-08T09:36:21.139Z" + }, + { + "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": "done", + "subtasks": [], + "updatedAt": "2026-08-14T23:05:31.782Z" + }, + { + "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": "done", + "subtasks": [], + "updatedAt": "2026-08-14T23:05:31.792Z" + }, + { + "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": "done", + "subtasks": [], + "updatedAt": "2026-08-15T22:10:49.721Z" + }, + { + "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": "done", + "subtasks": [], + "updatedAt": "2026-08-09T22:34:49.672Z" + }, + { + "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": "done", + "subtasks": [], + "updatedAt": "2026-08-09T19:44:20.538Z" + }, + { + "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": "done", + "subtasks": [], + "updatedAt": "2026-08-09T18:56:11.750Z" + }, + { + "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" + }, + { + "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" + }, + { + "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": "done", + "subtasks": [], + "updatedAt": "2026-08-09T20:55:31.468Z" + }, + { + "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": "done", + "subtasks": [], + "updatedAt": "2026-08-09T19:44:20.531Z" + }, + { + "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": "done", + "subtasks": [], + "updatedAt": "2026-08-09T22:34:49.682Z" + }, + { + "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": "done", + "subtasks": [], + "updatedAt": "2026-08-10T11:11:23.911Z" + }, + { + "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": "done", + "subtasks": [], + "updatedAt": "2026-08-09T21:09:27.647Z" + }, + { + "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" + }, + { + "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" + }, + { + "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": "done", + "subtasks": [], + "updatedAt": "2026-08-09T22:36:53.472Z" + }, + { + "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" + }, + { + "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": "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-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" + }, + { + "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": { "version": "1.0.0", - "lastModified": "2026-03-07T23:36:15.981Z", - "taskCount": 114, - "completedCount": 96, + "lastModified": "2026-08-15T22:54:16.433Z", + "taskCount": 177, + "completedCount": 138, "tags": [ "master" ] 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 9921d08..fe68342 100644 --- a/README.md +++ b/README.md @@ -660,6 +660,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 4a3a277..e4debf6 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 statusline login logout config get describe delete logs create edit apply chat chat-llm patch passwd errors trace backup approve review skills console cache provider test migrate rotate" - local project_commands="get describe delete logs create edit trace attach-server detach-server favourites" + local commands="status statusline login logout config repos tasks get describe delete logs create edit apply chat chat-llm patch passwd errors trace backup approve review skills console cache provider test migrate rotate" + local project_commands="get describe delete logs create edit trace 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 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 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 @@ -151,6 +151,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")) @@ -190,9 +235,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 --webhook-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 --criterion --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 --volume --health-check-tool --health-check-args --health-check-interval --health-check-timeout --tool-call-timeout --memory-limit-mb --health-check-failure-threshold --secret-delivery --entrypoint --from-template --env-from-secret --force -h --help" -- "$cur")) ;; @@ -332,6 +392,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 1fc5100..acbc043 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -4,8 +4,8 @@ # Erase any stale completions from previous versions complete -c mcpctl -e -set -l commands status statusline login logout config get describe delete logs create edit apply chat chat-llm patch passwd errors trace backup approve review skills console cache provider test migrate rotate -set -l project_commands get describe delete logs create edit trace attach-server detach-server favourites +set -l commands status statusline login logout config repos tasks get describe delete logs create edit apply chat chat-llm patch passwd errors trace backup approve review skills console cache provider test migrate rotate +set -l project_commands get describe delete logs create edit trace 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 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 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 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 @@ -72,14 +72,20 @@ 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 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 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 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 @@ -151,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 @@ -197,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] @@ -225,11 +276,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).' @@ -254,11 +307,13 @@ 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 trace -d 'Show the stage-by-stage timeline for one MCP request' 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 @@ -352,8 +407,52 @@ complete -c mcpctl -n "__mcpctl_subcmd_active config opencode" -l dry-run -d 'Pr # 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 — 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' + +# 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)' @@ -370,6 +469,44 @@ 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 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 +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 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 +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 @@ -653,7 +790,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 @@ -686,6 +823,10 @@ complete -c mcpctl -n "__fish_seen_subcommand_from errors" -s n -l limit -d 'max complete -c mcpctl -n "__fish_seen_subcommand_from trace" -s o -l output -d 'table (default) or json' -x complete -c mcpctl -n "__fish_seen_subcommand_from trace" -l strict -d 'exit non-zero if the trace contains an error or a degraded step' +# 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' @@ -702,6 +843,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/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/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/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.* 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/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/epics.md b/docs/epics.md new file mode 100644 index 0000000..6b766cd --- /dev/null +++ b/docs/epics.md @@ -0,0 +1,209 @@ +# 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. + +### 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` +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 d7845fc..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" @@ -154,7 +165,7 @@ mcpctl get task $ID ## RBAC -A new `tasks` resource. Default action mapping: +The `inference-tasks` resource. Default action mapping: | Method | Action | |--------|--------| @@ -162,13 +173,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/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)? diff --git a/scripts/generate-completions.ts b/scripts/generate-completions.ts index 639b4f7..3ee67f3 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([ @@ -186,7 +192,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', 'memberships', 'all', ]; const ALIAS_ENTRIES: [string, string][] = [ @@ -206,10 +214,19 @@ 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'], + ['membership', 'memberships'], ['memberships', 'memberships'], ['team', 'memberships'], ['all', 'all'], ]; @@ -442,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"); @@ -593,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 @@ -642,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 @@ -984,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/apply.ts b/src/cli/src/commands/apply.ts index 04d322c..05cd440 100644 --- a/src/cli/src/commands/apply.ts +++ b/src/cli/src/commands/apply.ts @@ -237,6 +237,80 @@ 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(), + // 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(), +}); + +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([]), @@ -252,6 +326,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 @@ -334,6 +413,11 @@ const KIND_TO_RESOURCE: Record = { secretbackend: 'secretbackends', llm: 'llms', agent: 'agents', + gitprovider: 'gitproviders', + repo: 'repos', + epic: 'epics', + epictask: 'epictasks', + task: 'tasks', }; /** @@ -613,6 +697,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/chat.ts b/src/cli/src/commands/chat.ts index 864cf46..b56bd9f 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 @@ export 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 @@ export function chatBody(subject: ChatSubject, message: string, threadId: string 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 }; // Guard the empty string, not just undefined: a turn that dies before its @@ -381,6 +399,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)}`, @@ -895,7 +917,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 @@ -918,7 +943,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/create.ts b/src/cli/src/commands/create.ts index 2fcecc8..6dfbd19 100644 --- a/src/cli/src/commands/create.ts +++ b/src/cli/src/commands/create.ts @@ -229,7 +229,130 @@ 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('--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})`); + }); + + 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('--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[]) { + 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/describe.ts b/src/cli/src/commands/describe.ts index 2449fb5..56c915d 100644 --- a/src/cli/src/commands/describe.ts +++ b/src/cli/src/commands/describe.ts @@ -860,6 +860,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'; @@ -1049,6 +1318,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 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/cli/src/commands/get.ts b/src/cli/src/commands/get.ts index 0014f95..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' }, @@ -413,8 +432,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': @@ -449,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': @@ -479,6 +571,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/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/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..081aba0 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', @@ -53,16 +69,23 @@ 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: 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..8a5114e --- /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 — merges its PR if it has one') + .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 bb4ade9..6affe4c 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'; @@ -13,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'; @@ -130,6 +138,31 @@ 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)}`); + } + + // `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)}`); @@ -212,6 +245,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), @@ -298,6 +339,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/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/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/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/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 a38497f..0da281f 100644 --- a/src/db/prisma/schema.prisma +++ b/src/db/prisma/schema.prisma @@ -28,6 +28,13 @@ model User { mcpTokens McpToken[] ownedAgents Agent[] chatThreads ChatThread[] + // ── Agentic teams ── + ownedEpics Epic[] + ownedEpicTasks EpicTask[] + ownedTasks Task[] + ownedRepos CodeRepo[] + ownedSessions AgentSession[] + gitProviders GitProvider[] @@index([email]) } @@ -211,8 +218,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[] + gitProviders GitProvider[] @@index([name]) @@index([backendId]) @@ -299,6 +309,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]) @@ -372,15 +385,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]) @@ -705,6 +725,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) ── @@ -735,6 +761,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]) @@ -931,3 +962,372 @@ 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? + // 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 + 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("") + // 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? + 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]) + // 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]) +} + +// ── 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 +} + +// ── 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? + // 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) + repos CodeRepo[] + + @@index([name]) + @@index([ownerId]) +} 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..03ffd97 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(); @@ -49,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 }, 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-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/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 7e4c87c..cb976da 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 { ServerIdentityService } from './services/server-identity.service.js'; @@ -40,8 +41,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'; @@ -51,6 +54,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'; @@ -60,11 +64,12 @@ 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 { mapUrlToPermission } from './middleware/url-permission.js'; import { bootstrapUngatedGlobal } from './bootstrap/ungated-global.js'; -import { SET_OWN_PASSWORD_OPERATION } from './bootstrap/self-password-permission.js'; 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, @@ -88,7 +93,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 { @@ -111,6 +115,12 @@ 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 { 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'; @@ -118,195 +128,27 @@ 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 { 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 { 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'; 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' }; - // Same operation guards the trace-event purge — both are bulk deletes of - // audit history and should not be separately grantable by accident. - if (url.startsWith('/api/v1/audit/events/purge')) 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. - 'inference-tasks': 'tasks', - 'agents': 'agents', - // Personalities inherit the agent's RBAC: managing a personality - // requires view/edit/create/delete on the `agents` resource. - 'personalities': 'agents', - }; - - 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 = [ @@ -414,6 +256,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); @@ -431,6 +275,13 @@ 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); + 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. @@ -452,6 +303,11 @@ async function main(): Promise { mcptokens: mcpTokenRepo, llms: llmRepo, agents: agentRepo, + 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) => { @@ -463,6 +319,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() @@ -599,9 +460,38 @@ 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, epicTaskRepo, workTaskRepo); + epicService.setRevisionService(resourceRevisionService); + const epicTaskService = new EpicTaskService(epicTaskRepo, epicRepo); + 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); - 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. @@ -741,6 +631,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({ @@ -760,6 +651,12 @@ 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)}`), + // 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); @@ -808,6 +705,18 @@ async function main(): Promise { registerRevisionRoutes(app, { revisionService: resourceRevisionService, promptService }); registerProposalRoutes(app, { proposalService: resourceProposalService, projectRepo, agentRepo }); registerSkillRoutes(app, skillService, projectRepo, agentRepo); + registerEpicRoutes(app, epicService, projectRepo); + registerEpicTaskRoutes(app, epicTaskService, epicRepo, epicTaskRepo, projectRepo); + 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); @@ -817,6 +726,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; @@ -864,6 +774,13 @@ 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 '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; } }; @@ -876,6 +793,11 @@ 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 '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/middleware/url-permission.ts b/src/mcpd/src/middleware/url-permission.ts new file mode 100644 index 0000000..d9546e0 --- /dev/null +++ b/src/mcpd/src/middleware/url-permission.ts @@ -0,0 +1,271 @@ +/** + * 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 }; + } + + // 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' }; + if (segment === 'audit-logs' && method === 'DELETE') return { kind: 'operation', operation: 'audit-purge' }; + // Same operation guards the trace-event purge — both are bulk deletes of + // audit history and should not be separately grantable by accident. + if (url.startsWith('/api/v1/audit/events/purge')) 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/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). + 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/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/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/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/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/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/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/repositories/task.repository.ts b/src/mcpd/src/repositories/task.repository.ts new file mode 100644 index 0000000..2fd8ea9 --- /dev/null +++ b/src/mcpd/src/repositories/task.repository.ts @@ -0,0 +1,170 @@ +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; + mergeCommit?: string; + 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; + /** 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; + 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 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 }); + } + + /** 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/agent-chat.ts b/src/mcpd/src/routes/agent-chat.ts index e332f7a..fc4f861 100644 --- a/src/mcpd/src/routes/agent-chat.ts +++ b/src/mcpd/src/routes/agent-chat.ts @@ -37,19 +37,36 @@ export function registerAgentChatRoutes( } const { - threadId, message, messages: messagesOverride, stream, personality, + threadId, message, messages: messagesOverride, stream, personality, project, ...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; + + // 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 ? { 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/routes/epics.ts b/src/mcpd/src/routes/epics.ts new file mode 100644 index 0000000..68a7da8 --- /dev/null +++ b/src/mcpd/src/routes/epics.ts @@ -0,0 +1,123 @@ +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); + }, + ); + + // 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) => { + 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 ── + + 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/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/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/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/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/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 440d4d2..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'; @@ -54,6 +55,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"). @@ -86,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 { @@ -100,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}`); @@ -228,6 +326,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/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/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 19e6a08..2c3eac5 100644 --- a/src/mcpd/src/services/chat.service.ts +++ b/src/mcpd/src/services/chat.service.ts @@ -39,6 +39,8 @@ 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. */ export interface ChatSecretResolver { @@ -82,13 +84,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; } @@ -137,6 +140,26 @@ 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. + */ + 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. */ @@ -197,6 +220,12 @@ 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; + /** 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; } export class ChatService { @@ -220,6 +249,13 @@ 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, + /** 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 }> { @@ -300,7 +336,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, authority: ctx.authority, ...(ctx.callerToken !== undefined ? { callerToken: ctx.callerToken } : {}) }); const resultMsg = await this.chatRepo.appendMessage({ threadId: ctx.threadId, role: 'tool', @@ -428,7 +464,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, 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, @@ -679,7 +715,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, @@ -693,10 +737,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 @@ -751,16 +795,43 @@ export class ChatService { history.push({ role: 'user', content: args.userMessage }); } - const toolList = await this.tools.listTools(projectId); + // 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, 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, @@ -769,6 +840,9 @@ export class ChatService { startingTurnIndex, maxIterations: resolveMaxIterations(agent.extras), allowSecrets: false, + toolset: agent.toolset, + authority, + ...(args.callerToken !== undefined ? { callerToken: args.callerToken } : {}), }; } @@ -883,6 +957,17 @@ 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', + // 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: [], + }, }; } @@ -1007,6 +1092,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 }); @@ -1073,7 +1165,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; 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. + 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'); } @@ -1097,7 +1204,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/code-repo.service.ts b/src/mcpd/src/services/code-repo.service.ts new file mode 100644 index 0000000..cd8d1b0 --- /dev/null +++ b/src/mcpd/src/services/code-repo.service.ts @@ -0,0 +1,232 @@ +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); + } + + // ── 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 ── + + /** + * 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/docker/container-manager.ts b/src/mcpd/src/services/docker/container-manager.ts index e1f4422..c3f5214 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': @@ -85,6 +101,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.scratchVolumes ?? []).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, @@ -99,7 +124,10 @@ export class DockerContainerManager implements McpOrchestrator { Memory: memoryLimit, ...(nanoCpus ? { NanoCpus: nanoCpus } : {}), NetworkMode: spec.network ?? 'bridge', - // Named volumes, created on demand by the engine. Named (not + // Scratch space shared with init containers; reaped with the container. + ...(mounts.length > 0 ? { Mounts: mounts } : {}), + ...(spec.restartPolicy ? { RestartPolicy: toDockerRestartPolicy(spec.restartPolicy) } : {}), + // Persistent named volumes, created on demand by the engine. Named (not // anonymous) matters: `removeContainer` passes `v: true`, which reaps // anonymous volumes but leaves named ones — which is what lets the // data outlive the instance. @@ -111,6 +139,17 @@ export class DockerContainerManager implements McpOrchestrator { 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); @@ -119,6 +158,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/epic-task.service.ts b/src/mcpd/src/services/epic-task.service.ts new file mode 100644 index 0000000..0ee328f --- /dev/null +++ b/src/mcpd/src/services/epic-task.service.ts @@ -0,0 +1,304 @@ +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 }, + ); + } + + this.assertCriteriaBelongToEpic(readCriteria(epic), data.doneCriteria, epic.name); + + 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); + 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 } : {}), + ...(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); + } + + /** + * 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 { + 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 ── + + /** + * 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/services/epic.service.ts b/src/mcpd/src/services/epic.service.ts new file mode 100644 index 0000000..9e5c2ff --- /dev/null +++ b/src/mcpd/src/services/epic.service.ts @@ -0,0 +1,532 @@ +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, + 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[]; +} + +// ── 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(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[] { + return (epic.acceptedTradeoffs ?? []) as unknown as AcceptedTradeoff[]; +} + +export class EpicService { + private revisionService: ResourceRevisionService | null = null; + + 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 { + 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), + }; + } + + // ── 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 { + 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); + } + + // ── 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 ── + + /** + * 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/services/git-provider.service.ts b/src/mcpd/src/services/git-provider.service.ts new file mode 100644 index 0000000..235820a --- /dev/null +++ b/src/mcpd/src/services/git-provider.service.ts @@ -0,0 +1,313 @@ +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 } : {}), + ...(data.webhookSecretKey !== undefined ? { webhookSecretKey: data.webhookSecretKey } : {}), + ...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 } : {}), + ...(data.webhookSecretKey !== undefined ? { webhookSecretKey: data.webhookSecretKey } : {}), + ...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); + } + + // ── 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 ── + + /** + * 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..a227ab6 --- /dev/null +++ b/src/mcpd/src/services/git/gitea.driver.ts @@ -0,0 +1,256 @@ +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. */ +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 }; +} + +interface GiteaPull { + number?: number; + html_url?: string; + url?: string; + title?: string; + state?: string; + merged?: boolean; + mergeable?: boolean; + merge_commit_sha?: string; + head?: { sha?: string; ref?: string }; + base?: { ref?: 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, + 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}`, { + 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}`); + } + + 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); + } + + // ── 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, + mergeCommitSha: p.merge_commit_sha ?? null, + }; +} + +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..f4879cb --- /dev/null +++ b/src/mcpd/src/services/git/types.ts @@ -0,0 +1,132 @@ +/** + * 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; +} + +/** 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; + /** Commit the merge produced. Null until merged — CI runs against this. */ + mergeCommitSha: string | 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. */ +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/services/k8s/manifest-generator.ts b/src/mcpd/src/services/k8s/manifest-generator.ts index 3d9c0af..d89035d 100644 --- a/src/mcpd/src/services/k8s/manifest-generator.ts +++ b/src/mcpd/src/services/k8s/manifest-generator.ts @@ -22,6 +22,7 @@ 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 }; @@ -35,10 +36,22 @@ 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 }>; + }>; restartPolicy: 'Always' | 'Never' | 'OnFailure'; automountServiceAccountToken: boolean; nodeSelector?: Record; - volumes?: Array<{ name: string; persistentVolumeClaim: { claimName: string } }>; + // One pod-level list carrying both kinds: PVC-backed volumes that outlive + // the instance, and emptyDir scratch shared with init containers. + volumes?: Array< + | { name: string; persistentVolumeClaim: { claimName: string } } + | { name: string; emptyDir: Record } + >; securityContext?: { fsGroup: number; fsGroupChangePolicy: string }; }; } @@ -203,21 +216,34 @@ function sanitizeName(name: string): string { function buildPodVolumes( spec: ContainerSpec, ): Pick { - if (!spec.volumes || spec.volumes.length === 0) return {}; + // Both kinds land in the same pod-level list, so they are built together — + // emitting them from two places would have the second spread silently + // overwrite the first. + const persistent = (spec.volumes ?? []).map((v) => ({ + name: sanitizeName(v.claimName), + persistentVolumeClaim: { claimName: sanitizeName(v.claimName) }, + })); + const scratch = (spec.scratchVolumes ?? []).map((v) => ({ name: v.name, emptyDir: {} })); + const volumes = [...persistent, ...scratch]; + if (volumes.length === 0) return {}; + return { - volumes: spec.volumes.map((v) => ({ - name: sanitizeName(v.claimName), - persistentVolumeClaim: { claimName: sanitizeName(v.claimName) }, - })), - securityContext: { - // Without this a fresh PVC mounts root:root and any image that dropped - // privileges cannot write to it. First declared fsGroup wins — a pod has - // exactly one, so per-volume groups are not expressible here. - fsGroup: spec.volumes[0]?.fsGroup ?? DEFAULT_VOLUME_FS_GROUP, - // Only chown when the top-level ownership is already wrong; the default - // ("Always") walks the whole volume on every single start. - fsGroupChangePolicy: 'OnRootMismatch', - }, + volumes, + // fsGroup exists for the PVC case only; an emptyDir is created writable by + // the container's own user, so scratch-only pods need no securityContext. + ...(spec.volumes && spec.volumes.length > 0 + ? { + securityContext: { + // Without this a fresh PVC mounts root:root and any image that dropped + // privileges cannot write to it. First declared fsGroup wins — a pod has + // exactly one, so per-volume groups are not expressible here. + fsGroup: spec.volumes[0]?.fsGroup ?? DEFAULT_VOLUME_FS_GROUP, + // Only chown when the top-level ownership is already wrong; the default + // ("Always") walks the whole volume on every single start. + fsGroupChangePolicy: 'OnRootMismatch' as const, + }, + } + : {}), }; } @@ -276,16 +302,54 @@ function buildContainerSpec(spec: ContainerSpec) { container.ports = [{ containerPort: spec.containerPort }]; } - if (spec.volumes && spec.volumes.length > 0) { - container.volumeMounts = spec.volumes.map((v) => ({ + if (spec.workdir !== undefined && spec.workdir !== '') { + container.workingDir = spec.workdir; + } + + // Both kinds mount into the main container: persistent volumes keyed by + // claimName, and scratch volumes it shares with its init containers. Init + // containers pick the subset they need via their own volumeMounts. + const volumeMounts = [ + ...(spec.volumes ?? []).map((v) => ({ name: sanitizeName(v.claimName), mountPath: v.mountPath, - })); + })), + ...(spec.scratchVolumes ?? []).map((v) => ({ name: v.name, mountPath: v.mountPath })), + ]; + if (volumeMounts.length > 0) { + container.volumeMounts = volumeMounts; } return container; } +/** + * Init containers for the pod spec. + * + * Volumes are deliberately NOT emitted here — `buildPodVolumes` owns the whole + * pod-level list so the two kinds cannot clobber one another. + */ +function buildPodExtras(spec: ContainerSpec): Partial { + const extras: Partial = {}; + + // 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; +} + /** * PVC for a server volume. Created once per server and left in place when * instances come and go — that outliving is the entire point of the resource. @@ -343,7 +407,10 @@ export function generatePodSpec(spec: ContainerSpec, namespace: string): K8sPodM ? { serviceAccountName: spec.serviceAccountName } : {}), 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 — EXCEPT when the OpenBao // agent is injected, which logs in with the projected SA token. automountServiceAccountToken: spec.automountServiceAccountToken ?? false, @@ -379,6 +446,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, ...buildPodVolumes(spec), 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/src/services/orchestrator.ts b/src/mcpd/src/services/orchestrator.ts index 74bf346..4608bca 100644 --- a/src/mcpd/src/services/orchestrator.ts +++ b/src/mcpd/src/services/orchestrator.ts @@ -85,6 +85,48 @@ 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. + * + * Distinct from `volumes` above, which is persistent storage keyed by a + * server-derived `claimName` and deliberately survives instance recreation. + * These are the opposite: the pod's lifetime bounds them, and they exist so + * an init container and the main container can share a filesystem. + */ + scratchVolumes?: 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/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/src/services/task-merger.ts b/src/mcpd/src/services/task-merger.ts new file mode 100644 index 0000000..95aa679 --- /dev/null +++ b/src/mcpd/src/services/task-merger.ts @@ -0,0 +1,120 @@ +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; mergeCommit?: 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 } : {}), + }, + ); + // 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) { + 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 new file mode 100644 index 0000000..361b6fc --- /dev/null +++ b/src/mcpd/src/services/task.service.ts @@ -0,0 +1,333 @@ +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 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'], + 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; +} + +/** 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; mergeCommit?: string }>; +} + +export class TaskService { + constructor( + private readonly repo: ITaskRepository, + private readonly epicTaskRepo: IEpicTaskRepository, + private readonly merger?: TaskMerger, + ) {} + + 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. + * + * 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. + */ + 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 }); + + // 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; + // 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( + id, + { toState: 'merged', note: note !== undefined ? `${note} — ${mergeDetail}` : mergeDetail }, + 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/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/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/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/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/src/validation/git-provider.schema.ts b/src/mcpd/src/validation/git-provider.schema.ts new file mode 100644 index 0000000..da79706 --- /dev/null +++ b/src/mcpd/src/validation/git-provider.schema.ts @@ -0,0 +1,57 @@ +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://' }, +); + +/** + * 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(), +}); + +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(), + // Nullable so webhook verification can be turned back off without deleting + // the provider. + webhookSecretKey: WebhookSecretKeySchema.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 61d6749..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', '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', 'gitproviders'] as const; /** Singular→plural map for resource names. */ const RESOURCE_ALIASES: Record = { @@ -17,13 +17,29 @@ 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', + gitprovider: 'gitproviders', + forge: 'gitproviders', }; /** Normalize a resource name to its canonical plural form. */ 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/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..5fa641f --- /dev/null +++ b/src/mcpd/tests/chat-service-membership.test.ts @@ -0,0 +1,262 @@ +/** + * `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 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'); + +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', 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.anything()); + }); + + 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', 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 204947e..db9be9e 100644 --- a/src/mcpd/tests/chat-service-project.test.ts +++ b/src/mcpd/tests/chat-service-project.test.ts @@ -169,7 +169,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-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 7086a19..6de6109 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, @@ -249,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. @@ -803,6 +812,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' }, @@ -823,6 +835,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' }, @@ -1108,6 +1123,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/docker-ephemeral.test.ts b/src/mcpd/tests/docker-ephemeral.test.ts new file mode 100644 index 0000000..ce680e6 --- /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', scratchVolumes: [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', + scratchVolumes: [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', + scratchVolumes: [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/epic-routes.test.ts b/src/mcpd/tests/epic-routes.test.ts new file mode 100644 index 0000000..2244463 --- /dev/null +++ b/src/mcpd/tests/epic-routes.test.ts @@ -0,0 +1,295 @@ +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'; + +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 }); + +/** 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 }; +}> { + 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 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, epicTaskRepo, taskRepo), + 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('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; + + 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/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(); + }); +}); diff --git a/src/mcpd/tests/k8s-manifest.test.ts b/src/mcpd/tests/k8s-manifest.test.ts index bb8a20f..0137e9b 100644 --- a/src/mcpd/tests/k8s-manifest.test.ts +++ b/src/mcpd/tests/k8s-manifest.test.ts @@ -247,3 +247,166 @@ 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, scratchVolumes: [{ 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, + scratchVolumes: [{ 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, scratchVolumes: [{ name: 'cache', mountPath: '/cache', kind: 'emptyDir' }] }, + 'ns', + ); + expect(dep.spec.template.spec.volumes).toEqual([{ name: 'cache', emptyDir: {} }]); + }); +}); + +describe('persistent and scratch volumes coexist', () => { + /** + * Two features developed in parallel both wanted the name `volumes` on + * ContainerSpec, and both emitted the pod-level `volumes` list from their own + * helper. Spreading two objects that each set `volumes` silently keeps only + * the last one, so a server with persistent storage would have lost its PVC + * the moment a session scratch volume was added (or vice versa). + * + * These pin the reconciliation: persistent volumes stay keyed by claimName, + * scratch volumes are `scratchVolumes`, and both reach the pod together. + */ + const bothSpec = { + ...baseSpec, + volumes: [{ claimName: 'docs-data', mountPath: '/data', sizeGb: 20 }], + scratchVolumes: [{ name: 'workspace', mountPath: '/workspace', kind: 'emptyDir' as const }], + }; + + it('declares both kinds in the pod volume list', () => { + const pod = generatePodSpec(bothSpec, 'ns'); + expect(pod.spec.volumes).toEqual([ + { name: 'docs-data', persistentVolumeClaim: { claimName: 'docs-data' } }, + { name: 'workspace', emptyDir: {} }, + ]); + }); + + it('mounts both into the main container', () => { + const pod = generatePodSpec(bothSpec, 'ns'); + expect(pod.spec.containers[0]?.volumeMounts).toEqual([ + { name: 'docs-data', mountPath: '/data' }, + { name: 'workspace', mountPath: '/workspace' }, + ]); + }); + + it('still sets fsGroup, which only the PVC needs', () => { + const pod = generatePodSpec(bothSpec, 'ns'); + expect(pod.spec.securityContext?.fsGroup).toBeDefined(); + }); + + it('omits securityContext when the pod has scratch space only', () => { + // An emptyDir is created writable by the container's own user, so forcing + // an fsGroup chown on a scratch-only pod is work with no purpose. + const pod = generatePodSpec( + { ...baseSpec, scratchVolumes: [{ name: 'workspace', mountPath: '/workspace', kind: 'emptyDir' }] }, + 'ns', + ); + expect(pod.spec.securityContext).toBeUndefined(); + expect(pod.spec.volumes).toEqual([{ name: 'workspace', emptyDir: {} }]); + }); + + it('carries both through the Deployment pod template too', () => { + const dep = generateDeploymentSpec(bothSpec, 'ns'); + expect(dep.spec.template.spec.volumes).toEqual([ + { name: 'docs-data', persistentVolumeClaim: { claimName: 'docs-data' } }, + { name: 'workspace', emptyDir: {} }, + ]); + }); +}); 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/mcpd/tests/security.test.ts b/src/mcpd/tests/security.test.ts index 0a1492b..29033f4 100644 --- a/src/mcpd/tests/security.test.ts +++ b/src/mcpd/tests/security.test.ts @@ -19,71 +19,17 @@ import { AuditEventService } from '../src/services/audit-event.service.js'; import type { IAuditEventRepository } from '../src/repositories/interfaces.js'; import { errorHandler } from '../src/middleware/error-handler.js'; import { CreateMcpServerSchema } from '../src/validation/mcp-server.schema.js'; +import { mapUrlToPermission } from '../src/middleware/url-permission.js'; // ───────────────────────────────────────────────────────── // § 1 audit-events endpoint bypasses RBAC // ───────────────────────────────────────────────────────── -/** - * Reproduce mapUrlToPermission from main.ts to test which URLs - * get RBAC checks and which are skipped. - */ -type PermissionCheck = - | { kind: 'resource'; resource: string; action: string; resourceName?: string } - | { kind: 'operation'; operation: string } - | { kind: 'skip' }; - -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; - - 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' }; - - const resourceMap: Record = { - 'servers': 'servers', - 'instances': 'instances', - 'secrets': 'secrets', - 'projects': 'projects', - 'templates': 'templates', - 'users': 'users', - 'groups': 'groups', - 'rbac': 'rbac', - 'audit-logs': 'rbac', - 'mcp': 'servers', - 'prompts': 'prompts', - 'promptrequests': 'promptrequests', - }; - - const resource = resourceMap[segment]; - if (resource === undefined) return { kind: 'skip' }; - - let action: string; - switch (method) { - case 'GET': - case 'HEAD': - action = 'view'; - break; - case 'POST': - action = 'create'; - break; - case 'DELETE': - action = 'delete'; - break; - default: - action = 'edit'; - break; - } - - 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; -} +// Uses the REAL mapping, not a copy. This file previously reproduced +// mapUrlToPermission by hand because main.ts self-invokes main() and nothing +// in it was importable. A security test asserting against a hand-copied +// duplicate stops testing the system the moment the real one changes — and +// this copy had already drifted. It now lives in middleware/url-permission.ts. describe('Security: RBAC coverage gaps in mapUrlToPermission', () => { it('audit-events endpoint is NOT in resourceMap — bypasses RBAC', () => { 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/); + }); +}); 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/epic-service.test.ts b/src/mcpd/tests/services/epic-service.test.ts new file mode 100644 index 0000000..5485e6b --- /dev/null +++ b/src/mcpd/tests/services/epic-service.test.ts @@ -0,0 +1,537 @@ +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 { + 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 }); + +/** 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; + 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; + + 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', () => { + 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.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(); }); + + 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(); + }); +}); 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..0a7b520 --- /dev/null +++ b/src/mcpd/tests/services/epic-task-service.test.ts @@ -0,0 +1,323 @@ +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 })); + } + }); +}); + +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(); + }); +}); 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..7fbd0a4 --- /dev/null +++ b/src/mcpd/tests/services/git-provider.test.ts @@ -0,0 +1,504 @@ +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, 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'; + +// ── 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(); + }); +}); + +// ── 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); + }); +}); + +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(); + }); +}); 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); + }); +}); 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); + }); +}); 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 new file mode 100644 index 0000000..aac6b54 --- /dev/null +++ b/src/mcpd/tests/services/task-service.test.ts @@ -0,0 +1,358 @@ +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'/); + }); +}); + +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') }), + ); + }); +}); 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' }); + }); +}); 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'); }); }); diff --git a/src/mcplocal/src/http/routes/proxy.ts b/src/mcplocal/src/http/routes/proxy.ts index 9b2bebd..577af0e 100644 --- a/src/mcplocal/src/http/routes/proxy.ts +++ b/src/mcplocal/src/http/routes/proxy.ts @@ -79,6 +79,10 @@ export function registerProxyRoutes(app: FastifyInstance, client: McpdClient): v return proxyStreaming(reply, client, request.method, path, querystring, body, userToken); } + 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); 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..74e1129 --- /dev/null +++ b/src/mcplocal/tests/smoke/agentic-teams.smoke.test.ts @@ -0,0 +1,222 @@ +/** + * 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/); + }); + + 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); + }); +}); 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']); + }); +});