mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-08-28 02:44:59 +00:00
refactor: move workflow definitions behind storage (#1195)
* refactor: move workflow definitions behind storage * fix: normalize workflow metadata descriptions * test: cover workflow storage failure paths
This commit is contained in:
parent
14b6d591a5
commit
06d7fa61f8
7 changed files with 399 additions and 129 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"schemaVersion": 1,
|
||||
"maximumEntries": 49,
|
||||
"maximumEntries": 48,
|
||||
"entries": [
|
||||
{
|
||||
"path": "server/src/services/agent-health-service.ts",
|
||||
|
|
@ -284,12 +284,6 @@
|
|||
"owner": "#1186",
|
||||
"rationale": "Coordination-state storage migration is tracked in issue #1186."
|
||||
},
|
||||
{
|
||||
"path": "server/src/services/workflow-service.ts",
|
||||
"category": "authoritative-persistence",
|
||||
"owner": "#1186",
|
||||
"rationale": "Coordination-state storage migration is tracked in issue #1186."
|
||||
},
|
||||
{
|
||||
"path": "server/src/services/workflow-step-executor.ts",
|
||||
"category": "authoritative-persistence",
|
||||
|
|
|
|||
|
|
@ -83,7 +83,9 @@
|
|||
"src/__tests__/task-service-sqlite.test.ts",
|
||||
"src/__tests__/work-product-run-launch-manifest.test.ts",
|
||||
"src/__tests__/work-product-schemas.test.ts",
|
||||
"src/__tests__/workflow-definition-repository.test.ts",
|
||||
"src/__tests__/workflow-run-service.test.ts",
|
||||
"src/__tests__/workflow-service.test.ts",
|
||||
"src/__tests__/workflow-step-executor-codex.test.ts",
|
||||
"src/__tests__/workflow-step-executor-openclaw.test.ts",
|
||||
"src/storage/sqlite/database.test.ts",
|
||||
|
|
|
|||
146
server/src/__tests__/workflow-definition-repository.test.ts
Normal file
146
server/src/__tests__/workflow-definition-repository.test.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { mkdir, mkdtemp, readFile, rm, symlink, truncate, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { WorkflowACL, WorkflowAuditEvent, WorkflowDefinition } from '../types/workflow.js';
|
||||
import { FileWorkflowDefinitionRepository } from '../storage/workflow-definition-repository.js';
|
||||
|
||||
function workflow(id: string): WorkflowDefinition {
|
||||
return {
|
||||
id,
|
||||
name: `Workflow ${id}`,
|
||||
version: 1,
|
||||
agents: [{ id: 'agent-1', name: 'Agent One', role: 'developer' }],
|
||||
steps: [{ id: 'step-1', type: 'agent', agent: 'agent-1', input: 'Do the work.' }],
|
||||
};
|
||||
}
|
||||
|
||||
function acl(workflowId: string): WorkflowACL {
|
||||
return {
|
||||
workflowId,
|
||||
owner: 'brad',
|
||||
editors: ['brad'],
|
||||
viewers: [],
|
||||
executors: [],
|
||||
isPublic: false,
|
||||
};
|
||||
}
|
||||
|
||||
describe('FileWorkflowDefinitionRepository', () => {
|
||||
let root: string;
|
||||
let workflowsDir: string;
|
||||
let repository: FileWorkflowDefinitionRepository;
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(path.join(process.cwd(), '.veritas-workflow-repository-'));
|
||||
workflowsDir = path.join(root, 'workflows');
|
||||
repository = new FileWorkflowDefinitionRepository(workflowsDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('atomically persists, lists, and deletes workflow definitions', async () => {
|
||||
await expect(repository.get('missing')).resolves.toBeNull();
|
||||
await expect(repository.getAcl('missing')).resolves.toBeNull();
|
||||
await repository.save(workflow('alpha'));
|
||||
|
||||
await expect(repository.get('alpha')).resolves.toMatchObject({ id: 'alpha' });
|
||||
await expect(repository.count()).resolves.toBe(1);
|
||||
await expect(repository.listMetadata()).resolves.toEqual([
|
||||
{ id: 'alpha', name: 'Workflow alpha', version: 1, description: '' },
|
||||
]);
|
||||
|
||||
await expect(repository.delete('alpha')).resolves.toBe(true);
|
||||
await expect(repository.delete('alpha')).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('loads both supported YAML extensions and skips invalid metadata', async () => {
|
||||
await mkdir(workflowsDir, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(workflowsDir, 'legacy.yaml'),
|
||||
[
|
||||
'id: legacy',
|
||||
'name: Legacy Workflow',
|
||||
'version: 1',
|
||||
'agents:',
|
||||
' - id: agent-1',
|
||||
' name: Agent One',
|
||||
'steps:',
|
||||
' - id: step-1',
|
||||
' type: agent',
|
||||
' agent: agent-1',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
await writeFile(path.join(workflowsDir, 'broken.yml'), ': invalid', 'utf8');
|
||||
|
||||
await expect(repository.listMetadata()).resolves.toEqual([
|
||||
{ id: 'legacy', name: 'Legacy Workflow', version: 1, description: '' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('serializes concurrent ACL updates and appends audit events', async () => {
|
||||
await Promise.all([repository.saveAcl(acl('alpha')), repository.saveAcl(acl('beta'))]);
|
||||
await expect(repository.getAcl('alpha')).resolves.toMatchObject({ workflowId: 'alpha' });
|
||||
await expect(repository.getAcl('beta')).resolves.toMatchObject({ workflowId: 'beta' });
|
||||
|
||||
const event: WorkflowAuditEvent = {
|
||||
timestamp: '2026-08-23T21:00:00.000Z',
|
||||
userId: 'brad',
|
||||
action: 'edit',
|
||||
workflowId: 'alpha',
|
||||
workflowVersion: 1,
|
||||
};
|
||||
await repository.appendAuditEvent(event);
|
||||
await expect(readFile(path.join(workflowsDir, '.audit.jsonl'), 'utf8')).resolves.toBe(
|
||||
`${JSON.stringify(event)}\n`
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects symbolic-link files and parent directories', async () => {
|
||||
await mkdir(workflowsDir, { recursive: true });
|
||||
const target = path.join(root, 'outside.yml');
|
||||
await writeFile(target, 'id: linked', 'utf8');
|
||||
await symlink(target, path.join(workflowsDir, 'linked.yml'));
|
||||
await expect(repository.get('linked')).rejects.toThrow(/symbolic link/i);
|
||||
|
||||
const realDirectory = path.join(root, 'real-workflows');
|
||||
const linkedDirectory = path.join(root, 'linked-workflows');
|
||||
await mkdir(realDirectory);
|
||||
await symlink(realDirectory, linkedDirectory, 'dir');
|
||||
const linkedRepository = new FileWorkflowDefinitionRepository(linkedDirectory);
|
||||
await expect(linkedRepository.save(workflow('unsafe'))).rejects.toThrow(/regular directory/i);
|
||||
});
|
||||
|
||||
it('rejects non-file, oversized, and linked persistence targets', async () => {
|
||||
await mkdir(workflowsDir, { recursive: true });
|
||||
const invalidWorkflowPath = path.join(workflowsDir, 'invalid.yml');
|
||||
await mkdir(invalidWorkflowPath);
|
||||
await expect(repository.get('invalid')).rejects.toThrow(/bounded regular file/i);
|
||||
await expect(repository.delete('invalid')).rejects.toThrow();
|
||||
await rm(invalidWorkflowPath, { recursive: true });
|
||||
|
||||
await expect(
|
||||
repository.save({ ...workflow('oversized'), description: 'x'.repeat(2 * 1024 * 1024) })
|
||||
).rejects.toThrow(/storage limit/i);
|
||||
|
||||
const auditPath = path.join(workflowsDir, '.audit.jsonl');
|
||||
await writeFile(auditPath, '', 'utf8');
|
||||
await truncate(auditPath, 64 * 1024 * 1024);
|
||||
const event: WorkflowAuditEvent = {
|
||||
timestamp: '2026-08-23T21:00:00.000Z',
|
||||
userId: 'brad',
|
||||
action: 'edit',
|
||||
workflowId: 'alpha',
|
||||
workflowVersion: 1,
|
||||
};
|
||||
await expect(repository.appendAuditEvent(event)).rejects.toThrow(/bounded regular file/i);
|
||||
|
||||
await rm(auditPath);
|
||||
const auditTarget = path.join(root, 'outside-audit.jsonl');
|
||||
await writeFile(auditTarget, '', 'utf8');
|
||||
await symlink(auditTarget, auditPath);
|
||||
await expect(repository.appendAuditEvent(event)).rejects.toThrow(/symbolic link/i);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import fs from 'fs/promises';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { WorkflowService } from '../services/workflow-service.js';
|
||||
|
|
@ -29,7 +28,7 @@ describe('WorkflowService', () => {
|
|||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'workflow-service-'));
|
||||
tmpDir = await fs.mkdtemp(path.join(process.cwd(), '.veritas-workflow-service-'));
|
||||
service = new WorkflowService(tmpDir);
|
||||
originalCodexEnv = {
|
||||
VERITAS_CODEX_EXECUTABLE: process.env.VERITAS_CODEX_EXECUTABLE,
|
||||
|
|
|
|||
|
|
@ -3,9 +3,6 @@
|
|||
* Phase 1: Core Engine
|
||||
*/
|
||||
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import yaml from 'yaml';
|
||||
import { PHASE_NAMES, type PhaseName } from '@veritas-kanban/shared';
|
||||
import type { WorkflowDefinition, WorkflowACL, WorkflowAuditEvent } from '../types/workflow.js';
|
||||
import { ValidationError } from '../types/workflow.js';
|
||||
|
|
@ -13,6 +10,7 @@ import { getWorkflowsDir } from '../utils/paths.js';
|
|||
import { createLogger } from '../lib/logger.js';
|
||||
import { SqliteDatabase, type SqliteConnectionOptions } from '../storage/sqlite/database.js';
|
||||
import { SqliteWorkflowDefinitionRepository } from '../storage/sqlite/workflow-repositories.js';
|
||||
import { FileWorkflowDefinitionRepository } from '../storage/workflow-definition-repository.js';
|
||||
import { evaluateCodexCommandPolicy, isCodexWorkflowAgent } from '../utils/codex-command-policy.js';
|
||||
|
||||
const log = createLogger('workflow-service');
|
||||
|
|
@ -28,16 +26,15 @@ const MAX_TOOLS_PER_AGENT = 50;
|
|||
const MAX_RETRY_DELAY_MS = 300000; // 5 minutes max delay
|
||||
|
||||
export class WorkflowService {
|
||||
private workflowsDir: string;
|
||||
private cache: Map<string, WorkflowDefinition> = new Map();
|
||||
private readonly fileRepository: FileWorkflowDefinitionRepository | null = null;
|
||||
private readonly repository: SqliteWorkflowDefinitionRepository | null = null;
|
||||
private readonly sqliteDatabase: SqliteDatabase | null = null;
|
||||
private readonly ownsSqliteDatabase: boolean = false;
|
||||
private readonly directoriesReady: Promise<void>;
|
||||
|
||||
constructor(options: string | WorkflowServiceOptions = {}) {
|
||||
const resolvedOptions = typeof options === 'string' ? { workflowsDir: options } : options;
|
||||
this.workflowsDir = resolvedOptions.workflowsDir || getWorkflowsDir();
|
||||
const workflowsDir = resolvedOptions.workflowsDir || getWorkflowsDir();
|
||||
const storageType =
|
||||
resolvedOptions.storageType ?? (process.env.VERITAS_STORAGE === 'sqlite' ? 'sqlite' : 'file');
|
||||
|
||||
|
|
@ -51,16 +48,10 @@ export class WorkflowService {
|
|||
}
|
||||
|
||||
if (!this.repository) {
|
||||
this.directoriesReady = this.ensureDirectories();
|
||||
} else {
|
||||
this.directoriesReady = Promise.resolve();
|
||||
this.fileRepository = new FileWorkflowDefinitionRepository(workflowsDir);
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureDirectories(): Promise<void> {
|
||||
await fs.mkdir(this.workflowsDir, { recursive: true });
|
||||
}
|
||||
|
||||
private normalizeWorkflowId(id: string): string {
|
||||
const trimmed = (id ?? '').trim();
|
||||
if (!trimmed) {
|
||||
|
|
@ -101,13 +92,12 @@ export class WorkflowService {
|
|||
return workflow;
|
||||
}
|
||||
|
||||
await this.directoriesReady;
|
||||
|
||||
const filePath = path.join(this.workflowsDir, `${normalizedId}.yml`);
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const workflow = yaml.parse(content) as WorkflowDefinition;
|
||||
const workflow = await this.getFileRepository().get(normalizedId);
|
||||
if (!workflow) {
|
||||
log.debug({ workflowId: normalizedId }, 'Workflow not found');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Validate schema
|
||||
this.validateWorkflow(workflow);
|
||||
|
|
@ -118,10 +108,6 @@ export class WorkflowService {
|
|||
log.info({ workflowId: normalizedId, version: workflow.version }, 'Workflow loaded');
|
||||
return workflow;
|
||||
} catch (err: unknown) {
|
||||
if (err && typeof err === 'object' && 'code' in err && err.code === 'ENOENT') {
|
||||
log.debug({ workflowId: normalizedId }, 'Workflow not found');
|
||||
return null;
|
||||
}
|
||||
log.error({ workflowId: normalizedId, err }, 'Failed to load workflow');
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
throw new ValidationError(`Invalid workflow YAML: ${message}`);
|
||||
|
|
@ -141,19 +127,10 @@ export class WorkflowService {
|
|||
return workflows;
|
||||
}
|
||||
|
||||
await this.directoriesReady;
|
||||
|
||||
const files = await fs.readdir(this.workflowsDir).catch(() => []);
|
||||
const workflows: WorkflowDefinition[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.yml') && !file.endsWith('.yaml')) continue;
|
||||
|
||||
const id = file.replace(/\.(yml|yaml)$/, '');
|
||||
const workflow = await this.loadWorkflow(id);
|
||||
if (workflow) {
|
||||
workflows.push(workflow);
|
||||
}
|
||||
const workflows = await this.getFileRepository().list();
|
||||
for (const workflow of workflows) {
|
||||
this.validateWorkflow(workflow);
|
||||
this.cache.set(workflow.id, workflow);
|
||||
}
|
||||
|
||||
log.info({ count: workflows.length }, 'Listed workflows');
|
||||
|
|
@ -173,32 +150,7 @@ export class WorkflowService {
|
|||
return metadata;
|
||||
}
|
||||
|
||||
await this.directoriesReady;
|
||||
|
||||
const files = await fs.readdir(this.workflowsDir).catch(() => []);
|
||||
const metadata: Array<Pick<WorkflowDefinition, 'id' | 'name' | 'version' | 'description'>> = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.yml') && !file.endsWith('.yaml')) continue;
|
||||
|
||||
const id = file.replace(/\.(yml|yaml)$/, '');
|
||||
const filePath = path.join(this.workflowsDir, file);
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const workflow = yaml.parse(content) as WorkflowDefinition;
|
||||
|
||||
metadata.push({
|
||||
id: workflow.id,
|
||||
name: workflow.name,
|
||||
version: workflow.version,
|
||||
description: workflow.description,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
log.warn({ workflowId: id, err }, 'Failed to read workflow metadata');
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const metadata = await this.getFileRepository().listMetadata();
|
||||
|
||||
log.info({ count: metadata.length }, 'Listed workflow metadata');
|
||||
return metadata;
|
||||
|
|
@ -225,28 +177,16 @@ export class WorkflowService {
|
|||
return;
|
||||
}
|
||||
|
||||
await this.directoriesReady;
|
||||
|
||||
const filePath = path.join(this.workflowsDir, `${normalizedId}.yml`);
|
||||
|
||||
// Check if this is a new workflow (not an update)
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
// File exists, this is an update
|
||||
} catch {
|
||||
// New workflow - check count limit
|
||||
const files = await fs.readdir(this.workflowsDir).catch(() => []);
|
||||
const workflowCount = files.filter((f) => f.endsWith('.yml') || f.endsWith('.yaml')).length;
|
||||
|
||||
if (workflowCount >= MAX_WORKFLOWS) {
|
||||
throw new ValidationError(
|
||||
`Maximum workflow limit (${MAX_WORKFLOWS}) reached. Delete unused workflows before creating new ones.`
|
||||
);
|
||||
}
|
||||
const fileRepository = this.getFileRepository();
|
||||
if (
|
||||
!(await fileRepository.get(normalizedId)) &&
|
||||
(await fileRepository.count()) >= MAX_WORKFLOWS
|
||||
) {
|
||||
throw new ValidationError(
|
||||
`Maximum workflow limit (${MAX_WORKFLOWS}) reached. Delete unused workflows before creating new ones.`
|
||||
);
|
||||
}
|
||||
|
||||
const content = yaml.stringify(workflow);
|
||||
await fs.writeFile(filePath, content, 'utf-8');
|
||||
await fileRepository.save(workflow);
|
||||
|
||||
// Update cache
|
||||
this.cache.set(normalizedId, workflow);
|
||||
|
|
@ -266,10 +206,7 @@ export class WorkflowService {
|
|||
return;
|
||||
}
|
||||
|
||||
await this.directoriesReady;
|
||||
|
||||
const filePath = path.join(this.workflowsDir, `${normalizedId}.yml`);
|
||||
await fs.unlink(filePath);
|
||||
await this.getFileRepository().delete(normalizedId);
|
||||
this.cache.delete(normalizedId);
|
||||
|
||||
log.info({ workflowId: normalizedId }, 'Workflow deleted');
|
||||
|
|
@ -411,18 +348,7 @@ export class WorkflowService {
|
|||
return this.repository.getAcl(workflowId);
|
||||
}
|
||||
|
||||
await this.directoriesReady;
|
||||
|
||||
const aclPath = path.join(this.workflowsDir, '.acl.json');
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(aclPath, 'utf-8');
|
||||
const acls = JSON.parse(content) as Record<string, WorkflowACL>;
|
||||
return acls[workflowId] || null;
|
||||
} catch (err: unknown) {
|
||||
if (err && typeof err === 'object' && 'code' in err && err.code === 'ENOENT') return null;
|
||||
throw err;
|
||||
}
|
||||
return this.getFileRepository().getAcl(workflowId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -435,22 +361,7 @@ export class WorkflowService {
|
|||
return;
|
||||
}
|
||||
|
||||
await this.directoriesReady;
|
||||
|
||||
const aclPath = path.join(this.workflowsDir, '.acl.json');
|
||||
|
||||
let acls: Record<string, WorkflowACL> = {};
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(aclPath, 'utf-8');
|
||||
acls = JSON.parse(content);
|
||||
} catch (err: unknown) {
|
||||
if (!(err && typeof err === 'object' && 'code' in err && err.code === 'ENOENT')) throw err;
|
||||
}
|
||||
|
||||
acls[acl.workflowId] = acl;
|
||||
|
||||
await fs.writeFile(aclPath, JSON.stringify(acls, null, 2), 'utf-8');
|
||||
await this.getFileRepository().saveAcl(acl);
|
||||
|
||||
log.info({ workflowId: acl.workflowId }, 'Workflow ACL saved');
|
||||
}
|
||||
|
|
@ -465,11 +376,7 @@ export class WorkflowService {
|
|||
return;
|
||||
}
|
||||
|
||||
await this.directoriesReady;
|
||||
|
||||
const auditPath = path.join(this.workflowsDir, '.audit.jsonl');
|
||||
const line = JSON.stringify(event) + '\n';
|
||||
await fs.appendFile(auditPath, line, 'utf-8');
|
||||
await this.getFileRepository().appendAuditEvent(event);
|
||||
|
||||
log.info({ event }, 'Workflow audit event logged');
|
||||
}
|
||||
|
|
@ -481,6 +388,13 @@ export class WorkflowService {
|
|||
this.cache.clear();
|
||||
}
|
||||
|
||||
private getFileRepository(): FileWorkflowDefinitionRepository {
|
||||
if (!this.fileRepository) {
|
||||
throw new Error('File workflow repository is not configured');
|
||||
}
|
||||
return this.fileRepository;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.ownsSqliteDatabase) {
|
||||
this.sqliteDatabase?.close();
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ export { LocalWorkspaceFileRepository } from './workspace-file-repository.js';
|
|||
export { FileProgressRepository, type ProgressRepository } from './progress-repository.js';
|
||||
export { FileStatusHistoryStore } from './status-history-repository.js';
|
||||
export { FileScheduledDeliverablesStore } from './scheduled-deliverables-repository.js';
|
||||
export { FileWorkflowDefinitionRepository } from './workflow-definition-repository.js';
|
||||
export {
|
||||
FileWorkspaceExecutionTrustRepository,
|
||||
InMemoryWorkspaceExecutionTrustRepository,
|
||||
|
|
|
|||
214
server/src/storage/workflow-definition-repository.ts
Normal file
214
server/src/storage/workflow-definition-repository.ts
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
import { constants } from 'node:fs';
|
||||
import { lstat, mkdir, open, readdir, unlink } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import yaml from 'yaml';
|
||||
import { withFileLock } from '../services/file-lock.js';
|
||||
import type { WorkflowACL, WorkflowAuditEvent, WorkflowDefinition } from '../types/workflow.js';
|
||||
import { ensureWithinBase, validatePathSegment } from '../utils/sanitize.js';
|
||||
import { atomicWriteFile } from './fs-helpers.js';
|
||||
|
||||
const MAX_WORKFLOW_BYTES = 2 * 1024 * 1024;
|
||||
const MAX_ACL_BYTES = 2 * 1024 * 1024;
|
||||
const MAX_AUDIT_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
type WorkflowMetadata = Pick<WorkflowDefinition, 'id' | 'name' | 'version' | 'description'>;
|
||||
|
||||
export class FileWorkflowDefinitionRepository {
|
||||
private readonly workflowsDir: string;
|
||||
|
||||
constructor(workflowsDir: string) {
|
||||
this.workflowsDir = path.resolve(workflowsDir);
|
||||
ensureWithinBase(path.dirname(this.workflowsDir), this.workflowsDir);
|
||||
}
|
||||
|
||||
async count(): Promise<number> {
|
||||
return (await this.listDefinitionFiles()).length;
|
||||
}
|
||||
|
||||
async get(id: string): Promise<WorkflowDefinition | null> {
|
||||
const content = await this.readOptionalBoundedFile(
|
||||
this.getWorkflowPath(id),
|
||||
MAX_WORKFLOW_BYTES
|
||||
);
|
||||
return content === null ? null : (yaml.parse(content) as WorkflowDefinition);
|
||||
}
|
||||
|
||||
async list(): Promise<WorkflowDefinition[]> {
|
||||
const workflows: WorkflowDefinition[] = [];
|
||||
for (const file of await this.listDefinitionFiles()) {
|
||||
const content = await this.readBoundedFile(
|
||||
ensureWithinBase(this.workflowsDir, path.join(this.workflowsDir, file)),
|
||||
MAX_WORKFLOW_BYTES
|
||||
);
|
||||
workflows.push(yaml.parse(content) as WorkflowDefinition);
|
||||
}
|
||||
return workflows;
|
||||
}
|
||||
|
||||
async listMetadata(): Promise<WorkflowMetadata[]> {
|
||||
const metadata: WorkflowMetadata[] = [];
|
||||
for (const file of await this.listDefinitionFiles()) {
|
||||
try {
|
||||
const content = await this.readBoundedFile(
|
||||
ensureWithinBase(this.workflowsDir, path.join(this.workflowsDir, file)),
|
||||
MAX_WORKFLOW_BYTES
|
||||
);
|
||||
const workflow = yaml.parse(content) as Partial<WorkflowDefinition> | null;
|
||||
if (
|
||||
!workflow ||
|
||||
typeof workflow.id !== 'string' ||
|
||||
typeof workflow.name !== 'string' ||
|
||||
typeof workflow.version !== 'number'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
metadata.push({
|
||||
id: workflow.id,
|
||||
name: workflow.name,
|
||||
version: workflow.version,
|
||||
description: workflow.description ?? '',
|
||||
});
|
||||
} catch {
|
||||
// Preserve the legacy metadata behavior by skipping unreadable definitions.
|
||||
}
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
|
||||
async save(workflow: WorkflowDefinition): Promise<void> {
|
||||
const workflowPath = this.getWorkflowPath(workflow.id);
|
||||
const content = yaml.stringify(workflow);
|
||||
this.assertBounded(content, MAX_WORKFLOW_BYTES, 'Workflow definition');
|
||||
await this.prepareDirectory();
|
||||
await withFileLock(workflowPath, () => atomicWriteFile(workflowPath, content, 'utf8'));
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<boolean> {
|
||||
const workflowPath = this.getWorkflowPath(id);
|
||||
await this.prepareDirectory();
|
||||
return withFileLock(workflowPath, async () => {
|
||||
try {
|
||||
await unlink(workflowPath);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false;
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getAcl(workflowId: string): Promise<WorkflowACL | null> {
|
||||
const content = await this.readOptionalBoundedFile(this.getAclPath(), MAX_ACL_BYTES);
|
||||
if (content === null) return null;
|
||||
const acls = JSON.parse(content) as Record<string, WorkflowACL>;
|
||||
return acls[workflowId] ?? null;
|
||||
}
|
||||
|
||||
async saveAcl(acl: WorkflowACL): Promise<void> {
|
||||
const aclPath = this.getAclPath();
|
||||
await this.prepareDirectory();
|
||||
await withFileLock(aclPath, async () => {
|
||||
const current = await this.readOptionalBoundedFile(aclPath, MAX_ACL_BYTES);
|
||||
const acls = current === null ? {} : (JSON.parse(current) as Record<string, WorkflowACL>);
|
||||
acls[acl.workflowId] = acl;
|
||||
const content = JSON.stringify(acls, null, 2);
|
||||
this.assertBounded(content, MAX_ACL_BYTES, 'Workflow ACL state');
|
||||
await atomicWriteFile(aclPath, content, 'utf8');
|
||||
});
|
||||
}
|
||||
|
||||
async appendAuditEvent(event: WorkflowAuditEvent): Promise<void> {
|
||||
const auditPath = this.getAuditPath();
|
||||
const line = `${JSON.stringify(event)}\n`;
|
||||
this.assertBounded(line, MAX_AUDIT_BYTES, 'Workflow audit event');
|
||||
await this.prepareDirectory();
|
||||
|
||||
await withFileLock(auditPath, async () => {
|
||||
let handle: Awaited<ReturnType<typeof open>> | undefined;
|
||||
try {
|
||||
handle = await open(
|
||||
auditPath,
|
||||
constants.O_APPEND | constants.O_CREAT | constants.O_WRONLY | (constants.O_NOFOLLOW ?? 0),
|
||||
0o600
|
||||
);
|
||||
const stats = await handle.stat();
|
||||
if (!stats.isFile() || stats.size + Buffer.byteLength(line, 'utf8') > MAX_AUDIT_BYTES) {
|
||||
throw new Error('Workflow audit log must use a bounded regular file');
|
||||
}
|
||||
await handle.writeFile(line, { encoding: 'utf8' });
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ELOOP') {
|
||||
throw new Error('Workflow audit log must not be a symbolic link', { cause: error });
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
await handle?.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private getWorkflowPath(id: string): string {
|
||||
const safeId = validatePathSegment(id);
|
||||
return ensureWithinBase(this.workflowsDir, path.join(this.workflowsDir, `${safeId}.yml`));
|
||||
}
|
||||
|
||||
private getAclPath(): string {
|
||||
return ensureWithinBase(this.workflowsDir, path.join(this.workflowsDir, '.acl.json'));
|
||||
}
|
||||
|
||||
private getAuditPath(): string {
|
||||
return ensureWithinBase(this.workflowsDir, path.join(this.workflowsDir, '.audit.jsonl'));
|
||||
}
|
||||
|
||||
private async listDefinitionFiles(): Promise<string[]> {
|
||||
await this.prepareDirectory();
|
||||
const entries = await readdir(this.workflowsDir);
|
||||
return entries.filter((entry) => entry.endsWith('.yml') || entry.endsWith('.yaml'));
|
||||
}
|
||||
|
||||
private async prepareDirectory(): Promise<void> {
|
||||
await mkdir(this.workflowsDir, { recursive: true, mode: 0o700 });
|
||||
const stats = await lstat(this.workflowsDir);
|
||||
if (!stats.isDirectory() || stats.isSymbolicLink()) {
|
||||
throw new Error('Workflow storage path must use a regular directory');
|
||||
}
|
||||
}
|
||||
|
||||
private async readBoundedFile(filePath: string, maximumBytes: number): Promise<string> {
|
||||
let handle: Awaited<ReturnType<typeof open>> | undefined;
|
||||
try {
|
||||
handle = await open(filePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
||||
const stats = await handle.stat();
|
||||
if (!stats.isFile() || stats.size > maximumBytes) {
|
||||
throw new Error('Workflow storage must use a bounded regular file');
|
||||
}
|
||||
return await handle.readFile({ encoding: 'utf8' });
|
||||
} catch (error) {
|
||||
const errorCode = (error as NodeJS.ErrnoException).code;
|
||||
if (errorCode === 'ELOOP') {
|
||||
throw new Error('Workflow storage must not use symbolic links', { cause: error });
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
await handle?.close();
|
||||
}
|
||||
}
|
||||
|
||||
private async readOptionalBoundedFile(
|
||||
filePath: string,
|
||||
maximumBytes: number
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
return await this.readBoundedFile(filePath, maximumBytes);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private assertBounded(content: string, maximumBytes: number, label: string): void {
|
||||
if (Buffer.byteLength(content, 'utf8') > maximumBytes) {
|
||||
throw new Error(`${label} exceeds the ${maximumBytes}-byte storage limit`);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue