Detect duplicate task identities

This commit is contained in:
Brad Groux 2026-06-03 08:14:38 -07:00
parent 72715f229f
commit fbfd9eb572
11 changed files with 919 additions and 38 deletions

View file

@ -181,6 +181,48 @@ the latest resource so the client can reload or reapply the edit:
}
```
### Duplicate Task Identity Diagnostics
File-backed boards validate task identity directly from markdown files so stale
cache entries cannot hide duplicate cards. The scanner detects:
- duplicate task `id` values across active, backlog, and archive task files
- duplicate GitHub issue identities such as `github:BradGroux/veritas-kanban#377`
- duplicate Git pull request identities such as `git-pr:BradGroux/veritas-kanban#123`
`GET /api/tasks` and `GET /api/backlog` keep their existing response data shape.
When conflicts exist, enveloped API responses include
`meta.taskIdentityDiagnostics`, and the response includes
`X-Veritas-Task-Identity-Conflicts` with the number of conflicts.
Mutating or moving a task with a duplicate identity fails with `409 CONFLICT`
instead of silently selecting one matching file. The error details include the
operation, target task ID, duplicate IDs, source paths, and destination path when
the operation moves a task:
```json
{
"code": "CONFLICT",
"message": "Duplicate task identity detected",
"details": {
"operation": "backlog.promote",
"taskId": "task_20260603_dup",
"destinationPath": "active",
"duplicateIds": ["task_20260603_dup"],
"conflicts": [
{
"kind": "task-id",
"id": "task_20260603_dup",
"sources": [
{ "location": "active", "path": "active/task_20260603_dup-active.md" },
{ "location": "backlog", "path": "backlog/task_20260603_dup-backlog.md" }
]
}
]
}
}
```
### List Tasks
```

View file

@ -85,6 +85,25 @@ describe('responseEnvelopeMiddleware', () => {
expect(res.body.meta.requestId).toBe('custom-id-123');
});
it('should include task identity diagnostics from response locals', async () => {
const app = createApp();
app.get('/test', (_req, res) => {
res.locals.taskIdentityDiagnostics = {
hasConflicts: true,
conflictCount: 1,
conflicts: [{ kind: 'task-id', id: 'task_1', sources: [] }],
};
res.json([]);
});
const res = await request(app).get('/test');
expect(res.body.meta.taskIdentityDiagnostics).toMatchObject({
hasConflicts: true,
conflictCount: 1,
});
});
});
// ── Error Responses ─────────────────────────────────────────

View file

@ -7,32 +7,45 @@ import request from 'supertest';
import express from 'express';
// Use vi.hoisted to declare mocks that vi.mock factories can reference
const { mockTaskService, mockWorktreeService, mockBlockingService, mockActivityService } =
vi.hoisted(() => ({
mockTaskService: {
listTasks: vi.fn(),
getTask: vi.fn(),
createTask: vi.fn(),
updateTask: vi.fn(),
deleteTask: vi.fn(),
reorderTasks: vi.fn(),
},
mockWorktreeService: {
createWorktree: vi.fn(),
getWorktreeStatus: vi.fn(),
deleteWorktree: vi.fn(),
rebaseWorktree: vi.fn(),
mergeWorktree: vi.fn(),
openInVSCode: vi.fn(),
},
mockBlockingService: {
getBlockingStatus: vi.fn(),
canMoveToInProgress: vi.fn(),
},
mockActivityService: {
logActivity: vi.fn().mockResolvedValue(undefined),
},
}));
const {
mockTaskService,
mockWorktreeService,
mockBlockingService,
mockActivityService,
mockBacklogService,
} = vi.hoisted(() => ({
mockTaskService: {
listTasks: vi.fn(),
getTask: vi.fn(),
createTask: vi.fn(),
updateTask: vi.fn(),
deleteTask: vi.fn(),
reorderTasks: vi.fn(),
getIdentityScanSources: vi.fn().mockReturnValue([]),
},
mockWorktreeService: {
createWorktree: vi.fn(),
getWorktreeStatus: vi.fn(),
deleteWorktree: vi.fn(),
rebaseWorktree: vi.fn(),
mergeWorktree: vi.fn(),
openInVSCode: vi.fn(),
},
mockBlockingService: {
getBlockingStatus: vi.fn(),
canMoveToInProgress: vi.fn(),
},
mockActivityService: {
logActivity: vi.fn().mockResolvedValue(undefined),
},
mockBacklogService: {
getTaskIdentityDiagnostics: vi
.fn()
.mockResolvedValue({ hasConflicts: false, conflictCount: 0, conflicts: [] }),
getBacklogCount: vi.fn().mockResolvedValue(0),
demoteToBacklog: vi.fn(),
},
}));
vi.mock('../../services/task-service.js', () => ({
getTaskService: () => mockTaskService,
@ -55,6 +68,10 @@ vi.mock('../../services/activity-service.js', () => ({
activityService: mockActivityService,
}));
vi.mock('../../services/backlog-service.js', () => ({
getBacklogService: () => mockBacklogService,
}));
vi.mock('../../services/broadcast-service.js', () => ({
broadcastTaskChange: vi.fn(),
}));
@ -106,6 +123,41 @@ describe('Tasks Routes (actual module)', () => {
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it('should expose duplicate identity diagnostics as a response header', async () => {
mockTaskService.listTasks.mockResolvedValue([]);
mockBacklogService.getTaskIdentityDiagnostics.mockResolvedValueOnce({
hasConflicts: true,
conflictCount: 1,
conflicts: [
{
kind: 'task-id',
id: 'task_20260603_dup',
sources: [
{
location: 'active',
path: 'active/task_20260603_dup-active.md',
filename: 'task_20260603_dup-active.md',
taskId: 'task_20260603_dup',
businessIds: [],
},
{
location: 'backlog',
path: 'backlog/task_20260603_dup-backlog.md',
filename: 'task_20260603_dup-backlog.md',
taskId: 'task_20260603_dup',
businessIds: [],
},
],
},
],
});
const res = await request(app).get('/api/tasks');
expect(res.status).toBe(200);
expect(res.headers['x-veritas-task-identity-conflicts']).toBe('1');
});
});
describe('POST /api/tasks/reorder', () => {

View file

@ -0,0 +1,283 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { BacklogService } from '../services/backlog-service.js';
import { TaskService } from '../services/task-service.js';
import {
filterTaskIdentityDiagnostics,
scanTaskIdentityDiagnostics,
} from '../services/task-identity-diagnostics.js';
import { BacklogRepository } from '../storage/backlog-repository.js';
import { TelemetryService } from '../services/telemetry-service.js';
const CREATED = '2026-06-03T00:00:00.000Z';
function taskMarkdown(input: {
id: string;
title: string;
githubIssue?: number;
githubRepo?: string;
}): string {
const github = input.githubIssue
? `
github:
repo: ${input.githubRepo ?? 'BradGroux/veritas-kanban'}
issueNumber: ${input.githubIssue}`
: '';
return `---
id: ${input.id}
title: ${input.title}
type: code
status: todo
priority: medium
created: ${CREATED}
updated: ${CREATED}${github}
---
${input.title}
`;
}
async function writeTask(dir: string, filename: string, markdown: string): Promise<void> {
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(path.join(dir, filename), markdown, 'utf-8');
}
describe('task identity diagnostics', () => {
let testRoot: string;
let activeDir: string;
let backlogDir: string;
let archiveDir: string;
beforeEach(async () => {
testRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'veritas-identity-'));
activeDir = path.join(testRoot, 'tasks', 'active');
backlogDir = path.join(testRoot, 'tasks', 'backlog');
archiveDir = path.join(testRoot, 'tasks', 'archive');
await Promise.all([
fs.mkdir(activeDir, { recursive: true }),
fs.mkdir(backlogDir, { recursive: true }),
fs.mkdir(archiveDir, { recursive: true }),
]);
});
afterEach(async () => {
await fs.rm(testRoot, { recursive: true, force: true });
});
it('reports deterministic duplicate task IDs and business IDs across task buckets', async () => {
await writeTask(
activeDir,
'task_20260603_dup-active.md',
taskMarkdown({ id: 'task_20260603_dup', title: 'Active duplicate' })
);
await writeTask(
backlogDir,
'task_20260603_dup-backlog.md',
taskMarkdown({ id: 'task_20260603_dup', title: 'Backlog duplicate' })
);
await writeTask(
activeDir,
'task_20260603_issue_a-active.md',
taskMarkdown({ id: 'task_20260603_issue_a', title: 'Issue active', githubIssue: 377 })
);
await writeTask(
archiveDir,
'task_20260603_issue_b-archive.md',
taskMarkdown({ id: 'task_20260603_issue_b', title: 'Issue archived', githubIssue: 377 })
);
const diagnostics = await scanTaskIdentityDiagnostics([
{ location: 'active', dir: activeDir },
{ location: 'backlog', dir: backlogDir },
{ location: 'archive', dir: archiveDir },
]);
expect(diagnostics.hasConflicts).toBe(true);
expect(diagnostics.conflicts).toEqual([
expect.objectContaining({
kind: 'business-id',
id: 'github:BradGroux/veritas-kanban#377',
sources: [
expect.objectContaining({
location: 'active',
path: 'active/task_20260603_issue_a-active.md',
}),
expect.objectContaining({
location: 'archive',
path: 'archive/task_20260603_issue_b-archive.md',
}),
],
}),
expect.objectContaining({
kind: 'task-id',
id: 'task_20260603_dup',
sources: [
expect.objectContaining({
location: 'active',
path: 'active/task_20260603_dup-active.md',
}),
expect.objectContaining({
location: 'backlog',
path: 'backlog/task_20260603_dup-backlog.md',
}),
],
}),
]);
});
it('filters conflicts to a target task before failing mutations', async () => {
await writeTask(
activeDir,
'task_20260603_dup-active.md',
taskMarkdown({ id: 'task_20260603_dup', title: 'Active duplicate' })
);
await writeTask(
archiveDir,
'task_20260603_dup-archive.md',
taskMarkdown({ id: 'task_20260603_dup', title: 'Archive duplicate' })
);
await writeTask(
backlogDir,
'task_20260603_ok-backlog.md',
taskMarkdown({ id: 'task_20260603_ok', title: 'Safe backlog task' })
);
const diagnostics = await scanTaskIdentityDiagnostics([
{ location: 'active', dir: activeDir },
{ location: 'backlog', dir: backlogDir },
{ location: 'archive', dir: archiveDir },
]);
expect(filterTaskIdentityDiagnostics(diagnostics, 'task_20260603_ok').hasConflicts).toBe(false);
expect(filterTaskIdentityDiagnostics(diagnostics, 'task_20260603_dup')).toMatchObject({
hasConflicts: true,
conflictCount: 1,
});
});
it('blocks active task updates when the target identity is duplicated on disk', async () => {
const taskService = new TaskService({
tasksDir: activeDir,
archiveDir,
telemetryService: new TelemetryService({
telemetryDir: path.join(testRoot, 'telemetry'),
config: { enabled: false },
}),
});
await writeTask(
activeDir,
'task_20260603_dup-active.md',
taskMarkdown({ id: 'task_20260603_dup', title: 'Active duplicate' })
);
await writeTask(
archiveDir,
'task_20260603_dup-archive.md',
taskMarkdown({ id: 'task_20260603_dup', title: 'Archive duplicate' })
);
await expect(
taskService.updateTask('task_20260603_dup', { title: 'Updated' })
).rejects.toMatchObject({
statusCode: 409,
code: 'CONFLICT',
details: expect.objectContaining({
operation: 'task.update',
taskId: 'task_20260603_dup',
duplicateIds: ['task_20260603_dup'],
}),
});
taskService.dispose();
});
it('blocks active task updates that would create a duplicate business identity', async () => {
const taskService = new TaskService({
tasksDir: activeDir,
archiveDir,
backlogDir,
telemetryService: new TelemetryService({
telemetryDir: path.join(testRoot, 'telemetry'),
config: { enabled: false },
}),
});
await writeTask(
activeDir,
'task_20260603_active-active.md',
taskMarkdown({ id: 'task_20260603_active', title: 'Active task' })
);
await writeTask(
backlogDir,
'task_20260603_backlog-backlog.md',
taskMarkdown({
id: 'task_20260603_backlog',
title: 'Backlog issue',
githubIssue: 377,
})
);
await expect(
taskService.updateTask('task_20260603_active', {
github: { issueNumber: 377, repo: 'BradGroux/veritas-kanban' },
})
).rejects.toMatchObject({
statusCode: 409,
code: 'CONFLICT',
details: expect.objectContaining({
operation: 'task.update',
taskId: 'task_20260603_active',
destinationPath: 'active/task_20260603_active-active-task.md',
duplicateIds: ['github:BradGroux/veritas-kanban#377'],
}),
});
taskService.dispose();
});
it('blocks backlog promotion when it would move one duplicate identity to active', async () => {
const taskService = new TaskService({
tasksDir: activeDir,
archiveDir,
telemetryService: new TelemetryService({
telemetryDir: path.join(testRoot, 'telemetry'),
config: { enabled: false },
}),
});
const backlogService = new BacklogService({
backlogRepo: new BacklogRepository({ backlogDir }),
taskService,
telemetry: new TelemetryService({
telemetryDir: path.join(testRoot, 'telemetry-backlog'),
config: { enabled: false },
}),
});
await writeTask(
activeDir,
'task_20260603_dup-active.md',
taskMarkdown({ id: 'task_20260603_dup', title: 'Active duplicate' })
);
await writeTask(
backlogDir,
'task_20260603_dup-backlog.md',
taskMarkdown({ id: 'task_20260603_dup', title: 'Backlog duplicate' })
);
await expect(backlogService.promoteToActive('task_20260603_dup')).rejects.toMatchObject({
statusCode: 409,
code: 'CONFLICT',
details: expect.objectContaining({
operation: 'backlog.promote',
taskId: 'task_20260603_dup',
destinationPath: 'active',
duplicateIds: ['task_20260603_dup'],
}),
});
taskService.dispose();
});
});

View file

@ -40,6 +40,7 @@ interface EnvelopeMeta {
utcOffset: number;
requestId?: string;
pagination?: PaginationMeta;
taskIdentityDiagnostics?: unknown;
}
interface SuccessEnvelope<T = unknown> {
@ -134,6 +135,9 @@ export function responseEnvelopeMiddleware(_req: Request, res: Response, next: N
if (pagination) {
meta.pagination = pagination;
}
if (res.locals.taskIdentityDiagnostics) {
meta.taskIdentityDiagnostics = res.locals.taskIdentityDiagnostics;
}
const statusCode = res.statusCode;

View file

@ -10,7 +10,7 @@
* POST /api/backlog/bulk-promote - Bulk promote tasks
*/
import { Router, type Router as RouterType } from 'express';
import { Router, type Response, type Router as RouterType } from 'express';
import { z } from 'zod';
import { getBacklogService } from '../services/backlog-service.js';
import { broadcastTaskChange } from '../services/broadcast-service.js';
@ -19,10 +19,18 @@ import { NotFoundError, ValidationError } from '../middleware/error-handler.js';
import { auditLog } from '../services/audit-service.js';
import type { AuthenticatedRequest } from '../middleware/auth.js';
import { sendPaginated } from '../middleware/response-envelope.js';
import type { TaskIdentityDiagnostics } from '../services/task-identity-diagnostics.js';
const router: RouterType = Router();
const backlogService = getBacklogService();
function attachTaskIdentityDiagnostics(res: Response, diagnostics: TaskIdentityDiagnostics): void {
if (!diagnostics.hasConflicts) return;
res.set('X-Veritas-Task-Identity-Conflicts', String(diagnostics.conflictCount));
res.locals.taskIdentityDiagnostics = diagnostics;
}
// Validation schemas
const createBacklogTaskSchema = z.object({
title: z.string().min(1, 'Title is required'),
@ -75,6 +83,7 @@ router.get(
limit,
offset,
});
attachTaskIdentityDiagnostics(res, await backlogService.getTaskIdentityDiagnostics());
sendPaginated(res, result.tasks, { page, limit, total: result.total });
})

View file

@ -1,4 +1,4 @@
import { Router, type Router as RouterType } from 'express';
import { Router, type Response, type Router as RouterType } from 'express';
import { z } from 'zod';
import { getTaskService } from '../services/task-service.js';
import { WorktreeService } from '../services/worktree-service.js';
@ -17,6 +17,7 @@ import { sanitizeTaskFields } from '../utils/sanitize.js';
import { auditLog } from '../services/audit-service.js';
import type { AuthenticatedRequest } from '../middleware/auth.js';
import { actorFromRequest, assertFreshRevision, setRevisionHeaders } from '../utils/concurrency.js';
import type { TaskIdentityDiagnostics } from '../services/task-identity-diagnostics.js';
const router: RouterType = Router();
const taskService = getTaskService();
@ -25,6 +26,18 @@ const blockingService = getBlockingService();
const delegationService = getDelegationService();
const progressService = getProgressService();
function attachTaskIdentityDiagnostics(res: Response, diagnostics: TaskIdentityDiagnostics): void {
if (!diagnostics.hasConflicts) return;
res.set('X-Veritas-Task-Identity-Conflicts', String(diagnostics.conflictCount));
res.locals.taskIdentityDiagnostics = diagnostics;
}
async function getRouteTaskIdentityDiagnostics(): Promise<TaskIdentityDiagnostics> {
const { getBacklogService } = await import('../services/backlog-service.js');
return getBacklogService().getTaskIdentityDiagnostics();
}
// Validation schemas
const reviewCommentSchema = z.object({
id: z.string(),
@ -226,6 +239,7 @@ router.get(
'/',
asyncHandler(async (req, res) => {
let tasks = await taskService.listTasks();
attachTaskIdentityDiagnostics(res, await getRouteTaskIdentityDiagnostics());
// --- Filtering ---
const statusFilter = req.query.status as string | undefined;

View file

@ -6,13 +6,18 @@
import { nanoid } from 'nanoid';
import type { Task, CreateTaskInput } from '@veritas-kanban/shared';
import { getBacklogRepository } from '../storage/backlog-repository.js';
import { getTaskService } from './task-service.js';
import { BacklogRepository, getBacklogRepository } from '../storage/backlog-repository.js';
import { getTaskService, type TaskService } from './task-service.js';
import { activityService } from './activity-service.js';
import { getTelemetryService } from './telemetry-service.js';
import type { TaskTelemetryEvent } from '@veritas-kanban/shared';
import { createLogger } from '../lib/logger.js';
import { NotFoundError } from '../middleware/error-handler.js';
import {
scanTaskIdentityDiagnostics,
type TaskIdentityDiagnostics,
type TaskIdentityScanSource,
} from './task-identity-diagnostics.js';
const log = createLogger('backlog-service');
@ -24,10 +29,44 @@ export interface BacklogFilterOptions {
offset?: number;
}
export interface BacklogServiceOptions {
backlogRepo?: BacklogRepository;
taskService?: TaskService;
telemetry?: ReturnType<typeof getTelemetryService>;
}
export class BacklogService {
private backlogRepo = getBacklogRepository();
private taskService = getTaskService();
private telemetry = getTelemetryService();
private backlogRepo: BacklogRepository;
private taskService: TaskService;
private telemetry: ReturnType<typeof getTelemetryService>;
constructor(options: BacklogServiceOptions = {}) {
this.backlogRepo = options.backlogRepo ?? getBacklogRepository();
this.taskService = options.taskService ?? getTaskService();
this.telemetry = options.telemetry ?? getTelemetryService();
}
getIdentityScanSources(): TaskIdentityScanSource[] {
return this.backlogRepo.getIdentityScanSources();
}
async getTaskIdentityDiagnostics(): Promise<TaskIdentityDiagnostics> {
return scanTaskIdentityDiagnostics([
...this.taskService.getIdentityScanSources(),
...this.getIdentityScanSources(),
]);
}
private async assertTaskIdentityIntegrity(
operation: string,
taskId?: string,
destinationPath?: string
): Promise<void> {
await this.taskService.assertTaskIdentityIntegrity(operation, taskId, {
destinationPath,
extraSources: this.getIdentityScanSources(),
});
}
/**
* Generate a task ID in the standard format: task_YYYYMMDD_XXXXXX
@ -88,6 +127,7 @@ export class BacklogService {
* Get a single backlog task by ID
*/
async getBacklogTask(id: string): Promise<Task | null> {
await this.assertTaskIdentityIntegrity('backlog.get', id);
return this.backlogRepo.findById(id);
}
@ -120,6 +160,8 @@ export class BacklogService {
attachments: [],
};
await this.assertTaskIdentityIntegrity('backlog.create', task.id);
const created = await this.backlogRepo.create(task);
// Log activity
@ -148,6 +190,8 @@ export class BacklogService {
* Update a backlog task
*/
async updateBacklogTask(id: string, updates: Partial<Task>): Promise<Task> {
await this.assertTaskIdentityIntegrity('backlog.update', id);
const task = await this.backlogRepo.findById(id);
if (!task) {
throw new NotFoundError('Backlog task not found');
@ -175,6 +219,8 @@ export class BacklogService {
* Delete a backlog task
*/
async deleteBacklogTask(id: string): Promise<boolean> {
await this.assertTaskIdentityIntegrity('backlog.delete', id);
const task = await this.backlogRepo.findById(id);
if (!task) {
return false;
@ -203,6 +249,13 @@ export class BacklogService {
* Moves the file from tasks/backlog/ to tasks/active/ and sets status to 'todo'
*/
async promoteToActive(id: string): Promise<Task> {
const activeTasksDir = this.taskService.getActiveTasksDir();
await this.assertTaskIdentityIntegrity(
'backlog.promote',
id,
this.taskService.getActiveTasksDestinationPath()
);
const task = await this.backlogRepo.findById(id);
if (!task) {
throw new NotFoundError('Backlog task not found');
@ -219,7 +272,6 @@ export class BacklogService {
await this.backlogRepo.update(id, { status: 'todo' });
// Move file to active tasks directory
const activeTasksDir = this.taskService['tasksDir']; // Access private field
await this.backlogRepo.moveToActive(id, activeTasksDir);
// Invalidate task service cache and reload to pick up the new task
@ -253,6 +305,8 @@ export class BacklogService {
* Moves the file from tasks/active/ to tasks/backlog/
*/
async demoteToBacklog(id: string): Promise<Task> {
await this.assertTaskIdentityIntegrity('task.demote', id);
const task = await this.taskService.getTask(id);
if (!task) {
throw new NotFoundError('Active task not found');

View file

@ -0,0 +1,265 @@
import fs from 'fs/promises';
import path from 'path';
import matter from 'gray-matter';
export type TaskIdentityLocation = 'active' | 'backlog' | 'archive';
export type TaskIdentityConflictKind = 'task-id' | 'business-id';
export interface TaskIdentityScanSource {
location: TaskIdentityLocation;
dir: string;
}
export interface TaskIdentitySource {
location: TaskIdentityLocation;
path: string;
filename: string;
taskId: string;
title?: string;
businessIds: string[];
}
export interface TaskIdentityConflict {
kind: TaskIdentityConflictKind;
id: string;
sources: TaskIdentitySource[];
}
export interface TaskIdentityDiagnostics {
hasConflicts: boolean;
conflictCount: number;
conflicts: TaskIdentityConflict[];
}
export interface TaskIdentityConflictDetails {
operation: string;
taskId?: string;
destinationPath?: string;
duplicateIds: string[];
conflicts: TaskIdentityConflict[];
}
export interface TaskIdentityCandidate {
location: TaskIdentityLocation;
path: string;
filename: string;
taskId: string;
title?: string;
git?: unknown;
github?: unknown;
businessIds?: string[];
}
export interface TaskIdentityScanOptions {
candidates?: TaskIdentityCandidate[];
excludeTaskIds?: string[];
}
const EMPTY_DIAGNOSTICS: TaskIdentityDiagnostics = {
hasConflicts: false,
conflictCount: 0,
conflicts: [],
};
function taskIdFromFilename(filename: string): string {
return filename.replace(/\.md$/, '').split('-')[0] ?? '';
}
function normalizeGithubBusinessId(value: unknown): string | null {
if (!value || typeof value !== 'object') return null;
const github = value as Record<string, unknown>;
const issueNumber =
typeof github.issueNumber === 'number' || typeof github.issueNumber === 'string'
? String(github.issueNumber).trim()
: '';
const repo = typeof github.repo === 'string' ? github.repo.trim() : '';
if (!issueNumber) return null;
return `github:${repo || 'unknown'}#${issueNumber}`;
}
function normalizeGitPullRequestBusinessId(value: unknown): string | null {
if (!value || typeof value !== 'object') return null;
const git = value as Record<string, unknown>;
const prNumber =
typeof git.prNumber === 'number' || typeof git.prNumber === 'string'
? String(git.prNumber).trim()
: '';
const repo = typeof git.repo === 'string' ? git.repo.trim() : '';
if (!prNumber) return null;
return `git-pr:${repo || 'unknown'}#${prNumber}`;
}
function buildBusinessIds(frontmatter: Record<string, unknown>): string[] {
return [
normalizeGithubBusinessId(frontmatter.github),
normalizeGitPullRequestBusinessId(frontmatter.git),
].filter((id): id is string => Boolean(id));
}
function commonRoot(sources: TaskIdentityScanSource[]): string {
const dirs = sources.map((source) => path.resolve(source.dir));
if (dirs.length === 0) return process.cwd();
const [first, ...rest] = dirs.map((dir) => dir.split(path.sep));
let index = 0;
while (
index < first.length &&
rest.every((parts) => parts[index] !== undefined && parts[index] === first[index])
) {
index += 1;
}
return first.slice(0, Math.max(1, index)).join(path.sep) || path.sep;
}
async function readMarkdownSources(
source: TaskIdentityScanSource,
rootDir: string
): Promise<TaskIdentitySource[]> {
let files: string[];
try {
files = await fs.readdir(source.dir);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return [];
}
throw error;
}
const markdownFiles = files.filter((filename) => filename.endsWith('.md')).sort();
const results: TaskIdentitySource[] = [];
for (const filename of markdownFiles) {
const filepath = path.join(source.dir, filename);
const content = await fs.readFile(filepath, 'utf-8');
const parsed = matter(content);
const frontmatter = parsed.data as Record<string, unknown>;
const taskId =
typeof frontmatter.id === 'string' && frontmatter.id.trim()
? frontmatter.id.trim()
: taskIdFromFilename(filename);
if (!taskId) continue;
results.push({
location: source.location,
path: path.relative(rootDir, filepath),
filename,
taskId,
title: typeof frontmatter.title === 'string' ? frontmatter.title : undefined,
businessIds: buildBusinessIds(frontmatter),
});
}
return results;
}
function sourceFromCandidate(
candidate: TaskIdentityCandidate,
rootDir: string
): TaskIdentitySource {
return {
location: candidate.location,
path: path.isAbsolute(candidate.path) ? path.relative(rootDir, candidate.path) : candidate.path,
filename: candidate.filename,
taskId: candidate.taskId,
title: candidate.title,
businessIds:
candidate.businessIds ?? buildBusinessIds({ github: candidate.github, git: candidate.git }),
};
}
function conflictsForSources(
sources: TaskIdentitySource[],
kind: TaskIdentityConflictKind,
getIds: (source: TaskIdentitySource) => string[]
): TaskIdentityConflict[] {
const byId = new Map<string, TaskIdentitySource[]>();
for (const source of sources) {
for (const id of getIds(source)) {
const existing = byId.get(id) ?? [];
existing.push(source);
byId.set(id, existing);
}
}
return Array.from(byId.entries())
.filter(([, values]) => values.length > 1)
.map(([id, values]) => ({
kind,
id,
sources: values.sort((a, b) => a.path.localeCompare(b.path)),
}))
.sort((a, b) => a.id.localeCompare(b.id));
}
export async function scanTaskIdentityDiagnostics(
sources: TaskIdentityScanSource[],
options: TaskIdentityScanOptions = {}
): Promise<TaskIdentityDiagnostics> {
const candidateSources = options.candidates ?? [];
const uniqueSources = Array.from(
new Map(
sources.map((source) => [`${source.location}:${path.resolve(source.dir)}`, source])
).values()
);
if (uniqueSources.length === 0 && candidateSources.length === 0) return EMPTY_DIAGNOSTICS;
const rootDir = commonRoot(uniqueSources);
const excludedTaskIds = new Set(options.excludeTaskIds ?? []);
const taskSources = (
await Promise.all(uniqueSources.map((source) => readMarkdownSources(source, rootDir)))
)
.flat()
.filter((source) => !excludedTaskIds.has(source.taskId))
.concat(candidateSources.map((candidate) => sourceFromCandidate(candidate, rootDir)))
.sort((a, b) => a.path.localeCompare(b.path));
const conflicts = [
...conflictsForSources(taskSources, 'task-id', (source) => [source.taskId]),
...conflictsForSources(taskSources, 'business-id', (source) => source.businessIds),
].sort((a, b) => `${a.kind}:${a.id}`.localeCompare(`${b.kind}:${b.id}`));
return {
hasConflicts: conflicts.length > 0,
conflictCount: conflicts.length,
conflicts,
};
}
export function buildTaskIdentityConflictDetails(
diagnostics: TaskIdentityDiagnostics,
operation: string,
options: { taskId?: string; destinationPath?: string } = {}
): TaskIdentityConflictDetails {
return {
operation,
taskId: options.taskId,
destinationPath: options.destinationPath,
duplicateIds: diagnostics.conflicts.map((conflict) => conflict.id),
conflicts: diagnostics.conflicts,
};
}
export function filterTaskIdentityDiagnostics(
diagnostics: TaskIdentityDiagnostics,
taskId?: string
): TaskIdentityDiagnostics {
if (!taskId) return diagnostics;
const conflicts = diagnostics.conflicts.filter((conflict) =>
conflict.sources.some((source) => source.taskId === taskId)
);
return {
hasConflicts: conflicts.length > 0,
conflictCount: conflicts.length,
conflicts,
};
}

