refactor: isolate operational evidence storage (#1216)

* refactor: isolate audit file storage

* refactor: isolate notification file storage

* refactor: centralize JSON service storage

* refactor: isolate operational file reads

* refactor: route maintenance IO through storage
This commit is contained in:
Brad Groux 2026-08-23 20:56:16 -05:00 committed by GitHub
parent ce9bc5c750
commit 87d203d2f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 436 additions and 390 deletions

View file

@ -1,6 +1,6 @@
{
"schemaVersion": 1,
"maximumEntries": 29,
"maximumEntries": 16,
"entries": [
{
"path": "server/src/services/attachment-service.ts",
@ -8,12 +8,6 @@
"owner": "#1188",
"rationale": "Managed-content storage migration is tracked in issue #1188."
},
{
"path": "server/src/services/audit-service.ts",
"category": "authoritative-persistence",
"owner": "#1187",
"rationale": "Operational evidence storage migration is tracked in issue #1187."
},
{
"path": "server/src/services/clawdbot-agent-service.ts",
"category": "transient-process-io",
@ -38,12 +32,6 @@
"owner": "#1188",
"rationale": "Managed-content storage migration is tracked in issue #1188."
},
{
"path": "server/src/services/external-tracker-service.ts",
"category": "authoritative-persistence",
"owner": "#1187",
"rationale": "Operational evidence storage migration is tracked in issue #1187."
},
{
"path": "server/src/services/file-lock.ts",
"category": "transient-process-io",
@ -56,42 +44,12 @@
"owner": "#1189",
"rationale": "Remaining process I/O migration is tracked in issue #1189."
},
{
"path": "server/src/services/github-sync-service.ts",
"category": "authoritative-persistence",
"owner": "#1187",
"rationale": "Operational evidence storage migration is tracked in issue #1187."
},
{
"path": "server/src/services/integrity-service.ts",
"category": "authoritative-persistence",
"owner": "#1187",
"rationale": "Operational evidence storage migration is tracked in issue #1187."
},
{
"path": "server/src/services/maintenance-service.ts",
"category": "authoritative-persistence",
"owner": "#1187",
"rationale": "Operational evidence storage migration is tracked in issue #1187."
},
{
"path": "server/src/services/managed-list-service.ts",
"category": "compatibility-debt",
"owner": "#1188",
"rationale": "Managed-content storage migration is tracked in issue #1188."
},
{
"path": "server/src/services/notification-service.ts",
"category": "authoritative-persistence",
"owner": "#1187",
"rationale": "Operational evidence storage migration is tracked in issue #1187."
},
{
"path": "server/src/services/outbound-integration-service.ts",
"category": "authoritative-persistence",
"owner": "#1187",
"rationale": "Operational evidence storage migration is tracked in issue #1187."
},
{
"path": "server/src/services/pdf-report-service.ts",
"category": "transient-process-io",
@ -104,24 +62,6 @@
"owner": "#1188",
"rationale": "Managed-content storage migration is tracked in issue #1188."
},
{
"path": "server/src/services/queue-intake-monitor-service.ts",
"category": "authoritative-persistence",
"owner": "#1187",
"rationale": "Operational evidence storage migration is tracked in issue #1187."
},
{
"path": "server/src/services/run-session-share-service.ts",
"category": "authoritative-persistence",
"owner": "#1187",
"rationale": "Operational evidence storage migration is tracked in issue #1187."
},
{
"path": "server/src/services/search-service.ts",
"category": "authoritative-persistence",
"owner": "#1187",
"rationale": "Operational evidence storage migration is tracked in issue #1187."
},
{
"path": "server/src/services/shared-resources-service.ts",
"category": "compatibility-debt",
@ -140,12 +80,6 @@
"owner": "#1189",
"rationale": "Remaining process I/O migration is tracked in issue #1189."
},
{
"path": "server/src/services/task-identity-diagnostics.ts",
"category": "authoritative-persistence",
"owner": "#1187",
"rationale": "Operational evidence storage migration is tracked in issue #1187."
},
{
"path": "server/src/services/template-service.ts",
"category": "compatibility-debt",
@ -163,18 +97,6 @@
"category": "compatibility-debt",
"owner": "#1188",
"rationale": "Managed-content storage migration is tracked in issue #1188."
},
{
"path": "server/src/services/trace-service.ts",
"category": "authoritative-persistence",
"owner": "#1187",
"rationale": "Operational evidence storage migration is tracked in issue #1187."
},
{
"path": "server/src/services/work-product-service.ts",
"category": "authoritative-persistence",
"owner": "#1187",
"rationale": "Operational evidence storage migration is tracked in issue #1187."
}
]
}

View file

@ -7,14 +7,11 @@
* Log files are stored as JSONL (one JSON object per line) with monthly rotation:
* {dataDir}/audit/audit-{YYYY-MM}.log
*/
import fs from 'fs/promises';
import { createReadStream } from 'fs';
import path from 'path';
import crypto from 'crypto';
import readline from 'readline';
import { createLogger } from '../lib/logger.js';
import { SqliteDatabase } from '../storage/sqlite/database.js';
import { SqliteAuditRepository } from '../storage/sqlite/audit-policy-repositories.js';
import { AuditFileRepository } from '../storage/audit-file-repository.js';
const log = createLogger('audit');
@ -57,6 +54,7 @@ import { getAuditDir } from '../utils/paths.js';
const AUDIT_DIR = getAuditDir();
const SQLITE_AUDIT_LOG_PATH = 'sqlite://audit/current';
const auditFileRepository = new AuditFileRepository(AUDIT_DIR);
// ---------------------------------------------------------------------------
// Internal State
@ -89,21 +87,19 @@ function logFilePath(date: Date = new Date()): string {
return SQLITE_AUDIT_LOG_PATH;
}
const yyyy = date.getFullYear();
const mm = String(date.getMonth() + 1).padStart(2, '0');
const month = `${yyyy}-${mm}`;
const month = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
// Cache to avoid path.join on every write
if (month !== currentMonth) {
currentMonth = month;
currentLogPath = path.join(AUDIT_DIR, `audit-${month}.log`);
currentLogPath = auditFileRepository.getMonthlyLogPath(date);
}
return currentLogPath;
}
/** Ensure the audit directory exists. */
async function ensureAuditDir(): Promise<void> {
await fs.mkdir(AUDIT_DIR, { recursive: true });
await auditFileRepository.ensureReady();
}
function isSqliteAuditEnabled(): boolean {
@ -131,22 +127,7 @@ async function seedLastHash(filePath: string): Promise<void> {
return;
}
try {
const content = await fs.readFile(filePath, 'utf8');
const lines = content.trimEnd().split('\n').filter(Boolean);
if (lines.length > 0) {
lastHash = sha256(lines[lines.length - 1]);
} else {
lastHash = '';
}
} catch (err: unknown) {
// File doesn't exist yet — first entry
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
lastHash = '';
} else {
throw err;
}
}
lastHash = await auditFileRepository.getLastHash(filePath);
}
/** Track whether we've seeded for the current file. */
@ -198,7 +179,7 @@ async function writeEntry(event: AuditEvent): Promise<void> {
if (isSqliteAuditEnabled()) {
getAuditRepository().save(entry, line);
} else {
await fs.appendFile(filePath, line + '\n', 'utf8');
await auditFileRepository.append(filePath, line);
}
// Update the running hash
@ -216,71 +197,7 @@ export async function verifyAuditLog(filePath: string): Promise<VerifyResult> {
return getAuditRepository().verify();
}
// Check if file exists
try {
await fs.access(filePath);
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
return { valid: true, entries: 0 };
}
throw err;
}
return new Promise((resolve, reject) => {
const stream = createReadStream(filePath, { encoding: 'utf8' });
const rl = readline.createInterface({
input: stream,
crlfDelay: Infinity,
});
let prevHash = '';
let lineIndex = 0;
let totalLines = 0;
let invalidResult: VerifyResult | null = null;
rl.on('line', (line) => {
if (invalidResult) return; // Already found an error
const trimmed = line.trim();
if (!trimmed) {
lineIndex++;
return;
}
totalLines++;
let entry: AuditEntry;
try {
entry = JSON.parse(trimmed) as AuditEntry;
} catch {
invalidResult = { valid: false, entries: totalLines, firstBroken: lineIndex };
rl.close();
stream.destroy();
return;
}
if (entry.integrity !== prevHash) {
invalidResult = { valid: false, entries: totalLines, firstBroken: lineIndex };
rl.close();
stream.destroy();
return;
}
prevHash = sha256(trimmed);
lineIndex++;
});
rl.on('close', () => {
if (invalidResult) {
resolve(invalidResult);
} else {
resolve({ valid: true, entries: totalLines });
}
});
rl.on('error', reject);
stream.on('error', reject);
});
return auditFileRepository.verify(filePath);
}
/**
@ -293,20 +210,7 @@ export async function readRecentAuditEntries(limit = 100): Promise<AuditEntry[]>
return getAuditRepository().readRecent(limit) as AuditEntry[];
}
let content: string;
try {
content = await fs.readFile(filePath, 'utf8');
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
return [];
}
throw err;
}
const lines = content.trimEnd().split('\n').filter(Boolean);
// Take the last `limit` entries and reverse for newest-first
const slice = lines.slice(-limit).reverse();
return slice.map((line) => JSON.parse(line) as AuditEntry);
return auditFileRepository.readRecent(filePath, limit);
}
/**

View file

@ -1,4 +1,3 @@
import fs from 'fs/promises';
import path from 'path';
import { nanoid } from 'nanoid';
import type {
@ -24,9 +23,9 @@ import type {
} from '@veritas-kanban/shared';
import { auditLog, type AuditEvent } from './audit-service.js';
import { activityService, type ActivityService } from './activity-service.js';
import { withFileLock } from './file-lock.js';
import { getTaskService } from './task-service.js';
import { ConflictError, NotFoundError, ValidationError } from '../middleware/error-handler.js';
import { JsonFileRepository } from '../storage/json-file-repository.js';
import { getRuntimeDir } from '../utils/paths.js';
import { stripHtml, validatePathSegment } from '../utils/sanitize.js';
@ -400,7 +399,7 @@ class MockExternalTrackerAdapter implements ExternalTrackerAdapter {
}
export class ExternalTrackerService {
private readonly storageDir: string;
private readonly repository: JsonFileRepository<ExternalTrackerState>;
private readonly persist: boolean;
private readonly audit: (event: AuditEvent) => Promise<void>;
private readonly taskService: ExternalTrackerTaskService;
@ -410,7 +409,8 @@ export class ExternalTrackerService {
private state: ExternalTrackerState = this.emptyState();
constructor(options: ExternalTrackerServiceOptions = {}) {
this.storageDir = options.storageDir ?? path.join(getRuntimeDir(), 'external-trackers');
const storageDir = options.storageDir ?? path.join(getRuntimeDir(), 'external-trackers');
this.repository = new JsonFileRepository(path.join(storageDir, STATE_FILE));
this.persist = options.persist ?? process.env.VITEST !== 'true';
this.audit = options.audit ?? auditLog;
this.taskService = options.taskService ?? getTaskService();
@ -946,10 +946,6 @@ export class ExternalTrackerService {
};
}
private get stateFile(): string {
return path.join(this.storageDir, STATE_FILE);
}
private async ensureLoaded(): Promise<void> {
if (this.loaded) return;
if (!this.persist) {
@ -960,10 +956,8 @@ export class ExternalTrackerService {
this.loaded = true;
return;
}
await fs.mkdir(this.storageDir, { recursive: true });
try {
const content = await fs.readFile(this.stateFile, 'utf8');
this.state = { ...this.emptyState(), ...JSON.parse(content) };
this.state = { ...this.emptyState(), ...(await this.repository.read()) };
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
this.state = this.emptyState();
@ -977,10 +971,7 @@ export class ExternalTrackerService {
private async saveState(): Promise<void> {
if (!this.persist) return;
await fs.mkdir(this.storageDir, { recursive: true });
await withFileLock(this.stateFile, async () => {
await fs.writeFile(this.stateFile, JSON.stringify(this.state, null, 2), 'utf8');
});
await this.repository.write(this.state);
}
}

View file

@ -8,11 +8,10 @@
* Persists config to `.veritas-kanban/integrations.json` and sync state to
* `.veritas-kanban/github-sync.json`.
*/
import { readFile, writeFile, mkdir } from 'fs/promises';
import { join } from 'path';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { fileExists } from '../storage/fs-helpers.js';
import { JsonFileRepository } from '../storage/json-file-repository.js';
import { getBreaker } from './circuit-registry.js';
import { getTaskService, type TaskService } from './task-service.js';
import { createLogger } from '../lib/logger.js';
@ -68,6 +67,10 @@ interface GhIssue {
const DATA_DIR = getRuntimeDir();
const INTEGRATIONS_FILE = join(DATA_DIR, 'integrations.json');
const SYNC_STATE_FILE = join(DATA_DIR, 'github-sync.json');
const integrationsRepository = new JsonFileRepository<Partial<IntegrationsConfig>>(
INTEGRATIONS_FILE
);
const syncStateRepository = new JsonFileRepository<Partial<SyncState>>(SYNC_STATE_FILE);
const DEFAULT_CONFIG: IntegrationsConfig = {
github: {
@ -97,13 +100,8 @@ export class GitHubSyncService {
// ── Config persistence ────────────────────────────────────
async getConfig(): Promise<IntegrationsConfig> {
await mkdir(DATA_DIR, { recursive: true });
if (!(await fileExists(INTEGRATIONS_FILE))) {
return { ...DEFAULT_CONFIG };
}
try {
const raw = await readFile(INTEGRATIONS_FILE, 'utf-8');
const parsed = JSON.parse(raw) as Partial<IntegrationsConfig>;
const parsed = await integrationsRepository.read();
return {
github: { ...DEFAULT_CONFIG.github, ...parsed.github },
};
@ -115,21 +113,15 @@ export class GitHubSyncService {
async updateConfig(patch: Partial<GitHubSyncConfig>): Promise<IntegrationsConfig> {
const current = await this.getConfig();
current.github = { ...current.github, ...patch };
await mkdir(DATA_DIR, { recursive: true });
await writeFile(INTEGRATIONS_FILE, JSON.stringify(current, null, 2), 'utf-8');
await integrationsRepository.write(current);
return current;
}
// ── Sync-state persistence ────────────────────────────────
async getSyncState(): Promise<SyncState> {
await mkdir(DATA_DIR, { recursive: true });
if (!(await fileExists(SYNC_STATE_FILE))) {
return { ...DEFAULT_STATE, issueMappings: {} };
}
try {
const raw = await readFile(SYNC_STATE_FILE, 'utf-8');
const parsed = JSON.parse(raw) as Partial<SyncState>;
const parsed = await syncStateRepository.read();
return {
lastSyncAt: parsed.lastSyncAt ?? null,
issueMappings: parsed.issueMappings ?? {},
@ -140,8 +132,7 @@ export class GitHubSyncService {
}
private async saveSyncState(state: SyncState): Promise<void> {
await mkdir(DATA_DIR, { recursive: true });
await writeFile(SYNC_STATE_FILE, JSON.stringify(state, null, 2), 'utf-8');
await syncStateRepository.write(state);
}
// ── gh CLI helpers ────────────────────────────────────────

View file

@ -1,7 +1,7 @@
import fs from 'fs/promises';
import path from 'path';
import matter, { type MarkdownFrontmatterFile } from '../utils/frontmatter.js';
import { createLogger } from '../lib/logger.js';
import * as fs from '../storage/fs-helpers.js';
const log = createLogger('integrity');

View file

@ -1,4 +1,3 @@
import fs from 'fs/promises';
import path from 'path';
import { createHash } from 'node:crypto';
import {
@ -33,6 +32,7 @@ import {
import { redactString } from '../lib/redact.js';
import { getSqliteStorageDiagnostics } from '../storage/sqlite/database.js';
import { getStorage } from '../storage/index.js';
import * as fs from '../storage/fs-helpers.js';
import { getRunPhaseAuthorityService } from './run-phase-authority-service.js';
import { getAdmissionControlService } from './admission-control-service.js';
@ -413,9 +413,7 @@ export class MaintenanceService {
(!artifact.retention.activeLeaseUntil ||
Date.parse(artifact.retention.activeLeaseUntil) <= current)
).length,
lastUsedAt: this.latestDate(
artifacts.map((artifact) => artifact.redaction.validatedAt)
),
lastUsedAt: this.latestDate(artifacts.map((artifact) => artifact.redaction.validatedAt)),
};
} catch {
return { bytes: 0, itemCount: 0, cleanupEligibleCount: 0 };

View file

@ -6,12 +6,10 @@
*/
import { createLogger } from '../lib/logger.js';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { withFileLock } from './file-lock.js';
import { SqliteDatabase, type SqliteConnectionOptions } from '../storage/sqlite/database.js';
import { SqliteNotificationRepository } from '../storage/sqlite/notification-repository.js';
import { getRuntimeDir } from '../utils/paths.js';
import { NotificationFileRepository } from '../storage/notification-file-repository.js';
const DATA_DIR = getRuntimeDir();
@ -98,17 +96,18 @@ export class NotificationService {
private notifications: Notification[] = [];
private subscriptions: ThreadSubscription[] = [];
private loaded = false;
private readonly notificationsFile: string;
private readonly subscriptionsFile: string;
private readonly fileRepository: NotificationFileRepository;
private readonly repository: SqliteNotificationRepository | null = null;
private readonly sqliteDatabase: SqliteDatabase | null = null;
private readonly ownsSqliteDatabase: boolean = false;
constructor(options: NotificationServiceOptions = {}) {
const dataDir = options.dataDir ?? DATA_DIR;
this.notificationsFile = options.notificationsFile ?? path.join(dataDir, 'notifications.json');
this.subscriptionsFile =
options.subscriptionsFile ?? path.join(dataDir, 'thread-subscriptions.json');
this.fileRepository = new NotificationFileRepository({
dataDir,
notificationsFile: options.notificationsFile,
subscriptionsFile: options.subscriptionsFile,
});
const storageType =
options.storageType ?? (process.env.VERITAS_STORAGE === 'sqlite' ? 'sqlite' : 'file');
@ -130,18 +129,8 @@ export class NotificationService {
return;
}
try {
const nData = await fs.readFile(this.notificationsFile, 'utf-8');
this.notifications = JSON.parse(nData);
} catch {
this.notifications = [];
}
try {
const sData = await fs.readFile(this.subscriptionsFile, 'utf-8');
this.subscriptions = JSON.parse(sData);
} catch {
this.subscriptions = [];
}
this.notifications = await this.fileRepository.loadNotifications<Notification>();
this.subscriptions = await this.fileRepository.loadSubscriptions<ThreadSubscription>();
this.loaded = true;
}
@ -151,9 +140,7 @@ export class NotificationService {
return;
}
await withFileLock(this.notificationsFile, async () => {
await fs.writeFile(this.notificationsFile, JSON.stringify(this.notifications, null, 2));
});
await this.fileRepository.saveNotifications(this.notifications);
}
private async saveSubscriptions(): Promise<void> {
@ -162,9 +149,7 @@ export class NotificationService {
return;
}
await withFileLock(this.subscriptionsFile, async () => {
await fs.writeFile(this.subscriptionsFile, JSON.stringify(this.subscriptions, null, 2));
});
await this.fileRepository.saveSubscriptions(this.subscriptions);
}
/**

View file

@ -1,4 +1,3 @@
import fs from 'fs/promises';
import path from 'path';
import { nanoid } from 'nanoid';
import type { FeatureSettings } from '@veritas-kanban/shared';
@ -9,6 +8,7 @@ import {
type UrlValidationOptions,
} from '../utils/url-validation.js';
import { getRuntimeDir } from '../utils/paths.js';
import { JsonFileRepository } from '../storage/json-file-repository.js';
import {
defaultDependencyCircuitExecutionService,
integrationDependencyIdentity,
@ -201,7 +201,8 @@ async function readLimitedResponseText(
}
export class OutboundIntegrationService {
private readonly storageDir: string;
private readonly endpointsRepository: JsonFileRepository<OutboundEndpointRecord[]>;
private readonly deliveriesRepository: JsonFileRepository<OutboundDeliveryAttempt[]>;
private readonly persist: boolean;
private readonly audit: (event: AuditEvent) => Promise<void>;
private readonly dependencyExecution: DependencyCircuitExecutionService;
@ -210,7 +211,9 @@ export class OutboundIntegrationService {
private deliveries: OutboundDeliveryAttempt[] = [];
constructor(options: OutboundIntegrationServiceOptions = {}) {
this.storageDir = options.storageDir || path.join(getRuntimeDir(), 'outbound-integrations');
const storageDir = options.storageDir || path.join(getRuntimeDir(), 'outbound-integrations');
this.endpointsRepository = new JsonFileRepository(path.join(storageDir, 'endpoints.json'));
this.deliveriesRepository = new JsonFileRepository(path.join(storageDir, 'deliveries.json'));
this.persist = options.persist ?? process.env.VITEST !== 'true';
this.audit = options.audit || auditLog;
this.dependencyExecution =
@ -388,8 +391,7 @@ export class OutboundIntegrationService {
const message = this.sanitizeError(err, endpoint.url);
const circuitRejected = err instanceof DependencyRouteUnavailableError;
const policyBlocked = err instanceof OutboundPolicyBlockError;
const dependencyResponse =
err instanceof OutboundDependencyResponseError ? err : undefined;
const dependencyResponse = err instanceof OutboundDependencyResponseError ? err : undefined;
const status: OutboundDeliveryStatus =
circuitRejected || policyBlocked
? 'blocked'
@ -412,7 +414,7 @@ export class OutboundIntegrationService {
? 'Dependency circuit rejected outbound delivery.'
: policyBlocked
? err.message
: message,
: message,
});
return {
ok: false,
@ -518,19 +520,15 @@ export class OutboundIntegrationService {
return;
}
await fs.mkdir(this.storageDir, { recursive: true });
try {
const raw = await fs.readFile(this.endpointsPath, 'utf-8');
const parsed = JSON.parse(raw) as OutboundEndpointRecord[];
const parsed = await this.endpointsRepository.read();
this.endpoints = new Map(parsed.map((endpoint) => [endpoint.id, endpoint]));
} catch {
this.endpoints = new Map();
}
try {
const raw = await fs.readFile(this.deliveriesPath, 'utf-8');
this.deliveries = JSON.parse(raw) as OutboundDeliveryAttempt[];
this.deliveries = await this.deliveriesRepository.read();
if (this.deliveries.length > MAX_DELIVERIES) {
this.deliveries = this.deliveries.slice(-MAX_DELIVERIES);
}
@ -541,28 +539,14 @@ export class OutboundIntegrationService {
this.loaded = true;
}
private get endpointsPath(): string {
return path.join(this.storageDir, 'endpoints.json');
}
private get deliveriesPath(): string {
return path.join(this.storageDir, 'deliveries.json');
}
private async saveEndpoints(): Promise<void> {
if (!this.persist) return;
await fs.mkdir(this.storageDir, { recursive: true });
await fs.writeFile(
this.endpointsPath,
JSON.stringify(Array.from(this.endpoints.values()), null, 2),
'utf-8'
);
await this.endpointsRepository.write(Array.from(this.endpoints.values()));
}
private async saveDeliveries(): Promise<void> {
if (!this.persist) return;
await fs.mkdir(this.storageDir, { recursive: true });
await fs.writeFile(this.deliveriesPath, JSON.stringify(this.deliveries, null, 2), 'utf-8');
await this.deliveriesRepository.write(this.deliveries);
}
private async recordDelivery(input: {

View file

@ -1,5 +1,4 @@
import { execFile } from 'node:child_process';
import fs from 'node:fs/promises';
import path from 'node:path';
import { promisify } from 'node:util';
import { nanoid } from 'nanoid';
@ -28,6 +27,7 @@ import type {
import { createLogger } from '../lib/logger.js';
import { NotFoundError, ValidationError } from '../middleware/error-handler.js';
import { getRuntimeDir } from '../utils/paths.js';
import { JsonFileRepository } from '../storage/json-file-repository.js';
import { getAgentBudgetService, type AgentBudgetService } from './agent-budget-service.js';
import { getBreaker } from './circuit-registry.js';
import {
@ -121,7 +121,7 @@ interface GateContext {
}
export class QueueIntakeMonitorService {
private readonly storeFile: string;
private readonly repository: JsonFileRepository<QueueMonitorStore>;
private readonly githubExec: (args: string[]) => Promise<string>;
private readonly watcherPolicyService: WatcherPolicyService;
private readonly sandboxPolicyService: SandboxPolicyService;
@ -136,7 +136,8 @@ export class QueueIntakeMonitorService {
private readonly runningMonitors = new Set<string>();
constructor(options: QueueIntakeMonitorServiceOptions = {}) {
this.storeFile = options.storeFile ?? path.join(getRuntimeDir(), 'queue-monitors.json');
const storeFile = options.storeFile ?? path.join(getRuntimeDir(), 'queue-monitors.json');
this.repository = new JsonFileRepository(storeFile);
this.githubExec = options.githubExec ?? defaultGhExec;
this.watcherPolicyService = options.watcherPolicyService ?? new WatcherPolicyService();
this.sandboxPolicyService = options.sandboxPolicyService ?? getSandboxPolicyService();
@ -965,8 +966,7 @@ export class QueueIntakeMonitorService {
private async ensureLoaded(): Promise<void> {
if (this.store) return;
try {
const raw = await fs.readFile(this.storeFile, 'utf-8');
const parsed = JSON.parse(raw) as Partial<QueueMonitorStore>;
const parsed = await this.repository.read();
this.store = normalizeStore(parsed);
} catch {
this.store = defaultStore();
@ -979,8 +979,7 @@ export class QueueIntakeMonitorService {
}
private async saveStore(): Promise<void> {
await fs.mkdir(path.dirname(this.storeFile), { recursive: true });
await fs.writeFile(this.storeFile, JSON.stringify(this.store, null, 2), 'utf-8');
await this.repository.write(this.currentStore());
}
}

View file

@ -1,4 +1,3 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { nanoid } from 'nanoid';
import type {
@ -19,6 +18,7 @@ import type {
UpdateRunSessionShareInput,
} from '@veritas-kanban/shared';
import { getDataDir } from '../utils/paths.js';
import { JsonFileRepository } from '../storage/json-file-repository.js';
import { validatePathSegment } from '../utils/sanitize.js';
import { redactString } from '../lib/redact.js';
import { ConflictError, ForbiddenError, NotFoundError } from '../middleware/error-handler.js';
@ -55,15 +55,16 @@ const MAX_EVENT_HISTORY = 5000;
const LOG_CONTEXT_LIMIT = 4000;
export class RunSessionShareService {
private readonly filePath: string;
private readonly repository: JsonFileRepository<RunSessionShareState>;
private readonly taskService: TaskService;
private readonly agentService: typeof clawdbotAgentService;
private readonly approvalBroker: RunApprovalBrokerService;
private state: RunSessionShareState | null = null;
constructor(options: RunSessionShareServiceOptions = {}) {
this.filePath =
const filePath =
options.filePath ?? path.join(getDataDir(), 'storage', 'run-session-shares.json');
this.repository = new JsonFileRepository(filePath, { trailingNewline: true });
this.taskService = options.taskService ?? getTaskService();
this.agentService = options.agentService ?? clawdbotAgentService;
this.approvalBroker = options.approvalBroker ?? getRunApprovalBrokerService();
@ -479,8 +480,7 @@ export class RunSessionShareService {
private async loadState(): Promise<RunSessionShareState> {
if (this.state) return this.state;
try {
const raw = await fs.readFile(this.filePath, 'utf8');
this.state = JSON.parse(raw) as RunSessionShareState;
this.state = await this.repository.read();
} catch {
this.state = { shares: [], events: [], forks: [] };
}
@ -489,8 +489,7 @@ export class RunSessionShareService {
private async saveState(state: RunSessionShareState): Promise<void> {
state.events = state.events.slice(-MAX_EVENT_HISTORY);
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
await fs.writeFile(this.filePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
await this.repository.write(state);
this.state = state;
}
}

View file

@ -1,5 +1,4 @@
import { execFile } from 'node:child_process';
import fs from 'node:fs/promises';
import path from 'node:path';
import type { AnyTelemetryEvent } from '@veritas-kanban/shared';
import { createLogger } from '../lib/logger.js';
@ -13,6 +12,10 @@ import { getWorkProductService, WorkProductService } from './work-product-servic
import { getWorkflowService, WorkflowService } from './workflow-service.js';
import { getWorkflowRunService, WorkflowRunService } from './workflow-run-service.js';
import { getStorageRoot } from '../utils/paths.js';
import {
SearchFileRepository,
type SearchFileDescriptor,
} from '../storage/search-file-repository.js';
const log = createLogger('search-service');
@ -80,10 +83,7 @@ interface SearchSource {
extensions: readonly string[];
}
interface SearchFile {
path: string;
mtimeMs: number;
}
type SearchFile = SearchFileDescriptor;
interface ScoreDetails {
score: number;
@ -118,6 +118,7 @@ const MAX_TELEMETRY_SEARCH_EVENTS =
: 2_000;
class SearchService {
private readonly fileRepository = new SearchFileRepository();
async search(request: SearchRequest): Promise<SearchResponse> {
const started = Date.now();
const query = request.query.trim();
@ -417,7 +418,7 @@ class SearchService {
): Promise<SearchResult | null> {
let content: string;
try {
content = await fs.readFile(file.path, 'utf-8');
content = await this.fileRepository.readText(file.path);
} catch {
return null;
}
@ -1130,45 +1131,11 @@ class SearchService {
dir: string,
extensions: readonly string[] = DEFAULT_FILE_EXTENSIONS
): Promise<SearchFile[]> {
const files: SearchFile[] = [];
const allowedExtensions = new Set(extensions.map((extension) => extension.toLowerCase()));
const visit = async (currentDir: string): Promise<void> => {
if (files.length >= MAX_FILES_PER_SOURCE) return;
let entries;
try {
entries = await fs.readdir(currentDir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (files.length >= MAX_FILES_PER_SOURCE) return;
if (SKIPPED_DIRECTORIES.has(entry.name)) continue;
const fullPath = path.join(currentDir, entry.name);
if (entry.isDirectory()) {
await visit(fullPath);
continue;
}
if (!entry.isFile()) continue;
const extension = path.extname(entry.name).toLowerCase();
if (!allowedExtensions.has(extension)) continue;
try {
const stats = await fs.stat(fullPath);
files.push({ path: fullPath, mtimeMs: stats.mtimeMs });
} catch {
continue;
}
}
};
await visit(dir);
return files.sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, MAX_FILES_PER_SOURCE);
return this.fileRepository.listFiles(dir, {
extensions,
maxFiles: MAX_FILES_PER_SOURCE,
skippedDirectories: SKIPPED_DIRECTORIES,
});
}
private extractTitle(

View file

@ -1,6 +1,6 @@
import fs from 'fs/promises';
import path from 'path';
import matter from '../utils/frontmatter.js';
import { TaskIdentityFileRepository } from '../storage/task-identity-file-repository.js';
export type TaskIdentityLocation = 'active' | 'backlog' | 'archive';
export type TaskIdentityConflictKind = 'task-id' | 'business-id';
@ -60,6 +60,7 @@ const EMPTY_DIAGNOSTICS: TaskIdentityDiagnostics = {
conflictCount: 0,
conflicts: [],
};
const fileRepository = new TaskIdentityFileRepository();
function taskIdFromFilename(filename: string): string {
return filename.replace(/\.md$/, '').split('-')[0] ?? '';
@ -121,22 +122,11 @@ async function readMarkdownSources(
source: TaskIdentityScanSource,
rootDir: string
): Promise<TaskIdentitySource[]> {
let files: string[];
try {
files = await fs.readdir(source.dir);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return [];
}
throw error;
}
const markdownFiles = files.filter((filename) => filename.endsWith('.md')).sort();
const results: TaskIdentitySource[] = [];
for (const filename of markdownFiles) {
const filepath = path.join(source.dir, filename);
const content = await fs.readFile(filepath, 'utf-8');
for (const { filename, absolutePath, content } of await fileRepository.readMarkdownFiles(
source.dir
)) {
const parsed = matter(content);
const frontmatter = parsed.data as Record<string, unknown>;
const taskId =
@ -148,7 +138,7 @@ async function readMarkdownSources(
results.push({
location: source.location,
path: path.relative(rootDir, filepath),
path: path.relative(rootDir, absolutePath),
filename,
taskId,
title: typeof frontmatter.title === 'string' ? frontmatter.title : undefined,

View file

@ -1,5 +1,3 @@
import fs from 'fs/promises';
import path from 'path';
import type {
AgentRunTrace,
AgentRunTraceMetadata,
@ -8,8 +6,9 @@ import type {
AgentType,
} from '@veritas-kanban/shared';
import { getTelemetryService } from './telemetry-service.js';
import { validatePathSegment, ensureWithinBase } from '../utils/sanitize.js';
import { validatePathSegment } from '../utils/sanitize.js';
import { getTracesDir } from '../utils/paths.js';
import { TraceFileRepository } from '../storage/trace-file-repository.js';
const TRACES_DIR = getTracesDir();
@ -21,11 +20,11 @@ export type Trace = AgentRunTrace;
const activeTraces = new Map<string, Trace>();
export class TraceService {
private tracesDir: string;
private readonly repository: TraceFileRepository;
private enabled: boolean = false;
constructor() {
this.tracesDir = TRACES_DIR;
this.repository = new TraceFileRepository(TRACES_DIR);
this.init();
}
@ -36,7 +35,7 @@ export class TraceService {
this.enabled = config.traces ?? false;
if (this.enabled) {
await fs.mkdir(this.tracesDir, { recursive: true });
await this.repository.ensureReady();
}
}
@ -54,7 +53,7 @@ export class TraceService {
this.enabled = enabled;
if (enabled) {
// Intentionally silent: best-effort directory creation
fs.mkdir(this.tracesDir, { recursive: true }).catch(() => {});
this.repository.ensureReady().catch(() => {});
}
}
@ -180,10 +179,7 @@ export class TraceService {
// Try to load from disk
try {
const filepath = path.join(this.tracesDir, `${attemptId}.json`);
ensureWithinBase(this.tracesDir, filepath);
const content = await fs.readFile(filepath, 'utf-8');
return JSON.parse(content) as Trace;
return await this.repository.read(attemptId);
} catch {
// Intentionally silent: trace file may not exist on disk
return null;
@ -208,20 +204,9 @@ export class TraceService {
// Load completed traces from disk
try {
const files = await fs.readdir(this.tracesDir);
for (const file of files) {
if (!file.endsWith('.json')) continue;
try {
const filepath = path.join(this.tracesDir, file);
ensureWithinBase(this.tracesDir, filepath);
const content = await fs.readFile(filepath, 'utf-8');
const trace = JSON.parse(content) as Trace;
if (trace.taskId === taskId && !activeTraces.has(trace.traceId)) {
traces.push(trace);
}
} catch {
// Skip invalid files
for (const trace of await this.repository.list()) {
if (trace.taskId === taskId && !activeTraces.has(trace.traceId)) {
traces.push(trace);
}
}
} catch {
@ -236,13 +221,7 @@ export class TraceService {
* Save a trace to disk
*/
private async saveTrace(trace: Trace): Promise<void> {
// Validate traceId to prevent path traversal
validatePathSegment(trace.traceId);
await fs.mkdir(this.tracesDir, { recursive: true });
const filepath = path.join(this.tracesDir, `${trace.traceId}.json`);
ensureWithinBase(this.tracesDir, filepath);
await fs.writeFile(filepath, JSON.stringify(trace, null, 2), 'utf-8');
await this.repository.write(trace);
}
}

View file

@ -1,4 +1,3 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import type {
@ -20,6 +19,7 @@ import type {
import { ForbiddenError } from '../middleware/error-handler.js';
import { SqliteDatabase, type SqliteConnectionOptions } from '../storage/sqlite/database.js';
import { SqliteWorkProductRepository } from '../storage/sqlite/work-product-repository.js';
import { JsonFileRepository } from '../storage/json-file-repository.js';
import { getRuntimeDir } from '../utils/paths.js';
const DATA_DIR = getRuntimeDir();
@ -40,7 +40,7 @@ export interface WorkProductServiceOptions {
}
export class WorkProductService {
private readonly filePath: string;
private readonly fileRepository: JsonFileRepository<WorkProductFileState | WorkProduct[]>;
private readonly versionLimit: number;
private readonly repository: SqliteWorkProductRepository | null = null;
private readonly sqliteDatabase: SqliteDatabase | null = null;
@ -50,7 +50,8 @@ export class WorkProductService {
constructor(options: WorkProductServiceOptions = {}) {
const dataDir = options.dataDir ?? DATA_DIR;
this.filePath = options.filePath ?? path.join(dataDir, 'work-products.json');
const filePath = options.filePath ?? path.join(dataDir, 'work-products.json');
this.fileRepository = new JsonFileRepository(filePath);
this.versionLimit = options.versionLimit ?? DEFAULT_VERSION_LIMIT;
const storageType =
options.storageType ?? (process.env.VERITAS_STORAGE === 'sqlite' ? 'sqlite' : 'file');
@ -809,8 +810,7 @@ export class WorkProductService {
if (this.loaded) return;
try {
const raw = await fs.readFile(this.filePath, 'utf-8');
const parsed = JSON.parse(raw) as WorkProductFileState | WorkProduct[];
const parsed = await this.fileRepository.read();
this.fileState = Array.isArray(parsed) ? { products: parsed, versions: [] } : parsed;
} catch {
this.fileState = { products: [], versions: [] };
@ -820,8 +820,7 @@ export class WorkProductService {
}
private async saveFileState(): Promise<void> {
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
await fs.writeFile(this.filePath, JSON.stringify(this.fileState, null, 2));
await this.fileRepository.write(this.fileState);
}
private createVersion(

View file

@ -0,0 +1,108 @@
import crypto from 'node:crypto';
import { appendFile, mkdir, readFile } from 'node:fs/promises';
import path from 'node:path';
import readline from 'node:readline';
import { createReadStream } from './fs-helpers.js';
export interface StoredAuditEntry {
timestamp: string;
action: string;
actor: string;
resource?: string;
details?: Record<string, unknown>;
integrity: string;
}
export interface AuditFileVerifyResult {
valid: boolean;
entries: number;
firstBroken?: number;
}
function sha256(data: string): string {
return crypto.createHash('sha256').update(data, 'utf8').digest('hex');
}
export class AuditFileRepository {
constructor(private readonly directory: string) {}
getMonthlyLogPath(date: Date): string {
const yyyy = date.getFullYear();
const mm = String(date.getMonth() + 1).padStart(2, '0');
return path.join(this.directory, `audit-${yyyy}-${mm}.log`);
}
ensureReady(): Promise<void> {
return mkdir(this.directory, { recursive: true }).then(() => undefined);
}
async getLastHash(filePath: string): Promise<string> {
try {
const content = await readFile(filePath, 'utf8');
const lines = content.trimEnd().split('\n').filter(Boolean);
return lines.length > 0 ? sha256(lines[lines.length - 1]) : '';
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return '';
throw error;
}
}
append(filePath: string, line: string): Promise<void> {
return appendFile(filePath, `${line}\n`, 'utf8');
}
async verify(filePath: string): Promise<AuditFileVerifyResult> {
const stream = createReadStream(filePath, { encoding: 'utf8' });
const reader = readline.createInterface({ input: stream, crlfDelay: Infinity });
let previousHash = '';
let lineIndex = 0;
let totalEntries = 0;
try {
for await (const line of reader) {
const trimmed = line.trim();
if (!trimmed) {
lineIndex += 1;
continue;
}
totalEntries += 1;
let entry: StoredAuditEntry;
try {
entry = JSON.parse(trimmed) as StoredAuditEntry;
} catch {
return { valid: false, entries: totalEntries, firstBroken: lineIndex };
}
if (entry.integrity !== previousHash) {
return { valid: false, entries: totalEntries, firstBroken: lineIndex };
}
previousHash = sha256(trimmed);
lineIndex += 1;
}
return { valid: true, entries: totalEntries };
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { valid: true, entries: 0 };
throw error;
} finally {
reader.close();
stream.destroy();
}
}
async readRecent(filePath: string, limit: number): Promise<StoredAuditEntry[]> {
try {
const content = await readFile(filePath, 'utf8');
return content
.trimEnd()
.split('\n')
.filter(Boolean)
.slice(-limit)
.reverse()
.map((line) => JSON.parse(line) as StoredAuditEntry);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
throw error;
}
}
}

View file

@ -14,12 +14,15 @@ import type { FSWatcher } from 'node:fs';
import { EventEmitter } from 'node:events';
import {
access,
copyFile as copyFileAsync,
lstat as lstatAsync,
mkdir as mkdirAsync,
readFile as readFileAsync,
readdir as readdirAsync,
rename as renameAsync,
rm as rmAsync,
stat as statAsync,
statfs as statfsAsync,
unlink as unlinkAsync,
writeFile as writeFileAsync,
} from 'node:fs/promises';
@ -70,12 +73,16 @@ export const createWriteStream = fs.createWriteStream;
// ---------------------------------------------------------------------------
export const mkdir = mkdirAsync;
export { access };
export const copyFile = copyFileAsync;
export const lstat = lstatAsync;
export const readFile = readFileAsync;
export const readdir = readdirAsync;
export const realpath = fs.promises.realpath;
export const rename = renameAsync;
export const rm = rmAsync;
export const stat = statAsync;
export const statfs = statfsAsync;
export const unlink = unlinkAsync;
export const writeFile = writeFileAsync;

View file

@ -0,0 +1,35 @@
import path from 'node:path';
import { withFileLock } from '../services/file-lock.js';
import { atomicWriteFile, mkdir, readFile } from './fs-helpers.js';
export interface JsonFileRepositoryOptions {
trailingNewline?: boolean;
}
/**
* Small persistence boundary for JSON-backed service state.
*
* Reads intentionally surface filesystem and parse errors so each service can
* retain its existing fallback policy. Writes are serialized across processes
* and replace the destination atomically.
*/
export class JsonFileRepository<T> {
private readonly trailingNewline: boolean;
constructor(
private readonly filePath: string,
options: JsonFileRepositoryOptions = {}
) {
this.trailingNewline = options.trailingNewline ?? false;
}
async read(): Promise<T> {
return JSON.parse(await readFile(this.filePath, 'utf8')) as T;
}
async write(value: T): Promise<void> {
await mkdir(path.dirname(this.filePath), { recursive: true });
const serialized = JSON.stringify(value, null, 2) + (this.trailingNewline ? '\n' : '');
await withFileLock(this.filePath, () => atomicWriteFile(this.filePath, serialized, 'utf8'));
}
}

View file

@ -0,0 +1,49 @@
import { readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { withFileLock } from '../services/file-lock.js';
export interface NotificationFileRepositoryOptions {
dataDir: string;
notificationsFile?: string;
subscriptionsFile?: string;
}
export class NotificationFileRepository {
private readonly notificationsFile: string;
private readonly subscriptionsFile: string;
constructor(options: NotificationFileRepositoryOptions) {
this.notificationsFile =
options.notificationsFile ?? path.join(options.dataDir, 'notifications.json');
this.subscriptionsFile =
options.subscriptionsFile ?? path.join(options.dataDir, 'thread-subscriptions.json');
}
loadNotifications<T>(): Promise<T[]> {
return this.loadArray<T>(this.notificationsFile);
}
loadSubscriptions<T>(): Promise<T[]> {
return this.loadArray<T>(this.subscriptionsFile);
}
saveNotifications<T>(notifications: T[]): Promise<void> {
return this.saveArray(this.notificationsFile, notifications);
}
saveSubscriptions<T>(subscriptions: T[]): Promise<void> {
return this.saveArray(this.subscriptionsFile, subscriptions);
}
private async loadArray<T>(filePath: string): Promise<T[]> {
try {
return JSON.parse(await readFile(filePath, 'utf8')) as T[];
} catch {
return [];
}
}
private saveArray<T>(filePath: string, values: T[]): Promise<void> {
return withFileLock(filePath, () => writeFile(filePath, JSON.stringify(values, null, 2)));
}
}

View file

@ -0,0 +1,61 @@
import path from 'node:path';
import { readFile, readdir, lstat } from './fs-helpers.js';
export interface SearchFileDescriptor {
path: string;
mtimeMs: number;
}
export interface SearchFileListOptions {
extensions: readonly string[];
maxFiles: number;
skippedDirectories: ReadonlySet<string>;
}
export class SearchFileRepository {
readText(filePath: string): Promise<string> {
return readFile(filePath, 'utf8');
}
async listFiles(dir: string, options: SearchFileListOptions): Promise<SearchFileDescriptor[]> {
const files: SearchFileDescriptor[] = [];
const allowedExtensions = new Set(
options.extensions.map((extension) => extension.toLowerCase())
);
const visit = async (currentDir: string): Promise<void> => {
if (files.length >= options.maxFiles) return;
let entries;
try {
entries = await readdir(currentDir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (files.length >= options.maxFiles) return;
if (options.skippedDirectories.has(entry.name)) continue;
const fullPath = path.join(currentDir, entry.name);
if (entry.isDirectory()) {
await visit(fullPath);
continue;
}
if (!entry.isFile() || !allowedExtensions.has(path.extname(entry.name).toLowerCase())) {
continue;
}
try {
const stats = await lstat(fullPath);
files.push({ path: fullPath, mtimeMs: stats.mtimeMs });
} catch {
// File may disappear while the index is being refreshed.
}
}
};
await visit(dir);
return files.sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, options.maxFiles);
}
}

View file

@ -0,0 +1,31 @@
import path from 'node:path';
import { readFile, readdir } from './fs-helpers.js';
export interface MarkdownSourceFile {
absolutePath: string;
filename: string;
content: string;
}
export class TaskIdentityFileRepository {
async readMarkdownFiles(directory: string): Promise<MarkdownSourceFile[]> {
let files: string[];
try {
files = await readdir(directory);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
throw error;
}
const results: MarkdownSourceFile[] = [];
for (const filename of files.filter((entry) => entry.endsWith('.md')).sort()) {
const absolutePath = path.join(directory, filename);
results.push({
absolutePath,
filename,
content: await readFile(absolutePath, 'utf8'),
});
}
return results;
}
}

View file

@ -0,0 +1,47 @@
import path from 'node:path';
import type { AgentRunTrace } from '@veritas-kanban/shared';
import { ensureWithinBase, validatePathSegment } from '../utils/sanitize.js';
import { mkdir, readdir } from './fs-helpers.js';
import { JsonFileRepository } from './json-file-repository.js';
export class TraceFileRepository {
constructor(private readonly tracesDir: string) {}
ensureReady(): Promise<void> {
return mkdir(this.tracesDir, { recursive: true }).then(() => undefined);
}
read(traceId: string): Promise<AgentRunTrace> {
return this.repositoryFor(traceId).read();
}
async list(): Promise<AgentRunTrace[]> {
let files: string[];
try {
files = await readdir(this.tracesDir);
} catch {
return [];
}
const traces: AgentRunTrace[] = [];
for (const file of files) {
if (!file.endsWith('.json')) continue;
try {
traces.push(await this.repositoryFor(file.slice(0, -'.json'.length)).read());
} catch {
// Ignore invalid or concurrently removed trace files.
}
}
return traces;
}
write(trace: AgentRunTrace): Promise<void> {
return this.repositoryFor(trace.traceId).write(trace);
}
private repositoryFor(traceId: string): JsonFileRepository<AgentRunTrace> {
validatePathSegment(traceId);
const filePath = ensureWithinBase(this.tracesDir, path.join(this.tracesDir, `${traceId}.json`));
return new JsonFileRepository(filePath);
}
}