From d36ee28a5d65c8a74a65492ebc116f322709e892 Mon Sep 17 00:00:00 2001 From: Brad Groux Date: Thu, 29 Jan 2026 05:00:30 -0600 Subject: [PATCH] feat(server): abstract file storage behind repository interface --- .../telemetry/events-2026-01-29.ndjson | 89 +++++ server/.veritas-kanban/activity.json | 44 +++ server/.veritas-kanban/agent-status.json | 2 +- server/.veritas-kanban/status-history.json | 8 + .../__tests__/storage/file-storage.test.ts | 359 ++++++++++++++++++ server/src/routes/health.ts | 5 + server/src/services/circuit-breaker.ts | 204 ++++++++++ server/src/services/circuit-registry.ts | 91 +++++ server/src/services/clawdbot-agent-service.ts | 6 +- server/src/services/github-service.ts | 15 +- server/src/storage/file-storage.ts | 132 +++++++ server/src/storage/index.ts | 70 ++++ server/src/storage/interfaces.ts | 61 +++ 13 files changed, 1079 insertions(+), 7 deletions(-) create mode 100644 server/src/__tests__/storage/file-storage.test.ts create mode 100644 server/src/services/circuit-breaker.ts create mode 100644 server/src/services/circuit-registry.ts create mode 100644 server/src/storage/file-storage.ts create mode 100644 server/src/storage/index.ts create mode 100644 server/src/storage/interfaces.ts diff --git a/.veritas-kanban/telemetry/events-2026-01-29.ndjson b/.veritas-kanban/telemetry/events-2026-01-29.ndjson index 9e09b9bf..c0d367e1 100644 --- a/.veritas-kanban/telemetry/events-2026-01-29.ndjson +++ b/.veritas-kanban/telemetry/events-2026-01-29.ndjson @@ -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"} diff --git a/server/.veritas-kanban/activity.json b/server/.veritas-kanban/activity.json index b607cca5..7269a42f 100644 --- a/server/.veritas-kanban/activity.json +++ b/server/.veritas-kanban/activity.json @@ -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", diff --git a/server/.veritas-kanban/agent-status.json b/server/.veritas-kanban/agent-status.json index 04844990..6cf06e4a 100644 --- a/server/.veritas-kanban/agent-status.json +++ b/server/.veritas-kanban/agent-status.json @@ -1,5 +1,5 @@ { "status": "idle", "subAgentCount": 0, - "lastUpdated": "2026-01-29T10:56:34.536Z" + "lastUpdated": "2026-01-29T11:00:12.397Z" } diff --git a/server/.veritas-kanban/status-history.json b/server/.veritas-kanban/status-history.json index e2829df0..eda28450 100644 --- a/server/.veritas-kanban/status-history.json +++ b/server/.veritas-kanban/status-history.json @@ -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", diff --git a/server/src/__tests__/storage/file-storage.test.ts b/server/src/__tests__/storage/file-storage.test.ts new file mode 100644 index 00000000..b9aa5250 --- /dev/null +++ b/server/src/__tests__/storage/file-storage.test.ts @@ -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 { + 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); + }); +}); diff --git a/server/src/routes/health.ts b/server/src/routes/health.ts index a15730d8..25e958d5 100644 --- a/server/src/routes/health.ts +++ b/server/src/routes/health.ts @@ -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, diff --git a/server/src/services/circuit-breaker.ts b/server/src/services/circuit-breaker.ts new file mode 100644 index 00000000..204ccff9 --- /dev/null +++ b/server/src/services/circuit-breaker.ts @@ -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(fn: () => Promise): Promise { + 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}` + ); + } +} diff --git a/server/src/services/circuit-registry.ts b/server/src/services/circuit-registry.ts new file mode 100644 index 00000000..30710b77 --- /dev/null +++ b/server/src/services/circuit-registry.ts @@ -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(); + +/** + * 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> +): 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 { + const result: Record = {}; + 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, +}); diff --git a/server/src/services/clawdbot-agent-service.ts b/server/src/services/clawdbot-agent-service.ts index 64eb3567..077219fd 100644 --- a/server/src/services/clawdbot-agent-service.ts +++ b/server/src/services/clawdbot-agent-service.ts @@ -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); diff --git a/server/src/services/github-service.ts b/server/src/services/github-service.ts index 6c3bf022..c8c5711c 100644 --- a/server/src/services/github-service.ts +++ b/server/src/services/github-service.ts @@ -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 { + 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 diff --git a/server/src/storage/file-storage.ts b/server/src/storage/file-storage.ts new file mode 100644 index 00000000..74d1638f --- /dev/null +++ b/server/src/storage/file-storage.ts @@ -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 { + return this.service.listTasks(); + } + + async findById(id: string): Promise { + return this.service.getTask(id); + } + + async create(task: Task): Promise { + // 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): Promise { + const result = await this.service.updateTask(id, updates); + if (!result) { + throw new Error(`Task not found: ${id}`); + } + return result; + } + + async delete(id: string): Promise { + const deleted = await this.service.deleteTask(id); + if (!deleted) { + throw new Error(`Task not found: ${id}`); + } + } + + async search(query: string): Promise { + 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 { + return this.service.getFeatureSettings(); + } + + async update(settings: Partial): Promise { + return this.service.updateFeatureSettings(settings as Record); + } +} + +// --------------------------------------------------------------------------- +// 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 { + // 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 { + this.taskService.dispose(); + this.configService.dispose(); + } +} diff --git a/server/src/storage/index.ts b/server/src/storage/index.ts new file mode 100644 index 00000000..4368660b --- /dev/null +++ b/server/src/storage/index.ts @@ -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 { + // 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; +} diff --git a/server/src/storage/interfaces.ts b/server/src/storage/interfaces.ts new file mode 100644 index 00000000..3fb85976 --- /dev/null +++ b/server/src/storage/interfaces.ts @@ -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; + + /** Look up a single task by ID. Returns null when not found. */ + findById(id: string): Promise; + + /** Persist a brand-new task and return it (with generated ID, timestamps, …). */ + create(task: Task): Promise; + + /** Apply a partial update and return the full updated task. Throws if not found. */ + update(id: string, updates: Partial): Promise; + + /** Delete a task by ID. Throws if not found. */ + delete(id: string): Promise; + + /** Full-text(-ish) search over tasks. */ + search(query: string): Promise; +} + +// --------------------------------------------------------------------------- +// Settings Repository +// --------------------------------------------------------------------------- + +export interface SettingsRepository { + /** Return the current feature settings (merged with defaults). */ + get(): Promise; + + /** Deep-merge a partial patch and return the resulting settings. */ + update(settings: Partial): Promise; +} + +// --------------------------------------------------------------------------- +// 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; + + /** Graceful shutdown (close watchers, release connections, etc.). */ + shutdown(): Promise; +}