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:
@@ -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<T extends Record<string, unknown>>(obj: T, keys: string[]): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = { ...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<string, string> = {
|
||||
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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user