feat(mcpd): auto-create project on virtual-agent register (v6 Stage 2)
Some checks failed
CI/CD / typecheck (pull_request) Successful in 55s
CI/CD / test (pull_request) Successful in 1m12s
CI/CD / lint (pull_request) Successful in 3m0s
CI/CD / smoke (pull_request) Failing after 1m44s
CI/CD / build (pull_request) Successful in 6m41s
CI/CD / publish (pull_request) Has been skipped
Some checks failed
CI/CD / typecheck (pull_request) Successful in 55s
CI/CD / test (pull_request) Successful in 1m12s
CI/CD / lint (pull_request) Successful in 3m0s
CI/CD / smoke (pull_request) Failing after 1m44s
CI/CD / build (pull_request) Successful in 6m41s
CI/CD / publish (pull_request) Has been skipped
Closes the v3-deferred "project must already exist" gap. When a virtual agent declares `project: "my-team"` and no such project exists, mcpd creates it idempotently with the publishing user as owner (instead of throwing 404 from registerVirtualAgents). ProjectService gains `ensureByName(name, ownerId, opts)` — find the project or create it with sensible defaults (description carries an audit note pointing at the registrar; proxyModel/gated take their schema defaults). First publisher to land on a name owns the row; subsequent publishers reuse the existing one. AgentService.registerVirtualAgents calls ensureByName instead of resolveAndGet, so the same agent register payload works regardless of whether the project pre-existed or not. Tests: 2 new tests (auto-creates a missing project on first publish; reuses an existing project without re-creating). Mock projects factory rebuilt to track _created names + maintain id→name reverse lookup so the agent's toView returns the correct project name (prior mock hardcoded 'mcpctl-dev'). Existing 13 virtual-agent tests + 870 mcpd suite green.
This commit is contained in:
@@ -140,14 +140,40 @@ function mockLlms(): LlmService {
|
||||
} as unknown as LlmService;
|
||||
}
|
||||
|
||||
function mockProjects(): ProjectService {
|
||||
return {
|
||||
getById: vi.fn(async (id: string) => ({ id, name: 'mcpctl-dev' })),
|
||||
resolveAndGet: vi.fn(async (idOrName: string) => ({
|
||||
id: idOrName === 'mcpctl-dev' ? 'proj-1' : 'proj-other',
|
||||
name: idOrName,
|
||||
})),
|
||||
} as unknown as ProjectService;
|
||||
function mockProjects(opts: { existingNames?: string[] } = {}): ProjectService & { _created: string[] } {
|
||||
const existing = new Set(opts.existingNames ?? ['mcpctl-dev']);
|
||||
const created: string[] = [];
|
||||
// Reverse-lookup id → name so toView's getById finds the project we
|
||||
// ensured/created (instead of always returning a hardcoded name).
|
||||
const idToName = new Map<string, string>();
|
||||
const remember = (name: string): { id: string; name: string } => {
|
||||
const id = `proj-${name}`;
|
||||
idToName.set(id, name);
|
||||
return { id, name };
|
||||
};
|
||||
// Pre-seed the lookup so existing projects round-trip correctly.
|
||||
for (const n of existing) remember(n);
|
||||
const svc = {
|
||||
_created: created,
|
||||
getById: vi.fn(async (id: string) => ({ id, name: idToName.get(id) ?? 'mcpctl-dev' })),
|
||||
resolveAndGet: vi.fn(async (idOrName: string) => {
|
||||
if (!existing.has(idOrName)) {
|
||||
const err = new Error(`Project not found: ${idOrName}`);
|
||||
err.name = 'NotFoundError';
|
||||
throw err;
|
||||
}
|
||||
return remember(idOrName);
|
||||
}),
|
||||
// v6: ensureByName auto-creates when missing.
|
||||
ensureByName: vi.fn(async (name: string, ownerId: string, _opts?: { auditNote?: string }) => {
|
||||
if (existing.has(name)) return remember(name);
|
||||
created.push(name);
|
||||
existing.add(name);
|
||||
const row = remember(name);
|
||||
return { ...row, ownerId };
|
||||
}),
|
||||
};
|
||||
return svc as unknown as ProjectService & { _created: string[] };
|
||||
}
|
||||
|
||||
describe('AgentService — virtual-agent lifecycle (v3 Stage 2)', () => {
|
||||
@@ -192,6 +218,37 @@ describe('AgentService — virtual-agent lifecycle (v3 Stage 2)', () => {
|
||||
expect(out[0]!.status).toBe('active');
|
||||
});
|
||||
|
||||
it('registerVirtualAgents auto-creates a missing project (v6 Stage 2)', async () => {
|
||||
// Pre-v6 the publisher had to ask an admin to create the project
|
||||
// first; otherwise registerVirtualAgents 404'd. v6: ensureByName
|
||||
// creates with sensible defaults and the publishing user as owner.
|
||||
const repo = mockAgentRepo();
|
||||
const projects = mockProjects({ existingNames: [] });
|
||||
const svc = new AgentService(repo, mockLlms(), projects);
|
||||
const out = await svc.registerVirtualAgents(
|
||||
'sess-1',
|
||||
[{ name: 'team-coder', llmName: 'vllm-local', project: 'platform' }],
|
||||
'owner-platform',
|
||||
);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]!.project?.name).toBe('platform');
|
||||
expect(projects._created).toEqual(['platform']);
|
||||
});
|
||||
|
||||
it('registerVirtualAgents reuses an existing project without re-creating it (v6 Stage 2)', async () => {
|
||||
// ensureByName must be idempotent — second publisher landing on
|
||||
// the same project name doesn't try to re-create (would 409).
|
||||
const repo = mockAgentRepo();
|
||||
const projects = mockProjects({ existingNames: ['platform'] });
|
||||
const svc = new AgentService(repo, mockLlms(), projects);
|
||||
await svc.registerVirtualAgents(
|
||||
'sess-2',
|
||||
[{ name: 'team-reviewer', llmName: 'vllm-local', project: 'platform' }],
|
||||
'owner-other',
|
||||
);
|
||||
expect(projects._created).toEqual([]);
|
||||
});
|
||||
|
||||
it('registerVirtualAgents refuses to overwrite a public agent (409)', async () => {
|
||||
const repo = mockAgentRepo([makeAgent({ name: 'reviewer', kind: 'public', providerSessionId: null })]);
|
||||
const svc = new AgentService(repo, mockLlms(), mockProjects());
|
||||
|
||||
Reference in New Issue
Block a user