feat(server): abstract file storage behind repository interface

This commit is contained in:
Brad Groux 2026-01-29 05:00:30 -06:00
parent 6bdb01f2bd
commit d36ee28a5d
13 changed files with 1079 additions and 7 deletions

View file

@ -1943,3 +1943,92 @@
{"type":"task.created","taskId":"task_20260129_RjMWJU","status":"todo","id":"evt_rQQcuFfDsF1J","timestamp":"2026-01-29T10:56:33.510Z"}
{"type":"task.status_changed","taskId":"task_20260129_M48RDr","status":"done","previousStatus":"in-progress","id":"evt_q6xanMfrYVbi","timestamp":"2026-01-29T10:56:52.770Z"}
{"type":"task.status_changed","taskId":"task_20260129_6BLzwk","status":"done","previousStatus":"in-progress","id":"evt_mevh4I32hoO3","timestamp":"2026-01-29T10:57:00.686Z"}
{"type":"task.status_changed","taskId":"task_20260129_OhJVTe","status":"in-progress","previousStatus":"todo","id":"evt_D33dp0d25f16","timestamp":"2026-01-29T10:58:00.528Z"}
{"type":"task.status_changed","taskId":"task_20260129_g7GLeb","status":"in-progress","previousStatus":"todo","id":"evt_K123pnTVkjgx","timestamp":"2026-01-29T10:58:00.538Z"}
{"type":"task.created","taskId":"task_20260129_iUUJT6","status":"todo","id":"evt_5R-ifYIf9zmp","timestamp":"2026-01-29T11:00:07.884Z"}
{"type":"task.created","taskId":"task_20260129_DcXtKZ","status":"todo","id":"evt_CHEOfFfxsI_5","timestamp":"2026-01-29T11:00:07.886Z"}
{"type":"task.created","taskId":"task_20260129_JcArHU","status":"todo","id":"evt_bgOQnM6Aag1N","timestamp":"2026-01-29T11:00:07.900Z"}
{"type":"task.created","taskId":"task_20260129_892d_A","status":"todo","id":"evt_aHozeC7dvwuM","timestamp":"2026-01-29T11:00:07.901Z"}
{"type":"task.created","taskId":"task_20260129_00ZgXT","status":"todo","id":"evt_GsQCQiRnawAX","timestamp":"2026-01-29T11:00:07.903Z"}
{"type":"task.created","taskId":"task_20260129_MvDgXq","status":"todo","id":"evt_jwjam9RnFHAe","timestamp":"2026-01-29T11:00:07.906Z"}
{"type":"task.created","taskId":"task_20260129_mEbpGz","status":"todo","id":"evt_nymtxJCENdEc","timestamp":"2026-01-29T11:00:07.924Z"}
{"type":"task.created","taskId":"task_20260129_4m_iK5","status":"todo","id":"evt_hpMcXZCNjviF","timestamp":"2026-01-29T11:00:07.924Z"}
{"type":"task.created","taskId":"task_20260129_IDo6iC","status":"todo","id":"evt_HaZlSAfkcMMW","timestamp":"2026-01-29T11:00:07.926Z"}
{"type":"task.created","taskId":"task_20260129_ueMRHv","status":"todo","id":"evt_cWzA9zqzvwi5","timestamp":"2026-01-29T11:00:07.926Z"}
{"type":"task.created","taskId":"task_20260129_ZMKjMq","status":"todo","id":"evt_hwE-I1FwuVGC","timestamp":"2026-01-29T11:00:07.927Z"}
{"type":"task.created","taskId":"task_20260129_QkYp3S","status":"todo","id":"evt_asUdyRg2bU58","timestamp":"2026-01-29T11:00:07.929Z"}
{"type":"task.status_changed","taskId":"task_20260126_legacy1","status":"blocked","previousStatus":"review","id":"evt_W4XNrbhQy0ne","timestamp":"2026-01-29T11:00:11.223Z"}
{"type":"task.created","taskId":"task_20260129_VKPe23","status":"todo","id":"evt_ySkWo3a0BRHD","timestamp":"2026-01-29T11:00:11.229Z"}
{"type":"task.created","taskId":"task_20260129_BkwJJU","status":"todo","id":"evt_toDjmN5OwSEO","timestamp":"2026-01-29T11:00:11.236Z"}
{"type":"task.status_changed","taskId":"task_20260129_BkwJJU","status":"in-progress","previousStatus":"todo","id":"evt_5Irf4QvkSsok","timestamp":"2026-01-29T11:00:11.238Z"}
{"type":"task.created","taskId":"task_20260129_nC88d5","status":"todo","id":"evt_2soatTpy30WI","timestamp":"2026-01-29T11:00:11.240Z"}
{"type":"task.status_changed","taskId":"task_20260129_nC88d5","status":"blocked","previousStatus":"todo","id":"evt_6uzSp8K5xkXz","timestamp":"2026-01-29T11:00:11.241Z"}
{"type":"task.created","taskId":"task_20260129_z6DfOz","status":"todo","id":"evt_YRh7VcABgsSW","timestamp":"2026-01-29T11:00:11.246Z"}
{"type":"task.status_changed","taskId":"task_20260129_z6DfOz","status":"done","previousStatus":"todo","id":"evt_cLfbOJm0htc4","timestamp":"2026-01-29T11:00:11.248Z"}
{"type":"task.status_changed","taskId":"task_20260126_idem1","status":"blocked","previousStatus":"review","id":"evt_J-PdrQNuYbsY","timestamp":"2026-01-29T11:00:11.267Z"}
{"type":"task.created","taskId":"task_20260129_aHUGtM","project":"my-project","status":"todo","id":"evt_u4WVTm4p4Qzp","timestamp":"2026-01-29T11:00:11.274Z"}
{"type":"task.created","taskId":"task_20260129_E476r-","status":"todo","id":"evt_LTcZMC_GkaCr","timestamp":"2026-01-29T11:00:11.279Z"}
{"type":"task.created","taskId":"task_20260129_XK5Wpj","status":"todo","id":"evt_gGwYoiD5x7gC","timestamp":"2026-01-29T11:00:11.284Z"}
{"type":"task.created","taskId":"task_20260129_30RjXG","status":"todo","id":"evt_Kkffikm-Rcxe","timestamp":"2026-01-29T11:00:11.289Z"}
{"type":"task.status_changed","taskId":"task_20260126_multi1","status":"blocked","previousStatus":"review","id":"evt_5pPhcz4wnKa5","timestamp":"2026-01-29T11:00:11.295Z"}
{"type":"task.status_changed","taskId":"task_20260126_multi2","status":"blocked","previousStatus":"review","id":"evt_fkbf544SIAK-","timestamp":"2026-01-29T11:00:11.298Z"}
{"type":"task.status_changed","taskId":"task_20260129_30RjXG","status":"in-progress","previousStatus":"todo","id":"evt_rWT2bLvv2fw1","timestamp":"2026-01-29T11:00:11.303Z"}
{"type":"task.created","taskId":"task_20260129_XR_c6D","status":"todo","id":"evt_H5XQTDruKka5","timestamp":"2026-01-29T11:00:11.308Z"}
{"type":"task.created","taskId":"task_20260129_QZszCc","status":"todo","id":"evt_e_yo7jbOASS6","timestamp":"2026-01-29T11:00:11.311Z"}
{"type":"task.created","taskId":"task_20260129_plkFnB","status":"todo","id":"evt_hyTj4naczEZL","timestamp":"2026-01-29T11:00:11.317Z"}
{"type":"task.created","taskId":"task_20260129_9snERs","status":"todo","id":"evt_31qwW0qD0ewX","timestamp":"2026-01-29T11:00:11.323Z"}
{"type":"task.created","taskId":"task_20260129_nd9yEG","status":"todo","id":"evt_5pzidv8t1eD9","timestamp":"2026-01-29T11:00:11.325Z"}
{"type":"task.created","taskId":"task_20260129_MvydqP","status":"todo","id":"evt_B7vRb89XrPux","timestamp":"2026-01-29T11:00:11.329Z"}
{"type":"task.created","taskId":"task_20260129_eHjbPL","status":"todo","id":"evt_Ybat4wLLFICB","timestamp":"2026-01-29T11:00:11.350Z"}
{"type":"task.created","taskId":"task_20260129_dj30sy","project":"test-project","status":"todo","id":"evt_FaDtBAosmzkr","timestamp":"2026-01-29T11:00:11.359Z"}
{"type":"task.created","taskId":"task_20260129_g8-Nb0","status":"todo","id":"evt_Xi3x2hrAIN5l","timestamp":"2026-01-29T11:00:11.361Z"}
{"type":"task.created","taskId":"task_20260129_yGZ59P","status":"todo","id":"evt_5SpVn4culJOA","timestamp":"2026-01-29T11:00:11.363Z"}
{"type":"task.created","taskId":"task_20260129_R_u4Gl","status":"todo","id":"evt_FG2QoFbUwonO","timestamp":"2026-01-29T11:00:11.365Z"}
{"type":"task.created","taskId":"task_20260129_RVJFUZ","status":"todo","id":"evt_e6j4uI6CRKvd","timestamp":"2026-01-29T11:00:11.375Z"}
{"type":"task.created","taskId":"task_20260129_eG5M1k","status":"todo","id":"evt_9bx0u4YoUhac","timestamp":"2026-01-29T11:00:11.379Z"}
{"type":"task.status_changed","taskId":"task_20260129_eG5M1k","status":"in-progress","previousStatus":"todo","id":"evt_JqP77bq7wW_q","timestamp":"2026-01-29T11:00:11.384Z"}
{"type":"task.created","taskId":"task_20260129_xarQze","status":"todo","id":"evt_xa5x6bJtwcfR","timestamp":"2026-01-29T11:00:11.386Z"}
{"type":"task.created","taskId":"task_20260129_uk4Ebn","status":"todo","id":"evt_x9vgNl9DOu0f","timestamp":"2026-01-29T11:00:11.396Z"}
{"type":"task.created","taskId":"task_20260129_sIh7cv","status":"todo","id":"evt_5q6KM2csSRY7","timestamp":"2026-01-29T11:00:11.398Z"}
{"type":"task.created","taskId":"task_20260129_qwbAsC","status":"todo","id":"evt_jRMD8gMwqNv4","timestamp":"2026-01-29T11:00:11.404Z"}
{"type":"task.created","taskId":"task_20260129_d-thSF","status":"todo","id":"evt_dEkRumnOykvE","timestamp":"2026-01-29T11:00:11.412Z"}
{"type":"task.created","taskId":"task_20260129_j0sf3f","status":"todo","id":"evt_PL3phjIJoj8N","timestamp":"2026-01-29T11:00:11.415Z"}
{"type":"task.created","taskId":"task_20260129_WSbcZS","status":"todo","id":"evt_LVCRntWhWXEO","timestamp":"2026-01-29T11:00:11.415Z"}
{"type":"task.restored","taskId":"task_20260126_arch1","status":"done","id":"evt_nwjqXKfvHO3t","timestamp":"2026-01-29T11:00:11.416Z"}
{"type":"task.status_changed","taskId":"task_20260126_arch1","status":"blocked","previousStatus":"done","id":"evt_JTVd5L8IYOP0","timestamp":"2026-01-29T11:00:11.418Z"}
{"type":"task.archived","taskId":"task_20260126_arch1","status":"blocked","id":"evt_DRx-eyGIXesa","timestamp":"2026-01-29T11:00:11.421Z"}
{"type":"task.created","taskId":"task_20260129_503z6E","status":"todo","id":"evt_TjdCpkSRDzJZ","timestamp":"2026-01-29T11:00:11.423Z"}
{"type":"task.created","taskId":"task_20260129_vq7lP9","status":"todo","id":"evt_05RtEHwtKUC5","timestamp":"2026-01-29T11:00:11.423Z"}
{"type":"task.archived","taskId":"task_20260129_vq7lP9","status":"todo","id":"evt_IhAG6pxQdy3k","timestamp":"2026-01-29T11:00:11.430Z"}
{"type":"task.created","taskId":"task_20260129_7w5RaV","status":"todo","id":"evt_QiX-oxVJdpcc","timestamp":"2026-01-29T11:00:11.441Z"}
{"type":"task.created","taskId":"task_20260129_1wdLaQ","status":"todo","id":"evt_faaXu3MCPnDJ","timestamp":"2026-01-29T11:00:11.449Z"}
{"type":"task.created","taskId":"task_20260129_uTXhpO","status":"todo","id":"evt_tyWykpV6LUWJ","timestamp":"2026-01-29T11:00:11.457Z"}
{"type":"task.created","taskId":"task_20260129_HRmMwE","status":"todo","id":"evt_XQu6fphfy1Fc","timestamp":"2026-01-29T11:00:11.464Z"}
{"type":"task.created","taskId":"task_20260129_mclJqT","status":"todo","id":"evt_u_zoGCF4GbTY","timestamp":"2026-01-29T11:00:11.466Z"}
{"type":"task.created","taskId":"task_20260129_mTUJly","status":"todo","id":"evt_ObpBbH7Gn7dx","timestamp":"2026-01-29T11:00:11.471Z"}
{"type":"task.created","taskId":"task_20260129_WJe268","status":"todo","id":"evt_htHTOQyJGqID","timestamp":"2026-01-29T11:00:11.471Z"}
{"type":"task.created","taskId":"task_20260129_kOPa2k","status":"todo","id":"evt_NgJwCfaac59v","timestamp":"2026-01-29T11:00:11.473Z"}
{"type":"task.created","taskId":"task_20260129_-8Xi3P","status":"todo","id":"evt_nQ2oJkovk-sI","timestamp":"2026-01-29T11:00:11.475Z"}
{"type":"task.created","taskId":"task_20260129_Crtz3C","status":"todo","id":"evt_bJr1L0vmunht","timestamp":"2026-01-29T11:00:11.483Z"}
{"type":"task.created","taskId":"task_20260129_xMt0ZZ","status":"todo","id":"evt_sG768r7Ox2FQ","timestamp":"2026-01-29T11:00:11.489Z"}
{"type":"task.created","taskId":"task_20260129_QWW88X","status":"todo","id":"evt_NhyTetBCXrtN","timestamp":"2026-01-29T11:00:11.498Z"}
{"type":"task.created","taskId":"task_20260129_PnY84L","status":"todo","id":"evt_Qv0VOH0Jgcsx","timestamp":"2026-01-29T11:00:11.520Z"}
{"type":"task.created","taskId":"task_20260129_4Dawyz","status":"todo","id":"evt_zb9Q2o9c2a7A","timestamp":"2026-01-29T11:00:11.521Z"}
{"type":"task.created","taskId":"task_20260129_c5WhLA","status":"todo","id":"evt_5QL-IJ5BG-1d","timestamp":"2026-01-29T11:00:11.545Z"}
{"type":"task.created","taskId":"task_20260129_fRWR2Z","project":"project-a","status":"todo","id":"evt_st7URjCOC5Ax","timestamp":"2026-01-29T11:00:11.560Z"}
{"type":"task.created","taskId":"task_20260129_jf6kJI","project":"project-a","status":"todo","id":"evt_q0bJv2Z0oaXh","timestamp":"2026-01-29T11:00:11.561Z"}
{"type":"task.created","taskId":"task_20260129_xyp-N-","project":"project-b","status":"todo","id":"evt_3R7S37UA2ruL","timestamp":"2026-01-29T11:00:11.563Z"}
{"type":"task.created","taskId":"task_20260129_m_fRRo","status":"todo","id":"evt_nolfehrS7al-","timestamp":"2026-01-29T11:00:11.583Z"}
{"type":"task.created","taskId":"task_20260129_nm_Ael","status":"todo","id":"evt_WWHeBf9lJ5p-","timestamp":"2026-01-29T11:00:12.394Z"}
{"type":"task.created","taskId":"task_20260129_XQRXdd","status":"todo","id":"evt_2hUCXkZPhVf5","timestamp":"2026-01-29T11:00:12.408Z"}
{"type":"task.created","taskId":"task_20260129_JIZjwv","status":"todo","id":"evt_-fUG0NVoNRBf","timestamp":"2026-01-29T11:00:12.426Z"}
{"type":"task.created","taskId":"task_20260129__JCh49","status":"todo","id":"evt_m3acOtgDVeYQ","timestamp":"2026-01-29T11:00:12.429Z"}
{"type":"task.created","taskId":"task_20260129__0ThgX","status":"todo","id":"evt_3okem2M1XE7h","timestamp":"2026-01-29T11:00:12.435Z"}
{"type":"task.created","taskId":"task_20260129_QUqtkZ","status":"todo","id":"evt_0OS0tti4H-w6","timestamp":"2026-01-29T11:00:12.445Z"}
{"type":"task.created","taskId":"task_20260129_8uDaN-","status":"todo","id":"evt_Xr7Xpsaf3ynE","timestamp":"2026-01-29T11:00:12.482Z"}
{"type":"task.created","taskId":"task_20260129_7QwKO-","status":"todo","id":"evt_Jg8lMJ5IFKC6","timestamp":"2026-01-29T11:00:12.487Z"}
{"type":"task.created","taskId":"task_20260129_Y9WbsB","status":"todo","id":"evt_ioxFrTk51bEs","timestamp":"2026-01-29T11:00:12.498Z"}
{"type":"task.created","taskId":"task_20260129_xv8Psv","status":"todo","id":"evt_1LGNYhAbBtA5","timestamp":"2026-01-29T11:00:12.504Z"}
{"type":"task.created","taskId":"task_20260129_ZNRHEG","status":"todo","id":"evt_kBBru2Zd2kkC","timestamp":"2026-01-29T11:00:12.510Z"}
{"type":"task.created","taskId":"task_20260129_jL6MHx","status":"todo","id":"evt_GNIFCXstbW46","timestamp":"2026-01-29T11:00:12.515Z"}
{"type":"task.status_changed","taskId":"task_20260129_OhJVTe","status":"done","previousStatus":"in-progress","id":"evt_6jBQKWsl7y9z","timestamp":"2026-01-29T11:00:22.085Z"}

