mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-08-28 02:44:59 +00:00
Detect duplicate task identities
This commit is contained in:
parent
72715f229f
commit
fbfd9eb572
11 changed files with 919 additions and 38 deletions
|
|
@ -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
|
||||
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
283
server/src/__tests__/task-identity-diagnostics.test.ts
Normal file
283
server/src/__tests__/task-identity-diagnostics.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
265
server/src/services/task-identity-diagnostics.ts
Normal file
265
server/src/services/task-identity-diagnostics.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue