fix(backup): cover the agentic-teams resources — closes the data-loss gap

The audit found that none of the new resources were in BACKUP_KINDS, so
git-backup never captured them and a restore would lose every epic, roadmap
node, repo and forge registration. Closes 135/165.

Backed up: gitprovider, repo, epic, epictask — configuration and the vision
layer. NOT tasks: a work item carries in-flight state (lifecycle position,
verdicts, PR links) and restoring a stale one from git would resurrect work that
has since moved on. That is the same reasoning that keeps McpInstance out of the
backup, and there is a test asserting it stays out.

Two properties the tests pin, because both are the kind of thing that fails
silently:

- **Credentials serialize as a REFERENCE, never a value.** The backup repo is a
  plaintext git checkout; emitting a resolved token would export every forge
  credential to disk in cleartext. credSecretId/credSecretKey are internal
  fields and become `credRef: {name, key}`; restore re-resolves against whatever
  the secret backend holds now.
- **Relations serialize by name, not foreign key.** A restore into a different
  database has different cuids, so a backed-up projectId would dangle.

Restore ordering follows dependencies: a forge before the repos that reference
it, an epic before its roadmap nodes.

Two deliberate refusals on the restore path: an Epic's `status` is never written
by a restore, so a stale backup cannot silently mark it delivered — that
terminal state is only reachable through setStatus and its rules; and a
GitProvider is not probed on restore, because the forge may legitimately be
unreachable while a backup replays and failing the whole restore for that is
worse than a row that needs a later `probe`.

The pre-existing APPLY_ORDER length guard caught this change, which is exactly
what it is for; updated 9 → 13 and tied it to BACKUP_KINDS.length so the two
cannot drift apart again.

Also marks 153 done — `mcpctl tasks accept/reject` shipped with the CLI work; the
generated graph had it blocked behind the git-provider merge, but the gate itself
does not need it.

Local: build clean, workspace 2463 passed (+6), lint unchanged at 794 errors.

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 20:44:20 +01:00
parent 300c42b404
commit 5f5efd1144
8 changed files with 299 additions and 14 deletions

View File

@@ -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"
]
}
}
}
}

View File

@@ -822,6 +822,7 @@ async function main(): Promise<void> {
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<void> {
// 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<void> {
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;
}
};

View File

@@ -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<string, unknown>): Record<string,
continue;
}
// Credential relation → credRef {name, key}. The VALUE is never
// serialized — the backup repo is a plaintext git checkout, and the point
// of storing credentials by reference is that they stay in the secret
// backend.
if (key === 'cred' && typeof value === 'object' && value !== null) {
const secretName = (value as { name?: string }).name;
const secretKey = raw['credSecretKey'];
if (secretName !== undefined && typeof secretKey === 'string') {
result.credRef = { name: secretName, key: secretKey };
}
continue;
}
// Epic / EpicTask relations → name
if ((key === 'epic' || key === 'epicTask' || key === 'gitProvider') && typeof value === 'object' && value !== null) {
result[key] = (value as { name: string }).name;
continue;
}
// Project relation → project name
if (key === 'project' && typeof value === 'object' && value !== null) {
result.project = (value as { name: string }).name;
@@ -114,11 +135,22 @@ export function resourcePath(kind: string, name: string): string {
}
/** Resource kinds that are backed up. */
export const BACKUP_KINDS = ['server', 'secret', 'project', 'user', 'group', 'rbac', 'prompt', 'skill', 'template'] as const;
export const BACKUP_KINDS = [
'server', 'secret', 'project', 'user', 'group', 'rbac', 'prompt', 'skill', 'template',
// Agentic teams. Configuration and the vision layer are backed up; work items
// deliberately are NOT — a Task carries in-flight state (lifecycle position,
// verdicts, PR links) and restoring a stale one from git would resurrect work
// that has since moved on. Same reasoning that keeps McpInstance out.
'gitprovider', 'repo', 'epic', 'epictask',
] as const;
export type BackupKind = (typeof BACKUP_KINDS)[number];
/** Apply order: dependencies before dependents. Skills follow prompts. */
export const APPLY_ORDER: BackupKind[] = ['secret', 'server', 'template', 'user', 'group', 'project', 'rbac', 'prompt', 'skill'];
export const APPLY_ORDER: BackupKind[] = [
'secret', 'server', 'template', 'user', 'group', 'project', 'rbac', 'prompt', 'skill',
// Dependency order: a repo needs its forge, an epictask needs its epic.
'gitprovider', 'repo', 'epic', 'epictask',
];
/** Parse a file path to extract kind and name. Returns null if path doesn't match backup structure. */
export function parseResourcePath(filePath: string): { kind: BackupKind; name: string } | null {
@@ -130,6 +162,7 @@ export function parseResourcePath(filePath: string): { kind: BackupKind; name: s
servers: 'server', secrets: 'secret', projects: 'project',
users: 'user', groups: 'group', rbac: 'rbac',
prompts: 'prompt', skills: 'skill', templates: 'template',
gitproviders: 'gitprovider', repos: 'repo', epics: 'epic', epictasks: 'epictask',
};
const kind = kindMap[dir!];
if (!kind) return null;
@@ -199,6 +232,32 @@ export async function serializeAll(prisma: PrismaClient): Promise<Map<string, st
files.set(resourcePath('skill', s.name), resourceToYaml('skill', s as unknown as Record<string, unknown>));
}
// 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<string, unknown>));
}
// 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<string, unknown>));
}
// 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<string, unknown>));
}
// 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<string, unknown>));
}
// Templates
const templates = await prisma.mcpTemplate.findMany();
for (const t of templates) {

View File

@@ -103,6 +103,49 @@ export class CodeRepoService {
await this.repo.delete(id);
}
// ── Backup / restore helpers ──
async upsertByName(data: Record<string, unknown>, ownerId: string): Promise<CodeRepo> {
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<void> {
const match = await this.repo.findByName(name);
if (match === null) return;
await this.repo.delete(match.id);
}
// ── Credentials ──
/**

View File

@@ -101,6 +101,36 @@ export class EpicTaskService {
await this.repo.delete(id);
}
// ── Backup / restore helpers ──
async upsertByName(data: Record<string, unknown>, ownerId: string): Promise<EpicTask> {
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<void> {
const match = await this.repo.findByName(name);
if (match === null) return;
await this.repo.delete(match.id);
}
// ── Edges ──
/**

View File

@@ -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<string, unknown>, ownerId: string): Promise<Epic> {
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<void> {
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 ──
/**

View File

@@ -125,6 +125,34 @@ export class GitProviderService {
await this.repo.delete(id);
}
// ── Backup / restore helpers ──
async upsertByName(data: Record<string, unknown>, ownerId: string): Promise<GitProvider> {
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<void> {
const match = await this.repo.findByName(name);
if (match === null) return;
await this.repo.delete(match.id);
}
// ── Discovery ──
/**

View File

@@ -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');
});
});