View file

@ -31,9 +31,18 @@ import {
type TaskSyncContext,
} from './agent-registry-service.js';
import { getWorkProductService } from './work-product-service.js';
import { getTasksActiveDir, getTasksArchiveDir } from '../utils/paths.js';
import { getTasksActiveDir, getTasksArchiveDir, getTasksBacklogDir } from '../utils/paths.js';
import { SqliteDatabase, type SqliteConnectionOptions } from '../storage/sqlite/database.js';
import { SqliteTaskRepository } from '../storage/sqlite/task-repository.js';
import {
buildTaskIdentityConflictDetails,
filterTaskIdentityDiagnostics,
scanTaskIdentityDiagnostics,
type TaskIdentityCandidate,
type TaskIdentityDiagnostics,
type TaskIdentityLocation,
type TaskIdentityScanSource,
} from './task-identity-diagnostics.js';
const log = createLogger('task-cache');
const TASK_SYNC_CONTEXT: TaskSyncContext = createTaskSyncToken('task-service');
@ -73,6 +82,7 @@ const DEFAULT_ARCHIVE_DIR = getTasksArchiveDir();
export interface TaskServiceOptions {
tasksDir?: string;
archiveDir?: string;
backlogDir?: string;
telemetryService?: TelemetryService;
storageType?: 'file' | 'sqlite';
sqliteDatabase?: SqliteDatabase;
@ -85,6 +95,7 @@ const WRITE_DEBOUNCE_MS = 200;
export class TaskService {
private tasksDir: string;
private archiveDir: string;
private backlogDir: string | null;
private telemetry: TelemetryService;
private sqliteDatabase: SqliteDatabase | null = null;
private sqliteTasks: SqliteTaskRepository | null = null;
@ -103,6 +114,8 @@ export class TaskService {
constructor(options: TaskServiceOptions = {}) {
this.tasksDir = options.tasksDir || DEFAULT_TASKS_DIR;
this.archiveDir = options.archiveDir || DEFAULT_ARCHIVE_DIR;
this.backlogDir =
options.backlogDir ?? (options.tasksDir || options.archiveDir ? null : getTasksBacklogDir());
this.telemetry = options.telemetryService || getTelemetryService();
const storageType =
options.storageType ?? (process.env.VERITAS_STORAGE === 'sqlite' ? 'sqlite' : 'file');
@ -366,6 +379,99 @@ export class TaskService {
await fs.mkdir(this.archiveDir, { recursive: true });
}
getIdentityScanSources(): TaskIdentityScanSource[] {
if (this.sqliteTasks) return [];
const sources: TaskIdentityScanSource[] = [
{ location: 'active', dir: this.tasksDir },
{ location: 'archive', dir: this.archiveDir },
];
if (this.backlogDir) {
sources.push({ location: 'backlog', dir: this.backlogDir });
}
return sources;
}
getActiveTasksDir(): string {
return this.tasksDir;
}
getActiveTasksDestinationPath(): string {
return this.diagnosticPath(this.tasksDir);
}
async getTaskIdentityDiagnostics(
extraSources: TaskIdentityScanSource[] = []
): Promise<TaskIdentityDiagnostics> {
return scanTaskIdentityDiagnostics([...this.getIdentityScanSources(), ...extraSources]);
}
async assertTaskIdentityIntegrity(
operation: string,
taskId?: string,
options: {
allowSameLocationTaskIdDuplicates?: boolean;
candidates?: TaskIdentityCandidate[];
destinationPath?: string;
excludeTaskIds?: string[];
extraSources?: TaskIdentityScanSource[];
} = {}
): Promise<void> {
let diagnostics = filterTaskIdentityDiagnostics(
await scanTaskIdentityDiagnostics(
[...this.getIdentityScanSources(), ...(options.extraSources ?? [])],
{
candidates: options.candidates,
excludeTaskIds: options.excludeTaskIds,
}
),
taskId
);
if (options.allowSameLocationTaskIdDuplicates && taskId) {
const conflicts = diagnostics.conflicts.filter((conflict) => {
if (conflict.kind !== 'task-id') return true;
if (conflict.id !== taskId) return true;
const locations = new Set(conflict.sources.map((source) => source.location));
return locations.size > 1;
});
diagnostics = {
hasConflicts: conflicts.length > 0,
conflictCount: conflicts.length,
conflicts,
};
}
if (!diagnostics.hasConflicts) return;
throw new ConflictError(
'Duplicate task identity detected',
buildTaskIdentityConflictDetails(diagnostics, operation, {
taskId,
destinationPath: options.destinationPath,
})
);
}
private taskIdentityCandidate(
task: Task,
location: TaskIdentityLocation,
filepath: string
): TaskIdentityCandidate {
return {
location,
path: filepath,
filename: path.basename(filepath),
taskId: task.id,
title: task.title,
git: task.git,
github: task.github,
};
}
private diagnosticPath(filepath: string): string {
return path.relative(path.dirname(this.tasksDir), filepath);
}
private generateId(): string {
const date = new Date().toISOString().slice(0, 10).replace(/-/g, '');
return `task_${date}_${nanoid(6)}`;
@ -594,6 +700,11 @@ export class TaskService {
}
async getTask(id: string): Promise<Task | null> {
await this.assertTaskIdentityIntegrity('task.get', id);
return this.getTaskWithoutIdentityCheck(id);
}
private async getTaskWithoutIdentityCheck(id: string): Promise<Task | null> {
if (this.sqliteTasks) {
return this.sqliteTasks.findById(id);
}
@ -647,6 +758,11 @@ export class TaskService {
const filepath = path.join(this.tasksDir, filename);
const content = this.taskToMarkdown(task);
await this.assertTaskIdentityIntegrity('task.create', task.id, {
candidates: [this.taskIdentityCandidate(task, 'active', filepath)],
destinationPath: this.diagnosticPath(filepath),
});
await withFileLock(filepath, async () => {
this.markWrite();
await fs.writeFile(filepath, content, 'utf-8');
@ -673,10 +789,12 @@ export class TaskService {
}
async updateTask(id: string, input: UpdateTaskInput): Promise<Task | null> {
await this.assertTaskIdentityIntegrity('task.update', id);
// Initial read to check existence and compute the lock filepath.
// NOTE: this data may be stale by the time we acquire the lock —
// the actual merge happens inside the lock with a fresh cache read.
const task = await this.getTask(id);
const task = await this.getTaskWithoutIdentityCheck(id);
if (!task) return null;
// Handle git field separately to merge properly
@ -883,6 +1001,12 @@ export class TaskService {
if (this.sqliteTasks) {
await this.sqliteTasks.replaceActive(updatedTask);
} else {
await this.assertTaskIdentityIntegrity('task.update', id, {
candidates: [this.taskIdentityCandidate(updatedTask, 'active', filepath)],
destinationPath: this.diagnosticPath(filepath),
excludeTaskIds: [id],
});
const content = this.taskToMarkdown(updatedTask);
this.markWrite();
@ -1023,7 +1147,11 @@ export class TaskService {
}
async deleteTask(id: string): Promise<boolean> {
const task = await this.getTask(id);
await this.assertTaskIdentityIntegrity('task.delete', id, {
allowSameLocationTaskIdDuplicates: true,
});
const task = await this.getTaskWithoutIdentityCheck(id);
if (!task) return false;
if (this.sqliteTasks) {
@ -1062,7 +1190,11 @@ export class TaskService {
}
async archiveTask(id: string): Promise<boolean> {
const task = await this.getTask(id);
await this.assertTaskIdentityIntegrity('task.archive', id, {
allowSameLocationTaskIdDuplicates: true,
});
const task = await this.getTaskWithoutIdentityCheck(id);
if (!task) return false;
if (this.sqliteTasks) {
@ -1171,6 +1303,8 @@ export class TaskService {
}
async restoreTask(id: string): Promise<Task | null> {
await this.assertTaskIdentityIntegrity('task.restore', id);
if (this.sqliteTasks) {
const sqliteTasks = this.sqliteTasks;
const restoredTask = await this.runSqliteMutation(() => sqliteTasks.restore(id));

View file

@ -11,6 +11,7 @@ import matter from 'gray-matter';
import type { Task } from '@veritas-kanban/shared';
import { createLogger } from '../lib/logger.js';
import { getTasksBacklogDir } from '../utils/paths.js';
import type { TaskIdentityScanSource } from '../services/task-identity-diagnostics.js';
const log = createLogger('backlog-repo');
@ -36,6 +37,10 @@ export class BacklogRepository {
});
}
getIdentityScanSources(): TaskIdentityScanSource[] {
return [{ location: 'backlog', dir: this.backlogDir }];
}
/**
* Parse a task markdown file
*/