diff --git a/.taskmaster/tasks/tasks.json b/.taskmaster/tasks/tasks.json index 287d7e1..40f3167 100644 --- a/.taskmaster/tasks/tasks.json +++ b/.taskmaster/tasks/tasks.json @@ -2891,9 +2891,9 @@ "dependencies": [ "137" ], - "status": "pending", + "status": "done", "subtasks": [], - "updatedAt": "2026-08-08T08:55:58.627Z" + "updatedAt": "2026-08-09T18:56:11.740Z" }, { "id": "139", @@ -3139,9 +3139,9 @@ "128", "131" ], - "status": "pending", + "status": "done", "subtasks": [], - "updatedAt": "2026-08-08T08:55:58.627Z" + "updatedAt": "2026-08-09T18:56:11.750Z" }, { "id": "156", @@ -3265,9 +3265,9 @@ ], "metadata": { "version": "1.0.0", - "lastModified": "2026-08-08T16:00:25.475Z", + "lastModified": "2026-08-09T18:56:11.750Z", "taskCount": 163, - "completedCount": 122, + "completedCount": 124, "tags": [ "master" ] diff --git a/src/cli/src/commands/apply.ts b/src/cli/src/commands/apply.ts index 5360a1a..36fd16a 100644 --- a/src/cli/src/commands/apply.ts +++ b/src/cli/src/commands/apply.ts @@ -215,6 +215,76 @@ const McpTokenSpecSchema = z.object({ bindings: z.array(RbacRoleBindingSchema).default([]), }); +/** Copy without the given keys — identity fields the PUT endpoints don't accept. */ +function omit>(obj: T, keys: string[]): Record { + const out: Record = { ...obj }; + for (const k of keys) delete out[k]; + return out; +} + +// ── Agentic teams ── +// Ordering below matters: gitproviders → repos → epics → epictasks → tasks, +// because each references the previous by name. + +const GitProviderSpecSchema = z.object({ + name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/), + url: z.string().url(), + type: z.string().optional(), + description: z.string().optional(), + credRef: z.object({ name: z.string(), key: z.string() }).nullable().optional(), + skipProbe: z.boolean().optional(), +}); + +const CodeRepoSpecSchema = z.object({ + name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/), + project: z.string().optional(), + projectId: z.string().optional(), + url: z.string().min(1), + defaultBranch: z.string().optional(), + path: z.string().optional(), + provider: z.string().optional(), + credRef: z.object({ name: z.string(), key: z.string() }).nullable().optional(), +}); + +const DoneCriterionSpecSchema = z.object({ + id: z.string().min(1), + text: z.string().min(1), + kind: z.enum(['outcome', 'quality']).optional(), + met: z.boolean().optional(), +}); + +const EpicSpecSchema = z.object({ + name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/), + project: z.string().optional(), + projectId: z.string().optional(), + content: z.string().min(1), + description: z.string().optional(), + doneCriteria: z.array(DoneCriterionSpecSchema).optional(), + semver: z.string().optional(), +}); + +const EpicTaskSpecSchema = z.object({ + name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/), + epic: z.string().optional(), + epicId: z.string().optional(), + project: z.string().optional(), + description: z.string().optional(), + doneCriteria: z.array(DoneCriterionSpecSchema).optional(), + status: z.enum(['todo', 'in_progress', 'done', 'cancelled']).optional(), + /** Prerequisite epictask names within the same Epic. */ + dependsOn: z.array(z.string()).optional(), +}); + +const TaskSpecSchema = z.object({ + name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/), + epictask: z.string().optional(), + epicTaskId: z.string().optional(), + title: z.string().min(1), + description: z.string().optional(), + risk: z.enum(['trivial', 'normal', 'high']).optional(), + repoId: z.string().optional(), +}); + const ApplyConfigSchema = z.object({ secretbackends: z.array(SecretBackendSpecSchema).default([]), secrets: z.array(SecretSpecSchema).default([]), @@ -230,6 +300,11 @@ const ApplyConfigSchema = z.object({ rbac: z.array(RbacBindingSpecSchema).default([]), prompts: z.array(PromptSpecSchema).default([]), mcptokens: z.array(McpTokenSpecSchema).default([]), + gitproviders: z.array(GitProviderSpecSchema).default([]), + repos: z.array(CodeRepoSpecSchema).default([]), + epics: z.array(EpicSpecSchema).default([]), + epictasks: z.array(EpicTaskSpecSchema).default([]), + tasks: z.array(TaskSpecSchema).default([]), }).transform((data) => ({ ...data, // Merge rbac into rbacBindings so both keys work @@ -312,6 +387,11 @@ const KIND_TO_RESOURCE: Record = { secretbackend: 'secretbackends', llm: 'llms', agent: 'agents', + gitprovider: 'gitproviders', + repo: 'repos', + epic: 'epics', + epictask: 'epictasks', + task: 'tasks', }; /** @@ -591,6 +671,102 @@ async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args } } + // ── Agentic teams ── + // Applied in dependency order: a repo needs its provider, an epictask needs + // its epic, a task needs its epictask. Each block is independently + // fault-tolerant so one bad entry does not abort the rest of the manifest. + + for (const gp of config.gitproviders) { + try { + const existing = await cachedFindByName('gitproviders', gp.name); + if (existing) { + await withRetry(() => client.put(`/api/v1/gitproviders/${existing.id}`, omit(gp, ['name']))); + log(`Updated gitprovider: ${gp.name}`); + } else { + await withRetry(() => client.post('/api/v1/gitproviders', gp)); + invalidateCache('gitproviders'); + log(`Created gitprovider: ${gp.name}`); + } + } catch (err) { + log(`Error applying gitprovider '${gp.name}': ${err instanceof Error ? err.message : err}`); + } + } + + for (const repo of config.repos) { + try { + const existing = await cachedFindByName('repos', repo.name); + if (existing) { + await withRetry(() => client.put(`/api/v1/repos/${existing.id}`, omit(repo, ['name', 'project', 'projectId']))); + log(`Updated repo: ${repo.name}`); + } else { + await withRetry(() => client.post('/api/v1/repos', repo)); + invalidateCache('repos'); + log(`Created repo: ${repo.name}`); + } + } catch (err) { + log(`Error applying repo '${repo.name}': ${err instanceof Error ? err.message : err}`); + } + } + + for (const epic of config.epics) { + try { + const existing = await cachedFindByName('epics', epic.name); + if (existing) { + await withRetry(() => client.put(`/api/v1/epics/${existing.id}`, omit(epic, ['name', 'project', 'projectId']))); + log(`Updated epic: ${epic.name}`); + } else { + await withRetry(() => client.post('/api/v1/epics', epic)); + invalidateCache('epics'); + log(`Created epic: ${epic.name}`); + } + } catch (err) { + log(`Error applying epic '${epic.name}': ${err instanceof Error ? err.message : err}`); + } + } + + for (const et of config.epictasks) { + try { + const existing = await cachedFindByName('epictasks', et.name); + const { dependsOn, ...spec } = et; + let id: string; + if (existing) { + await withRetry(() => client.put(`/api/v1/epictasks/${existing.id}`, omit(spec, ['name', 'epic', 'epicId', 'project']))); + id = existing.id; + log(`Updated epictask: ${et.name}`); + } else { + const created = await withRetry(() => client.post<{ id: string }>('/api/v1/epictasks', spec)); + invalidateCache('epictasks'); + id = created.id; + log(`Created epictask: ${et.name}`); + } + // Edges are idempotent server-side, so re-applying a manifest is safe. + for (const dep of dependsOn ?? []) { + await withRetry(() => client.post(`/api/v1/epictasks/${id}/edges`, { to: dep })); + } + } catch (err) { + log(`Error applying epictask '${et.name}': ${err instanceof Error ? err.message : err}`); + } + } + + for (const task of config.tasks) { + try { + const existing = await cachedFindByName('tasks', task.name); + if (existing) { + // `state` is deliberately absent from the spec: lifecycle moves go + // through `mcpctl tasks advance`, which enforces the transition table. + // A manifest that could set state would route around that. + await withRetry(() => client.put(`/api/v1/tasks/${existing.id}`, omit(task, ['name', 'epictask', 'epicTaskId']))); + log(`Updated task: ${task.name}`); + } else { + await withRetry(() => client.post('/api/v1/tasks', task)); + invalidateCache('tasks'); + log(`Created task: ${task.name}`); + } + } catch (err) { + log(`Error applying task '${task.name}': ${err instanceof Error ? err.message : err}`); + } + } + // Apply RBAC bindings for (const rbacBinding of config.rbacBindings) { try { diff --git a/src/cli/src/commands/edit.ts b/src/cli/src/commands/edit.ts index de8b8e5..7aa1cc1 100644 --- a/src/cli/src/commands/edit.ts +++ b/src/cli/src/commands/edit.ts @@ -51,7 +51,7 @@ export function createEditCommand(deps: EditCommandDeps): Command { return; } - const validResources = ['servers', 'secrets', 'projects', 'groups', 'rbac', 'prompts', 'promptrequests', 'personalities']; + const validResources = ['servers', 'secrets', 'projects', 'groups', 'rbac', 'prompts', 'promptrequests', 'personalities', 'epics', 'epictasks', 'tasks', 'repos', 'gitproviders']; if (!validResources.includes(resource)) { log(`Error: unknown resource type '${resourceArg}'`); process.exitCode = 1; diff --git a/src/mcplocal/tests/smoke/agentic-teams.smoke.test.ts b/src/mcplocal/tests/smoke/agentic-teams.smoke.test.ts new file mode 100644 index 0000000..b208b4e --- /dev/null +++ b/src/mcplocal/tests/smoke/agentic-teams.smoke.test.ts @@ -0,0 +1,172 @@ +/** + * Smoke: the agentic-teams vertical against a live mcpd. + * + * gitprovider → discover → import → epic → epictask → task → lifecycle + * + * Also enforces the project rule that `get -o yaml` must + * round-trip through `apply -f`: the flow re-applies what it read back, and a + * drift there means the two surfaces have diverged. + * + * Skips (does not fail) when mcpd is unreachable. A FAILURE against a reachable + * daemon means the daemon predates this feature — deploy it, do not relax the + * assertion. + */ +import { execSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import http from 'node:http'; +import https from 'node:https'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; + +const MCPD_URL = process.env.MCPD_URL ?? 'https://mcpctl.ad.itaz.eu'; +const SUFFIX = Date.now().toString(36); +const PROJECT = `smoke-at-${SUFFIX}`; +const EPIC = `smoke-epic-${SUFFIX}`; +const EPICTASK = `smoke-et-${SUFFIX}`; +const TASK = `smoke-task-${SUFFIX}`; + +interface CliResult { code: number; stdout: string; stderr: string } + +function run(args: string): CliResult { + try { + const stdout = execSync(`mcpctl --direct ${args}`, { + encoding: 'utf-8', timeout: 60_000, stdio: ['ignore', 'pipe', 'pipe'], + }); + return { code: 0, stdout: stdout.trim(), stderr: '' }; + } catch (err) { + const e = err as { status?: number; stdout?: Buffer | string; stderr?: Buffer | string }; + const str = (v: Buffer | string | undefined): string => + v === undefined ? '' : typeof v === 'string' ? v : v.toString('utf-8'); + return { code: e.status ?? 1, stdout: str(e.stdout), stderr: str(e.stderr) }; + } +} + +function healthz(url: string, timeoutMs = 5000): Promise { + return new Promise((resolve) => { + const parsed = new URL(`${url.replace(/\/$/, '')}/healthz`); + const driver = parsed.protocol === 'https:' ? https : http; + const req = driver.get({ + hostname: parsed.hostname, + port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80), + path: parsed.pathname, + timeout: timeoutMs, + }, (res) => { resolve((res.statusCode ?? 500) < 500); res.resume(); }); + req.on('error', () => resolve(false)); + req.on('timeout', () => { req.destroy(); resolve(false); }); + }); +} + +let up = false; +let workDir: string | null = null; + +describe('agentic teams smoke', () => { + beforeAll(async () => { + up = await healthz(MCPD_URL); + if (!up) { + console.warn(`\n ○ agentic-teams smoke: skipped — ${MCPD_URL}/healthz unreachable.\n`); + return; + } + workDir = mkdtempSync(join(tmpdir(), 'mcpctl-smoke-at-')); + const res = run(`create project ${PROJECT} --description "agentic teams smoke"`); + expect(res.code, `project create failed: ${res.stderr || res.stdout}`).toBe(0); + }, 90_000); + + afterAll(() => { + if (up) { + run(`delete task ${TASK}`); + run(`delete epictask ${EPICTASK}`); + run(`delete epic ${EPIC}`); + run(`delete project ${PROJECT}`); + } + if (workDir !== null) rmSync(workDir, { recursive: true, force: true }); + }); + + it('creates an epic with mixed outcome and quality criteria', () => { + if (!up) return; + const res = run( + `create epic ${EPIC} --project ${PROJECT} --content "ship the thing" ` + + `--criterion "builds=it builds" --criterion "q:feel=it feels finished"`, + ); + expect(res.code, res.stderr || res.stdout).toBe(0); + + const list = run(`get epics --project ${PROJECT} -o json`); + const epics = JSON.parse(list.stdout) as Array<{ name: string; completeness?: { total: number; percent: number } }>; + const mine = epics.find((e) => e.name === EPIC); + // Both kinds count — grading only the mechanical half is the failure the + // Epic exists to prevent. + expect(mine?.completeness?.total).toBe(2); + expect(mine?.completeness?.percent).toBe(0); + }); + + it('round-trips an epic through get -o yaml → apply -f', () => { + if (!up) return; + const yaml = run(`get epic ${EPIC} -o yaml`); + expect(yaml.code, yaml.stderr).toBe(0); + + const path = join(workDir as string, 'epic.yaml'); + writeFileSync(path, yaml.stdout, 'utf-8'); + + // The project rule: what `get -o yaml` emits must be valid `apply -f` input. + const applied = run(`apply -f ${path}`); + expect(applied.code, `apply rejected its own get output: ${applied.stderr || applied.stdout}`).toBe(0); + expect(applied.stdout).toMatch(new RegExp(`(Updated|Created) epic: ${EPIC}`)); + }); + + it('chains an epictask and exposes the roadmap', () => { + if (!up) return; + expect(run(`create epictask ${EPICTASK} --epic ${EPIC}`).code).toBe(0); + + const list = run(`get epictasks -o json`); + const nodes = JSON.parse(list.stdout) as Array<{ name: string; status: string }>; + expect(nodes.find((n) => n.name === EPICTASK)?.status).toBe('todo'); + }); + + it('drives a task through the lifecycle, with the risk gate enforced', () => { + if (!up) return; + expect(run(`create task ${TASK} --epictask ${EPICTASK} --title "smoke" --risk normal`).code).toBe(0); + + for (const state of ['in_progress', 'building', 'self_testing']) { + const r = run(`tasks advance ${TASK} ${state}`); + expect(r.code, `${state}: ${r.stderr || r.stdout}`).toBe(0); + } + + // normal risk requires a passing QA verdict before done. + const blocked = run(`tasks advance ${TASK} done`); + expect(blocked.code).not.toBe(0); + expect(`${blocked.stderr}${blocked.stdout}`).toMatch(/passing verdict from QA/); + + expect(run(`tasks advance ${TASK} qa_review`).code).toBe(0); + expect(run( + `tasks verdict ${TASK} --stage qa_review --commit abc1234 --pass --summary "looks good"`, + ).code).toBe(0); + expect(run(`tasks advance ${TASK} done`).code, 'done should pass once QA passed').toBe(0); + }); + + it('rejects an illegal transition rather than silently allowing it', () => { + if (!up) return; + const r = run(`tasks advance ${TASK} building`); + expect(r.code).not.toBe(0); + expect(`${r.stderr}${r.stdout}`).toMatch(/Illegal transition/); + }); + + it('records the full history: every transition plus both verdict outcomes', () => { + if (!up) return; + const r = run(`tasks history ${TASK}`); + expect(r.code, r.stderr).toBe(0); + expect(r.stdout).toContain('(new) → todo'); + expect(r.stdout).toContain('self_testing → qa_review'); + expect(r.stdout).toMatch(/PASS\s+qa_review/); + }); + + it('requires a comment to reject — the comment is the direction', () => { + if (!up) return; + expect(run(`tasks advance ${TASK} awaiting_review`).code).toBe(0); + const noNote = run(`tasks reject ${TASK}`); + expect(noNote.code, 'reject without --note must fail').not.toBe(0); + + const withNote = run(`tasks reject ${TASK} --note "needs a test"`); + expect(withNote.code, withNote.stderr || withNote.stdout).toBe(0); + expect(withNote.stdout).toMatch(/rejected → in_progress/); + }); +});