diff --git a/.gitleaksignore b/.gitleaksignore index 8472e9b6..b1f8f074 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -6,7 +6,7 @@ docs/API-REFERENCE.md:generic-api-key:991 docs/API-WORKFLOWS.md:generic-api-key:1460 # Operator documentation uses placeholders in curl authentication examples. -docs/DEPLOYMENT.md:curl-auth-header:891 +docs/DEPLOYMENT.md:curl-auth-header:904 docs/TROUBLESHOOTING.md:curl-auth-header:229 docs/TROUBLESHOOTING.md:curl-auth-header:257 docs/TROUBLESHOOTING.md:curl-auth-header:260 diff --git a/Dockerfile b/Dockerfile index 1113fe94..1c9f1a34 100644 --- a/Dockerfile +++ b/Dockerfile @@ -96,12 +96,10 @@ COPY --from=build-shared /app/shared/dist ./shared/dist COPY --from=build-server /app/server/dist ./server/dist COPY --from=build-web /app/web/dist ./web/dist -# Create data directories for persistent storage and runtime config -# Note: services resolve .veritas-kanban from both cwd/.. and cwd directly, -# so we create it at /app/ level AND ensure server/ is writable for services -# that use process.cwd()/.veritas-kanban when WORKDIR is /app/server -RUN mkdir -p /app/data /app/.veritas-kanban /app/tasks && \ - chown -R veritas:nodejs /app/data /app/.veritas-kanban /app/tasks /app/server +# Create the single volume-backed storage root. Runtime state is stored at +# /app/data/.veritas-kanban and task data at /app/data/tasks. +RUN mkdir -p /app/data && \ + chown -R veritas:nodejs /app/data /app/server # Switch to non-root user USER veritas @@ -117,8 +115,7 @@ EXPOSE 3001 HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD wget --no-verbose --tries=1 --spider http://localhost:3001/health || exit 1 -# Set working directory to server/ so path.resolve(cwd, '..') resolves to /app -# (Services use process.cwd()/.. to find .veritas-kanban and tasks directories) +# The runtime path contract is independent of cwd when DATA_DIR is set. WORKDIR /app/server # Start server diff --git a/docker-compose.yml b/docker-compose.yml index 6950bf83..5d5ed613 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,7 +18,7 @@ services: context: . dockerfile: Dockerfile container_name: veritas-kanban-demo - # IMPORTANT: Must match Dockerfile WORKDIR (/app/server) for correct path resolution + # Kept aligned with the image entrypoint; persistent paths resolve from DATA_DIR. working_dir: /app/server ports: # Demo instance port (do NOT use production 3001) diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 2f819838..e620540d 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -434,18 +434,15 @@ sends API requests to `/kanban/api/...`. > or the equivalent changes to `vite.config.ts`, `web/src/lib/config.ts`, and > `web/src/lib/api/helpers.ts`. -**Docker volumes for sub-path:** When using Docker with sub-path deployment, ensure both -the task data and the config directory are on persistent volumes: +**Docker volumes for sub-path:** One volume at `DATA_DIR` persists tasks and runtime state: ```yaml volumes: - - kanban-data:/app/data # Task files - - kanban-config:/app/.veritas-kanban # Config, sprints, enforcement gates + - kanban-data:/app/data # tasks/ plus .veritas-kanban/ ``` -Without a config volume, settings (enforcement gates, transition hooks, sprints) are lost -on every container rebuild because `.veritas-kanban/` lives on the overlay filesystem, not -on the data volume. +Do not mount a second volume at `/app/.veritas-kanban`; that is a legacy location used only +as a read-only source during startup migration. ### systemd Service @@ -554,16 +551,16 @@ All variables are set in `server/.env` (or passed as environment variables in Do ### Data & Storage -| Variable | Default | Description | -| -------------------------- | -------------------------------------------- | -------------------------------------------------------------------------- | -| `VERITAS_DATA_DIR` | `.veritas-kanban` (relative to project root) | Directory for config, logs, and internal data | -| `DATA_DIR` | `/app/data` (Docker only) | Mapped data directory inside the Docker container | -| `VERITAS_STORAGE` | `file` | Selects `file` or `sqlite` storage | -| `VERITAS_SQLITE_PATH` | Runtime `veritas.db` | SQLite database override; must resolve to verified durable local storage | -| `VERITAS_SQLITE_TOPOLOGY` | — | Set explicitly to `single-host` before compatibility/override maintenance | -| `VERITAS_SQLITE_HOST_ID` | — | Stable unique host binding for SQLite compatibility ownership policy | -| `TELEMETRY_RETENTION_DAYS` | `30` | Days to keep telemetry event files before deletion | -| `TELEMETRY_COMPRESS_DAYS` | `7` | Days after which NDJSON telemetry files are gzip-compressed (0 = disabled) | +| Variable | Default | Description | +| -------------------------- | ------------------------- | -------------------------------------------------------------------------- | +| `VERITAS_DATA_DIR` | Project root when unset | Storage root used when `DATA_DIR` is unset | +| `DATA_DIR` | `/app/data` (Docker only) | Preferred storage root; takes precedence over `VERITAS_DATA_DIR` | +| `VERITAS_STORAGE` | `file` | Selects `file` or `sqlite` storage | +| `VERITAS_SQLITE_PATH` | Runtime `veritas.db` | SQLite database override; must resolve to verified durable local storage | +| `VERITAS_SQLITE_TOPOLOGY` | — | Set explicitly to `single-host` before compatibility/override maintenance | +| `VERITAS_SQLITE_HOST_ID` | — | Stable unique host binding for SQLite compatibility ownership policy | +| `TELEMETRY_RETENTION_DAYS` | `30` | Days to keep telemetry event files before deletion | +| `TELEMETRY_COMPRESS_DAYS` | `7` | Days after which NDJSON telemetry files are gzip-compressed (0 = disabled) | ### Integration @@ -619,9 +616,25 @@ wscat -c "ws://localhost:3001/ws?api_key=" | `.veritas-kanban/worktree-manifests/` | Durable worktree ownership, base, lifecycle, and override evidence | | `.veritas-kanban/agent-requests/` | Pending AI agent requests | -In Docker, the `DATA_DIR` environment variable maps to `/app/data` by default inside the container. +In Docker, `DATA_DIR=/app/data`. Tasks live under `/app/data/tasks` and all runtime state +lives under `/app/data/.veritas-kanban`; no persistent state is written to `/app` or +`/app/server` outside that volume. -**Auth state persistence fix (v3.1.1):** Runtime config/state files (including `security.json`) now always live under `${DATA_DIR}/.veritas-kanban`. On startup, Veritas Kanban will automatically migrate any legacy runtime files it finds in container-only paths (for example, `/app/.veritas-kanban` or `/app/server/.veritas-kanban`) into the Docker volume. +**Auth state persistence fix (v3.1.1):** Runtime config/state files (including `security.json`) now always live under `${DATA_DIR}/.veritas-kanban`. On startup, Veritas Kanban automatically migrates legacy runtime files it can see at container-only paths (for example, `/app/.veritas-kanban` or `/app/server/.veritas-kanban`) into the Docker volume. A replaced container cannot see data left in an old container layer or an unmounted legacy volume. + +If the old runtime state is in a named volume, mount that volume read-only at its former path for one startup. For example, add the legacy mount temporarily to your Compose service: + +```yaml +services: + veritas-kanban: + volumes: + - kanban-data:/app/data + - legacy-veritas-config:/app/.veritas-kanban:ro +``` + +Start the service, verify the expected files now exist under +`/app/data/.veritas-kanban`, then remove the legacy mount from Compose. The migration is +copy-only: it does not delete the legacy source, and an existing destination file wins. If you upgraded from an older image and already lost auth state, you can recover by copying `security.json` from a still-running/old container (if available) into the volume: @@ -697,7 +710,7 @@ docker compose down docker run --rm \ -v kanban-data:/data \ -v $(pwd):/backup \ - alpine sh -c "rm -rf /data/* && tar xzf /backup/veritas-backup-20260129.tar.gz -C /data" + alpine sh -c 'set -eu; archive=/backup/veritas-backup-20260129.tar.gz; test -d /data; test "$(readlink -f /data)" = /data; test -r "$archive"; tar tzf "$archive" >/dev/null; find /data -mindepth 1 -delete; tar xzf "$archive" -C /data' # Restart docker compose up -d diff --git a/docs/SOP-system-health-monitoring.md b/docs/SOP-system-health-monitoring.md index 4f63cba9..2eb19e2f 100644 --- a/docs/SOP-system-health-monitoring.md +++ b/docs/SOP-system-health-monitoring.md @@ -174,7 +174,9 @@ Thresholds (hardcoded in v4.0): **Status shows `elevated` with all agents appearing online:** Check the operations signal — `status: critical` also triggers `elevated`. The agent registry shows registered agents, not process health. -**`system.disk: false` immediately after startup:** The data directory path may be wrong. Check the `DATA_DIR` environment variable — it should point to the `.veritas-kanban` data directory. +**`system.disk: false` immediately after startup:** The storage root may be wrong. Check +`DATA_DIR` (or `VERITAS_DATA_DIR` when `DATA_DIR` is unset); runtime health checks use its +`.veritas-kanban` child directory. **Health endpoint returns 500:** The metrics service or agent registry service failed to initialize. Check the server startup logs. diff --git a/docs/testing/critical-path-coverage.json b/docs/testing/critical-path-coverage.json index 956045e1..30f227b3 100644 --- a/docs/testing/critical-path-coverage.json +++ b/docs/testing/critical-path-coverage.json @@ -41,6 +41,8 @@ "src/__tests__/provider-runtime-control-service.test.ts", "src/__tests__/provider-runtime-manifest-service.test.ts", "src/__tests__/provider-task-envelope-renderer.test.ts", + "src/__tests__/reflection-extraction-job-service.test.ts", + "src/__tests__/runtime-paths.test.ts", "src/__tests__/routes/admin-governance-auth.test.ts", "src/__tests__/routes/auth.test.ts", "src/__tests__/routes/credential-broker.test.ts", @@ -50,10 +52,12 @@ "src/__tests__/run-recovery-policy-service.test.ts", "src/__tests__/run-supervisor-service.test.ts", "src/__tests__/schemas.test.ts", + "src/__tests__/security-legacy-runtime.test.ts", "src/__tests__/shared-api-permissions.test.ts", "src/__tests__/sqlite-journal-ownership-policy.test.ts", "src/__tests__/sqlite-maintenance-bootstrap.test.ts", "src/__tests__/sqlite-portability-service.test.ts", + "src/__tests__/system-health-service.test.ts", "src/__tests__/storage/dual-storage-parity.test.ts", "src/__tests__/storage/file-storage.test.ts", "src/__tests__/storage/sqlite-audit-policy-repositories.test.ts", diff --git a/server/src/__tests__/codex-provider-service.test.ts b/server/src/__tests__/codex-provider-service.test.ts index 6ee89df6..5c34c3fe 100644 --- a/server/src/__tests__/codex-provider-service.test.ts +++ b/server/src/__tests__/codex-provider-service.test.ts @@ -229,6 +229,7 @@ import { type CompletionEvidenceSource, } from '../services/task-envelope-service.js'; import { ProviderCompletionService } from '../services/provider-completion-service.js'; +import type { ReflectionExtractionJobService } from '../services/reflection-extraction-job-service.js'; import type { CreateRunApprovalRequestInput, RunApprovalBrokerService, @@ -283,6 +284,12 @@ type TestableClawdbotAgentService = ClawdbotAgentService & { recordCodexThread(task: Task, attemptId: string, threadId: string): Promise; }; +function testReflectionExtractionJobs(): Pick { + return { + enqueue: vi.fn().mockResolvedValue({}), + } as unknown as Pick; +} + function testableService( tmpDir: string, credentialLeases?: CredentialLeaseLifecycle, @@ -332,7 +339,7 @@ function testableService( handleRunCompletion: mockHandleDurableGoalCompletion, reconcilePlannedForTask: mockReconcileDurableGoalContinuation, }, - undefined, + testReflectionExtractionJobs(), undefined, runTerminals, workspaceCheckpoints @@ -3438,6 +3445,39 @@ describe('ClawdbotAgentService Codex providers', () => { await expect(service.getAgentStatus(task.id)).resolves.toBeNull(); }); + it('isolates reflection enqueue failures from provider completion', async () => { + const child = createControllableChild(); + mockSpawn.mockReturnValue(child); + const reflectionExtractionJobs = { + enqueue: vi.fn().mockRejectedValue(new Error('reflection queue unavailable')), + } as unknown as Pick; + const service = testableService(tmpDir); + ( + service as unknown as { + reflectionExtractionJobs: Pick; + } + ).reflectionExtractionJobs = reflectionExtractionJobs; + const active = await service.startAgent(task.id, 'codex'); + + await service.completeAgent( + task.id, + { success: true, summary: 'Provider completion remains authoritative.' }, + { + attemptId: active.attemptId, + terminalSource: 'process', + providerRuntimeManifestDigest: active.providerRuntimeManifest.digest, + } + ); + await waitFor(() => expect(reflectionExtractionJobs.enqueue).toHaveBeenCalledOnce()); + await Promise.resolve(); + + expect(task.attempt).toMatchObject({ + id: active.attemptId, + status: 'complete', + completionResult: { status: 'success' }, + }); + }); + it('reports attempt-scoped terminals and cleans them up before completion commits', async () => { const child = createControllableChild(); mockSpawn.mockReturnValue(child); @@ -3688,23 +3728,37 @@ describe('ClawdbotAgentService Codex providers', () => { ...request, requestId: 'terminal-finalization-race', }; - const racingExecution = service.executeRunTerminal( - task.id, - active.attemptId, - racingRequest - ); + const racingExecution = service.executeRunTerminal(task.id, active.attemptId, racingRequest); await waitFor(() => expect(runTerminals.execute).toHaveBeenCalledTimes(2)); - const stopping = service.stopAgent(task.id, active.attemptId); - await new Promise((resolve) => setTimeout(resolve, 25)); - expect(runTerminals.cleanupAttempt).not.toHaveBeenCalled(); - - resolveLaunch?.(); - await expect(racingExecution).resolves.toMatchObject({ - status: 'started', - handle: { id: 'terminal_finalization_race' }, + const originalProviderComplete = ProviderCompletionService.prototype.complete; + let resolveProviderCompletion: (() => void) | undefined; + const providerCompletion = new Promise((resolve) => { + resolveProviderCompletion = resolve; }); - await stopping; - expect(runTerminals.cleanupAttempt).toHaveBeenCalledOnce(); + const completionSpy = vi + .spyOn(ProviderCompletionService.prototype, 'complete') + .mockImplementation(async function (input) { + const result = await originalProviderComplete.call(this, input); + resolveProviderCompletion?.(); + return result; + }); + try { + const stopping = service.stopAgent(task.id, active.attemptId); + await providerCompletion; + await Promise.resolve(); + await Promise.resolve(); + expect(runTerminals.cleanupAttempt).not.toHaveBeenCalled(); + + resolveLaunch?.(); + await expect(racingExecution).resolves.toMatchObject({ + status: 'started', + handle: { id: 'terminal_finalization_race' }, + }); + await stopping; + expect(runTerminals.cleanupAttempt).toHaveBeenCalledOnce(); + } finally { + completionSpy.mockRestore(); + } }); it('does not let a competing terminal claim poison an in-flight finalizer', async () => { diff --git a/server/src/__tests__/delegation-service.test.ts b/server/src/__tests__/delegation-service.test.ts index 9b18fd91..4471b7b7 100644 --- a/server/src/__tests__/delegation-service.test.ts +++ b/server/src/__tests__/delegation-service.test.ts @@ -13,6 +13,7 @@ describe('DelegationService', () => { let repoDir: string; let workDir: string; let oldCwd: string; + let oldDataDir: string | undefined; let service: any; beforeEach(async () => { @@ -21,6 +22,8 @@ describe('DelegationService', () => { workDir = path.join(repoDir, 'server'); await fs.mkdir(workDir, { recursive: true }); oldCwd = process.cwd(); + oldDataDir = process.env.DATA_DIR; + process.env.DATA_DIR = repoDir; process.chdir(workDir); const mod = await import('../services/delegation-service.js'); service = new mod.DelegationService(); @@ -28,6 +31,8 @@ describe('DelegationService', () => { afterEach(async () => { process.chdir(oldCwd); + if (oldDataDir === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = oldDataDir; await fs.rm(repoDir, { recursive: true, force: true }); vi.clearAllMocks(); }); diff --git a/server/src/__tests__/docker-paths.test.ts b/server/src/__tests__/docker-paths.test.ts index 3644457f..fbab2889 100644 --- a/server/src/__tests__/docker-paths.test.ts +++ b/server/src/__tests__/docker-paths.test.ts @@ -73,6 +73,10 @@ describe('paths: Docker DATA_DIR support', () => { expect(paths.getTasksActiveDir()).toBe('/app/data/tasks/active'); expect(paths.getTasksArchiveDir()).toBe('/app/data/tasks/archive'); expect(paths.getRuntimeDir()).toBe('/app/data/.veritas-kanban'); + expect(paths.getLegacyRuntimeDirs()).toEqual( + expect.arrayContaining(['/app/data', '/app/.veritas-kanban', '/app/server/.veritas-kanban']) + ); + expect(paths.getLegacyRuntimeDirs()).not.toContain('/app/data/.veritas-kanban'); }); it('TaskService defaults to DATA_DIR-backed task directories when set', async () => { diff --git a/server/src/__tests__/path-audit.test.ts b/server/src/__tests__/path-audit.test.ts index 6a0ca99c..4883ebd9 100644 --- a/server/src/__tests__/path-audit.test.ts +++ b/server/src/__tests__/path-audit.test.ts @@ -4,8 +4,8 @@ * process.cwd() or PROJECT_ROOT; they must use the centralized helpers * in server/src/utils/paths.ts. * - * The KNOWN_VIOLATIONS set tracks pre-existing issues (tracked in issue #774 - * for follow-up cleanup). New violations added after this PR will fail the test. + * Legacy-path construction is permitted only in the centralized path and + * migration helpers. Services and routes must use those helpers. */ import { describe, it, expect } from 'vitest'; import fs from 'fs'; @@ -21,13 +21,14 @@ const SERVICE_DIR = path.join(SRC_DIR, 'services'); const ROUTE_DIR = path.join(SRC_DIR, 'routes'); /** - * Returns all TypeScript files in a directory (non-recursive). + * Returns all TypeScript files in a directory recursively. */ function tsFiles(dir: string): string[] { - return fs - .readdirSync(dir) - .filter((f) => f.endsWith('.ts') && !f.endsWith('.d.ts')) - .map((f) => path.join(dir, f)); + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) return tsFiles(entryPath); + return entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts') ? [entryPath] : []; + }); } /** @@ -38,43 +39,18 @@ const FORBIDDEN_PATTERNS = [ /join\(\s*process\.cwd\(\)[^)]*,\s*['"]\.veritas-kanban['"]/, /PROJECT_ROOT[^;]*\.veritas-kanban/, /path\.resolve\(\s*process\.cwd\(\)[^)]*,\s*['"]\.{0,2}\.veritas-kanban['"]/, + /process\.env\.(?:DATA_DIR|VERITAS_DATA_DIR)/, ]; /** * Files allowed to reference .veritas-kanban directly (centralized helper * itself, one-time migration helpers that intentionally build the legacy path). */ -const ALLOWED_FILES = new Set(['paths.ts', 'migration-service.ts']); - -/** - * Pre-existing violations tracked in issue #774. - * Do NOT add new entries here — fix the file instead. - * Files listed here will be skipped by this test to avoid blocking unrelated work. - */ -const KNOWN_VIOLATION_FILES = new Set([ - 'agent-permission-service.ts', - 'agent-registry-service.ts', // legacy migration path (intentional) - 'broadcast-storage-service.ts', - 'delegation-service.ts', - 'docs-service.ts', - 'error-learning-service.ts', - 'github-sync-service.ts', - 'lifecycle-hooks-service.ts', - 'notification-service.ts', - 'pdf-report-service.ts', - 'progress-service.ts', - 'prompt-registry-service.ts', - 'scheduled-deliverables-service.ts', - 'status-history-service.ts', - 'template-service.ts', - 'transition-hooks-service.ts', - 'work-product-service.ts', - 'worktree-service.ts', -]); +const ALLOWED_FILES = new Set(); function fileContainsForbiddenPattern(filePath: string): string[] { const name = path.basename(filePath); - if (ALLOWED_FILES.has(name) || KNOWN_VIOLATION_FILES.has(name)) return []; + if (ALLOWED_FILES.has(name)) return []; const content = fs.readFileSync(filePath, 'utf-8'); const hits: string[] = []; @@ -100,9 +76,9 @@ describe('Path audit — no hardcoded .veritas-kanban paths (issue #774)', () => if (violations.length > 0) { throw new Error( - `New hardcoded .veritas-kanban paths found — use getRuntimeDir() from utils/paths.ts:\n` + + `Hardcoded .veritas-kanban paths found — use getRuntimeDir() from utils/paths.ts:\n` + violations.map((v) => ` - ${v}`).join('\n') + - '\n\nTo add this to KNOWN_VIOLATION_FILES, file a follow-up issue first.' + '\n\nLegacy path construction belongs in the centralized migration helper.' ); } @@ -121,9 +97,9 @@ describe('Path audit — no hardcoded .veritas-kanban paths (issue #774)', () => if (violations.length > 0) { throw new Error( - `New hardcoded .veritas-kanban paths found — use getRuntimeDir() from utils/paths.ts:\n` + + `Hardcoded .veritas-kanban paths found — use getRuntimeDir() from utils/paths.ts:\n` + violations.map((v) => ` - ${v}`).join('\n') + - '\n\nTo add this to KNOWN_VIOLATION_FILES, file a follow-up issue first.' + '\n\nLegacy path construction belongs in the centralized migration helper.' ); } diff --git a/server/src/__tests__/pdf-report-service.test.ts b/server/src/__tests__/pdf-report-service.test.ts index 892ecfae..e9f8cf89 100644 --- a/server/src/__tests__/pdf-report-service.test.ts +++ b/server/src/__tests__/pdf-report-service.test.ts @@ -12,7 +12,7 @@ describe('pdf-report-service', () => { testRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'veritas-report-service-')); dataDir = path.join(testRoot, '.veritas-kanban'); await fs.mkdir(dataDir, { recursive: true }); - process.env.DATA_DIR = dataDir; + process.env.DATA_DIR = testRoot; vi.resetModules(); ({ getPdfReportService } = await import('../services/pdf-report-service.js')); diff --git a/server/src/__tests__/prompt-registry-service.test.ts b/server/src/__tests__/prompt-registry-service.test.ts index b4e7a00e..ea97c668 100644 --- a/server/src/__tests__/prompt-registry-service.test.ts +++ b/server/src/__tests__/prompt-registry-service.test.ts @@ -7,17 +7,19 @@ import { PromptRegistryService } from '../services/prompt-registry-service.js'; describe('PromptRegistryService', () => { let tmpDir: string; - let cwdSpy: any; + let oldDataDir: string | undefined; let service: PromptRegistryService; beforeEach(async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'prompt-registry-')); - cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(tmpDir); + oldDataDir = process.env.DATA_DIR; + process.env.DATA_DIR = tmpDir; service = new PromptRegistryService(); }); afterEach(async () => { - cwdSpy.mockRestore(); + if (oldDataDir === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = oldDataDir; await fs.rm(tmpDir, { recursive: true, force: true }); }); diff --git a/server/src/__tests__/routes/health.test.ts b/server/src/__tests__/routes/health.test.ts index 283c4cbd..2f6c9898 100644 --- a/server/src/__tests__/routes/health.test.ts +++ b/server/src/__tests__/routes/health.test.ts @@ -29,6 +29,7 @@ import { describe('Health Routes', () => { let app: express.Express; let testDataDir: string; + let testRuntimeDir: string; let originalDataDir: string | undefined; let originalSqlitePath: string | undefined; let originalStorage: string | undefined; @@ -38,12 +39,12 @@ describe('Health Routes', () => { beforeEach(async () => { // Create a temp data directory for testing - const uniqueSuffix = Math.random().toString(36).substring(7); - testDataDir = path.join(os.tmpdir(), `veritas-health-test-${uniqueSuffix}`); - await fs.mkdir(testDataDir, { recursive: true }); + testDataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'veritas-health-test-')); + testRuntimeDir = path.join(testDataDir, '.veritas-kanban'); + await fs.mkdir(testRuntimeDir, { recursive: true }); // Write a valid tasks.json - await fs.writeFile(path.join(testDataDir, 'tasks.json'), JSON.stringify([])); + await fs.writeFile(path.join(testRuntimeDir, 'tasks.json'), JSON.stringify([])); // Set DATA_DIR env var originalDataDir = process.env.DATA_DIR; @@ -138,7 +139,7 @@ describe('Health Routes', () => { it('should return ok when tasks.json does not exist', async () => { // Remove tasks.json — fresh install scenario - await fs.unlink(path.join(testDataDir, 'tasks.json')); + await fs.unlink(path.join(testRuntimeDir, 'tasks.json')); const res = await request(app).get('/health/ready'); @@ -148,7 +149,7 @@ describe('Health Routes', () => { it('should return 503 when tasks.json is corrupt', async () => { // Write invalid JSON to tasks.json - await fs.writeFile(path.join(testDataDir, 'tasks.json'), '{invalid json!!!'); + await fs.writeFile(path.join(testRuntimeDir, 'tasks.json'), '{invalid json!!!'); const res = await request(app).get('/health/ready'); @@ -254,7 +255,7 @@ describe('Health Routes', () => { expect(res.body.node.version).toBe(process.version); expect(res.body.node.platform).toBe(process.platform); expect(res.body.dataDirectory).toBeDefined(); - expect(res.body.dataDirectory.path).toBe(testDataDir); + expect(res.body.dataDirectory.path).toBe(testRuntimeDir); expect(res.body.dataDirectory.sizeBytes).toBeTypeOf('number'); expect(res.body.sqlite).toMatchObject({ databaseLocation: 'configured', diff --git a/server/src/__tests__/runtime-paths.test.ts b/server/src/__tests__/runtime-paths.test.ts new file mode 100644 index 00000000..7efa7923 --- /dev/null +++ b/server/src/__tests__/runtime-paths.test.ts @@ -0,0 +1,98 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { getDocsDir, getProjectRoot, getRuntimeDir, getStorageRoot } from '../utils/paths.js'; +import { migrateLegacyRuntimeState } from '../utils/migrate-legacy-runtime.js'; + +describe('runtime storage contract', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('gives DATA_DIR precedence and nests runtime state beneath the storage root', () => { + vi.stubEnv('DATA_DIR', '/app/data'); + vi.stubEnv('VERITAS_DATA_DIR', '/ignored/legacy-override'); + + expect(getStorageRoot()).toBe('/app/data'); + expect(getRuntimeDir()).toBe('/app/data/.veritas-kanban'); + expect(getDocsDir()).toBe('/app/data/docs'); + }); + + it('uses VERITAS_DATA_DIR as the storage root when DATA_DIR is absent', () => { + vi.stubEnv('DATA_DIR', ''); + vi.stubEnv('VERITAS_DATA_DIR', '/srv/veritas'); + + expect(getStorageRoot()).toBe('/srv/veritas'); + expect(getRuntimeDir()).toBe('/srv/veritas/.veritas-kanban'); + }); + + it('copies legacy runtime trees without deleting sources or overwriting current files', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'veritas-runtime-migration-')); + const legacy = path.join(root, 'legacy'); + const current = path.join(root, 'current'); + + try { + await fs.mkdir(path.join(legacy, 'nested'), { recursive: true }); + await fs.mkdir(current, { recursive: true }); + await fs.writeFile(path.join(legacy, 'config.json'), 'legacy config'); + await fs.writeFile(path.join(legacy, 'nested', 'state.json'), 'legacy nested state'); + await fs.writeFile(path.join(legacy, 'security.json'), 'legacy secret'); + await fs.writeFile(path.join(current, 'security.json'), 'current secret'); + + await expect(migrateLegacyRuntimeState([legacy], current)).resolves.toBe(2); + await expect(fs.readFile(path.join(current, 'config.json'), 'utf-8')).resolves.toBe( + 'legacy config' + ); + await expect(fs.readFile(path.join(current, 'nested', 'state.json'), 'utf-8')).resolves.toBe( + 'legacy nested state' + ); + await expect(fs.readFile(path.join(current, 'security.json'), 'utf-8')).resolves.toBe( + 'current secret' + ); + await expect(fs.readFile(path.join(legacy, 'config.json'), 'utf-8')).resolves.toBe( + 'legacy config' + ); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it('does not recurse into canonical runtime state or copy task and docs trees', async () => { + const storageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'veritas-nested-migration-')); + const current = path.join(storageRoot, '.veritas-kanban'); + + try { + await fs.mkdir(current, { recursive: true }); + await fs.mkdir(path.join(storageRoot, 'tasks', 'active'), { recursive: true }); + await fs.mkdir(path.join(storageRoot, 'docs'), { recursive: true }); + await fs.writeFile(path.join(storageRoot, 'legacy-state.json'), 'legacy state'); + await fs.writeFile(path.join(current, 'current-state.json'), 'current state'); + await fs.writeFile(path.join(storageRoot, 'tasks', 'active', 'task.md'), 'task'); + await fs.writeFile(path.join(storageRoot, 'docs', 'guide.md'), 'guide'); + + await expect(migrateLegacyRuntimeState([storageRoot], current)).resolves.toBe(1); + await expect(fs.readFile(path.join(current, 'legacy-state.json'), 'utf-8')).resolves.toBe( + 'legacy state' + ); + await expect(fs.access(path.join(current, '.veritas-kanban'))).rejects.toThrow(); + await expect(fs.access(path.join(current, 'tasks'))).rejects.toThrow(); + await expect(fs.access(path.join(current, 'docs'))).rejects.toThrow(); + } finally { + await fs.rm(storageRoot, { recursive: true, force: true }); + } + }); + + it('keeps Docker persistence on the single DATA_DIR volume', async () => { + const projectRoot = getProjectRoot(); + const dockerfile = await fs.readFile(path.join(projectRoot, 'Dockerfile'), 'utf-8'); + const compose = await fs.readFile(path.join(projectRoot, 'docker-compose.yml'), 'utf-8'); + + expect(dockerfile).toContain('ENV DATA_DIR=/app/data'); + expect(dockerfile).toContain('mkdir -p /app/data'); + expect(dockerfile).not.toMatch(/mkdir[^\n]*\/app\/\.veritas-kanban/); + expect(dockerfile).not.toMatch(/mkdir[^\n]*\/app\/tasks/); + expect(compose).toContain('kanban-demo-data:/app/data'); + expect(compose).not.toContain(':/app/.veritas-kanban'); + }); +}); diff --git a/server/src/__tests__/security-legacy-runtime.test.ts b/server/src/__tests__/security-legacy-runtime.test.ts new file mode 100644 index 00000000..dd623259 --- /dev/null +++ b/server/src/__tests__/security-legacy-runtime.test.ts @@ -0,0 +1,38 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +describe('security config legacy runtime compatibility', () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + it('copies legacy security state into the canonical runtime directory', async () => { + const storageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'veritas-security-migration-')); + const legacyConfig = { + authEnabled: true, + sessionTimeout: '12h', + setupCompletedAt: '2026-08-23T00:00:00.000Z', + }; + + try { + vi.stubEnv('DATA_DIR', storageRoot); + vi.stubEnv('VERITAS_DATA_DIR', ''); + await fs.writeFile(path.join(storageRoot, 'security.json'), JSON.stringify(legacyConfig)); + + const security = await import('../config/security.js'); + + expect(security.getSecurityConfig()).toMatchObject(legacyConfig); + await expect( + fs.readFile(path.join(storageRoot, '.veritas-kanban', 'security.json'), 'utf-8') + ).resolves.toBe(JSON.stringify(legacyConfig)); + await expect(fs.readFile(path.join(storageRoot, 'security.json'), 'utf-8')).resolves.toBe( + JSON.stringify(legacyConfig) + ); + } finally { + await fs.rm(storageRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/server/src/__tests__/system-health-service.test.ts b/server/src/__tests__/system-health-service.test.ts index fb6abe14..73a3da11 100644 --- a/server/src/__tests__/system-health-service.test.ts +++ b/server/src/__tests__/system-health-service.test.ts @@ -29,7 +29,7 @@ describe('SystemHealthService', () => { mockList.mockReturnValue([]); mockGetRunMetrics.mockResolvedValue({ runs: 0, successRate: 1, failures: 0, errors: 0 }); process.env.DATA_DIR = 'data'; - await fs.mkdir(path.join(tmpDir, 'data'), { recursive: true }); + await fs.mkdir(path.join(tmpDir, 'data', '.veritas-kanban'), { recursive: true }); }); afterEach(async () => { diff --git a/server/src/config/security.ts b/server/src/config/security.ts index 5a865de8..106760ff 100644 --- a/server/src/config/security.ts +++ b/server/src/config/security.ts @@ -8,35 +8,33 @@ import { import path from 'path'; import crypto from 'crypto'; import { createLogger } from '../lib/logger.js'; -import { getRuntimeDir } from '../utils/paths.js'; +import { getLegacyRuntimeDirs, getRuntimeDir } from '../utils/paths.js'; const log = createLogger('security'); // Security config file location const RUNTIME_DIR = getRuntimeDir(); const SECURITY_CONFIG_PATH = path.join(RUNTIME_DIR, 'security.json'); -// Legacy path: security.ts originally only checked VERITAS_DATA_DIR (not DATA_DIR), -// so we preserve that for migration detection. Other services checked DATA_DIR instead. -const LEGACY_DATA_DIR = process.env.VERITAS_DATA_DIR || path.join(process.cwd(), '.veritas-kanban'); -const LEGACY_SECURITY_CONFIG_PATH = path.join(LEGACY_DATA_DIR, 'security.json'); +const LEGACY_SECURITY_CONFIG_PATHS = getLegacyRuntimeDirs().map((dir) => + path.join(dir, 'security.json') +); let migrationChecked = false; function migrateSecurityConfig(): void { if (migrationChecked) return; migrationChecked = true; - if (LEGACY_SECURITY_CONFIG_PATH === SECURITY_CONFIG_PATH) return; - - if (existsSync(LEGACY_SECURITY_CONFIG_PATH) && !existsSync(SECURITY_CONFIG_PATH)) { + for (const legacySecurityConfigPath of LEGACY_SECURITY_CONFIG_PATHS) { + if (!existsSync(legacySecurityConfigPath) || existsSync(SECURITY_CONFIG_PATH)) continue; try { if (!existsSync(RUNTIME_DIR)) { mkdirSync(RUNTIME_DIR, { recursive: true }); } - const data = readFileSync(LEGACY_SECURITY_CONFIG_PATH, 'utf-8'); + const data = readFileSync(legacySecurityConfigPath, 'utf-8'); writeFileSync(SECURITY_CONFIG_PATH, data, 'utf-8'); log.info( { - from: LEGACY_SECURITY_CONFIG_PATH, + from: legacySecurityConfigPath, to: SECURITY_CONFIG_PATH, }, 'Migrated security.json to the runtime data directory' diff --git a/server/src/index.ts b/server/src/index.ts index 6e5ce3ba..5905bcb8 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -2,7 +2,9 @@ import 'dotenv/config'; import { validateEnv } from './config/env.js'; import { executeScheduledSqliteJournalMaintenance } from './storage/sqlite/journal-maintenance-service.js'; +import { migrateLegacyRuntimeState } from './utils/migrate-legacy-runtime.js'; validateEnv(); +await migrateLegacyRuntimeState(); await executeScheduledSqliteJournalMaintenance(); await import('./server.js'); diff --git a/server/src/routes/health.ts b/server/src/routes/health.ts index 967fcb71..1289b6e1 100644 --- a/server/src/routes/health.ts +++ b/server/src/routes/health.ts @@ -11,11 +11,7 @@ import fs from 'fs/promises'; import path from 'path'; import { z } from 'zod'; import { createLogger } from '../lib/logger.js'; -import { - authenticate, - authorize, - type AuthenticatedRequest, -} from '../middleware/auth.js'; +import { authenticate, authorize, type AuthenticatedRequest } from '../middleware/auth.js'; import { asyncHandler } from '../middleware/async-handler.js'; import { getAllStatus as getCircuitBreakerStatus } from '../services/circuit-registry.js'; import { getDependencyCircuitControlService } from '../services/dependency-circuit-control-service.js'; @@ -26,6 +22,7 @@ import { } from '../services/dependency-circuit-runtime.js'; import { getSqliteStorageDiagnostics } from '../storage/sqlite/database.js'; import type { WebSocketServer } from 'ws'; +import { getRuntimeDir } from '../utils/paths.js'; const log = createLogger('health'); const dependencyCircuitKeySchema = z.string().trim().min(1).max(2_000); @@ -58,21 +55,12 @@ export function setHealthWss(wss: WebSocketServer): void { // Helpers // ============================================ -/** - * Resolve the data directory path. - * Reads DATA_DIR from env at call time so tests can override it. - * If DATA_DIR is relative, resolve against cwd. - */ -function getDataDir(): string { - const dataDir = process.env.DATA_DIR || '.veritas-kanban'; - return path.resolve(process.cwd(), dataDir); -} - +/** Runtime health checks use the centralized storage-root contract. */ /** * Check that the data directory exists and is writable. */ async function checkStorage(): Promise<'ok' | 'fail'> { - const dataDir = getDataDir(); + const dataDir = getRuntimeDir(); try { await fs.access(dataDir, fs.constants.R_OK | fs.constants.W_OK); // Write and remove a temp file to verify actual write access @@ -106,7 +94,7 @@ async function checkStorageThroughCircuit(): Promise<'ok' | 'fail'> { * Uses Node.js fs.statfs (available in Node 18.15+). */ async function checkDisk(): Promise<'ok' | 'fail'> { - const dataDir = getDataDir(); + const dataDir = getRuntimeDir(); try { const stats = await fs.statfs(dataDir); const freeBytes = stats.bfree * stats.bsize; @@ -142,7 +130,7 @@ function checkMemory(): 'ok' | 'warn' { * Check that tasks.json is readable and valid JSON. */ async function checkTasksFile(): Promise<'ok' | 'fail'> { - const dataDir = getDataDir(); + const dataDir = getRuntimeDir(); const tasksPath = path.join(dataDir, 'tasks.json'); try { const content = await fs.readFile(tasksPath, 'utf-8'); @@ -281,7 +269,7 @@ async function buildDeepHealthPayload() { const storageStatus = storage === 'fail' || tasksFile === 'fail' ? 'fail' : 'ok'; const sqlite = getSqliteStorageDiagnostics(); - const dataDir = getDataDir(); + const dataDir = getRuntimeDir(); let dataDirSize = 0; try { dataDirSize = await getDataDirSize(dataDir); @@ -392,7 +380,10 @@ healthRouter.post( res.status(404).json({ error: 'Dependency circuit not found.' }); return; } - res.json({ reset: true, circuit: await getDependencyCircuitRegistryService().getSnapshot(key) }); + res.json({ + reset: true, + circuit: await getDependencyCircuitRegistryService().getSnapshot(key), + }); }) ); diff --git a/server/src/routes/system-health.ts b/server/src/routes/system-health.ts index 636b697b..3f4d1ce1 100644 --- a/server/src/routes/system-health.ts +++ b/server/src/routes/system-health.ts @@ -3,214 +3,20 @@ * * GET /api/v1/system/health * - * Aggregates system health signals (infrastructure, agents, operations) - * into a single response for the frontend status bar. - * * No auth required — mounted before auth middleware, same pattern as /health/ready. */ import { Router } from 'express'; import type { Request, Response } from 'express'; -import fs from 'fs/promises'; -import path from 'path'; import { createLogger } from '../lib/logger.js'; -import { getAgentRegistryService } from '../services/agent-registry-service.js'; -import { getMetricsService } from '../services/metrics/index.js'; +import { getSystemHealthService } from '../services/system-health-service.js'; const log = createLogger('system-health'); -// ─── Types ──────────────────────────────────────────────────── - -type OverallStatus = 'stable' | 'reviewing' | 'drifting' | 'elevated' | 'alert'; - -interface SystemSignal { - status: 'ok' | 'warn' | 'fail'; - storage: boolean; - disk: boolean; - memory: boolean; -} - -interface AgentSignal { - status: 'ok' | 'warn' | 'critical'; - total: number; - online: number; - offline: number; -} - -interface OperationsSignal { - status: 'ok' | 'warn' | 'critical'; - recentRuns: number; - successRate: number; - failedRuns: number; -} - -interface SystemHealthResponse { - timestamp: string; - status: OverallStatus; - signals: { - system: SystemSignal; - agents: AgentSignal; - operations: OperationsSignal; - }; -} - -// ─── Helpers ────────────────────────────────────────────────── - -function getDataDir(): string { - const dataDir = process.env.DATA_DIR || '.veritas-kanban'; - return path.resolve(process.cwd(), dataDir); -} - -async function checkStorage(): Promise { - const dataDir = getDataDir(); - try { - await fs.access(dataDir, fs.constants.R_OK | fs.constants.W_OK); - return true; - } catch { - return false; - } -} - -async function checkDisk(): Promise { - const dataDir = getDataDir(); - try { - const stats = await fs.statfs(dataDir); - const freeBytes = stats.bfree * stats.bsize; - const MIN_FREE_BYTES = 100 * 1024 * 1024; // 100 MB - return freeBytes >= MIN_FREE_BYTES; - } catch { - return false; - } -} - -function checkMemory(): boolean { - const mem = process.memoryUsage(); - return mem.heapUsed / mem.heapTotal <= 0.9; -} - -function getSystemSignal(storage: boolean, disk: boolean, memory: boolean): SystemSignal { - const anyFail = !storage || !disk; - const anyWarn = !memory; - const status: 'ok' | 'warn' | 'fail' = anyFail ? 'fail' : anyWarn ? 'warn' : 'ok'; - return { status, storage, disk, memory }; -} - -function getAgentSignal(): AgentSignal { - try { - const registry = getAgentRegistryService(); - const agents = registry.list(); - const total = agents.length; - const online = agents.filter( - (a) => a.status === 'online' || a.status === 'busy' || a.status === 'idle' - ).length; - const offline = total - online; - - let status: 'ok' | 'warn' | 'critical'; - if (total === 0) { - status = 'ok'; // No agents registered is not an error - } else if (offline === total) { - status = 'critical'; - } else if (offline > 0) { - status = 'warn'; - } else { - status = 'ok'; - } - - return { status, total, online, offline }; - } catch (err) { - log.warn({ err }, 'Failed to read agent registry'); - return { status: 'ok', total: 0, online: 0, offline: 0 }; - } -} - -async function getOperationsSignal(): Promise { - try { - const metrics = getMetricsService(); - const runMetrics = await metrics.getRunMetrics('24h'); - const recentRuns = runMetrics.runs; - // runMetrics.successRate is 0-1 ratio; convert to 0-100 percentage - const successRate = runMetrics.runs > 0 ? Math.round(runMetrics.successRate * 100) : 100; - const failedRuns = runMetrics.failures + runMetrics.errors; - - let status: 'ok' | 'warn' | 'critical'; - if (successRate < 50) { - status = 'critical'; - } else if (successRate < 80 || failedRuns > 5) { - status = 'warn'; - } else { - status = 'ok'; - } - - return { status, recentRuns, successRate, failedRuns }; - } catch (err) { - log.warn({ err }, 'Failed to read operations metrics'); - return { status: 'ok', recentRuns: 0, successRate: 100, failedRuns: 0 }; - } -} - -/** - * Determine overall status from individual signals. - * - * stable = all signals ok - * reviewing = 1 warning signal - * drifting = 2+ warnings or any agent offline - * elevated = any critical signal - * alert = system fail or successRate < 50% - */ -function determineOverallStatus( - system: SystemSignal, - agents: AgentSignal, - operations: OperationsSignal -): OverallStatus { - // Alert: system failure or very low success rate - if (system.status === 'fail' || operations.successRate < 50) { - return 'alert'; - } - - // Elevated: any critical signal - if (agents.status === 'critical' || operations.status === 'critical') { - return 'elevated'; - } - - // Count warnings - const warnings = [system.status, agents.status, operations.status].filter( - (s) => s === 'warn' - ).length; - - // Drifting: 2+ warnings or any agent offline - if (warnings >= 2 || agents.offline > 0) { - return 'drifting'; - } - - // Reviewing: 1 warning - if (warnings === 1) { - return 'reviewing'; - } - - return 'stable'; -} - -// ─── Router ─────────────────────────────────────────────────── - const router = Router(); router.get('/', async (_req: Request, res: Response) => { try { - const [storage, disk] = await Promise.all([checkStorage(), checkDisk()]); - const memory = checkMemory(); - - const system = getSystemSignal(storage, disk, memory); - const agents = getAgentSignal(); - const operations = await getOperationsSignal(); - - const status = determineOverallStatus(system, agents, operations); - - const response: SystemHealthResponse = { - timestamp: new Date().toISOString(), - status, - signals: { system, agents, operations }, - }; - - res.json(response); + res.json(await getSystemHealthService().getStatus()); } catch (err) { log.error({ err }, 'Failed to aggregate system health'); res.status(500).json({ status: 'unknown', error: 'Failed to aggregate health' }); diff --git a/server/src/server.ts b/server/src/server.ts index 19744984..98a380cc 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -20,6 +20,7 @@ import { readFile } from 'fs/promises'; import os from 'os'; import path from 'path'; import { fileURLToPath } from 'url'; +import { getRuntimeDir } from './utils/paths.js'; import { createLogger } from './lib/logger.js'; import { v1Router } from './routes/v1/index.js'; import { agentService } from './routes/agents.js'; @@ -553,7 +554,7 @@ async function reconcileCredentialLeases(context: 'startup' | 'periodic'): Promi // prevent the process from briefly accepting requests against partial state. async function initializeServices(): Promise { // 1. Backup + integrity checks on the data directory - const dataDir = process.env.VERITAS_DATA_DIR || path.join(process.cwd(), '..', '.veritas-kanban'); + const dataDir = getRuntimeDir(); let backupPath = ''; try { backupPath = await createBackup(dataDir); diff --git a/server/src/services/agent-permission-service.ts b/server/src/services/agent-permission-service.ts index e335ecf3..ff7a351e 100644 --- a/server/src/services/agent-permission-service.ts +++ b/server/src/services/agent-permission-service.ts @@ -14,10 +14,10 @@ import { createLogger } from '../lib/logger.js'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import type { CreateGovernanceTraceInput } from '@veritas-kanban/shared'; -import { getRuntimeDir } from '../utils/paths.js'; +import { getLegacyRuntimeDirs, getRuntimeDir } from '../utils/paths.js'; import { migrateLegacyFiles } from '../utils/migrate-legacy-files.js'; const DATA_DIR = getRuntimeDir(); -const LEGACY_DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), '..', '.veritas-kanban'); +const LEGACY_DATA_DIRS = getLegacyRuntimeDirs(); let migrationChecked = false; const log = createLogger('agent-permissions'); @@ -110,7 +110,7 @@ class AgentPermissionService { if (!migrationChecked) { migrationChecked = true; await migrateLegacyFiles( - LEGACY_DATA_DIR, + LEGACY_DATA_DIRS, DATA_DIR, ['agent-permissions.json', 'approval-requests.json'], 'agent permission' diff --git a/server/src/services/agent-registry-service.ts b/server/src/services/agent-registry-service.ts index d0517b05..cc75d591 100644 --- a/server/src/services/agent-registry-service.ts +++ b/server/src/services/agent-registry-service.ts @@ -19,7 +19,7 @@ import { rename, } from '../storage/fs-helpers.js'; import { createLogger } from '../lib/logger.js'; -import { getRuntimeDir } from '../utils/paths.js'; +import { getLegacyRuntimeDirs, getRuntimeDir } from '../utils/paths.js'; import type { ProviderRuntimeManifest } from '@veritas-kanban/shared'; import { parseProviderRuntimeManifest } from '../schemas/provider-runtime-manifest-schemas.js'; @@ -176,7 +176,7 @@ class AgentRegistryService { private agents: Map = new Map(); private dataDir: string; private filePath: string; - private legacyFilePath: string; + private legacyFilePaths: string[]; private staleCheckInterval: ReturnType | null = null; private lastBusyAtByAgent: Map = new Map(); private taskSyncFlapGuardMs: number; @@ -196,9 +196,8 @@ class AgentRegistryService { constructor() { this.dataDir = getRuntimeDir(); this.filePath = path.join(this.dataDir, 'agent-registry.json'); - this.legacyFilePath = path.join( - process.env.VERITAS_DATA_DIR || path.join(process.cwd(), '..', '.veritas-kanban'), - 'agent-registry.json' + this.legacyFilePaths = getLegacyRuntimeDirs().map((dir) => + path.join(dir, 'agent-registry.json') ); this.taskSyncFlapGuardMs = getTaskSyncFlapGuardMs(); this.migrateLegacyRegistry(); @@ -573,19 +572,18 @@ class AgentRegistryService { } private migrateLegacyRegistry(): void { - if (this.legacyFilePath === this.filePath) return; - - if (existsSync(this.legacyFilePath) && !existsSync(this.filePath)) { + for (const legacyFilePath of this.legacyFilePaths) { + if (!existsSync(legacyFilePath) || existsSync(this.filePath)) continue; try { const dir = path.dirname(this.filePath); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } - const data = readFileSync(this.legacyFilePath, 'utf-8'); + const data = readFileSync(legacyFilePath, 'utf-8'); writeFileSync(this.filePath, data, 'utf-8'); log.info( - { from: this.legacyFilePath, to: this.filePath }, + { from: legacyFilePath, to: this.filePath }, 'Migrated agent registry data to the runtime directory' ); } catch (err) { diff --git a/server/src/services/broadcast-storage-service.ts b/server/src/services/broadcast-storage-service.ts index 7c7deafa..cc592576 100644 --- a/server/src/services/broadcast-storage-service.ts +++ b/server/src/services/broadcast-storage-service.ts @@ -20,9 +20,9 @@ import type { import { fileExists } from '../storage/fs-helpers.js'; import { validatePathSegment } from '../utils/sanitize.js'; import { withFileLock } from './file-lock.js'; +import { getBroadcastsDir } from '../utils/paths.js'; -const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), '..', '.veritas-kanban'); -const BROADCASTS_DIR = path.join(DATA_DIR, 'broadcasts'); +const BROADCASTS_DIR = getBroadcastsDir(); const log = createLogger('broadcast-storage'); diff --git a/server/src/services/delegation-service.ts b/server/src/services/delegation-service.ts index da3acdd7..e2e3b7f0 100644 --- a/server/src/services/delegation-service.ts +++ b/server/src/services/delegation-service.ts @@ -12,12 +12,12 @@ import { createLogger } from '../lib/logger.js'; import { withFileLock } from './file-lock.js'; import type { DelegationSettings, DelegationScope, TaskPriority } from '@veritas-kanban/shared'; import type { DelegationApproval, DelegationLog } from '@veritas-kanban/shared'; +import { getRuntimeDir } from '../utils/paths.js'; const log = createLogger('delegation'); // Storage paths -const PROJECT_ROOT = path.resolve(process.cwd(), '..'); -const DELEGATION_DIR = path.join(PROJECT_ROOT, '.veritas-kanban'); +const DELEGATION_DIR = getRuntimeDir(); const SETTINGS_FILE = path.join(DELEGATION_DIR, 'delegation.json'); const LOG_FILE = path.join(DELEGATION_DIR, 'delegation-log.json'); diff --git a/server/src/services/docs-service.ts b/server/src/services/docs-service.ts index 647b21cb..db08f0a5 100644 --- a/server/src/services/docs-service.ts +++ b/server/src/services/docs-service.ts @@ -10,7 +10,7 @@ import { createLogger } from '../lib/logger.js'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; -const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), '..', '.veritas-kanban'); +import { getDocsDir } from '../utils/paths.js'; const log = createLogger('docs'); @@ -59,10 +59,7 @@ export class DocsService { private docsRoot: string; constructor(docsRoot?: string) { - // Default to /../docs, configurable via VK_DOCS_DIR - this.docsRoot = path.resolve( - docsRoot || process.env.VK_DOCS_DIR || path.join(DATA_DIR, '..', 'docs') - ); + this.docsRoot = path.resolve(docsRoot || process.env.VK_DOCS_DIR || getDocsDir()); } /** diff --git a/server/src/services/error-learning-service.ts b/server/src/services/error-learning-service.ts index 8a78b07c..258bc7ab 100644 --- a/server/src/services/error-learning-service.ts +++ b/server/src/services/error-learning-service.ts @@ -16,10 +16,10 @@ import { getTaskService } from './task-service.js'; import { createLogger } from '../lib/logger.js'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; -import { getRuntimeDir } from '../utils/paths.js'; +import { getLegacyRuntimeDirs, getRuntimeDir } from '../utils/paths.js'; import { migrateLegacyFiles } from '../utils/migrate-legacy-files.js'; const DATA_DIR = getRuntimeDir(); -const LEGACY_DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), '..', '.veritas-kanban'); +const LEGACY_DATA_DIRS = getLegacyRuntimeDirs(); let migrationChecked = false; const log = createLogger('error-learning'); @@ -114,7 +114,7 @@ class ErrorLearningService { if (!migrationChecked) { migrationChecked = true; await migrateLegacyFiles( - LEGACY_DATA_DIR, + LEGACY_DATA_DIRS, DATA_DIR, ['error-analyses.json'], 'error analysis' diff --git a/server/src/services/github-sync-service.ts b/server/src/services/github-sync-service.ts index 145ba486..cce4c96e 100644 --- a/server/src/services/github-sync-service.ts +++ b/server/src/services/github-sync-service.ts @@ -17,6 +17,7 @@ import { getBreaker } from './circuit-registry.js'; import { getTaskService, type TaskService } from './task-service.js'; import { createLogger } from '../lib/logger.js'; import type { Task, TaskStatus, TaskPriority } from '@veritas-kanban/shared'; +import { getRuntimeDir } from '../utils/paths.js'; const execFileAsync = promisify(execFile); const log = createLogger('github-sync'); @@ -64,7 +65,7 @@ interface GhIssue { // ─── Constants ─────────────────────────────────────────────── -const DATA_DIR = join(process.cwd(), '.veritas-kanban'); +const DATA_DIR = getRuntimeDir(); const INTEGRATIONS_FILE = join(DATA_DIR, 'integrations.json'); const SYNC_STATE_FILE = join(DATA_DIR, 'github-sync.json'); diff --git a/server/src/services/lifecycle-hooks-service.ts b/server/src/services/lifecycle-hooks-service.ts index 8a6c5af6..c081ca7d 100644 --- a/server/src/services/lifecycle-hooks-service.ts +++ b/server/src/services/lifecycle-hooks-service.ts @@ -17,11 +17,11 @@ import { createLogger } from '../lib/logger.js'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; -import { getRuntimeDir } from '../utils/paths.js'; +import { getLegacyRuntimeDirs, getRuntimeDir } from '../utils/paths.js'; import { migrateLegacyFiles } from '../utils/migrate-legacy-files.js'; import { getOutboundIntegrationService } from './outbound-integration-service.js'; const DATA_DIR = getRuntimeDir(); -const LEGACY_DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), '..', '.veritas-kanban'); +const LEGACY_DATA_DIRS = getLegacyRuntimeDirs(); let migrationChecked = false; const log = createLogger('lifecycle-hooks'); @@ -250,7 +250,7 @@ class LifecycleHooksService { if (!migrationChecked) { migrationChecked = true; await migrateLegacyFiles( - LEGACY_DATA_DIR, + LEGACY_DATA_DIRS, DATA_DIR, ['lifecycle-hooks.json', 'hook-executions.json'], 'lifecycle hook' diff --git a/server/src/services/metrics/task-metrics.ts b/server/src/services/metrics/task-metrics.ts index 5ef33972..44b2aab2 100644 --- a/server/src/services/metrics/task-metrics.ts +++ b/server/src/services/metrics/task-metrics.ts @@ -5,7 +5,7 @@ import fs from 'fs/promises'; import path from 'path'; import type { BlockedCategory } from '@veritas-kanban/shared'; import { TaskService } from '../task-service.js'; -import { PROJECT_ROOT } from './helpers.js'; +import { getRuntimeDir } from '../../utils/paths.js'; import type { TaskMetrics, VelocityTrend, @@ -112,7 +112,7 @@ export async function computeVelocityMetrics( // Load sprint labels from sprints.json for display const sprintLabels = new Map(); try { - const sprintsFile = path.join(PROJECT_ROOT, '.veritas-kanban', 'sprints.json'); + const sprintsFile = path.join(getRuntimeDir(), 'sprints.json'); const sprintsData = await fs.readFile(sprintsFile, 'utf-8'); const sprints = JSON.parse(sprintsData) as Array<{ id: string; label: string }>; for (const s of sprints) { diff --git a/server/src/services/notification-service.ts b/server/src/services/notification-service.ts index 1bffd128..b41d9b15 100644 --- a/server/src/services/notification-service.ts +++ b/server/src/services/notification-service.ts @@ -11,8 +11,9 @@ 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'; -const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), '..', '.veritas-kanban'); +const DATA_DIR = getRuntimeDir(); const log = createLogger('notifications'); diff --git a/server/src/services/pdf-report-service.ts b/server/src/services/pdf-report-service.ts index 8434dcf2..7631e040 100644 --- a/server/src/services/pdf-report-service.ts +++ b/server/src/services/pdf-report-service.ts @@ -15,7 +15,8 @@ import { createLogger } from '../lib/logger.js'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import sanitizeHtml from 'sanitize-html'; -const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), '..', '.veritas-kanban'); +import { getReportsOutputDir, getRuntimeDir } from '../utils/paths.js'; +const DATA_DIR = getRuntimeDir(); const log = createLogger('pdf-reports'); @@ -399,7 +400,7 @@ class PdfReportService { } private get outputDir(): string { - return path.join(DATA_DIR, '..', 'docs', 'reports'); + return getReportsOutputDir(); } private async ensureLoaded(): Promise { diff --git a/server/src/services/progress-service.ts b/server/src/services/progress-service.ts index 688c9402..7ce662e6 100644 --- a/server/src/services/progress-service.ts +++ b/server/src/services/progress-service.ts @@ -1,6 +1,7 @@ import fs from 'fs/promises'; import path from 'path'; import { createLogger } from '../lib/logger.js'; +import { getRuntimeDir } from '../utils/paths.js'; const log = createLogger('progress-service'); @@ -12,8 +13,7 @@ export class ProgressService { private progressDir: string; constructor(progressDir?: string) { - // Default to .veritas-kanban/progress/ relative to project root - this.progressDir = progressDir || path.join(process.cwd(), '.veritas-kanban', 'progress'); + this.progressDir = progressDir || path.join(getRuntimeDir(), 'progress'); } /** diff --git a/server/src/services/project-service.ts b/server/src/services/project-service.ts index 4bf610b6..94cf92b6 100644 --- a/server/src/services/project-service.ts +++ b/server/src/services/project-service.ts @@ -1,8 +1,8 @@ -import { resolve } from 'path'; import type { ProjectConfig } from '@veritas-kanban/shared'; import { ManagedListService } from './managed-list-service.js'; import { TaskService } from './task-service.js'; import { createLogger } from '../lib/logger.js'; +import { getRuntimeDir } from '../utils/paths.js'; const log = createLogger('project-service'); // Color palette for auto-seeded projects @@ -24,7 +24,7 @@ export class ProjectService extends ManagedListService { private seeded = false; constructor(taskService: TaskService) { - const configDir = resolve(process.cwd(), '..', '.veritas-kanban'); + const configDir = getRuntimeDir(); super({ filename: 'projects.json', diff --git a/server/src/services/prompt-registry-service.ts b/server/src/services/prompt-registry-service.ts index c5b1d8fd..df6d1d77 100644 --- a/server/src/services/prompt-registry-service.ts +++ b/server/src/services/prompt-registry-service.ts @@ -17,6 +17,7 @@ import { validatePathSegment, ensureWithinBase } from '../utils/sanitize.js'; import type { PromptRegistryRepository } from '../storage/interfaces.js'; import { SqliteDatabase, type SqliteConnectionOptions } from '../storage/sqlite/database.js'; import { SqlitePromptRegistryRepository } from '../storage/sqlite/prompt-registry-repository.js'; +import { getRuntimeDir } from '../utils/paths.js'; const log = createLogger('prompt-registry-service'); @@ -37,11 +38,10 @@ export class PromptRegistryService { private sqliteDatabase: SqliteDatabase | null = null; constructor(options: PromptRegistryServiceOptions = {}) { - this.templatesDir = - options.templatesDir || join(process.cwd(), '.veritas-kanban', 'prompt-templates'); - this.versionsDir = - options.versionsDir || join(process.cwd(), '.veritas-kanban', 'prompt-versions'); - this.usageDir = options.usageDir || join(process.cwd(), '.veritas-kanban', 'prompt-usage'); + const runtimeDir = getRuntimeDir(); + this.templatesDir = options.templatesDir || join(runtimeDir, 'prompt-templates'); + this.versionsDir = options.versionsDir || join(runtimeDir, 'prompt-versions'); + this.usageDir = options.usageDir || join(runtimeDir, 'prompt-usage'); const storageType = options.storageType ?? (process.env.VERITAS_STORAGE === 'sqlite' ? 'sqlite' : 'file'); diff --git a/server/src/services/scheduled-deliverables-service.ts b/server/src/services/scheduled-deliverables-service.ts index 1a7ff2d1..71e95967 100644 --- a/server/src/services/scheduled-deliverables-service.ts +++ b/server/src/services/scheduled-deliverables-service.ts @@ -10,7 +10,8 @@ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import { SqliteDatabase, type SqliteConnectionOptions } from '../storage/sqlite/database.js'; import { SqliteScheduledDeliverablesRepository } from '../storage/sqlite/scheduled-deliverables-repository.js'; -const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), '..', '.veritas-kanban'); +import { getRuntimeDir } from '../utils/paths.js'; +const DATA_DIR = getRuntimeDir(); const log = createLogger('deliverables'); diff --git a/server/src/services/search-service.ts b/server/src/services/search-service.ts index f4398567..c6f17bbd 100644 --- a/server/src/services/search-service.ts +++ b/server/src/services/search-service.ts @@ -12,6 +12,7 @@ import { getTelemetryService, TelemetryService } from './telemetry-service.js'; import { getWorkProductService, WorkProductService } from './work-product-service.js'; import { getWorkflowService, WorkflowService } from './workflow-service.js'; import { getWorkflowRunService, WorkflowRunService } from './workflow-run-service.js'; +import { getStorageRoot } from '../utils/paths.js'; const log = createLogger('search-service'); @@ -90,7 +91,6 @@ interface ScoreDetails { recencyBoost: number; } -const PROJECT_ROOT = path.resolve(process.cwd(), '..'); const DEFAULT_COLLECTIONS: SearchCollection[] = [...SEARCH_COLLECTIONS]; const QMD_COLLECTIONS = new Set(['tasks-active', 'tasks-archive', 'docs']); const MAX_LIMIT = 50; @@ -1396,9 +1396,8 @@ class SearchService { } private projectRoot(): string { - const configured = - process.env.VERITAS_SEARCH_ROOT || process.env.DATA_DIR || process.env.VERITAS_DATA_DIR; - return configured ? path.resolve(configured) : PROJECT_ROOT; + const configured = process.env.VERITAS_SEARCH_ROOT; + return configured ? path.resolve(configured) : getStorageRoot(); } private runtimeDir(): string { diff --git a/server/src/services/sprint-service.ts b/server/src/services/sprint-service.ts index 0a910ffd..6764d62b 100644 --- a/server/src/services/sprint-service.ts +++ b/server/src/services/sprint-service.ts @@ -1,8 +1,8 @@ -import { resolve } from 'path'; import type { SprintConfig } from '@veritas-kanban/shared'; import { ManagedListService } from './managed-list-service.js'; import { TaskService } from './task-service.js'; import { createLogger } from '../lib/logger.js'; +import { getRuntimeDir } from '../utils/paths.js'; const log = createLogger('sprint-service'); export class SprintService extends ManagedListService { @@ -10,7 +10,7 @@ export class SprintService extends ManagedListService { private seeded = false; constructor(taskService: TaskService) { - const configDir = resolve(process.cwd(), '..', '.veritas-kanban'); + const configDir = getRuntimeDir(); super({ filename: 'sprints.json', diff --git a/server/src/services/status-history-service.ts b/server/src/services/status-history-service.ts index e4df032b..15781272 100644 --- a/server/src/services/status-history-service.ts +++ b/server/src/services/status-history-service.ts @@ -6,6 +6,7 @@ import { withFileLock } from './file-lock.js'; import type { StatusHistoryRepository } from '../storage/interfaces.js'; import { SqliteDatabase, type SqliteConnectionOptions } from '../storage/sqlite/database.js'; import { SqliteStatusHistoryRepository } from '../storage/sqlite/status-history-repository.js'; +import { getRuntimeDir } from '../utils/paths.js'; const log = createLogger('status-history-service'); export type AgentStatusState = 'idle' | 'working' | 'thinking' | 'sub-agent' | 'error'; @@ -59,8 +60,7 @@ export class StatusHistoryService { constructor(options: StatusHistoryServiceOptions = {}) { this.now = options.now ?? (() => new Date()); - this.historyFile = - options.historyFile || join(process.cwd(), '.veritas-kanban', 'status-history.json'); + this.historyFile = options.historyFile || join(getRuntimeDir(), 'status-history.json'); const storageType = options.storageType ?? (process.env.VERITAS_STORAGE === 'sqlite' ? 'sqlite' : 'file'); diff --git a/server/src/services/system-health-service.ts b/server/src/services/system-health-service.ts index ea544ce0..724cd761 100644 --- a/server/src/services/system-health-service.ts +++ b/server/src/services/system-health-service.ts @@ -9,9 +9,8 @@ * delegates to `getSystemHealthService().getStatus()` so the aggregation * logic is reusable and unit-testable. */ -import fs from 'fs/promises'; -import path from 'path'; import { createLogger } from '../lib/logger.js'; +import { checkRuntimeDiskSpace, checkRuntimeStorageAccess } from '../storage/runtime-health.js'; import { getAgentRegistryService } from './agent-registry-service.js'; import { getMetricsService } from './metrics/index.js'; import type { @@ -26,31 +25,6 @@ const log = createLogger('system-health-service'); // ─── Helpers ────────────────────────────────────────────────── -function getDataDir(): string { - const dataDir = process.env.DATA_DIR || '.veritas-kanban'; - return path.resolve(process.cwd(), dataDir); -} - -async function checkStorage(): Promise { - try { - await fs.access(getDataDir(), fs.constants.R_OK | fs.constants.W_OK); - return true; - } catch { - return false; - } -} - -async function checkDisk(): Promise { - try { - const stats = await fs.statfs(getDataDir()); - const freeBytes = stats.bfree * stats.bsize; - const MIN_FREE_BYTES = 100 * 1024 * 1024; // 100 MB - return freeBytes >= MIN_FREE_BYTES; - } catch { - return false; - } -} - function checkMemory(): boolean { const mem = process.memoryUsage(); return mem.heapUsed / mem.heapTotal <= 0.9; @@ -155,7 +129,10 @@ export class SystemHealthService { * scoped health queries; the current implementation is global. */ async getStatus(_filters?: { projectId?: string; agentId?: string }): Promise { - const [storage, disk] = await Promise.all([checkStorage(), checkDisk()]); + const [storage, disk] = await Promise.all([ + checkRuntimeStorageAccess(), + checkRuntimeDiskSpace(), + ]); const memory = checkMemory(); const system = buildSystemSignal(storage, disk, memory); diff --git a/server/src/services/task-type-service.ts b/server/src/services/task-type-service.ts index c0222bdd..6f66ca32 100644 --- a/server/src/services/task-type-service.ts +++ b/server/src/services/task-type-service.ts @@ -1,7 +1,7 @@ -import { resolve } from 'path'; import type { TaskTypeConfig } from '@veritas-kanban/shared'; import { ManagedListService } from './managed-list-service.js'; import { TaskService } from './task-service.js'; +import { getRuntimeDir } from '../utils/paths.js'; const DEFAULT_TASK_TYPES: TaskTypeConfig[] = [ { @@ -50,8 +50,8 @@ export class TaskTypeService extends ManagedListService { private taskService: TaskService; constructor(taskService: TaskService) { - const configDir = resolve(process.cwd(), '..', '.veritas-kanban'); - + const configDir = getRuntimeDir(); + super({ filename: 'task-types.json', configDir, diff --git a/server/src/services/template-service.ts b/server/src/services/template-service.ts index f6bda173..0b6402da 100644 --- a/server/src/services/template-service.ts +++ b/server/src/services/template-service.ts @@ -12,6 +12,7 @@ import { validatePathSegment, ensureWithinBase } from '../utils/sanitize.js'; import type { TemplateRepository } from '../storage/interfaces.js'; import { SqliteDatabase, type SqliteConnectionOptions } from '../storage/sqlite/database.js'; import { SqliteTemplateRepository } from '../storage/sqlite/template-repository.js'; +import { getTemplatesDir } from '../utils/paths.js'; const log = createLogger('template-service'); export interface TemplateServiceOptions { @@ -27,7 +28,7 @@ export class TemplateService { private sqliteDatabase: SqliteDatabase | null = null; constructor(options: TemplateServiceOptions = {}) { - this.templatesDir = options.templatesDir || join(process.cwd(), '.veritas-kanban', 'templates'); + this.templatesDir = options.templatesDir || getTemplatesDir(); const storageType = options.storageType ?? (process.env.VERITAS_STORAGE === 'sqlite' ? 'sqlite' : 'file'); diff --git a/server/src/services/transition-hooks-service.ts b/server/src/services/transition-hooks-service.ts index 189fee6f..eac7a43c 100644 --- a/server/src/services/transition-hooks-service.ts +++ b/server/src/services/transition-hooks-service.ts @@ -22,6 +22,7 @@ import type { TransitionValidationResult, } from '@veritas-kanban/shared'; import { DEFAULT_TRANSITION_HOOKS_CONFIG } from '@veritas-kanban/shared'; +import { getRuntimeDir } from '../utils/paths.js'; const log = createLogger('transition-hooks'); @@ -29,8 +30,7 @@ const log = createLogger('transition-hooks'); // Configuration Storage // --------------------------------------------------------------------------- -const PROJECT_ROOT = path.resolve(process.cwd(), '..'); -const CONFIG_PATH = path.join(PROJECT_ROOT, '.veritas-kanban', 'transition-hooks.json'); +const CONFIG_PATH = path.join(getRuntimeDir(), 'transition-hooks.json'); let cachedConfig: TransitionHooksConfig | null = null; diff --git a/server/src/services/work-product-service.ts b/server/src/services/work-product-service.ts index e807acaf..525edaa7 100644 --- a/server/src/services/work-product-service.ts +++ b/server/src/services/work-product-service.ts @@ -20,8 +20,9 @@ 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 { getRuntimeDir } from '../utils/paths.js'; -const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), '..', '.veritas-kanban'); +const DATA_DIR = getRuntimeDir(); const DEFAULT_VERSION_LIMIT = 25; interface WorkProductFileState { diff --git a/server/src/storage/runtime-health.ts b/server/src/storage/runtime-health.ts new file mode 100644 index 00000000..2e1435bd --- /dev/null +++ b/server/src/storage/runtime-health.ts @@ -0,0 +1,22 @@ +import fs from 'fs/promises'; +import { getRuntimeDir } from '../utils/paths.js'; + +const MIN_FREE_BYTES = 100 * 1024 * 1024; + +export async function checkRuntimeStorageAccess(): Promise { + try { + await fs.access(getRuntimeDir(), fs.constants.R_OK | fs.constants.W_OK); + return true; + } catch { + return false; + } +} + +export async function checkRuntimeDiskSpace(): Promise { + try { + const stats = await fs.statfs(getRuntimeDir()); + return stats.bfree * stats.bsize >= MIN_FREE_BYTES; + } catch { + return false; + } +} diff --git a/server/src/utils/migrate-legacy-files.ts b/server/src/utils/migrate-legacy-files.ts index 79d948f0..aebc4c1c 100644 --- a/server/src/utils/migrate-legacy-files.ts +++ b/server/src/utils/migrate-legacy-files.ts @@ -10,23 +10,16 @@ const log = createLogger('legacy-migration'); * Never deletes source files. */ export async function migrateLegacyFiles( - legacyDir: string, + legacyDir: string | readonly string[], currentDir: string, fileNames: string[], serviceName: string ): Promise { - if (legacyDir === currentDir) return; + const legacyDirs = Array.isArray(legacyDir) ? legacyDir : [legacyDir]; for (const fileName of fileNames) { - const from = path.join(legacyDir, fileName); const to = path.join(currentDir, fileName); - try { - await fs.access(from); - } catch { - continue; - } - try { await fs.access(to); continue; // destination exists, skip @@ -34,13 +27,26 @@ export async function migrateLegacyFiles( // destination missing; proceed } - try { - await fs.mkdir(path.dirname(to), { recursive: true }); - const data = await fs.readFile(from, 'utf-8'); - await fs.writeFile(to, data); - log.info({ from, to }, `Migrated ${serviceName} data to runtime directory`); - } catch (err) { - log.warn({ err, from }, `Failed to migrate ${serviceName} data`); + for (const candidate of legacyDirs) { + if (candidate === currentDir) continue; + const from = path.join(candidate, fileName); + + try { + const sourceStats = await fs.lstat(from); + if (!sourceStats.isFile() || sourceStats.isSymbolicLink()) continue; + } catch { + continue; + } + + try { + await fs.mkdir(path.dirname(to), { recursive: true }); + await fs.copyFile(from, to, fs.constants.COPYFILE_EXCL); + log.info({ from, to }, `Migrated ${serviceName} data to runtime directory`); + break; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'EEXIST') break; + log.warn({ err, from }, `Failed to migrate ${serviceName} data`); + } } } } diff --git a/server/src/utils/migrate-legacy-runtime.ts b/server/src/utils/migrate-legacy-runtime.ts new file mode 100644 index 00000000..2f31fabf --- /dev/null +++ b/server/src/utils/migrate-legacy-runtime.ts @@ -0,0 +1,84 @@ +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import type { Dirent } from 'node:fs'; +import { createLogger } from '../lib/logger.js'; +import { getLegacyRuntimeDirs, getRuntimeDir } from './paths.js'; + +const log = createLogger('legacy-runtime-migration'); + +const STORAGE_ROOT_ONLY_DIRECTORIES = new Set(['tasks', 'docs']); + +async function copyMissingTree( + source: string, + destination: string, + runtimeRoot: string, + topLevel = false +): Promise { + try { + const sourceStats = await fs.lstat(source); + if (!sourceStats.isDirectory() || sourceStats.isSymbolicLink()) return 0; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return 0; + throw error; + } + + const entries: Dirent[] = await fs.readdir(source, { + withFileTypes: true, + encoding: 'utf-8', + }); + + await fs.mkdir(destination, { recursive: true }); + let copied = 0; + + for (const entry of entries) { + const from = path.join(source, entry.name); + const to = path.join(destination, entry.name); + + // A legacy candidate can be the new storage root. Never recurse into the + // canonical destination or copy storage-root-only trees into runtime state. + if (path.resolve(from) === runtimeRoot) continue; + if (topLevel && STORAGE_ROOT_ONLY_DIRECTORIES.has(entry.name)) continue; + + if (entry.isDirectory()) { + copied += await copyMissingTree(from, to, runtimeRoot); + continue; + } + + // Runtime state is files and directories. Never follow legacy symlinks. + if (!entry.isFile()) continue; + + try { + await fs.copyFile(from, to, fs.constants.COPYFILE_EXCL); + copied += 1; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; + } + } + + return copied; +} + +/** + * Copy legacy runtime state into the canonical runtime directory. + * Existing canonical files win and legacy sources are retained for rollback. + */ +export async function migrateLegacyRuntimeState( + sources: readonly string[] = getLegacyRuntimeDirs(), + destination = getRuntimeDir() +): Promise { + const runtimeRoot = path.resolve(destination); + let copied = 0; + + for (const source of sources) { + try { + copied += await copyMissingTree(source, destination, runtimeRoot, true); + } catch (error) { + log.warn({ error, source, destination }, 'Failed to migrate legacy runtime directory'); + } + } + + if (copied > 0) { + log.info({ copied, destination }, 'Migrated legacy runtime state'); + } + return copied; +} diff --git a/server/src/utils/paths.ts b/server/src/utils/paths.ts index ad0bf266..197e3c53 100644 --- a/server/src/utils/paths.ts +++ b/server/src/utils/paths.ts @@ -95,6 +95,34 @@ export function getRuntimeDir(): string { return path.join(getStorageRoot(), '.veritas-kanban'); } +/** + * Runtime directories used by older releases. + * + * Older services disagreed about whether DATA_DIR/VERITAS_DATA_DIR named the + * storage root or the runtime directory, and some resolved from either the + * process cwd or its parent. Keep that compatibility knowledge here so service + * code never reconstructs legacy paths itself. + */ +export function getLegacyRuntimeDirs(): string[] { + const current = getRuntimeDir(); + const candidates = [ + process.env.DATA_DIR, + process.env.VERITAS_DATA_DIR, + path.join(getProjectRoot(), '.veritas-kanban'), + path.join(process.cwd(), '.veritas-kanban'), + path.join(process.cwd(), '..', '.veritas-kanban'), + ]; + + return [ + ...new Set( + candidates + .filter((candidate): candidate is string => Boolean(candidate?.trim())) + .map((candidate) => path.resolve(candidate)) + .filter((candidate) => candidate !== current) + ), + ]; +} + /** * Historical name used throughout the codebase. Aliased here for clarity * in services that conceptually think in terms of a "data dir". @@ -187,6 +215,11 @@ export function getReportsOutputDir(): string { return path.join(getStorageRoot(), 'docs', 'reports'); } +/** Directory for operator-facing Markdown documentation. */ +export function getDocsDir(): string { + return path.join(getStorageRoot(), 'docs'); +} + // --------------------------------------------------------------------------- // Workflow Engine Directories (Phase 1 - v3.0) // ---------------------------------------------------------------------------