diff --git a/.taskmaster/tasks/tasks.json b/.taskmaster/tasks/tasks.json index 7c19711..e62c9f2 100644 --- a/.taskmaster/tasks/tasks.json +++ b/.taskmaster/tasks/tasks.json @@ -2846,9 +2846,9 @@ "131", "133" ], - "status": "pending", + "status": "done", "subtasks": [], - "updatedAt": "2026-08-08T08:55:58.627Z" + "updatedAt": "2026-08-09T19:44:20.519Z" }, { "id": "136", @@ -3104,9 +3104,9 @@ "131", "149" ], - "status": "pending", + "status": "done", "subtasks": [], - "updatedAt": "2026-08-08T08:55:58.627Z" + "updatedAt": "2026-08-09T19:44:20.538Z" }, { "id": "154", @@ -3286,9 +3286,9 @@ "dependencies": [ "135" ], - "status": "pending", + "status": "done", "subtasks": [], - "updatedAt": "2026-08-09T19:36:34.052Z" + "updatedAt": "2026-08-09T19:44:20.531Z" }, { "id": "166", @@ -3405,12 +3405,12 @@ ], "metadata": { "version": "1.0.0", - "lastModified": "2026-08-09T18:56:11.750Z", - "taskCount": 163, - "completedCount": 124, + "lastModified": "2026-08-09T19:44:20.538Z", + "taskCount": 173, + "completedCount": 127, "tags": [ "master" ] } } -} +} \ No newline at end of file diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index 58cd157..3beb8d0 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -822,6 +822,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; @@ -872,6 +873,10 @@ async function main(): Promise { // 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; } }; @@ -885,6 +890,10 @@ async function main(): Promise { 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/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/code-repo.service.ts b/src/mcpd/src/services/code-repo.service.ts index 6535418..cd8d1b0 100644 --- a/src/mcpd/src/services/code-repo.service.ts +++ b/src/mcpd/src/services/code-repo.service.ts @@ -103,6 +103,49 @@ export class CodeRepoService { 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 ── /** diff --git a/src/mcpd/src/services/epic-task.service.ts b/src/mcpd/src/services/epic-task.service.ts index eba9a45..667968b 100644 --- a/src/mcpd/src/services/epic-task.service.ts +++ b/src/mcpd/src/services/epic-task.service.ts @@ -101,6 +101,36 @@ export class EpicTaskService { await this.repo.delete(id); } + // ── 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 ── /** diff --git a/src/mcpd/src/services/epic.service.ts b/src/mcpd/src/services/epic.service.ts index d948bb2..90b1530 100644 --- a/src/mcpd/src/services/epic.service.ts +++ b/src/mcpd/src/services/epic.service.ts @@ -156,6 +156,52 @@ export class EpicService { 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 ── /** diff --git a/src/mcpd/src/services/git-provider.service.ts b/src/mcpd/src/services/git-provider.service.ts index d80736c..038df36 100644 --- a/src/mcpd/src/services/git-provider.service.ts +++ b/src/mcpd/src/services/git-provider.service.ts @@ -125,6 +125,34 @@ export class GitProviderService { 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 ── /** 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'); }); });