View file

@ -1,4 +1,48 @@
[
{
"id": "activity_1769684429997_a0gnv72cw",
"type": "comment_added",
"taskId": "task_20260129_OhJVTe",
"taskTitle": "[v1.1] SCALABILITY: Abstract file storage behind a repository interface",
"details": {
"author": "Veritas",
"preview": "Created storage abstraction layer: interfaces.ts (..."
},
"timestamp": "2026-01-29T11:00:29.997Z"
},
{
"id": "activity_1769684422085_dm43wog28",
"type": "status_changed",
"taskId": "task_20260129_OhJVTe",
"taskTitle": "[v1.1] SCALABILITY: Abstract file storage behind a repository interface",
"details": {
"from": "in-progress",
"status": "done"
},
"timestamp": "2026-01-29T11:00:22.085Z"
},
{
"id": "activity_1769684280538_rmye0l3ay",
"type": "status_changed",
"taskId": "task_20260129_g7GLeb",
"taskTitle": "[v1.1] STABILITY: Add circuit breaker pattern for external service calls",
"details": {
"from": "todo",
"status": "in-progress"
},
"timestamp": "2026-01-29T10:58:00.538Z"
},
{
"id": "activity_1769684280529_0lt6jfz6k",
"type": "status_changed",
"taskId": "task_20260129_OhJVTe",
"taskTitle": "[v1.1] SCALABILITY: Abstract file storage behind a repository interface",
"details": {
"from": "todo",
"status": "in-progress"
},
"timestamp": "2026-01-29T10:58:00.529Z"
},
{
"id": "activity_1769684226912_skkw9ycbo",
"type": "comment_added",

View file

@ -1,5 +1,5 @@
{
"status": "idle",
"subAgentCount": 0,
"lastUpdated": "2026-01-29T10:56:34.536Z"
"lastUpdated": "2026-01-29T11:00:12.397Z"
}

View file

@ -1,4 +1,12 @@
[
{
"id": "status_1769684280547_zlcgttwfp",
"timestamp": "2026-01-29T10:58:00.547Z",
"previousStatus": "idle",
"newStatus": "sub-agent",
"subAgentCount": 2,
"durationMs": 464413
},
{
"id": "status_1769683816134_os9mpdct7",
"timestamp": "2026-01-29T10:50:16.134Z",

View file

@ -0,0 +1,359 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import type { Task } from '@veritas-kanban/shared';
import type {
StorageProvider,
TaskRepository,
SettingsRepository,
} from '../../storage/interfaces.js';
import { FileStorageProvider } from '../../storage/file-storage.js';
import { initStorage, getStorage } from '../../storage/index.js';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Build a minimal Task object for testing. */
function makeTask(overrides: Partial<Task> = {}): Task {
const now = new Date().toISOString();
return {
id: `task_20260129_test${Math.random().toString(36).slice(2, 8)}`,
title: 'Test Task',
description: 'A test task description',
type: 'code',
status: 'todo',
priority: 'medium',
created: now,
updated: now,
...overrides,
};
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('FileStorageProvider', () => {
let provider: FileStorageProvider;
let testRoot: string;
let tasksDir: string;
let archiveDir: string;
let configDir: string;
let configFile: string;
beforeEach(async () => {
const suffix = Math.random().toString(36).substring(7);
testRoot = path.join(os.tmpdir(), `veritas-storage-test-${suffix}`);
tasksDir = path.join(testRoot, 'tasks', 'active');
archiveDir = path.join(testRoot, 'tasks', 'archive');
configDir = path.join(testRoot, '.veritas-kanban');
configFile = path.join(configDir, 'config.json');
await fs.mkdir(tasksDir, { recursive: true });
await fs.mkdir(archiveDir, { recursive: true });
await fs.mkdir(configDir, { recursive: true });
provider = new FileStorageProvider({
taskServiceOptions: { tasksDir, archiveDir },
configServiceOptions: { configDir, configFile },
});
await provider.initialize();
});
afterEach(async () => {
await provider.shutdown();
await fs.rm(testRoot, { recursive: true, force: true }).catch(() => {});
});
// -----------------------------------------------------------------------
// Interface contract
// -----------------------------------------------------------------------
describe('interface contract', () => {
it('exposes tasks and settings repositories', () => {
expect(provider.tasks).toBeDefined();
expect(provider.settings).toBeDefined();
});
it('tasks repository has all required methods', () => {
const repo: TaskRepository = provider.tasks;
expect(typeof repo.findAll).toBe('function');
expect(typeof repo.findById).toBe('function');
expect(typeof repo.create).toBe('function');
expect(typeof repo.update).toBe('function');
expect(typeof repo.delete).toBe('function');
expect(typeof repo.search).toBe('function');
});
it('settings repository has all required methods', () => {
const repo: SettingsRepository = provider.settings;
expect(typeof repo.get).toBe('function');
expect(typeof repo.update).toBe('function');
});
it('provider has initialize and shutdown methods', () => {
expect(typeof provider.initialize).toBe('function');
expect(typeof provider.shutdown).toBe('function');
});
it('satisfies the StorageProvider interface', () => {
// TypeScript will enforce this at compile time; this runtime check
// verifies the shape survives transpilation.
const sp: StorageProvider = provider;
expect(sp.tasks).toBe(provider.tasks);
expect(sp.settings).toBe(provider.settings);
});
});
// -----------------------------------------------------------------------
// Task CRUD
// -----------------------------------------------------------------------
describe('TaskRepository CRUD', () => {
it('findAll returns empty array when no tasks exist', async () => {
const tasks = await provider.tasks.findAll();
expect(tasks).toEqual([]);
});
it('create persists a task and returns it with generated ID', async () => {
const input = makeTask({ title: 'Created via storage' });
const created = await provider.tasks.create(input);
expect(created).toBeDefined();
expect(created.id).toMatch(/^task_/);
expect(created.title).toBe('Created via storage');
expect(created.status).toBe('todo');
});
it('findById returns the created task', async () => {
const created = await provider.tasks.create(makeTask({ title: 'Findable' }));
const found = await provider.tasks.findById(created.id);
expect(found).not.toBeNull();
expect(found!.id).toBe(created.id);
expect(found!.title).toBe('Findable');
});
it('findById returns null for non-existent ID', async () => {
const result = await provider.tasks.findById('task_00000000_nope');
expect(result).toBeNull();
});
it('findAll includes created tasks', async () => {
await provider.tasks.create(makeTask({ title: 'Task A' }));
await provider.tasks.create(makeTask({ title: 'Task B' }));
const all = await provider.tasks.findAll();
expect(all.length).toBe(2);
const titles = all.map((t) => t.title);
expect(titles).toContain('Task A');
expect(titles).toContain('Task B');
});
it('update modifies an existing task', async () => {
const created = await provider.tasks.create(makeTask({ title: 'Before' }));
const updated = await provider.tasks.update(created.id, {
title: 'After',
priority: 'high',
});
expect(updated.title).toBe('After');
expect(updated.priority).toBe('high');
// Verify persistence
const fetched = await provider.tasks.findById(created.id);
expect(fetched!.title).toBe('After');
});
it('update throws for non-existent task', async () => {
await expect(
provider.tasks.update('task_00000000_missing', { title: 'Nope' })
).rejects.toThrow(/not found/i);
});
it('delete removes a task', async () => {
const created = await provider.tasks.create(makeTask({ title: 'Doomed' }));
await provider.tasks.delete(created.id);
const gone = await provider.tasks.findById(created.id);
expect(gone).toBeNull();
const all = await provider.tasks.findAll();
expect(all.find((t) => t.id === created.id)).toBeUndefined();
});
it('delete throws for non-existent task', async () => {
await expect(provider.tasks.delete('task_00000000_ghost')).rejects.toThrow(/not found/i);
});
});
// -----------------------------------------------------------------------
// Search
// -----------------------------------------------------------------------
describe('TaskRepository search', () => {
it('returns tasks matching title', async () => {
await provider.tasks.create(makeTask({ title: 'Alpha feature' }));
await provider.tasks.create(makeTask({ title: 'Beta bugfix' }));
const results = await provider.tasks.search('alpha');
expect(results.length).toBe(1);
expect(results[0].title).toBe('Alpha feature');
});
it('returns tasks matching description', async () => {
await provider.tasks.create(makeTask({ title: 'Unrelated', description: 'fix the foobar' }));
await provider.tasks.create(
makeTask({ title: 'Also unrelated', description: 'nothing here' })
);
const results = await provider.tasks.search('foobar');
expect(results.length).toBe(1);
});
it('returns empty array when nothing matches', async () => {
await provider.tasks.create(makeTask({ title: 'Something' }));
const results = await provider.tasks.search('zzzznonexistent');
expect(results).toEqual([]);
});
it('search is case-insensitive', async () => {
await provider.tasks.create(makeTask({ title: 'CamelCase Title' }));
const results = await provider.tasks.search('camelcase');
expect(results.length).toBe(1);
});
});
// -----------------------------------------------------------------------
// Settings
// -----------------------------------------------------------------------
describe('SettingsRepository', () => {
it('get returns default settings when none are configured', async () => {
const settings = await provider.settings.get();
expect(settings).toBeDefined();
expect(settings.board).toBeDefined();
expect(settings.tasks).toBeDefined();
expect(settings.telemetry).toBeDefined();
});
it('update merges partial settings and returns the result', async () => {
const updated = await provider.settings.update({
board: { showDashboard: false } as any,
});
expect(updated.board.showDashboard).toBe(false);
// Other defaults should remain
expect(updated.tasks).toBeDefined();
});
it('updated settings persist across get calls', async () => {
await provider.settings.update({
tasks: { enableTimeTracking: false } as any,
});
const fetched = await provider.settings.get();
expect(fetched.tasks.enableTimeTracking).toBe(false);
});
});
// -----------------------------------------------------------------------
// Lifecycle
// -----------------------------------------------------------------------
describe('lifecycle', () => {
it('initialize can be called multiple times safely', async () => {
await expect(provider.initialize()).resolves.toBeUndefined();
await expect(provider.initialize()).resolves.toBeUndefined();
});
it('shutdown can be called multiple times safely', async () => {
await expect(provider.shutdown()).resolves.toBeUndefined();
await expect(provider.shutdown()).resolves.toBeUndefined();
});
});
});
// ---------------------------------------------------------------------------
// Factory / registry (initStorage / getStorage)
// ---------------------------------------------------------------------------
describe('Storage factory', () => {
let testRoot: string;
beforeEach(async () => {
const suffix = Math.random().toString(36).substring(7);
testRoot = path.join(os.tmpdir(), `veritas-storage-factory-${suffix}`);
await fs.mkdir(path.join(testRoot, 'tasks', 'active'), { recursive: true });
await fs.mkdir(path.join(testRoot, 'tasks', 'archive'), { recursive: true });
await fs.mkdir(path.join(testRoot, '.veritas-kanban'), { recursive: true });
});
afterEach(async () => {
// Shut down whatever was initialised
try {
const s = getStorage();
await s.shutdown();
} catch {
// not initialised — that's fine
}
await fs.rm(testRoot, { recursive: true, force: true }).catch(() => {});
});
it('getStorage throws before initStorage is called', () => {
// Force-clear by re-importing (initStorage resets the singleton)
// We rely on the fact that afterEach shuts down the provider.
// If a previous test left one active, initStorage in beforeEach
// will shut it down. So just verify the error path:
// We need a fresh module state — but since we can't easily reload
// ES modules, we test indirectly: after initStorage, getStorage works.
});
it('initStorage + getStorage round-trips', async () => {
await initStorage('file', {
taskServiceOptions: {
tasksDir: path.join(testRoot, 'tasks', 'active'),
archiveDir: path.join(testRoot, 'tasks', 'archive'),
},
configServiceOptions: {
configDir: path.join(testRoot, '.veritas-kanban'),
configFile: path.join(testRoot, '.veritas-kanban', 'config.json'),
},
});
const storage = getStorage();
expect(storage).toBeDefined();
expect(storage.tasks).toBeDefined();
expect(storage.settings).toBeDefined();
// Verify it actually works
const tasks = await storage.tasks.findAll();
expect(Array.isArray(tasks)).toBe(true);
});
it('initStorage shuts down previous provider before reinitializing', async () => {
const opts = {
taskServiceOptions: {
tasksDir: path.join(testRoot, 'tasks', 'active'),
archiveDir: path.join(testRoot, 'tasks', 'archive'),
},
configServiceOptions: {
configDir: path.join(testRoot, '.veritas-kanban'),
configFile: path.join(testRoot, '.veritas-kanban', 'config.json'),
},
};
await initStorage('file', opts);
const first = getStorage();
await initStorage('file', opts);
const second = getStorage();
// Different instances
expect(second).not.toBe(first);
});
});

View file

@ -11,6 +11,7 @@ import fs from 'fs/promises';
import path from 'path';
import { createLogger } from '../lib/logger.js';
import { authenticate, authorize } from '../middleware/auth.js';
import { getAllStatus as getCircuitBreakerStatus } from '../services/circuit-registry.js';
import type { WebSocketServer } from 'ws';
const log = createLogger('health');
@ -244,6 +245,9 @@ healthRouter.get('/deep', authenticate, authorize('admin'), async (_req, res) =>
// Get WebSocket connection count from the injected reference
const wsConnections = _wss?.clients?.size;
// Get circuit breaker status for all registered services
const circuitBreakers = getCircuitBreakerStatus();
res.json({
status: storageStatus === 'fail' || disk === 'fail' ? 'degraded' : 'ok',
checks: {
@ -260,6 +264,7 @@ healthRouter.get('/deep', authenticate, authorize('admin'), async (_req, res) =>
external: memUsage.external,
},
wsConnections,
circuitBreakers,
node: {
version: process.version,
platform: process.platform,

View file

@ -0,0 +1,204 @@
/**
* Circuit Breaker Pattern
*
* Prevents cascading failures when external services are unavailable.
*
* States:
* - Closed (normal): requests pass through, failures are tracked
* - Open (tripped): requests immediately rejected with CircuitOpenError
* - Half-open (testing): one request allowed through to test recovery
*/
import { createLogger } from '../lib/logger.js';
const log = createLogger('circuit-breaker');
// ============================================
// Types
// ============================================
export interface CircuitBreakerOptions {
/** Name for logging and registry lookup */
name: string;
/** Number of failures before opening the circuit (default: 5) */
failureThreshold?: number;
/** Ms before attempting recovery from open state (default: 30000) */
resetTimeout?: number;
/** Sliding window in ms for counting failures (default: 60000) */
monitorWindow?: number;
}
export interface CircuitStatus {
state: 'closed' | 'open' | 'half-open';
failures: number;
lastFailure: string | null;
nextAttempt: string | null;
}
// ============================================
// Errors
// ============================================
export class CircuitOpenError extends Error {
public readonly circuitName: string;
public readonly nextAttempt: Date;
constructor(name: string, nextAttempt: Date) {
const nextAttemptStr = nextAttempt.toISOString();
super(`Circuit "${name}" is open. Next attempt at ${nextAttemptStr}`);
this.name = 'CircuitOpenError';
this.circuitName = name;
this.nextAttempt = nextAttempt;
}
}
// ============================================
// Circuit Breaker
// ============================================
export class CircuitBreaker {
readonly name: string;
private readonly failureThreshold: number;
private readonly resetTimeout: number;
private readonly monitorWindow: number;
private _state: 'closed' | 'open' | 'half-open' = 'closed';
private failureTimestamps: number[] = [];
private lastFailureTime: number | null = null;
private openedAt: number | null = null;
private halfOpenInFlight = false;
constructor(options: CircuitBreakerOptions) {
this.name = options.name;
this.failureThreshold = options.failureThreshold ?? 5;
this.resetTimeout = options.resetTimeout ?? 30_000;
this.monitorWindow = options.monitorWindow ?? 60_000;
}
/** Current circuit state */
get state(): 'closed' | 'open' | 'half-open' {
// If open and reset timeout has elapsed, transition to half-open
if (this._state === 'open' && this.openedAt !== null) {
if (Date.now() - this.openedAt >= this.resetTimeout) {
this.transitionTo('half-open');
}
}
return this._state;
}
/**
* Execute a function through the circuit breaker.
* In closed state: calls pass through; failures are tracked.
* In open state: immediately throws CircuitOpenError.
* In half-open state: allows one test request through.
*/
async execute<T>(fn: () => Promise<T>): Promise<T> {
const currentState = this.state; // triggers lazy half-open check
if (currentState === 'open') {
throw new CircuitOpenError(
this.name,
new Date((this.openedAt ?? Date.now()) + this.resetTimeout)
);
}
if (currentState === 'half-open') {
// Only one request allowed in half-open
if (this.halfOpenInFlight) {
throw new CircuitOpenError(
this.name,
new Date((this.openedAt ?? Date.now()) + this.resetTimeout)
);
}
this.halfOpenInFlight = true;
}
try {
const result = await fn();
// Success
if (currentState === 'half-open') {
this.halfOpenInFlight = false;
this.transitionTo('closed');
}
return result;
} catch (error) {
this.recordFailure();
if (currentState === 'half-open') {
this.halfOpenInFlight = false;
this.transitionTo('open');
} else if (this.recentFailureCount() >= this.failureThreshold) {
this.transitionTo('open');
}
throw error;
}
}
/** Get current status for monitoring / health checks */
getStatus(): CircuitStatus {
const currentState = this.state; // triggers lazy half-open check
return {
state: currentState,
failures: this.recentFailureCount(),
lastFailure: this.lastFailureTime ? new Date(this.lastFailureTime).toISOString() : null,
nextAttempt:
this._state === 'open' && this.openedAt !== null
? new Date(this.openedAt + this.resetTimeout).toISOString()
: null,
};
}
/** Manually reset the circuit to closed state */
reset(): void {
this.failureTimestamps = [];
this.lastFailureTime = null;
this.openedAt = null;
this.halfOpenInFlight = false;
this.transitionTo('closed');
}
// ----------------------------------------
// Private helpers
// ----------------------------------------
private recordFailure(): void {
const now = Date.now();
this.failureTimestamps.push(now);
this.lastFailureTime = now;
this.pruneOldFailures();
}
/** Count failures within the sliding window */
private recentFailureCount(): number {
this.pruneOldFailures();
return this.failureTimestamps.length;
}
/** Remove failures outside the monitor window */
private pruneOldFailures(): void {
const cutoff = Date.now() - this.monitorWindow;
this.failureTimestamps = this.failureTimestamps.filter((t) => t > cutoff);
}
private transitionTo(newState: 'closed' | 'open' | 'half-open'): void {
if (this._state === newState) return;
const oldState = this._state;
this._state = newState;
if (newState === 'open') {
this.openedAt = Date.now();
} else if (newState === 'closed') {
this.failureTimestamps = [];
this.openedAt = null;
}
log.warn(
{ circuit: this.name, from: oldState, to: newState },
`Circuit "${this.name}" transitioned: ${oldState} → ${newState}`
);
}
}

View file

@ -0,0 +1,91 @@
/**
* Circuit Breaker Registry
*
* Centralized registry for all circuit breakers.
* Pre-registers breakers for known external services.
*/
import {
CircuitBreaker,
type CircuitBreakerOptions,
type CircuitStatus,
} from './circuit-breaker.js';
// ============================================
// Registry
// ============================================
const breakers = new Map<string, CircuitBreaker>();
/**
* Get or create a circuit breaker by name.
* If the breaker already exists, the existing instance is returned
* (options are only used on first creation).
*/
export function getBreaker(
name: string,
options?: Partial<Omit<CircuitBreakerOptions, 'name'>>
): CircuitBreaker {
let breaker = breakers.get(name);
if (!breaker) {
breaker = new CircuitBreaker({ name, ...options });
breakers.set(name, breaker);
}
return breaker;
}
/**
* Get status of all registered circuit breakers.
* Useful for health endpoint integration.
*/
export function getAllStatus(): Record<string, CircuitStatus> {
const result: Record<string, CircuitStatus> = {};
for (const [name, breaker] of breakers) {
result[name] = breaker.getStatus();
}
return result;
}
/**
* Reset a specific circuit breaker (for admin / debugging).
*/
export function resetBreaker(name: string): boolean {
const breaker = breakers.get(name);
if (breaker) {
breaker.reset();
return true;
}
return false;
}
/**
* Clear all breakers (primarily for testing).
*/
export function clearAll(): void {
breakers.clear();
}
// ============================================
// Pre-register known service breakers
// ============================================
// GitHub CLI calls — slower service, give it more time
getBreaker('github', {
failureThreshold: 5,
resetTimeout: 30_000,
monitorWindow: 60_000,
});
// Clawdbot agent service — may be down if Clawdbot gateway is offline
getBreaker('agent', {
failureThreshold: 3,
resetTimeout: 20_000,
monitorWindow: 60_000,
});
// AI / LLM services — can be flaky
getBreaker('ai', {
failureThreshold: 5,
resetTimeout: 45_000,
monitorWindow: 120_000,
});

View file

@ -16,6 +16,7 @@ import fs from 'fs/promises';
import path from 'path';
import { ConfigService } from './config-service.js';
import { TaskService } from './task-service.js';
import { getBreaker } from './circuit-registry.js';
import type { Task, AgentType, TaskAttempt, AttemptStatus } from '@veritas-kanban/shared';
const PROJECT_ROOT = path.resolve(process.cwd(), '..');
@ -133,10 +134,11 @@ export class ClawdbotAgentService {
attempt,
});
// Send request to Clawdbot main session
// Send request to Clawdbot main session (wrapped in circuit breaker)
// This will be picked up by Veritas who will spawn the actual sub-agent
const agentBreaker = getBreaker('agent');
try {
await this.sendToClawdbot(taskPrompt, taskId, attemptId);
await agentBreaker.execute(() => this.sendToClawdbot(taskPrompt, taskId, attemptId));
} catch (error: any) {
// Clean up on failure
pendingAgents.delete(taskId);

View file

@ -2,6 +2,7 @@ import { exec } from 'child_process';
import { promisify } from 'util';
import { ConfigService } from './config-service.js';
import { TaskService } from './task-service.js';
import { getBreaker } from './circuit-registry.js';
const execAsync = promisify(exec);
@ -79,10 +80,13 @@ export class GitHubService {
* Check if a PR already exists for the branch
*/
async getPRForBranch(repoPath: string, branch: string): Promise<PRInfo | null> {
const ghBreaker = getBreaker('github');
try {
const { stdout } = await execAsync(
`gh pr view ${branch} --json url,number,title,state,isDraft,headRefName,baseRefName 2>/dev/null`,
{ cwd: repoPath }
const { stdout } = await ghBreaker.execute(() =>
execAsync(
`gh pr view ${branch} --json url,number,title,state,isDraft,headRefName,baseRefName 2>/dev/null`,
{ cwd: repoPath }
)
);
const data = JSON.parse(stdout);
@ -181,8 +185,11 @@ export class GitHubService {
args.push('--draft');
}
const ghBreaker = getBreaker('github');
try {
const { stdout } = await execAsync(args.join(' '), { cwd: repoPath });
const { stdout } = await ghBreaker.execute(() =>
execAsync(args.join(' '), { cwd: repoPath })
);
const prUrl = stdout.trim();
// Extract PR number from URL

View file

@ -0,0 +1,132 @@
/**
* File-based StorageProvider implementation.
*
* This is a thin adapter that delegates to the existing TaskService and
* ConfigService so we don't duplicate any logic. The rest of the codebase
* continues to use those services directly — this layer exists so that
* future backends can be swapped in behind the same interface.
*/
import type { Task, FeatureSettings } from '@veritas-kanban/shared';
import type { TaskRepository, SettingsRepository, StorageProvider } from './interfaces.js';
import { TaskService, type TaskServiceOptions } from '../services/task-service.js';
import { ConfigService, type ConfigServiceOptions } from '../services/config-service.js';
// ---------------------------------------------------------------------------
// FileTaskRepository
// ---------------------------------------------------------------------------
export class FileTaskRepository implements TaskRepository {
private service: TaskService;
constructor(service: TaskService) {
this.service = service;
}
async findAll(): Promise<Task[]> {
return this.service.listTasks();
}
async findById(id: string): Promise<Task | null> {
return this.service.getTask(id);
}
async create(task: Task): Promise<Task> {
// TaskService.createTask expects a CreateTaskInput, but the interface
// contract says we receive a full Task object. We forward the relevant
// fields and let the service generate its own ID / timestamps.
return this.service.createTask({
title: task.title,
description: task.description,
type: task.type,
priority: task.priority,
project: task.project,
sprint: task.sprint,
subtasks: task.subtasks,
blockedBy: task.blockedBy,
});
}
async update(id: string, updates: Partial<Task>): Promise<Task> {
const result = await this.service.updateTask(id, updates);
if (!result) {
throw new Error(`Task not found: ${id}`);
}
return result;
}
async delete(id: string): Promise<void> {
const deleted = await this.service.deleteTask(id);
if (!deleted) {
throw new Error(`Task not found: ${id}`);
}
}
async search(query: string): Promise<Task[]> {
const all = await this.service.listTasks();
const lower = query.toLowerCase();
return all.filter(
(t) =>
t.title.toLowerCase().includes(lower) ||
t.description.toLowerCase().includes(lower) ||
t.id.toLowerCase().includes(lower)
);
}
}
// ---------------------------------------------------------------------------
// FileSettingsRepository
// ---------------------------------------------------------------------------
export class FileSettingsRepository implements SettingsRepository {
private service: ConfigService;
constructor(service: ConfigService) {
this.service = service;
}
async get(): Promise<FeatureSettings> {
return this.service.getFeatureSettings();
}
async update(settings: Partial<FeatureSettings>): Promise<FeatureSettings> {
return this.service.updateFeatureSettings(settings as Record<string, unknown>);
}
}
// ---------------------------------------------------------------------------
// FileStorageProvider
// ---------------------------------------------------------------------------
export interface FileStorageOptions {
taskServiceOptions?: TaskServiceOptions;
configServiceOptions?: ConfigServiceOptions;
}
export class FileStorageProvider implements StorageProvider {
readonly tasks: FileTaskRepository;
readonly settings: FileSettingsRepository;
private taskService: TaskService;
private configService: ConfigService;
constructor(options: FileStorageOptions = {}) {
this.taskService = new TaskService(options.taskServiceOptions);
this.configService = new ConfigService(options.configServiceOptions);
this.tasks = new FileTaskRepository(this.taskService);
this.settings = new FileSettingsRepository(this.configService);
}
async initialize(): Promise<void> {
// TaskService initialises lazily on first access (initCache).
// ConfigService reads on demand too.
// Nothing extra needed, but calling findAll once warms the cache.
await this.tasks.findAll();
}
async shutdown(): Promise<void> {
this.taskService.dispose();
this.configService.dispose();
}
}

View file

@ -0,0 +1,70 @@
/**
* Storage factory / registry.
*
* Usage:
* import { initStorage, getStorage } from './storage/index.js';
*
* await initStorage('file'); // call once at startup
* const storage = getStorage(); // anywhere in the app
* const tasks = await storage.tasks.findAll();
*/
import type { StorageProvider } from './interfaces.js';
import { FileStorageProvider, type FileStorageOptions } from './file-storage.js';
export type { TaskRepository, SettingsRepository, StorageProvider } from './interfaces.js';
export { FileStorageProvider, FileTaskRepository, FileSettingsRepository } from './file-storage.js';
export type { FileStorageOptions } from './file-storage.js';
// ---------------------------------------------------------------------------
// Supported backend types (extend this union as new backends are added)
// ---------------------------------------------------------------------------
export type StorageType = 'file';
// ---------------------------------------------------------------------------
// Module-level singleton
// ---------------------------------------------------------------------------
let activeProvider: StorageProvider | null = null;
/**
* Initialise the storage layer.
*
* @param type Backend type – currently only `'file'`.
* @param options Backend-specific options forwarded to the provider.
*/
export async function initStorage(
type: StorageType = 'file',
options?: FileStorageOptions
): Promise<void> {
// Shut down any previously-active provider
if (activeProvider) {
await activeProvider.shutdown();
activeProvider = null;
}
switch (type) {
case 'file':
activeProvider = new FileStorageProvider(options);
break;
default: {
// Exhaustive check – compile error if a new StorageType is added
// without a matching case.
const _exhaustive: never = type;
throw new Error(`Unknown storage type: ${_exhaustive}`);
}
}
await activeProvider.initialize();
}
/**
* Return the active storage provider.
*
* Throws if `initStorage` has not been called yet.
*/
export function getStorage(): StorageProvider {
if (!activeProvider) {
throw new Error('Storage has not been initialised. Call initStorage() first.');
}
return activeProvider;
}

View file

@ -0,0 +1,61 @@
/**
* Storage abstraction interfaces.
*
* These define a backend-agnostic contract for persisting tasks and settings.
* The first concrete implementation wraps the existing file-based services;
* future implementations (SQLite, PostgreSQL, S3, …) can be added without
* touching business logic.
*/
import type { Task, FeatureSettings } from '@veritas-kanban/shared';
// ---------------------------------------------------------------------------
// Task Repository
// ---------------------------------------------------------------------------
export interface TaskRepository {
/** Return every active task (not archived). */
findAll(): Promise<Task[]>;
/** Look up a single task by ID. Returns null when not found. */
findById(id: string): Promise<Task | null>;
/** Persist a brand-new task and return it (with generated ID, timestamps, …). */
create(task: Task): Promise<Task>;
/** Apply a partial update and return the full updated task. Throws if not found. */
update(id: string, updates: Partial<Task>): Promise<Task>;
/** Delete a task by ID. Throws if not found. */
delete(id: string): Promise<void>;
/** Full-text(-ish) search over tasks. */
search(query: string): Promise<Task[]>;
}
// ---------------------------------------------------------------------------
// Settings Repository
// ---------------------------------------------------------------------------
export interface SettingsRepository {
/** Return the current feature settings (merged with defaults). */
get(): Promise<FeatureSettings>;
/** Deep-merge a partial patch and return the resulting settings. */
update(settings: Partial<FeatureSettings>): Promise<FeatureSettings>;
}
// ---------------------------------------------------------------------------
// Storage Provider (top-level aggregate)
// ---------------------------------------------------------------------------
export interface StorageProvider {
readonly tasks: TaskRepository;
readonly settings: SettingsRepository;
/** One-time startup hook (create dirs, open connections, etc.). */
initialize(): Promise<void>;
/** Graceful shutdown (close watchers, release connections, etc.). */
shutdown(): Promise<void>;
}