fix: centralize runtime state under DATA_DIR (#1184)

* fix: centralize runtime state paths

* chore: realign reviewed secret fingerprint

* test: cover legacy security migration

* test: isolate centralized runtime paths

* fix: address runtime path review findings

* test: include runtime health in critical coverage

* chore: realign deployment secret fingerprint

* test: stabilize provider coverage

* test: cover reflection job storage

* test: secure health route temp files
This commit is contained in:
Brad Groux 2026-08-23 15:02:07 -05:00 committed by GitHub
parent 93e946693d
commit 2a581a451a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
48 changed files with 542 additions and 428 deletions

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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=<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

View file

@ -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.

View file

@ -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",

View file

@ -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<void>;
};
function testReflectionExtractionJobs(): Pick<ReflectionExtractionJobService, 'enqueue'> {
return {
enqueue: vi.fn().mockResolvedValue({}),
} as unknown as Pick<ReflectionExtractionJobService, 'enqueue'>;
}
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<ReflectionExtractionJobService, 'enqueue'>;
const service = testableService(tmpDir);
(
service as unknown as {
reflectionExtractionJobs: Pick<ReflectionExtractionJobService, 'enqueue'>;
}
).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<void>((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 () => {

View file

@ -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();
});

View file

@ -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 () => {

View file

@ -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<string>();
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.'
);
}

View file

@ -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'));

View file

@ -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 });
});

View file

@ -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',

View file

@ -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');
});
});

View file

@ -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 });
}
});
});

View file

@ -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 () => {

View file

@ -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'

View file

@ -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');

View file

@ -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),
});
})
);

View file

@ -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<boolean> {
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<boolean> {
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<OperationsSignal> {
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' });

View file

@ -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<void> {
// 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);

View file

@ -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'

View file

@ -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<string, RegisteredAgent> = new Map();
private dataDir: string;
private filePath: string;
private legacyFilePath: string;
private legacyFilePaths: string[];
private staleCheckInterval: ReturnType<typeof setInterval> | null = null;
private lastBusyAtByAgent: Map<string, number> = 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) {

View file

@ -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');

View file

@ -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');

View file

@ -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 <storage>/../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());
}
/**

View file

@ -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'

View file

@ -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');

View file

@ -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'

View file

@ -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<string, string>();
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) {

View file

@ -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');

View file

@ -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<void> {

View file

@ -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');
}
/**

View file

@ -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<ProjectConfig> {
private seeded = false;
constructor(taskService: TaskService) {
const configDir = resolve(process.cwd(), '..', '.veritas-kanban');
const configDir = getRuntimeDir();
super({
filename: 'projects.json',

View file

@ -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');

View file

@ -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');

View file

@ -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<SearchCollection>(['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 {

View file

@ -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<SprintConfig> {
@ -10,7 +10,7 @@ export class SprintService extends ManagedListService<SprintConfig> {
private seeded = false;
constructor(taskService: TaskService) {
const configDir = resolve(process.cwd(), '..', '.veritas-kanban');
const configDir = getRuntimeDir();
super({
filename: 'sprints.json',

View file

@ -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');

View file

@ -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<boolean> {
try {
await fs.access(getDataDir(), fs.constants.R_OK | fs.constants.W_OK);
return true;
} catch {
return false;
}
}
async function checkDisk(): Promise<boolean> {
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<HealthStatus> {
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);

View file

@ -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<TaskTypeConfig> {
private taskService: TaskService;
constructor(taskService: TaskService) {
const configDir = resolve(process.cwd(), '..', '.veritas-kanban');
const configDir = getRuntimeDir();
super({
filename: 'task-types.json',
configDir,

View file

@ -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');

View file

@ -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;

View file

@ -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 {

View file

@ -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<boolean> {
try {
await fs.access(getRuntimeDir(), fs.constants.R_OK | fs.constants.W_OK);
return true;
} catch {
return false;
}
}
export async function checkRuntimeDiskSpace(): Promise<boolean> {
try {
const stats = await fs.statfs(getRuntimeDir());
return stats.bfree * stats.bsize >= MIN_FREE_BYTES;
} catch {
return false;
}
}

View file

@ -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<void> {
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`);
}
}
}
}

View file

@ -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<number> {
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<string>[] = 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<number> {
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;
}

View file

@ -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)
// ---------------------------------------------------------------------------