mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-10-09 05:07:53 +00:00
feat(server): abstract file storage behind repository interface
This commit is contained in:
parent
6bdb01f2bd
commit
d36ee28a5d
13 changed files with 1079 additions and 7 deletions
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"status": "idle",
|
||||
"subAgentCount": 0,
|
||||
"lastUpdated": "2026-01-29T10:56:34.536Z"
|
||||
"lastUpdated": "2026-01-29T11:00:12.397Z"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
359
server/src/__tests__/storage/file-storage.test.ts
Normal file
359
server/src/__tests__/storage/file-storage.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
204
server/src/services/circuit-breaker.ts
Normal file
204
server/src/services/circuit-breaker.ts
Normal 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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
91
server/src/services/circuit-registry.ts
Normal file
91
server/src/services/circuit-registry.ts
Normal 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,
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
132
server/src/storage/file-storage.ts
Normal file
132
server/src/storage/file-storage.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
70
server/src/storage/index.ts
Normal file
70
server/src/storage/index.ts
Normal 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;
|
||||
}
|
||||
61
server/src/storage/interfaces.ts
Normal file
61
server/src/storage/interfaces.ts
Normal 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>;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue