mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-08-28 02:44:59 +00:00
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:
parent
93e946693d
commit
2a581a451a
48 changed files with 542 additions and 428 deletions
|
|
@ -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
|
||||
|
|
|
|||
13
Dockerfile
13
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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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.'
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
98
server/src/__tests__/runtime-paths.test.ts
Normal file
98
server/src/__tests__/runtime-paths.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
38
server/src/__tests__/security-legacy-runtime.test.ts
Normal file
38
server/src/__tests__/security-legacy-runtime.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
22
server/src/storage/runtime-health.ts
Normal file
22
server/src/storage/runtime-health.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
84
server/src/utils/migrate-legacy-runtime.ts
Normal file
84
server/src/utils/migrate-legacy-runtime.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue