feat(cli): apply -f support for agentic-teams resources, plus vertical smoke tests

Closes two stated project rules this work had been violating.

**`get -o yaml` must round-trip through `apply -f`.** The five new resources
were creatable and listable but absent from apply, so what `get` emitted was not
valid apply input. They are now applied in dependency order — gitproviders →
repos → epics → epictasks → tasks — since each references the previous by name,
and each block is independently fault-tolerant so one bad entry does not abort
the rest of a manifest. `edit` accepts them too.

`state` is deliberately NOT in the task spec. Lifecycle moves go through
`mcpctl tasks advance`, which enforces the transition table and the risk gate; a
manifest that could set state would route straight around both. epictask
`dependsOn` edges are re-applied on every run, which is safe because the server
treats a duplicate edge as a no-op.

**Every feature ships smoke tests.** Adds the vertical: create an epic with
mixed outcome/quality criteria, round-trip it through get -o yaml → apply -f,
chain an epictask, then drive a task through the lifecycle asserting the risk
gate blocks `done` without a passing QA verdict, that an illegal transition is
refused, that history carries every transition and the verdict, and that reject
without a comment fails. Skips when mcpd is unreachable; a failure against a
reachable daemon means it predates the feature.

The round-trip assertion is the interesting one: it feeds `get`'s own output
straight back into `apply` and fails if the two surfaces have drifted, rather
than trusting that they agree.

Local: build clean, workspace 2457 passed, db 128 passed (same 4 pre-existing
Secret failures), lint back to the repo's baseline 794 errors after replacing
destructured discards with an explicit omit() helper.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
This commit is contained in:
Michal
2026-08-09 19:56:11 +01:00
parent 77c45a2715
commit 53507e876a
4 changed files with 355 additions and 7 deletions

View File

@@ -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 <resource> <name> -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<boolean> {
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/);
});
});