124 KiB
Workflow Engine Architecture — Veritas Kanban
Author: TARS (sub-agent)
Created: 2026-02-09
Revised: 2026-02-09 (Codex review revision)
GitHub Issue: #107
Status: Architecture Specification
Revision History
2026-02-09 — Codex Review Revision (TARS)
Addressed issues identified in Ava's codex review:
🟡 Important Issues Addressed:
- Retry routing (
retry_step) defined — Added step queue refactor and retry routing semantics with state machine diagram - Loop verification flags wired — Added executor logic for
verify_each,verify_step, andcompletion:any_done - RBAC/auditing on workflow CRUD APIs — Added access control model, audit logging, and workflow versioning strategy
- Retention and concurrency strategy — Added retention policy, cleanup jobs, concurrency limits, and session cleanup
- WebSocket payloads aligned with frontend — Ensured event payloads match React component expectations with full run state
- Antfarm parity gaps closed — Added setup steps, step timeouts/watchdogs, and CLI parity commands
🟢 Nice-to-Have Issues Addressed: 7. Acceptance criteria validation — Added StepOutputValidator with regex/JSON Schema/custom functions 8. Task payload injection — Ensured workflow run context includes full task payload in initial context
All changes preserve backward compatibility with existing YAML schemas while expanding functionality.
1. Overview & Goals
What the Workflow Engine Is
The Veritas Kanban workflow engine is a deterministic multi-step agent orchestration system inspired by CI/CD pipelines but designed specifically for AI agent workflows. It transforms VK from an ad-hoc task board into a repeatable, observable, and reliable agent execution platform.
Think of it as GitHub Actions for AI agents — YAML-defined pipelines that coordinate multiple agents, manage state, handle failures, and provide real-time observability.
What It Is NOT
- Not a general-purpose workflow engine (like Temporal, Airflow) — optimized specifically for AI agents
- Not a replacement for OpenClaw — workflows invoke OpenClaw sessions, they don't replace them
- Not a programming language — workflows are declarative YAML, not imperative scripts
- Not a task scheduler — workflows run on-demand (triggered by task creation or manual start)
Core Principles
- Deterministic Execution — same workflow + same inputs = same execution path (modulo agent non-determinism)
- Agent-Agnostic — workflows don't care which LLM/agent implementation runs steps (OpenClaw handles that)
- YAML-First — workflows are version-controlled YAML files, not database records
- Observable — every step logs outputs, status broadcasts via WebSocket, squad chat integration
- Fail-Safe — explicit retry/escalation policies, no silent failures
- Fresh Context by Default — each step spawns a fresh OpenClaw session (divergence from antfarm's Ralph loop pattern)
How It Fits Into VK's Architecture
User creates task (optionally linked to a workflow)
↓
TaskService creates task.md
↓
Task detail panel shows "Run Workflow" button
↓
WorkflowRunService spawns workflow run
↓
WorkflowStepExecutor iterates steps:
- Spawns OpenClaw session per step (sessions_spawn)
- Waits for completion (sessions_wait_for)
- Writes step output to .veritas-kanban/workflow-runs/<run-id>/step-outputs/<step-id>.md
- Broadcasts status via WebSocket
- Posts to squad chat
↓
Run completes → task status updated
Integration points:
- Tasks: workflows update task status, deliverables, time tracking
- Squad chat: agents post progress to squad chat during workflow execution
- Telemetry: workflow runs emit
run.started,run.completed, token usage events - Progress files: each step reads/writes progress.md for context passing
What Problems It Solves
| Problem (Before) | Solution (After) |
|---|---|
| Ad-hoc agent execution — no repeatability | YAML workflows → version-controlled pipelines |
| No multi-agent coordination | Steps define agent handoffs with context passing |
| Agents repeat mistakes | Retry/escalation policies + lessons learned |
| No visibility into agent progress | Real-time WebSocket status + squad chat narration |
| Hard to debug agent failures | Step-by-step outputs persisted in workflow-runs/ |
| Can't reuse successful patterns | Workflow library → copy/paste/adapt |
2. YAML Workflow Schema
Schema Overview
id: <unique-workflow-id>
name: <human-readable-name>
version: <integer>
description: |
Multi-line description of what this workflow does
and when to use it.
# Global workflow configuration
config:
timeout: 7200 # Max workflow duration in seconds (default: 2 hours)
fresh_session_default: true # All steps spawn fresh sessions unless overridden
progress_file: progress.md # Shared progress file (default: progress.md)
telemetry_tags: ['workflow', 'feature-dev'] # Tags for telemetry events
# Agent definitions — who can run steps
agents:
- id: planner
name: Planner
role: analysis # Role slug (maps to toolPolicy in #110)
model: github-copilot/claude-opus-4.6 # Default model for this agent
description: Decomposes tasks into user stories
- id: developer
name: Developer
role: coding
model: github-copilot/claude-sonnet-4.5
description: Implements features
# Sequential steps (future: parallel steps)
steps:
- id: plan
name: 'Plan: Decompose task'
agent: planner # Which agent runs this step
type: agent # Step type: "agent" | "loop" | "gate" | "parallel"
# Fresh session control (overrides global default)
fresh_session: true
# Input prompt template — Jinja2-style variable substitution
input: |
Decompose the following task into ordered user stories.
TASK: {{task.title}}
{{task.description}}
Output YAML:
stories:
- id: story-1
title: ...
acceptance_criteria: [...]
# Output configuration — what this step produces
output:
file: plan.yml # Written to step-outputs/plan.yml
schema: plan_output # JSON schema for validation (optional)
# Acceptance criteria — step not complete until these pass
acceptance_criteria:
- 'Output contains valid YAML'
- 'At least 3 stories defined'
- 'Each story has acceptance_criteria'
# Retry/failure policy
on_fail:
retry: 2 # Max retries
escalate_to: human # "human" | "agent:<agent-id>" | "skip"
escalate_message: 'Planning failed — manual decomposition needed'
# Timeout override (seconds)
timeout: 600
- id: implement
name: 'Implement: Execute stories'
agent: developer
type: loop # Loop type — iterates over a collection
loop:
over: '{{plan.stories}}' # Iterate over plan output
item_var: current_story # Variable name for current item
completion: all_done # "all_done" | "any_done" | "first_success"
fresh_session_per_iteration: true # New session for each story
verify_each: true # Verify after each iteration
verify_step: verify # Step ID to run for verification
input: |
Implement this user story.
STORY: {{current_story.title}}
{{current_story.description}}
COMPLETED STORIES:
{{loop.completed | join(", ")}}
PROGRESS:
{{progress}}
output:
file: 'implement-{{loop.index}}.md'
on_fail:
retry: 1
escalate_to: human
- id: verify
name: 'Verify: Check implementation'
agent: verifier
type: agent
input: |
Verify the developer's work.
STORY: {{current_story.title}}
CHANGES: {{implement.output}}
Check:
- Code exists (not TODOs)
- Acceptance criteria met
- Tests pass
output:
file: 'verify-{{loop.index}}.md'
# Gate behavior — if verification fails, retry the previous step
on_fail:
retry_step: implement # Retry a different step
max_retries: 2
on_exhausted:
escalate_to: human
- id: gate-check
name: 'Gate: Quality Check'
type: gate # Gate type — boolean decision point
condition: |
{{verify.decision == "approved" and test.status == "passed"}}
on_false:
escalate_to: human
escalate_message: 'Quality gate failed — code review required'
# Variable definitions (accessible to all steps)
variables:
repo_path: '/path/to/repo'
base_branch: 'main'
test_command: 'npm test'
# Output schema definitions (optional — for validation)
schemas:
plan_output:
type: object
required: [stories]
properties:
stories:
type: array
items:
type: object
required: [id, title, acceptance_criteria]
TypeScript Interfaces
// Workflow Definition Types
export interface WorkflowDefinition {
id: string;
name: string;
version: number;
description: string;
config?: WorkflowConfig;
agents: WorkflowAgent[];
steps: WorkflowStep[];
variables?: Record<string, any>;
schemas?: Record<string, any>;
}
export interface WorkflowConfig {
timeout?: number; // seconds
fresh_session_default?: boolean;
progress_file?: string;
telemetry_tags?: string[];
}
export interface WorkflowAgent {
id: string;
name: string;
role: string; // maps to toolPolicy
model?: string; // default model for this agent
description: string;
}
export type StepType = 'agent' | 'loop' | 'gate' | 'parallel';
export interface WorkflowStep {
id: string;
name: string;
agent?: string; // agent ID (required for type=agent|loop)
type: StepType;
fresh_session?: boolean;
input?: string; // Jinja2 template
output?: StepOutput;
acceptance_criteria?: string[];
on_fail?: FailurePolicy;
timeout?: number;
// Loop-specific config
loop?: LoopConfig;
// Gate-specific config
condition?: string; // Jinja2 expression evaluating to boolean
on_false?: EscalationPolicy;
}
export interface StepOutput {
file: string; // Filename in step-outputs/
schema?: string; // Schema ID for validation
}
export interface FailurePolicy {
retry?: number;
retry_step?: string; // Retry a different step ID
escalate_to?: 'human' | `agent:${string}` | 'skip';
escalate_message?: string;
on_exhausted?: EscalationPolicy;
}
export interface EscalationPolicy {
escalate_to: 'human' | `agent:${string}` | 'skip';
escalate_message?: string;
}
export interface LoopConfig {
over: string; // Jinja2 expression returning array
item_var?: string; // Variable name for current item (default: "item")
index_var?: string; // Variable name for loop index (default: "index")
completion: 'all_done' | 'any_done' | 'first_success';
fresh_session_per_iteration?: boolean;
verify_each?: boolean;
verify_step?: string; // Step ID to run after each iteration
max_iterations?: number;
}
// Workflow Run Types
export type WorkflowRunStatus = 'pending' | 'running' | 'blocked' | 'completed' | 'failed';
export type StepRunStatus = 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
export interface WorkflowRun {
id: string; // run_<timestamp>_<nanoid>
workflowId: string;
workflowVersion: number;
taskId?: string; // Optional task association
status: WorkflowRunStatus;
currentStep?: string; // Current step ID
context: Record<string, any>; // Shared context across steps
startedAt: string;
completedAt?: string;
error?: string;
steps: StepRun[];
}
export interface StepRun {
stepId: string;
status: StepRunStatus;
agent?: string;
sessionKey?: string; // OpenClaw session key
startedAt?: string;
completedAt?: string;
duration?: number; // seconds
retries: number;
output?: string; // Path to output file
error?: string;
// Loop-specific state
loopState?: {
totalIterations: number;
currentIteration: number;
completedIterations: number;
failedIterations: number;
};
}
3. Retry Routing & Failure Handling
3.1 Retry Semantics
When a step fails, the on_fail policy determines what happens next. The workflow engine supports three retry strategies:
- Retry the same step (
retry: N) — Re-execute the failed step up to N times - Retry a different step (
retry_step: <step-id>) — Jump back to a specific step and re-execute from there - Escalate (
escalate_to: human|agent:<id>|skip) — Block the workflow, delegate to another agent, or skip
3.2 Retry Routing Implementation
Problem: The initial implementation used a simple for loop to iterate through steps sequentially. This doesn't support jumping back to a previous step when retry_step is specified.
Solution: Refactor the executor to use a step queue instead of a for loop:
// WorkflowRunService.executeRun() — Refactored
private async executeRun(run: WorkflowRun, workflow: WorkflowDefinition): Promise<void> {
try {
// Build initial step queue (ordered by workflow.steps)
const stepQueue: string[] = workflow.steps.map(s => s.id);
while (stepQueue.length > 0) {
const stepId = stepQueue.shift()!;
const step = workflow.steps.find(s => s.id === stepId)!;
// Update current step
run.currentStep = step.id;
await this.saveRun(run);
broadcastWorkflowStatus(run);
// Execute the step
const stepRun = run.steps.find(s => s.stepId === step.id)!;
stepRun.status = 'running';
stepRun.startedAt = new Date().toISOString();
await this.saveRun(run);
try {
const result = await this.stepExecutor.executeStep(step, run);
// Step succeeded
stepRun.status = 'completed';
stepRun.completedAt = new Date().toISOString();
stepRun.duration = Math.floor(
(new Date(stepRun.completedAt).getTime() - new Date(stepRun.startedAt!).getTime()) / 1000
);
stepRun.output = result.outputPath;
// Merge step output into run context
run.context[step.id] = result.output;
await this.saveRun(run);
broadcastWorkflowStatus(run);
} catch (err: any) {
// Step failed
stepRun.status = 'failed';
stepRun.error = err.message;
stepRun.completedAt = new Date().toISOString();
await this.saveRun(run);
// Handle failure policy
const handled = await this.handleStepFailure(step, stepRun, stepQueue, workflow, run);
if (!handled) {
// No retry policy — fail the entire workflow
throw err;
}
}
}
// All steps completed
run.status = 'completed';
run.completedAt = new Date().toISOString();
await this.saveRun(run);
broadcastWorkflowStatus(run);
log.info({ runId: run.id, workflowId: run.workflowId }, 'Workflow run completed');
} catch (err: any) {
run.status = 'failed';
run.error = err.message;
run.completedAt = new Date().toISOString();
await this.saveRun(run);
broadcastWorkflowStatus(run);
log.error({ runId: run.id, err }, 'Workflow run failed');
}
}
/**
* Handle step failure according to on_fail policy
* Returns true if handled (retry queued), false if should fail workflow
*/
private async handleStepFailure(
step: WorkflowStep,
stepRun: StepRun,
stepQueue: string[],
workflow: WorkflowDefinition,
run: WorkflowRun
): Promise<boolean> {
const policy = step.on_fail;
if (!policy) return false;
// Strategy 1: Retry the same step
if (policy.retry && stepRun.retries < policy.retry) {
stepRun.retries++;
stepRun.status = 'pending';
stepRun.error = undefined;
// Re-queue this step at the front
stepQueue.unshift(step.id);
await this.saveRun(run);
log.info({ stepId: step.id, retry: stepRun.retries }, 'Retrying step');
return true;
}
// Strategy 2: Retry a different step
if (policy.retry_step) {
const retryStep = workflow.steps.find(s => s.id === policy.retry_step);
if (!retryStep) {
throw new Error(`retry_step references unknown step: ${policy.retry_step}`);
}
// Reset the retry step's state
const retryStepRun = run.steps.find(s => s.stepId === retryStep.id)!;
retryStepRun.status = 'pending';
retryStepRun.retries = 0;
retryStepRun.error = undefined;
// Build a new queue starting from the retry step
const retryIndex = workflow.steps.findIndex(s => s.id === policy.retry_step);
const newQueue = workflow.steps.slice(retryIndex).map(s => s.id);
// Replace the queue
stepQueue.length = 0;
stepQueue.push(...newQueue);
// Store failure context for the retry step
run.context._retryContext = {
failedStep: step.id,
error: stepRun.error,
retries: stepRun.retries,
};
await this.saveRun(run);
log.info({ failedStep: step.id, retryStep: retryStep.id }, 'Routing to retry step');
return true;
}
// Strategy 3: Escalation
if (policy.escalate_to === 'human') {
run.status = 'blocked';
run.error = policy.escalate_message || `Step ${step.id} failed`;
await this.saveRun(run);
broadcastWorkflowStatus(run);
// Workflow is blocked — human must resume
return true; // Handled (blocked, not failed)
}
if (policy.escalate_to === 'skip') {
stepRun.status = 'skipped';
await this.saveRun(run);
log.info({ stepId: step.id }, 'Skipping failed step');
return true;
}
if (policy.escalate_to?.startsWith('agent:')) {
// Delegate to another agent (future feature)
throw new Error('Agent escalation not yet implemented');
}
return false; // No policy matched — fail the workflow
}
3.3 Retry Routing State Machine
┌──────────────┐
│ Step Start │
└──────┬───────┘
│
▼
┌──────────────┐
│ Executing │
└──────┬───────┘
│
├─ Success ──────────────────────┐
│ │
└─ Failure ───┐ │
▼ │
┌──────────────┐ │
│ Check Policy │ │
└──────┬───────┘ │
│ │
┌─────────────┼─────────────┐ │
│ │ │ │
▼ ▼ ▼ │
retry < max? retry_step? escalate │
│ │ │ │
│ YES │ YES │ │
▼ ▼ ▼ │
Re-queue Jump back Block/ │
same step to step Skip │
│ │ │ │
└─────────────┴─────────────┴────┼─> Next step
│ or complete
▼
┌──────────────┐
│ Complete │
└──────────────┘
3.4 Failure Context Passing
When retry_step is triggered, the failure context is stored in run.context._retryContext:
{
failedStep: 'test', // Which step failed
error: 'Tests did not pass', // Error message
retries: 2, // How many times the failed step was retried
}
The retry step's input template can access this context:
steps:
- id: implement
name: 'Implement: Code the feature'
agent: developer
input: |
{{#if _retryContext}}
RETRY ATTEMPT: The previous implementation failed.
FAILED STEP: {{_retryContext.failedStep}}
ERROR: {{_retryContext.error}}
Please fix the issue and re-implement.
{{/if}}
Feature: {{task.title}}
...
3.5 Example: Verifier-Triggered Retry
From the feature-dev workflow:
steps:
- id: implement
name: 'Implement: Code'
agent: developer
# ... implementation logic ...
- id: verify
name: 'Verify: Check implementation'
agent: verifier
input: |
Check the developer's work.
CHANGES: {{implement.output}}
Acceptance criteria:
- Code exists (not TODOs)
- Tests pass
- Meets requirements
on_fail:
retry_step: implement # Jump back to implement
max_retries: 2 # Max 2 full cycles
on_exhausted:
escalate_to: human
Execution flow:
implementruns → produces codeverifyruns → finds issuesverifyfails →retry_step: implementtriggers- Executor jumps back to
implementwith_retryContext implementre-runs with failure contextverifyruns again- If
verifyfails again → repeat (up tomax_retries) - If still failing → escalate to human
4. Example Workflows
3.1 Feature Development (Classic)
id: feature-dev
name: Feature Development Workflow
version: 1
description: |
End-to-end feature development pipeline.
Planner decomposes → Developer implements stories → Tester validates → Reviewer approves.
config:
timeout: 7200
fresh_session_default: true
progress_file: progress.md
telemetry_tags: ['workflow', 'feature-dev']
agents:
- id: planner
name: Planner
role: analysis
model: github-copilot/claude-opus-4.6
description: Task decomposition specialist
- id: developer
name: Developer
role: coding
model: github-copilot/claude-sonnet-4.5
description: Feature implementation
- id: tester
name: Tester
role: testing
model: github-copilot/claude-sonnet-4.5
description: Integration testing
- id: reviewer
name: Reviewer
role: analysis
model: github-copilot/claude-opus-4.6
description: Code review
variables:
repo_path: '{{task.git.worktreePath}}'
base_branch: '{{task.git.baseBranch}}'
feature_branch: '{{task.git.branch}}'
test_command: 'npm test'
steps:
- id: plan
name: 'Plan: Decompose into stories'
agent: planner
type: agent
fresh_session: true
input: |
Decompose this task into 5-10 implementable user stories.
TASK: {{task.title}}
{{task.description}}
REPO: {{repo_path}}
Guidelines:
- Each story fits in one coding session (one context window)
- Order by dependency (backend before frontend)
- Include acceptance criteria that are mechanically verifiable
- Every story must include test criteria
Output format (YAML):
```yaml
stories:
- id: story-1
title: "Add user registration endpoint"
description: "POST /api/auth/register with email/password validation"
acceptance_criteria:
- "POST /api/auth/register endpoint exists"
- "Email validation rejects invalid emails"
- "Password hashing uses bcrypt"
- "Tests for registration pass"
- "Typecheck passes"
- id: story-2
title: ...
```
output:
file: plan.yml
acceptance_criteria:
- 'Output contains valid YAML'
- 'At least 3 stories defined'
- 'Each story has acceptance_criteria'
on_fail:
retry: 2
escalate_to: human
escalate_message: 'Planning failed — need manual story breakdown'
timeout: 600
- id: implement
name: 'Implement: Execute stories'
agent: developer
type: loop
loop:
over: '{{plan.stories}}'
item_var: story
completion: all_done
fresh_session_per_iteration: true
verify_each: false
max_iterations: 20
input: |
Implement this user story in the feature branch.
STORY {{loop.index + 1}}/{{loop.total}}: {{story.title}}
{{story.description}}
ACCEPTANCE CRITERIA:
{{story.acceptance_criteria | join("\n")}}
REPO: {{repo_path}}
BRANCH: {{feature_branch}}
TEST_CMD: {{test_command}}
COMPLETED STORIES:
{{loop.completed | map(attribute='title') | join("\n")}}
PROGRESS LOG:
{{progress}}
Instructions:
1. Read progress.md for context from previous stories
2. cd {{repo_path}}
3. git pull origin {{feature_branch}}
4. Implement the story (code + tests)
5. Run {{test_command}} — tests must pass
6. Run npm run typecheck — must pass
7. Commit: feat({{story.id}}): {{story.title}}
8. Append to progress.md with what you did
Reply with:
STATUS: done
CHANGES: <what was implemented>
TESTS: <what tests were added>
output:
file: 'implement-{{loop.index}}.md'
acceptance_criteria:
- 'STATUS: done appears in output'
- 'CHANGES section is not empty'
- 'TESTS section lists test files'
on_fail:
retry: 2
escalate_to: human
escalate_message: 'Story {{story.title}} failed after 2 retries'
timeout: 1200
- id: test
name: 'Test: Integration validation'
agent: tester
type: agent
input: |
Run integration tests on the complete feature.
TASK: {{task.title}}
REPO: {{repo_path}}
BRANCH: {{feature_branch}}
TEST_CMD: {{test_command}}
IMPLEMENTED STORIES:
{{implement.outputs | map(attribute='changes') | join("\n\n")}}
PROGRESS LOG:
{{progress}}
Instructions:
1. cd {{repo_path}}
2. git pull origin {{feature_branch}}
3. Run full test suite: {{test_command}}
4. Check for integration issues between stories
5. Verify the feature works end-to-end
6. Check edge cases and error handling
Reply with:
STATUS: done
RESULTS: <test outcomes>
ISSUES: <any bugs found, or "none">
output:
file: test-results.md
acceptance_criteria:
- 'STATUS: done appears in output'
- 'All tests passed (or ISSUES lists failures)'
on_fail:
retry_step: implement # Go back and fix the broken story
max_retries: 2
on_exhausted:
escalate_to: human
timeout: 900
- id: gate-quality
name: 'Gate: Quality Check'
type: gate
condition: |
{{test.status == "completed" and "ISSUES: none" in test.output}}
on_false:
escalate_to: human
escalate_message: 'Quality gate failed — tests did not pass or issues found'
- id: pr
name: 'PR: Create pull request'
agent: developer
type: agent
input: |
Create a pull request for this feature.
TASK: {{task.title}}
REPO: {{repo_path}}
BRANCH: {{feature_branch}}
BASE: {{base_branch}}
CHANGES:
{{implement.outputs | map(attribute='changes') | join("\n\n")}}
TEST RESULTS:
{{test.output}}
Instructions:
1. cd {{repo_path}}
2. Create PR: gh pr create --base {{base_branch}} --head {{feature_branch}}
3. Title: feat: {{task.title}}
4. Body should include:
- What was implemented (from CHANGES)
- Test coverage (from TEST RESULTS)
- Reference to VK task ({{task.id}})
Reply with:
STATUS: done
PR: <PR URL>
output:
file: pr.md
on_fail:
escalate_to: human
timeout: 300
- id: review
name: 'Review: Code review'
agent: reviewer
type: agent
input: |
Review the pull request for this feature.
PR: {{pr.url}}
TASK: {{task.title}}
CHANGES:
{{implement.outputs | map(attribute='changes') | join("\n\n")}}
Instructions:
1. gh pr view {{pr.number}}
2. gh pr diff {{pr.number}}
3. Review for:
- Code quality and clarity
- Potential bugs
- Test coverage
- Follows project conventions
4. If issues found, add review comments: gh pr review {{pr.number}} --comment
Reply with:
STATUS: done
DECISION: approved|changes_requested
FEEDBACK: <review comments, if any>
output:
file: review.md
on_fail:
retry_step: implement
max_retries: 2
on_exhausted:
escalate_to: human
timeout: 600
3.2 Security Audit
id: security-audit
name: Security Audit & Remediation
version: 1
description: |
Comprehensive security audit workflow.
Scanner finds vulnerabilities → Prioritizer ranks them → Fixer patches each issue → Tester validates.
config:
timeout: 10800 # 3 hours
fresh_session_default: true
telemetry_tags: ['workflow', 'security']
agents:
- id: scanner
name: Security Scanner
role: scanning
model: github-copilot/claude-opus-4.6
description: Vulnerability detection
- id: prioritizer
name: Prioritizer
role: analysis
model: github-copilot/claude-opus-4.6
description: Risk assessment and ranking
- id: fixer
name: Security Fixer
role: coding
model: github-copilot/claude-sonnet-4.5
description: Security patch implementation
- id: tester
name: Security Tester
role: testing
model: github-copilot/claude-sonnet-4.5
description: Vulnerability validation
variables:
repo_path: '{{task.git.worktreePath}}'
security_branch: "security-audit-{{now | date('YYYY-MM-DD')}}"
steps:
- id: scan
name: 'Scan: Find vulnerabilities'
agent: scanner
type: agent
input: |
Perform a comprehensive security audit of the codebase.
REPO: {{repo_path}}
Scan for:
1. Dependency vulnerabilities (npm audit / yarn audit / pip-audit)
2. Hardcoded secrets (API keys, passwords, tokens in source)
3. SQL injection (raw queries, string concatenation)
4. XSS (unescaped user input in templates/responses)
5. CSRF (missing tokens on state-changing endpoints)
6. Auth bypass (missing auth middleware)
7. Directory traversal (user input in file paths)
8. SSRF (user-controlled URLs in server requests)
9. Insecure deserialization
10. Missing input validation
11. Security headers (CORS, CSP, HSTS)
For each finding, document:
- Severity: critical|high|medium|low
- File and line number
- Description of the vulnerability
- Suggested fix
Output format (YAML):
```yaml
vulnerabilities:
- id: vuln-001
severity: critical
type: sql-injection
file: server/routes/users.ts
line: 42
description: "Raw SQL query with user input"
suggested_fix: "Use parameterized queries"
- id: vuln-002
...
```
output:
file: scan-results.yml
acceptance_criteria:
- 'Output contains valid YAML'
- 'Each vulnerability has severity, file, line, description'
on_fail:
retry: 1
escalate_to: human
timeout: 1800
- id: prioritize
name: 'Prioritize: Rank and group vulnerabilities'
agent: prioritizer
type: agent
input: |
Prioritize the security findings into a fix plan.
VULNERABILITIES:
{{scan.vulnerabilities | to_yaml}}
Instructions:
1. Deduplicate (same root cause = one fix)
2. Group related issues
3. Rank by: exploitability × impact
4. Create max 20 fixes (defer rest)
5. Each fix becomes a "story" with acceptance criteria
Output format (YAML):
```yaml
fix_plan:
- id: fix-001
severity: critical
title: "Fix SQL injection in user routes"
description: "Replace raw queries with parameterized queries"
affected_files: ["server/routes/users.ts"]
acceptance_criteria:
- "All user queries use parameterized statements"
- "SQL injection test fails (query blocked)"
- "Tests pass"
- id: fix-002
...
deferred:
- id: vuln-042
reason: "Low priority, manual review needed"
```
output:
file: fix-plan.yml
on_fail:
retry: 1
escalate_to: human
timeout: 600
- id: fix
name: 'Fix: Implement security patches'
agent: fixer
type: loop
loop:
over: '{{prioritize.fix_plan}}'
item_var: fix
completion: all_done
fresh_session_per_iteration: true
max_iterations: 20
input: |
Implement this security fix.
FIX {{loop.index + 1}}/{{loop.total}}: {{fix.title}}
SEVERITY: {{fix.severity}}
{{fix.description}}
AFFECTED FILES:
{{fix.affected_files | join("\n")}}
ACCEPTANCE CRITERIA:
{{fix.acceptance_criteria | join("\n")}}
REPO: {{repo_path}}
BRANCH: {{security_branch}}
PROGRESS LOG:
{{progress}}
Instructions:
1. cd {{repo_path}}
2. Create/checkout security branch: git checkout -b {{security_branch}}
3. Implement the fix (minimal, targeted changes)
4. Write a regression test that verifies the vulnerability is patched
5. Run tests — must pass
6. Commit: fix(security): {{fix.title}}
7. Append to progress.md
Reply with:
STATUS: done
CHANGES: <what was fixed>
REGRESSION_TEST: <test file and description>
output:
file: 'fix-{{loop.index}}.md'
acceptance_criteria:
- 'STATUS: done appears'
- 'REGRESSION_TEST describes a test'
on_fail:
retry: 2
escalate_to: human
timeout: 1200
- id: test
name: 'Test: Validate security fixes'
agent: tester
type: agent
input: |
Validate that all security fixes are effective.
REPO: {{repo_path}}
BRANCH: {{security_branch}}
FIXES APPLIED:
{{fix.outputs | map(attribute='changes') | join("\n\n")}}
Instructions:
1. cd {{repo_path}}
2. Run full test suite — all tests must pass
3. Run npm audit (or equivalent) — compare before/after
4. For each fix, verify the regression test exists and tests the right thing
5. Try to bypass each fix (adversarial testing)
Reply with:
STATUS: done
RESULTS: <test outcomes>
AUDIT_COMPARISON: <before vs after npm audit results>
BYPASS_ATTEMPTS: <any successful bypasses, or "none">
output:
file: test-results.md
acceptance_criteria:
- 'STATUS: done appears'
- 'All tests passed'
- 'BYPASS_ATTEMPTS: none'
on_fail:
retry_step: fix
max_retries: 2
on_exhausted:
escalate_to: human
timeout: 1800
- id: pr
name: 'PR: Create security PR'
agent: fixer
type: agent
input: |
Create a pull request for the security fixes.
REPO: {{repo_path}}
BRANCH: {{security_branch}}
SCAN RESULTS: {{scan.vulnerabilities | length}} vulnerabilities found
FIXES APPLIED: {{fix.outputs | length}}
DEFERRED: {{prioritize.deferred | length}}
TEST RESULTS:
{{test.output}}
Instructions:
1. cd {{repo_path}}
2. Create PR: gh pr create --base main --head {{security_branch}}
3. Title: fix(security): audit and remediation {{now | date('YYYY-MM-DD')}}
4. Body format:
## Security Audit Summary
**Scan Date**: {{now | date('YYYY-MM-DD')}}
**Vulnerabilities Found**: {{scan.vulnerabilities | length}}
**Vulnerabilities Fixed**: {{fix.outputs | length}}
**Deferred**: {{prioritize.deferred | length}}
## Fixes Applied
(table of fixes from {{fix.outputs}})
## Test Results
{{test.output}}
5. Label: security
Reply with:
STATUS: done
PR: <PR URL>
output:
file: pr.md
on_fail:
escalate_to: human
timeout: 300
3.3 Content Pipeline
id: content-pipeline
name: Content Creation Pipeline
version: 1
description: |
End-to-end content workflow.
Researcher gathers info → Writer drafts → Editor refines → Publisher releases.
config:
timeout: 3600
fresh_session_default: true
telemetry_tags: ['workflow', 'content']
agents:
- id: researcher
name: Researcher
role: research
model: github-copilot/claude-opus-4.6
description: Information gathering and synthesis
- id: writer
name: Writer
role: writing
model: github-copilot/claude-sonnet-4.5
description: Content creation
- id: editor
name: Editor
role: editing
model: github-copilot/claude-opus-4.6
description: Quality assurance and refinement
- id: publisher
name: Publisher
role: publishing
model: github-copilot/claude-sonnet-4.5
description: Publication and distribution
variables:
topic: '{{task.title}}'
target_audience: '{{task.project}}'
word_count_min: 1500
word_count_max: 2500
steps:
- id: research
name: 'Research: Gather information'
agent: researcher
type: agent
input: |
Research this topic in depth.
TOPIC: {{topic}}
TARGET AUDIENCE: {{target_audience}}
Instructions:
1. Use web_search to find 10-15 authoritative sources
2. Synthesize key findings, statistics, expert quotes
3. Identify unique angles and insights
4. Note gaps in existing coverage (opportunity for original content)
Output format (Markdown):
```markdown
# Research: {{topic}}
## Key Findings
- Finding 1 (Source: ...)
- Finding 2 (Source: ...)
## Statistics
- Stat 1 (Source: ...)
## Expert Quotes
> Quote 1 — Expert Name, Title
## Unique Angles
- Angle 1: ...
## Sources
1. [Source Title](https://example.com/source)
2. ...
```
output:
file: research.md
acceptance_criteria:
- 'At least 10 sources listed'
- 'Key Findings section has 5+ findings'
- 'Unique Angles section is not empty'
on_fail:
retry: 2
escalate_to: human
timeout: 900
- id: write
name: 'Write: Draft content'
agent: writer
type: agent
input: |
Write a comprehensive article based on the research.
TOPIC: {{topic}}
TARGET AUDIENCE: {{target_audience}}
WORD COUNT: {{word_count_min}}-{{word_count_max}} words
RESEARCH:
{{research.output}}
Instructions:
1. Write in a clear, engaging style
2. Structure: introduction, body (3-5 sections), conclusion
3. Include the unique angles from research
4. Cite sources inline (Markdown links)
5. Add subheadings for scannability
6. Include a TL;DR at the top
Output format (Markdown):
```markdown
# {{topic}}
**TL;DR**: One-paragraph summary
## Introduction
...
## Section 1: ...
...
## Conclusion
...
---
**Word count**: <actual count>
```
output:
file: draft.md
acceptance_criteria:
- 'Word count is between {{word_count_min}} and {{word_count_max}}'
- 'At least 3 body sections'
- 'TL;DR is present'
- 'At least 5 inline citations'
on_fail:
retry: 2
escalate_to: human
timeout: 1200
- id: edit
name: 'Edit: Refine and polish'
agent: editor
type: agent
input: |
Edit the draft for clarity, accuracy, and style.
DRAFT:
{{write.output}}
RESEARCH (for fact-checking):
{{research.output}}
Instructions:
1. Check for factual errors (cross-reference research)
2. Improve clarity (simplify complex sentences)
3. Fix grammar, spelling, punctuation
4. Strengthen transitions between sections
5. Verify all citations are correct
6. Ensure consistent tone and style
7. Add callout boxes for key insights (> blockquotes)
Output the edited version (Markdown) AND a change log:
```markdown
# {{topic}} (Edited)
<edited content>
---
## Editorial Changes
- Fixed factual error in section 2 (source mismatch)
- Clarified introduction (removed jargon)
- Added blockquote for key stat
- ...
```
output:
file: edited.md
acceptance_criteria:
- 'Editorial Changes section lists at least 3 changes'
- 'All citations are valid URLs'
on_fail:
retry: 1
escalate_to: human
timeout: 900
- id: gate-quality
name: 'Gate: Quality Check'
type: gate
condition: |
{{edit.status == "completed" and write.word_count >= word_count_min}}
on_false:
escalate_to: human
escalate_message: 'Quality gate failed — content too short or editing incomplete'
- id: publish
name: 'Publish: Release content'
agent: publisher
type: agent
input: |
Publish the final content.
FINAL CONTENT:
{{edit.output}}
TARGET: {{task.deliverables[0].path}} # Example: blog, newsletter, docs site
Instructions:
1. If target is a blog:
- Convert Markdown to HTML (preserve formatting)
- Add frontmatter (title, date, author, tags)
- Save to content/posts/{{topic | slugify}}.md
- Commit and push
2. If target is a newsletter:
- Format for email (HTML)
- Save draft to newsletter-drafts/
3. If target is docs:
- Update the appropriate docs page
- Rebuild docs site
Reply with:
STATUS: done
PUBLISHED_URL: <URL or file path>
NEXT_STEPS: <any manual steps needed>
output:
file: publish.md
on_fail:
escalate_to: human
timeout: 600
4. Server Architecture
New Services
4.1 WorkflowService
Responsibilities: YAML loading, validation, CRUD operations on workflow definitions.
// server/src/services/workflow-service.ts
import fs from 'fs/promises';
import path from 'path';
import yaml from 'yaml';
import Ajv from 'ajv';
import type { WorkflowDefinition } from '@veritas-kanban/shared';
import { getWorkflowsDir } from '../utils/paths.js';
export class WorkflowService {
private workflowsDir: string;
private ajv: Ajv;
private cache: Map<string, WorkflowDefinition> = new Map();
constructor(workflowsDir?: string) {
this.workflowsDir = workflowsDir || getWorkflowsDir();
this.ajv = new Ajv();
this.ensureDirectories();
}
private async ensureDirectories(): Promise<void> {
await fs.mkdir(this.workflowsDir, { recursive: true });
}
/**
* Load and parse a workflow YAML file
*/
async loadWorkflow(id: string): Promise<WorkflowDefinition | null> {
// Check cache first
if (this.cache.has(id)) {
return this.cache.get(id)!;
}
const filePath = path.join(this.workflowsDir, `${id}.yml`);
try {
const content = await fs.readFile(filePath, 'utf-8');
const workflow = yaml.parse(content) as WorkflowDefinition;
// Validate schema
this.validateWorkflow(workflow);
// Cache it
this.cache.set(id, workflow);
return workflow;
} catch (err: any) {
if (err.code === 'ENOENT') return null;
throw new ValidationError(`Invalid workflow YAML: ${err.message}`);
}
}
/**
* List all available workflows
*/
async listWorkflows(): Promise<WorkflowDefinition[]> {
const files = await fs.readdir(this.workflowsDir);
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);
}
}
return workflows;
}
/**
* Save a workflow definition
*/
async saveWorkflow(workflow: WorkflowDefinition): Promise<void> {
this.validateWorkflow(workflow);
const filePath = path.join(this.workflowsDir, `${workflow.id}.yml`);
const content = yaml.stringify(workflow);
await fs.writeFile(filePath, content, 'utf-8');
// Update cache
this.cache.set(workflow.id, workflow);
}
/**
* Delete a workflow definition
*/
async deleteWorkflow(id: string): Promise<void> {
const filePath = path.join(this.workflowsDir, `${id}.yml`);
await fs.unlink(filePath);
this.cache.delete(id);
}
/**
* Validate workflow definition against schema
*/
private validateWorkflow(workflow: WorkflowDefinition): void {
// Required fields
if (!workflow.id || !workflow.name || !workflow.version) {
throw new ValidationError('Workflow must have id, name, and version');
}
// At least one agent
if (!workflow.agents || workflow.agents.length === 0) {
throw new ValidationError('Workflow must define at least one agent');
}
// At least one step
if (!workflow.steps || workflow.steps.length === 0) {
throw new ValidationError('Workflow must define at least one step');
}
// Validate step references
const agentIds = new Set(workflow.agents.map((a) => a.id));
const stepIds = new Set(workflow.steps.map((s) => s.id));
for (const step of workflow.steps) {
// Agent steps must reference a valid agent
if ((step.type === 'agent' || step.type === 'loop') && !agentIds.has(step.agent!)) {
throw new ValidationError(`Step ${step.id} references unknown agent ${step.agent}`);
}
// retry_step must reference a valid step
if (step.on_fail?.retry_step && !stepIds.has(step.on_fail.retry_step)) {
throw new ValidationError(
`Step ${step.id} retry_step references unknown step ${step.on_fail.retry_step}`
);
}
// Loop verify_step must reference a valid step
if (step.loop?.verify_step && !stepIds.has(step.loop.verify_step)) {
throw new ValidationError(
`Step ${step.id} verify_step references unknown step ${step.loop.verify_step}`
);
}
}
}
/**
* Clear the cache (useful for tests)
*/
clearCache(): void {
this.cache.clear();
}
}
// Singleton
let workflowServiceInstance: WorkflowService | null = null;
export function getWorkflowService(): WorkflowService {
if (!workflowServiceInstance) {
workflowServiceInstance = new WorkflowService();
}
return workflowServiceInstance;
}
4.1.1 RBAC & Audit Logging
Access Control Model:
// server/src/middleware/workflow-auth.ts
export type WorkflowPermission = 'view' | 'create' | 'edit' | 'delete' | 'execute';
export interface WorkflowACL {
workflowId: string;
owner: string; // User ID or 'system'
editors: string[]; // Users who can edit
viewers: string[]; // Users who can view
executors: string[]; // Users who can trigger runs
isPublic: boolean; // Anyone can view/execute
}
/**
* Check if a user has permission to perform an action on a workflow
*/
export function checkWorkflowPermission(
workflowId: string,
userId: string,
permission: WorkflowPermission
): boolean {
// Load ACL from .veritas-kanban/workflows/.acl.json
const acl = loadWorkflowACL(workflowId);
// Owner has all permissions
if (acl.owner === userId) return true;
// System workflows (shipped by VK) are view/execute only for all users
if (acl.owner === 'system') {
return permission === 'view' || permission === 'execute';
}
// Check specific permissions
switch (permission) {
case 'view':
return acl.isPublic || acl.viewers.includes(userId) || acl.editors.includes(userId);
case 'execute':
return acl.isPublic || acl.executors.includes(userId) || acl.editors.includes(userId);
case 'edit':
return acl.editors.includes(userId);
case 'delete':
return acl.owner === userId; // Only owner can delete
case 'create':
return true; // Any authenticated user can create workflows
default:
return false;
}
}
Audit Logging:
Every workflow change (create/edit/delete) is logged to .veritas-kanban/workflows/.audit.jsonl:
// server/src/services/workflow-audit.ts
export interface WorkflowAuditEvent {
timestamp: string;
userId: string;
action: 'create' | 'edit' | 'delete' | 'run';
workflowId: string;
workflowVersion?: number;
changes?: {
field: string;
oldValue: any;
newValue: any;
}[];
runId?: string; // For 'run' events
}
export async function auditWorkflowChange(event: WorkflowAuditEvent): Promise<void> {
const auditPath = path.join(getWorkflowsDir(), '.audit.jsonl');
const line = JSON.stringify(event) + '\n';
await fs.appendFile(auditPath, line, 'utf-8');
log.info({ event }, 'Workflow audit event logged');
}
Workflow Versioning Strategy:
- Version field — Each workflow YAML has a
version: <integer>field - Version immutability — Editing a workflow increments the version
- Active runs use snapshot — When a run starts, the workflow YAML is snapshotted into
workflow-runs/<run-id>/workflow.yml - Breaking changes — If a workflow is edited while runs are active:
- Active runs continue with their snapshotted version
- New runs use the latest version
- No interruption to in-flight runs
TypeScript Interfaces:
export interface WorkflowACL {
workflowId: string;
owner: string;
editors: string[];
viewers: string[];
executors: string[];
isPublic: boolean;
}
export interface WorkflowAuditEvent {
timestamp: string;
userId: string;
action: 'create' | 'edit' | 'delete' | 'run';
workflowId: string;
workflowVersion?: number;
changes?: Array<{ field: string; oldValue: any; newValue: any }>;
runId?: string;
}
Workflow Routes with RBAC:
// server/src/routes/workflows.ts (updated)
import { requireAuth } from '../middleware/auth.js';
import { checkWorkflowPermission, auditWorkflowChange } from '../services/workflow-auth.js';
import { diffWorkflows } from '../utils/workflow-diff.js';
// GET /api/workflows — List workflows (filtered by user permissions)
router.get(
'/',
requireAuth,
asyncHandler(async (req, res) => {
const userId = req.user.id;
const allWorkflows = await workflowService.listWorkflows();
// Filter by permissions
const visibleWorkflows = allWorkflows.filter((w) =>
checkWorkflowPermission(w.id, userId, 'view')
);
res.json(visibleWorkflows);
})
);
// POST /api/workflows — Create workflow (requires authentication)
router.post(
'/',
requireAuth,
asyncHandler(async (req, res) => {
const userId = req.user.id;
const workflow = req.body as WorkflowDefinition;
// Set owner ACL
const acl: WorkflowACL = {
workflowId: workflow.id,
owner: userId,
editors: [],
viewers: [],
executors: [],
isPublic: false,
};
await workflowService.saveWorkflow(workflow);
await workflowService.saveACL(acl);
// Audit log
await auditWorkflowChange({
timestamp: new Date().toISOString(),
userId,
action: 'create',
workflowId: workflow.id,
workflowVersion: workflow.version,
});
res.status(201).json({ success: true });
})
);
// PUT /api/workflows/:id — Edit workflow (requires edit permission)
router.put(
'/:id',
requireAuth,
asyncHandler(async (req, res) => {
const userId = req.user.id;
const workflow = req.body as WorkflowDefinition;
if (!checkWorkflowPermission(workflow.id, userId, 'edit')) {
throw new ForbiddenError('You do not have permission to edit this workflow');
}
// Load previous version for change tracking
const previousVersion = await workflowService.loadWorkflow(workflow.id);
// Increment version
workflow.version = (previousVersion?.version || 0) + 1;
await workflowService.saveWorkflow(workflow);
// Audit log with changes
await auditWorkflowChange({
timestamp: new Date().toISOString(),
userId,
action: 'edit',
workflowId: workflow.id,
workflowVersion: workflow.version,
changes: diffWorkflows(previousVersion, workflow),
});
res.json({ success: true });
})
);
// DELETE /api/workflows/:id — Delete workflow (owner only)
router.delete(
'/:id',
requireAuth,
asyncHandler(async (req, res) => {
const userId = req.user.id;
const workflowId = req.params.id;
if (!checkWorkflowPermission(workflowId, userId, 'delete')) {
throw new ForbiddenError('Only the workflow owner can delete it');
}
await workflowService.deleteWorkflow(workflowId);
// Audit log
await auditWorkflowChange({
timestamp: new Date().toISOString(),
userId,
action: 'delete',
workflowId,
});
res.status(204).send();
})
);
Workflow Snapshot on Run Start:
// In WorkflowRunService.startRun()
const workflow = await this.workflowService.loadWorkflow(workflowId);
if (!workflow) {
throw new NotFoundError(`Workflow ${workflowId} not found`);
}
// Snapshot the workflow YAML into the run directory
const runDir = path.join(this.runsDir, runId);
await fs.mkdir(runDir, { recursive: true });
const snapshotPath = path.join(runDir, 'workflow.yml');
await fs.writeFile(snapshotPath, yaml.stringify(workflow), 'utf-8');
// This ensures the run always uses the exact workflow version it started with,
// even if the workflow is edited mid-run.
4.2 WorkflowRunService
Responsibilities: Executes workflows, manages run state, orchestrates step execution.
// server/src/services/workflow-run-service.ts
import fs from 'fs/promises';
import path from 'path';
import { nanoid } from 'nanoid';
import type {
WorkflowRun,
StepRun,
WorkflowDefinition,
WorkflowStep,
} from '@veritas-kanban/shared';
import { getWorkflowService } from './workflow-service.js';
import { WorkflowStepExecutor } from './workflow-step-executor.js';
import { broadcastWorkflowStatus } from './broadcast-service.js';
import { getWorkflowRunsDir } from '../utils/paths.js';
import { createLogger } from '../lib/logger.js';
import { getTaskService } from './task-service.js';
const log = createLogger('workflow-run');
export class WorkflowRunService {
private runsDir: string;
private workflowService: WorkflowService;
private stepExecutor: WorkflowStepExecutor;
constructor(runsDir?: string) {
this.runsDir = runsDir || getWorkflowRunsDir();
this.workflowService = getWorkflowService();
this.stepExecutor = new WorkflowStepExecutor();
this.ensureDirectories();
}
private async ensureDirectories(): Promise<void> {
await fs.mkdir(this.runsDir, { recursive: true });
}
/**
* Start a new workflow run
*/
async startRun(
workflowId: string,
taskId?: string,
initialContext?: Record<string, any>
): Promise<WorkflowRun> {
const workflow = await this.workflowService.loadWorkflow(workflowId);
if (!workflow) {
throw new NotFoundError(`Workflow ${workflowId} not found`);
}
// ✅ Load the full task payload (if taskId provided)
const taskService = getTaskService();
const task = taskId ? await taskService.getTask(taskId) : null;
// ✅ Ensure task payload includes ALL fields agents might need
const taskPayload = task
? {
id: task.id,
title: task.title,
description: task.description,
status: task.status,
priority: task.priority,
type: task.type,
project: task.project,
tags: task.tags,
assignee: task.assignee,
subtasks: task.subtasks,
deliverables: task.deliverables,
git: task.git, // Worktree path, branch, PR URL, etc.
automation: task.automation,
createdAt: task.createdAt,
updatedAt: task.updatedAt,
}
: null;
const runId = `run_${Date.now()}_${nanoid(8)}`;
const now = new Date().toISOString();
const run: WorkflowRun = {
id: runId,
workflowId: workflow.id,
workflowVersion: workflow.version,
taskId,
status: 'running',
currentStep: workflow.steps[0].id,
context: {
// ✅ Full task payload first (so it can be overridden by initialContext if needed)
task: taskPayload,
// Workflow variables
...workflow.variables,
// Custom initial context (from API caller)
...initialContext,
// Run metadata
workflow: { id: workflow.id, version: workflow.version },
run: { id: runId, startedAt: now },
},
startedAt: now,
steps: workflow.steps.map((step) => ({
stepId: step.id,
status: 'pending',
retries: 0,
})),
};
// Persist initial run state
await this.saveRun(run);
// Broadcast status
broadcastWorkflowStatus(run);
// Start execution (async — don't await)
this.executeRun(run, workflow).catch((err) => {
log.error({ runId, err }, 'Workflow run failed');
});
return run;
}
/**
* Execute the workflow run (iterates through steps)
*/
private async executeRun(run: WorkflowRun, workflow: WorkflowDefinition): Promise<void> {
try {
// Build initial step queue so we can jump backwards when retry_step is set
const stepQueue: string[] = workflow.steps.map((s) => s.id);
while (stepQueue.length > 0) {
const stepId = stepQueue.shift()!;
const step = workflow.steps.find((s) => s.id === stepId)!;
// Update current step for UI/telemetry
run.currentStep = step.id;
await this.saveRun(run);
broadcastWorkflowStatus(run);
const stepRun = run.steps.find((s) => s.stepId === step.id)!;
stepRun.status = 'running';
stepRun.startedAt = new Date().toISOString();
await this.saveRun(run);
try {
const result = await this.stepExecutor.executeStep(step, run);
stepRun.status = 'completed';
stepRun.completedAt = new Date().toISOString();
stepRun.duration = Math.floor(
(new Date(stepRun.completedAt).getTime() - new Date(stepRun.startedAt!).getTime()) /
1000
);
stepRun.output = result.outputPath;
run.context[step.id] = result.output;
await this.saveRun(run);
broadcastWorkflowStatus(run);
} catch (err: any) {
stepRun.status = 'failed';
stepRun.error = err.message;
stepRun.completedAt = new Date().toISOString();
await this.saveRun(run);
const handled = await this.handleStepFailure(step, stepRun, stepQueue, workflow, run);
if (!handled) {
throw err;
}
}
}
run.status = 'completed';
run.completedAt = new Date().toISOString();
await this.saveRun(run);
broadcastWorkflowStatus(run);
log.info({ runId: run.id, workflowId: run.workflowId }, 'Workflow run completed');
} catch (err: any) {
run.status = 'failed';
run.error = err.message;
run.completedAt = new Date().toISOString();
await this.saveRun(run);
broadcastWorkflowStatus(run);
log.error({ runId: run.id, err }, 'Workflow run failed');
}
}
private async handleStepFailure(
step: WorkflowStep,
stepRun: StepRun,
stepQueue: string[],
workflow: WorkflowDefinition,
run: WorkflowRun
): Promise<boolean> {
const policy = step.on_fail;
if (!policy) return false;
// Strategy 1: retry the same step in-place
if (policy.retry && stepRun.retries < policy.retry) {
stepRun.retries++;
stepRun.status = 'pending';
stepRun.error = undefined;
stepQueue.unshift(step.id);
await this.saveRun(run);
log.info({ stepId: step.id, retry: stepRun.retries }, 'Retrying step');
return true;
}
// Strategy 2: jump to another step
if (policy.retry_step) {
const retryStep = workflow.steps.find((s) => s.id === policy.retry_step);
if (!retryStep) {
throw new Error(`retry_step references unknown step: ${policy.retry_step}`);
}
const retryStepRun = run.steps.find((s) => s.stepId === retryStep.id)!;
retryStepRun.status = 'pending';
retryStepRun.retries = 0;
retryStepRun.error = undefined;
const retryIndex = workflow.steps.findIndex((s) => s.id === policy.retry_step);
const newQueue = workflow.steps.slice(retryIndex).map((s) => s.id);
stepQueue.length = 0;
stepQueue.push(...newQueue);
run.context._retryContext = {
failedStep: step.id,
error: stepRun.error,
retries: stepRun.retries,
};
await this.saveRun(run);
log.info({ failedStep: step.id, retryStep: retryStep.id }, 'Routing to retry step');
return true;
}
// Strategy 3: escalate or skip
if (policy.escalate_to === 'human') {
run.status = 'blocked';
run.error = policy.escalate_message || `Step ${step.id} failed`;
await this.saveRun(run);
broadcastWorkflowStatus(run);
return true; // Blocked, requires manual intervention
}
if (policy.escalate_to === 'skip') {
stepRun.status = 'skipped';
await this.saveRun(run);
log.info({ stepId: step.id }, 'Skipping failed step');
return true;
}
if (policy.escalate_to?.startsWith('agent:')) {
throw new Error('Agent escalation not yet implemented');
}
return false; // No policy handled the failure
}
/**
* Get a workflow run by ID
*/
async getRun(runId: string): Promise<WorkflowRun | null> {
const runPath = path.join(this.runsDir, runId, 'run.json');
try {
const content = await fs.readFile(runPath, 'utf-8');
return JSON.parse(content) as WorkflowRun;
} catch (err: any) {
if (err.code === 'ENOENT') return null;
throw err;
}
}
/**
* List all workflow runs (with optional filters)
*/
async listRuns(filters?: {
taskId?: string;
workflowId?: string;
status?: string;
}): Promise<WorkflowRun[]> {
const runDirs = await fs.readdir(this.runsDir);
const runs: WorkflowRun[] = [];
for (const dir of runDirs) {
if (!dir.startsWith('run_')) continue;
const run = await this.getRun(dir);
if (!run) continue;
// Apply filters
if (filters?.taskId && run.taskId !== filters.taskId) continue;
if (filters?.workflowId && run.workflowId !== filters.workflowId) continue;
if (filters?.status && run.status !== filters.status) continue;
runs.push(run);
}
// Sort by startedAt descending
runs.sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime());
return runs;
}
/**
* Resume a blocked workflow run
*/
async resumeRun(runId: string, resumeContext?: Record<string, any>): Promise<WorkflowRun> {
const run = await this.getRun(runId);
if (!run) {
throw new NotFoundError(`Run ${runId} not found`);
}
if (run.status !== 'blocked') {
throw new ValidationError(`Run ${runId} is not blocked (status: ${run.status})`);
}
// Merge resume context
run.context = { ...run.context, ...resumeContext };
run.status = 'running';
await this.saveRun(run);
// Resume execution
const workflow = await this.workflowService.loadWorkflow(run.workflowId);
if (!workflow) {
throw new NotFoundError(`Workflow ${run.workflowId} not found`);
}
this.executeRun(run, workflow).catch((err) => {
log.error({ runId, err }, 'Workflow resume failed');
});
return run;
}
/**
* Save run state to disk
*/
private async saveRun(run: WorkflowRun): Promise<void> {
const runDir = path.join(this.runsDir, run.id);
await fs.mkdir(runDir, { recursive: true });
const runPath = path.join(runDir, 'run.json');
await fs.writeFile(runPath, JSON.stringify(run, null, 2), 'utf-8');
}
}
// Singleton
let workflowRunServiceInstance: WorkflowRunService | null = null;
export function getWorkflowRunService(): WorkflowRunService {
if (!workflowRunServiceInstance) {
workflowRunServiceInstance = new WorkflowRunService();
}
return workflowRunServiceInstance;
}
4.2.1 Retention & Concurrency Strategy
Workflow Run Retention Policy:
// server/src/config/workflow-config.ts
export const WORKFLOW_RETENTION_CONFIG = {
// How many completed runs to keep per workflow
maxCompletedRunsPerWorkflow: 50,
// How long to keep completed runs (days)
completedRunRetentionDays: 30,
// How long to keep failed runs (days)
failedRunRetentionDays: 90,
// Maximum total disk usage for workflow runs (MB)
maxTotalDiskUsageMB: 5000,
// Cleanup job interval (hours)
cleanupIntervalHours: 24,
};
Automatic Cleanup Job:
// server/src/jobs/workflow-cleanup.ts
import fs from 'fs/promises';
import path from 'path';
import { getWorkflowRunsDir } from '../utils/paths.js';
import { WORKFLOW_RETENTION_CONFIG } from '../config/workflow-config.js';
import { createLogger } from '../lib/logger.js';
const log = createLogger('workflow-cleanup');
export async function runWorkflowCleanup(): Promise<void> {
const runsDir = getWorkflowRunsDir();
const runDirs = await fs.readdir(runsDir);
const now = Date.now();
const completedCutoff =
now - WORKFLOW_RETENTION_CONFIG.completedRunRetentionDays * 24 * 60 * 60 * 1000;
const failedCutoff = now - WORKFLOW_RETENTION_CONFIG.failedRunRetentionDays * 24 * 60 * 60 * 1000;
let deletedCount = 0;
let reclaimedBytes = 0;
for (const runDir of runDirs) {
if (!runDir.startsWith('run_')) continue;
const runPath = path.join(runsDir, runDir);
const runJsonPath = path.join(runPath, 'run.json');
try {
const runData = JSON.parse(await fs.readFile(runJsonPath, 'utf-8'));
// Skip active/blocked runs
if (runData.status === 'running' || runData.status === 'blocked') {
continue;
}
const completedAt = new Date(runData.completedAt).getTime();
// Determine if should delete
let shouldDelete = false;
if (runData.status === 'completed' && completedAt < completedCutoff) {
shouldDelete = true;
} else if (runData.status === 'failed' && completedAt < failedCutoff) {
shouldDelete = true;
}
if (shouldDelete) {
// Calculate disk usage before deletion
const size = await getDirSize(runPath);
// Delete the run
await fs.rm(runPath, { recursive: true, force: true });
deletedCount++;
reclaimedBytes += size;
log.info(
{ runId: runDir, sizeMB: (size / 1024 / 1024).toFixed(2) },
'Deleted workflow run'
);
}
} catch (err: any) {
log.error({ runDir, err }, 'Failed to process run for cleanup');
}
}
log.info(
{
deletedRuns: deletedCount,
reclaimedMB: (reclaimedBytes / 1024 / 1024).toFixed(2),
},
'Workflow cleanup completed'
);
}
async function getDirSize(dirPath: string): Promise<number> {
let total = 0;
const files = await fs.readdir(dirPath, { withFileTypes: true });
for (const file of files) {
const filePath = path.join(dirPath, file.name);
if (file.isDirectory()) {
total += await getDirSize(filePath);
} else {
const stats = await fs.stat(filePath);
total += stats.size;
}
}
return total;
}
Concurrency and Capacity Admission:
Workflow concurrency is not authorized by process-local sets, counters, or
queues. WorkflowRunService obtains an atomic
admission-reservation/v1 root claim before a run becomes active. The root
uses admission provider workflow-control and binds its workspace, root task,
run ID, host, and lease.
Every provider-backed step resolves its runtime and host first, then obtains a child reservation before mutating the step attempt or dispatching the adapter. Retry and fallback attempts release the prior child reservation before claiming another. Global, workspace, root-task, provider, and host limits use the same durable policies as direct task launches.
The root and every executable step also persist execution-tree-identity/v1.
Step, retry, and fallback edges retain the workflow root objective and exact
parent node. The admission repository evaluates capacity and the aggregate
workflow/root budget in the same file lock or SQLite BEGIN IMMEDIATE
transaction. Each step commits only its own provider-reported usage; cumulative
workflow totals are not copied into child records. This keeps deep, wide, and
replayed workflows attributable without descendant double counting.
Temporary root or step overload persists one bounded FIFO admission entry and leaves the run or step visibly waiting without provider dispatch. Claims revalidate workflow, run, provider, host, phase, budget, and execution-tree evidence before transferring ownership to workflow recovery. Retry and fallback replacements cannot queue until predecessor capacity is released. An impossible request or stale queue authority fails closed. Priority aging and cross-workspace fairness remain later scheduler concerns.
OpenClaw Session Cleanup:
When a workflow run completes, clean up all spawned OpenClaw sessions:
// In WorkflowRunService.executeRun() — after completion
if (run.status === 'completed' || run.status === 'failed') {
// Clean up all OpenClaw sessions spawned by this run
for (const stepRun of run.steps) {
if (stepRun.sessionKey) {
try {
await this.stepExecutor.cleanupSession(stepRun.sessionKey);
log.info({ sessionKey: stepRun.sessionKey }, 'Cleaned up OpenClaw session');
} catch (err: any) {
log.warn({ sessionKey: stepRun.sessionKey, err }, 'Failed to cleanup session');
}
}
}
}
CLI Cleanup Command:
# Manual cleanup (for debugging or maintenance)
vk workflow cleanup --dry-run
vk workflow cleanup --force
vk workflow cleanup --older-than 30d
Scheduled Cleanup Job:
// server/src/jobs/scheduler.ts
import cron from 'node-cron';
import { runWorkflowCleanup } from './workflow-cleanup.js';
import { WORKFLOW_RETENTION_CONFIG } from '../config/workflow-config.js';
// Run cleanup every 24 hours (configurable)
const cronSchedule = `0 */${WORKFLOW_RETENTION_CONFIG.cleanupIntervalHours} * * *`;
cron.schedule(cronSchedule, async () => {
await runWorkflowCleanup();
});
4.3 WorkflowStepExecutor
Responsibilities: Executes individual steps (agent invocation via OpenClaw).
// server/src/services/workflow-step-executor.ts
import fs from 'fs/promises';
import path from 'path';
import nunjucks from 'nunjucks';
import sanitizeFilename from 'sanitize-filename';
import yaml from 'yaml';
import { Parser as ExpressionParser } from 'expr-eval';
import type { WorkflowStep, WorkflowRun } from '@veritas-kanban/shared';
import { getWorkflowRunsDir } from '../utils/paths.js';
import { createLogger } from '../lib/logger.js';
const log = createLogger('workflow-step-executor');
// Jinja2-compatible templating
const templateEnv = nunjucks.configure({ autoescape: false });
const expressionParser = new ExpressionParser({
operators: {
add: true,
multiply: true,
logical: true,
comparison: true,
},
});
export interface StepExecutionResult {
output: any; // Parsed output (for context passing)
outputPath: string; // Path to output file
}
export class WorkflowStepExecutor {
private runsDir: string;
constructor(runsDir?: string) {
this.runsDir = runsDir || getWorkflowRunsDir();
}
/**
* Execute a single workflow step
*/
async executeStep(step: WorkflowStep, run: WorkflowRun): Promise<StepExecutionResult> {
log.info({ runId: run.id, stepId: step.id, type: step.type }, 'Executing step');
switch (step.type) {
case 'agent':
return this.executeAgentStep(step, run);
case 'loop':
return this.executeLoopStep(step, run);
case 'gate':
return this.executeGateStep(step, run);
case 'parallel':
throw new Error('Parallel steps not yet implemented');
default:
throw new Error(`Unknown step type: ${step.type}`);
}
}
/**
* Execute an agent step (spawns OpenClaw session)
*/
private async executeAgentStep(
step: WorkflowStep,
run: WorkflowRun
): Promise<StepExecutionResult> {
// Render the input prompt with context
const prompt = this.renderTemplate(step.input || '', run.context);
// Spawn OpenClaw session
const sessionKey = await this.spawnAgent(step.agent!, prompt, run.taskId);
// Wait for completion
const result = await this.waitForSession(sessionKey);
const parsed = this.parseStepOutput(result, step);
// ✅ Validate acceptance criteria before marking step as complete
await this.validateAcceptanceCriteria(step, result, parsed);
// Write output to step-outputs/
const outputPath = await this.saveStepOutput(run.id, step.id, result);
return {
output: parsed,
outputPath,
};
}
/**
* Execute a loop step (iterates over a collection)
*/
private async executeLoopStep(
step: WorkflowStep,
run: WorkflowRun
): Promise<StepExecutionResult> {
if (!step.loop) {
throw new Error(`Loop step ${step.id} missing loop config`);
}
// Evaluate the loop collection
const collection = this.evaluateExpression(step.loop.over, run.context);
if (!Array.isArray(collection)) {
throw new Error(`Loop step ${step.id} 'over' expression did not return an array`);
}
const itemVar = step.loop.item_var || 'item';
const indexVar = step.loop.index_var || 'index';
const results: any[] = [];
const maxIterations = step.loop.max_iterations || collection.length;
for (let i = 0; i < Math.min(collection.length, maxIterations); i++) {
const item = collection[i];
// Build loop context
const loopContext = {
...run.context,
[itemVar]: item,
[indexVar]: i,
loop: {
index: i,
total: collection.length,
completed: results.filter((r) => r.success).map((r) => r.item),
failed: results.filter((r) => !r.success).map((r) => r.item),
},
};
// Render the input prompt
const prompt = this.renderTemplate(step.input || '', loopContext);
// Spawn agent (fresh session per iteration if configured)
const sessionKey = await this.spawnAgent(step.agent!, prompt, run.taskId);
// Wait for completion
try {
const result = await this.waitForSession(sessionKey);
const parsed = this.parseStepOutput(result, step);
results.push({ success: true, item, output: parsed });
// Save iteration output
const outputFile = step.output?.file || `${step.id}-${i}.md`;
await this.saveStepOutput(run.id, step.id, result, outputFile);
// ✅ VERIFY_EACH: Run verification after each iteration
if (step.loop.verify_each && step.loop.verify_step) {
const verificationPassed = await this.runVerificationStep(
step.loop.verify_step,
loopContext,
run
);
if (!verificationPassed) {
// Verification failed — treat iteration as failed
results[results.length - 1].success = false;
results[results.length - 1].verificationError = 'Verification step failed';
// Handle failure according to completion strategy
if (step.loop.completion === 'all_done') {
throw new Error(`Verification failed for iteration ${i}`);
}
}
}
// ✅ COMPLETION: ANY_DONE — short-circuit on first success
if (step.loop.completion === 'any_done' && results.some((r) => r.success)) {
log.info({ stepId: step.id, iteration: i }, 'Loop short-circuited (any_done)');
break;
}
} catch (err: any) {
results.push({ success: false, item, error: err.message });
// Handle loop failure
if (step.loop.completion === 'all_done') {
// Fail the entire loop
throw err;
} else if (step.loop.completion === 'first_success') {
// Continue until first success
continue;
} else if (step.loop.completion === 'any_done') {
// Continue — any success is enough (already checked above)
continue;
}
}
}
// ✅ COMPLETION: Check final state
const hasAnySuccess = results.some((r) => r.success);
const hasAllSuccess = results.every((r) => r.success);
if (step.loop.completion === 'all_done' && !hasAllSuccess) {
throw new Error(`Loop ${step.id} failed — not all iterations succeeded`);
}
if (step.loop.completion === 'first_success' && !hasAnySuccess) {
throw new Error(`Loop ${step.id} failed — no successful iterations`);
}
if (step.loop.completion === 'any_done' && !hasAnySuccess) {
throw new Error(`Loop ${step.id} failed — no successful iterations`);
}
// Aggregate results
const outputPath = await this.saveStepOutput(run.id, step.id, { results });
// Update step run with loop state
const stepRun = run.steps.find((s) => s.stepId === step.id)!;
stepRun.loopState = {
totalIterations: collection.length,
currentIteration: results.length,
completedIterations: results.filter((r) => r.success).length,
failedIterations: results.filter((r) => !r.success).length,
};
return {
output: { results },
outputPath,
};
}
/**
* ✅ Run a verification step (for loop.verify_step)
*/
private async runVerificationStep(
verifyStepId: string,
iterationContext: Record<string, any>,
run: WorkflowRun
): Promise<boolean> {
// Find the verification step definition
const workflowService = getWorkflowService();
const workflow = await workflowService.loadWorkflow(run.workflowId);
if (!workflow) {
throw new Error(`Workflow ${run.workflowId} not found`);
}
const verifyStep = workflow.steps.find((s) => s.id === verifyStepId);
if (!verifyStep) {
throw new Error(`Verification step ${verifyStepId} not found in workflow`);
}
// Render the verification prompt with iteration context
const prompt = this.renderTemplate(verifyStep.input || '', iterationContext);
// Spawn verification agent
const sessionKey = await this.spawnAgent(verifyStep.agent!, prompt, run.taskId);
// Wait for result
try {
const result = await this.waitForSession(sessionKey);
// Parse verification result
// Convention: verification steps should output "DECISION: approved" or "DECISION: rejected"
const decision = result.match(/DECISION:\s*(approved|rejected)/i)?.[1]?.toLowerCase();
if (decision === 'approved') {
return true;
} else if (decision === 'rejected') {
return false;
} else {
// If no explicit decision, assume failure
log.warn({ verifyStepId, result }, 'Verification step did not return explicit decision');
return false;
}
} catch (err: any) {
log.error({ verifyStepId, err }, 'Verification step failed');
return false;
}
}
/**
* Execute a gate step (boolean decision point)
*/
private async executeGateStep(
step: WorkflowStep,
run: WorkflowRun
): Promise<StepExecutionResult> {
if (!step.condition) {
throw new Error(`Gate step ${step.id} missing condition`);
}
// Evaluate the condition
const result = this.evaluateExpression(step.condition, run.context);
if (!result) {
// Gate failed
if (step.on_false?.escalate_to === 'human') {
throw new Error(step.on_false.escalate_message || `Gate ${step.id} failed`);
} else if (step.on_false?.escalate_to === 'skip') {
// Skip (no-op)
}
}
// Save gate result
const outputPath = await this.saveStepOutput(run.id, step.id, { passed: result });
return {
output: { passed: result },
outputPath,
};
}
/**
* Spawn an OpenClaw agent session
*/
private async spawnAgent(agentId: string, prompt: string, taskId?: string): Promise<string> {
// TODO: Call OpenClaw sessions_spawn via HTTP or direct SDK
// For now, placeholder:
log.info({ agentId, taskId }, 'Spawning agent session');
// Simulated spawn
return `session_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}
/**
* Wait for an OpenClaw session to complete
*/
private async waitForSession(sessionKey: string): Promise<string> {
// TODO: Poll OpenClaw sessions_wait_for endpoint
// For now, placeholder:
log.info({ sessionKey }, 'Waiting for session completion');
// Simulated wait
await new Promise((resolve) => setTimeout(resolve, 1000));
return 'Agent output goes here';
}
/**
* Render a Jinja2 template with context
*/
private renderTemplate(template: string, context: Record<string, any>): string {
return templateEnv.renderString(template, context);
}
/**
* Evaluate a Jinja2 expression (for gates, loop 'over')
*/
private evaluateExpression(expression: string, context: Record<string, any>): any {
try {
const trimmed = expression.trim();
const normalized =
trimmed.startsWith('{{') && trimmed.endsWith('}}') ? trimmed.slice(2, -2).trim() : trimmed;
const ast = expressionParser.parse(normalized);
return ast.evaluate(context);
} catch (err) {
throw new Error(`Failed to evaluate expression safely: ${expression}`);
}
}
/**
* Parse agent output into structured data for context passing
*/
private parseStepOutput(rawOutput: string, step: WorkflowStep): any {
if (!rawOutput) return rawOutput;
const hintedFile = step.output?.file || '';
const extension = path.extname(hintedFile).toLowerCase();
try {
if (extension === '.yml' || extension === '.yaml') {
return yaml.parse(rawOutput);
}
if (extension === '.json') {
return JSON.parse(rawOutput);
}
// Allow schema-driven parsing in the future
return rawOutput;
} catch (err) {
throw new Error(`Failed to parse output for step ${step.id}: ${(err as Error).message}`);
}
}
/**
* Save step output to disk
*/
private async saveStepOutput(
runId: string,
stepId: string,
output: any,
filename?: string
): Promise<string> {
const outputDir = path.join(this.runsDir, runId, 'step-outputs');
await fs.mkdir(outputDir, { recursive: true });
const candidate = filename || `${stepId}.md`;
const safeName = sanitizeFilename(candidate) || `${stepId}.md`;
const outputPath = path.join(outputDir, safeName);
const content = typeof output === 'string' ? output : JSON.stringify(output, null, 2);
await fs.writeFile(outputPath, content, 'utf-8');
return outputPath;
}
/**
* ✅ Validate step output against acceptance criteria
*/
private async validateAcceptanceCriteria(
step: WorkflowStep,
output: string,
parsedOutput: any
): Promise<void> {
if (!step.acceptance_criteria || step.acceptance_criteria.length === 0) {
return; // No criteria to validate
}
const validator = new StepOutputValidator();
for (const criterion of step.acceptance_criteria) {
const passed = await validator.validate(criterion, output, parsedOutput);
if (!passed) {
throw new Error(`Acceptance criterion not met: "${criterion}"`);
}
}
log.info(
{ stepId: step.id, criteria: step.acceptance_criteria.length },
'All acceptance criteria passed'
);
}
}
/**
* ✅ Step Output Validator — Supports regex, JSON Schema, and custom functions
*/
class StepOutputValidator {
private ajv: Ajv;
private customValidators: Map<string, (output: any) => boolean> = new Map();
constructor() {
this.ajv = new Ajv();
this.registerCustomValidators();
}
/**
* Validate a single acceptance criterion
*/
async validate(criterion: string, rawOutput: string, parsedOutput: any): Promise<boolean> {
// Strategy 1: Regex pattern match
if (criterion.startsWith('/') && criterion.endsWith('/')) {
const pattern = criterion.slice(1, -1);
const regex = new RegExp(pattern);
return regex.test(rawOutput);
}
// Strategy 2: JSON Schema reference
if (criterion.startsWith('schema:')) {
const schemaName = criterion.replace('schema:', '');
// Load schema from workflow.schemas[schemaName]
const schema = this.loadSchema(schemaName);
const validate = this.ajv.compile(schema);
return validate(parsedOutput) as boolean;
}
// Strategy 3: Custom function reference
if (criterion.startsWith('fn:')) {
const fnName = criterion.replace('fn:', '');
const fn = this.customValidators.get(fnName);
if (!fn) {
throw new Error(`Custom validator "${fnName}" not found`);
}
return fn(parsedOutput);
}
// Strategy 4: Simple substring match (default)
return rawOutput.includes(criterion);
}
/**
* Load a JSON schema by name
*/
private loadSchema(schemaName: string): any {
// TODO: Load from workflow.schemas
throw new Error(`Schema loading not yet implemented: ${schemaName}`);
}
/**
* Register custom validation functions
*/
private registerCustomValidators(): void {
// Example custom validators
this.customValidators.set('has_tests', (output) => {
return output && output.tests && output.tests.length > 0;
});
this.customValidators.set('no_todos', (output) => {
return !output.includes('TODO') && !output.includes('FIXME');
});
this.customValidators.set('valid_yaml', (output) => {
try {
yaml.parse(output);
return true;
} catch {
return false;
}
});
this.customValidators.set('valid_json', (output) => {
try {
JSON.parse(output);
return true;
} catch {
return false;
}
});
}
}
New Routes
// server/src/routes/workflows.ts
import { Router } from 'express';
import { z } from 'zod';
import type { WorkflowDefinition } from '@veritas-kanban/shared';
import { getWorkflowService } from '../services/workflow-service.js';
import { getWorkflowRunService } from '../services/workflow-run-service.js';
import { asyncHandler } from '../middleware/async-handler.js';
import { requireAuth } from '../middleware/auth.js';
import { ForbiddenError, NotFoundError, ValidationError } from '../middleware/error-handler.js';
import { checkWorkflowPermission, auditWorkflowChange } from '../services/workflow-auth.js';
const router = Router();
const workflowService = getWorkflowService();
const workflowRunService = getWorkflowRunService();
const startRunSchema = z.object({
taskId: z.string().optional(),
context: z.record(z.any()).optional(),
});
const resumeRunSchema = z.object({
context: z.record(z.any()).optional(),
});
// GET /api/workflows — List workflows visible to the user
router.get(
'/',
requireAuth,
asyncHandler(async (req, res) => {
const userId = req.user.id;
const workflows = await workflowService.listWorkflows();
const visible = workflows.filter((w) => checkWorkflowPermission(w.id, userId, 'view'));
res.json(visible);
})
);
// GET /api/workflows/:id — View a workflow
router.get(
'/:id',
requireAuth,
asyncHandler(async (req, res) => {
const workflow = await workflowService.loadWorkflow(req.params.id);
if (!workflow || !checkWorkflowPermission(workflow.id, req.user.id, 'view')) {
throw new NotFoundError(`Workflow ${req.params.id} not found or not visible`);
}
res.json(workflow);
})
);
// POST /api/workflows — Create workflow (any authenticated user)
router.post(
'/',
requireAuth,
asyncHandler(async (req, res) => {
const userId = req.user.id;
const workflow = req.body as WorkflowDefinition;
await workflowService.saveWorkflow(workflow);
await workflowService.saveACL({
workflowId: workflow.id,
owner: userId,
editors: [],
viewers: [],
executors: [],
isPublic: false,
});
await auditWorkflowChange({
timestamp: new Date().toISOString(),
userId,
action: 'create',
workflowId: workflow.id,
workflowVersion: workflow.version,
});
res.status(201).json({ success: true });
})
);
// PUT /api/workflows/:id — Update workflow (edit permission)
router.put(
'/:id',
requireAuth,
asyncHandler(async (req, res) => {
const userId = req.user.id;
const workflow = req.body as WorkflowDefinition;
if (!checkWorkflowPermission(workflow.id, userId, 'edit')) {
throw new ForbiddenError('You do not have permission to edit this workflow');
}
const previousVersion = await workflowService.loadWorkflow(workflow.id);
workflow.version = (previousVersion?.version || 0) + 1;
await workflowService.saveWorkflow(workflow);
await auditWorkflowChange({
timestamp: new Date().toISOString(),
userId,
action: 'edit',
workflowId: workflow.id,
workflowVersion: workflow.version,
changes: diffWorkflows(previousVersion, workflow),
});
res.json({ success: true });
})
);
// DELETE /api/workflows/:id — Owner only
router.delete(
'/:id',
requireAuth,
asyncHandler(async (req, res) => {
const userId = req.user.id;
const workflowId = req.params.id;
if (!checkWorkflowPermission(workflowId, userId, 'delete')) {
throw new ForbiddenError('Only the owner can delete this workflow');
}
await workflowService.deleteWorkflow(workflowId);
await auditWorkflowChange({
timestamp: new Date().toISOString(),
userId,
action: 'delete',
workflowId,
});
res.status(204).send();
})
);
// POST /api/workflows/:id/runs — Start a workflow run (execute permission)
router.post(
'/:id/runs',
requireAuth,
asyncHandler(async (req, res) => {
if (!checkWorkflowPermission(req.params.id, req.user.id, 'execute')) {
throw new ForbiddenError('You do not have permission to run this workflow');
}
const { taskId, context } = startRunSchema.parse(req.body);
const run = await workflowRunService.startRun(req.params.id, taskId, context);
await auditWorkflowChange({
timestamp: new Date().toISOString(),
userId: req.user.id,
action: 'run',
workflowId: req.params.id,
workflowVersion: run.workflowVersion,
runId: run.id,
});
res.status(201).json(run);
})
);
// GET /api/workflow-runs — List runs user can view
router.get(
'/runs',
requireAuth,
asyncHandler(async (req, res) => {
const filters = {
taskId: req.query.taskId as string | undefined,
workflowId: req.query.workflowId as string | undefined,
status: req.query.status as string | undefined,
};
const runs = await workflowRunService.listRuns(filters);
const visibleRuns = runs.filter((run) =>
checkWorkflowPermission(run.workflowId, req.user.id, 'view')
);
res.json(visibleRuns);
})
);
// GET /api/workflow-runs/:id — Run details
router.get(
'/runs/:id',
requireAuth,
asyncHandler(async (req, res) => {
const run = await workflowRunService.getRun(req.params.id);
if (!run || !checkWorkflowPermission(run.workflowId, req.user.id, 'view')) {
throw new NotFoundError(`Run ${req.params.id} not found`);
}
res.json(run);
})
);
// POST /api/workflow-runs/:id/resume — Resume blocked run (execute permission)
router.post(
'/runs/:id/resume',
requireAuth,
asyncHandler(async (req, res) => {
const run = await workflowRunService.getRun(req.params.id);
if (!run || !checkWorkflowPermission(run.workflowId, req.user.id, 'execute')) {
throw new ForbiddenError('You do not have permission to resume this run');
}
if (run.status !== 'blocked') {
throw new ValidationError(`Run ${req.params.id} is not blocked`);
}
const { context } = resumeRunSchema.parse(req.body || {});
const resumed = await workflowRunService.resumeRun(req.params.id, context);
res.json(resumed);
})
);
export { router as workflowRoutes };
Storage Structure
.veritas-kanban/
workflows/
feature-dev.yml
security-audit.yml
content-pipeline.yml
workflow-runs/
run_20260209_abc12345/
run.json # Run state
step-outputs/
plan.yml # Step outputs
implement-0.md
implement-1.md
test-results.md
pr.md
review.md
Integration with TaskService
When a workflow run completes, update the associated task:
// In WorkflowRunService.executeRun()
if (run.status === 'completed' && run.taskId) {
const taskService = getTaskService();
await taskService.updateTask(run.taskId, {
status: 'done',
automation: {
sessionKey: run.id,
completedAt: run.completedAt,
result: 'Workflow completed successfully',
},
});
}
WebSocket Events
Broadcast workflow status updates to connected clients with full run state to avoid extra HTTP fetches:
// server/src/services/broadcast-service.ts
export function broadcastWorkflowStatus(run: WorkflowRun): void {
if (!wss) return;
// ✅ Send the FULL WorkflowRun object (not just minimal fields)
// This matches what WorkflowRunView expects and avoids extra HTTP calls
const message = JSON.stringify({
type: 'workflow:status',
payload: {
// Full run state
id: run.id,
workflowId: run.workflowId,
workflowVersion: run.workflowVersion,
taskId: run.taskId,
status: run.status,
currentStep: run.currentStep,
startedAt: run.startedAt,
completedAt: run.completedAt,
error: run.error,
// Full step details (not just stepId + status)
steps: run.steps.map((s) => ({
stepId: s.stepId,
status: s.status,
agent: s.agent,
sessionKey: s.sessionKey,
startedAt: s.startedAt,
completedAt: s.completedAt,
duration: s.duration,
retries: s.retries,
output: s.output,
error: s.error,
loopState: s.loopState,
})),
// Context is NOT sent (could be large/sensitive)
// Frontend can fetch context via GET /api/workflow-runs/:id if needed
},
// Metadata for filtering/debugging
meta: {
timestamp: new Date().toISOString(),
version: 1, // Event schema version (for future compatibility)
},
});
wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
});
}
Event Frequency & Debouncing:
- Step changes: Emit immediately (step start, step complete, step fail)
- Loop iterations: Emit after each iteration completes (can be high-frequency for long loops)
- Debouncing: Not currently implemented — if loop steps create too many events, add a debounce layer:
// Optional: Debounce high-frequency updates
let debounceTimer: NodeJS.Timeout | null = null;
let pendingRun: WorkflowRun | null = null;
export function broadcastWorkflowStatusDebounced(run: WorkflowRun): void {
pendingRun = run;
if (debounceTimer) return; // Already scheduled
debounceTimer = setTimeout(() => {
if (pendingRun) {
broadcastWorkflowStatus(pendingRun);
pendingRun = null;
}
debounceTimer = null;
}, 500); // 500ms debounce window
}
Frontend Integration:
// web/src/components/workflows/WorkflowRunView.tsx (updated)
useWebSocket({
onMessage: (event) => {
const message = JSON.parse(event.data);
if (message.type === 'workflow:status' && message.payload.id === runId) {
// ✅ Full run state is in message.payload
setRun(message.payload);
// No need to fetch from API — we have everything we need
}
},
});
5. Frontend Architecture
New Pages/Components
5.1 WorkflowsPage
List all available workflows.
// web/src/pages/WorkflowsPage.tsx
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import type { WorkflowDefinition } from '@veritas-kanban/shared';
export function WorkflowsPage({ onBack }: { onBack: () => void }) {
const [workflows, setWorkflows] = useState<WorkflowDefinition[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch('/api/workflows')
.then((res) => res.json())
.then((data) => {
setWorkflows(data);
setLoading(false);
});
}, []);
if (loading) {
return <div>Loading workflows...</div>;
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold">Workflows</h1>
<Button onClick={onBack}>Back to Board</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{workflows.map((workflow) => (
<Card key={workflow.id}>
<CardHeader>
<CardTitle>{workflow.name}</CardTitle>
<CardDescription>v{workflow.version}</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">{workflow.description}</p>
<div className="flex gap-2">
<Button size="sm" variant="outline">
View
</Button>
<Button size="sm">Run</Button>
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
}
5.2 WorkflowRunView
Step-by-step progress visualization for a running workflow.
// web/src/components/workflows/WorkflowRunView.tsx
import { useState, useEffect } from 'react';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import type { WorkflowRun, StepRun } from '@veritas-kanban/shared';
import { useWebSocket } from '@/hooks/useWebSocket';
interface Props {
runId: string;
}
export function WorkflowRunView({ runId }: Props) {
const [run, setRun] = useState<WorkflowRun | null>(null);
// Subscribe to workflow status updates
useWebSocket({
onMessage: (event) => {
const message = JSON.parse(event.data);
if (message.type === 'workflow:status' && message.runId === runId) {
// Update run state from WebSocket
setRun((prev) => (prev ? { ...prev, ...message } : null));
}
},
});
useEffect(() => {
// Load initial run state
fetch(`/api/workflow-runs/${runId}`)
.then((res) => res.json())
.then(setRun);
}, [runId]);
if (!run) {
return <div>Loading workflow run...</div>;
}
const completedSteps = run.steps.filter((s) => s.status === 'completed').length;
const progress = (completedSteps / run.steps.length) * 100;
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold">{run.workflowId}</h2>
<Badge variant={run.status === 'running' ? 'default' : 'secondary'}>{run.status}</Badge>
</div>
<Progress value={progress} />
<div className="space-y-4">
{run.steps.map((step) => (
<StepCard key={step.stepId} step={step} />
))}
</div>
{run.status === 'blocked' && <Button onClick={() => handleResume(run.id)}>Resume</Button>}
</div>
);
}
function StepCard({ step }: { step: StepRun }) {
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>{step.stepId}</CardTitle>
<Badge
variant={
step.status === 'completed'
? 'success'
: step.status === 'running'
? 'default'
: step.status === 'failed'
? 'destructive'
: 'secondary'
}
>
{step.status}
</Badge>
</div>
</CardHeader>
{step.duration && (
<CardContent>
<p className="text-sm text-muted-foreground">Completed in {step.duration}s</p>
</CardContent>
)}
</Card>
);
}
5.3 Integration with TaskDetailPanel
Add a "Run Workflow" button to the task detail panel:
// web/src/components/board/TaskDetailPanel.tsx
{
task.git && (
<div className="mt-4">
<Button onClick={() => startWorkflow(task.id, 'feature-dev')}>
Run Feature Development Workflow
</Button>
</div>
);
}
async function startWorkflow(taskId: string, workflowId: string) {
const res = await fetch(`/api/workflows/${workflowId}/runs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ taskId }),
});
const run = await res.json();
// Navigate to WorkflowRunView
setView({ type: 'workflow-run', runId: run.id });
}
Navigation Tab
Add a "Workflows" tab to the header navigation:
// web/src/components/layout/Header.tsx
<Button
variant="ghost"
onClick={() => setView('workflows')}
className={view === 'workflows' ? 'bg-accent' : ''}
>
Workflows
</Button>
6. Data Flow Diagram
┌─────────────────────────────────────────────────────────────────────┐
│ User │
└──────────────────────────┬──────────────────────────────────────────┘
│
│ 1. Create task or click "Run Workflow"
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Frontend (React) │
│ - WorkflowsPage (list workflows) │
│ - TaskDetailPanel ("Run Workflow" button) │
└──────────────────────────┬──────────────────────────────────────────┘
│
│ 2. POST /api/workflows/:id/runs
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Server (Express) │
│ ┌────────────────────────────────────────────────────┐ │
│ │ WorkflowService │ │
│ │ - loadWorkflow(id) → YAML → WorkflowDefinition │ │
│ │ - Validate schema │ │
│ └────────────────────────┬───────────────────────────┘ │
│ │ │
│ ┌────────────────────────▼───────────────────────────┐ │
│ │ WorkflowRunService │ │
│ │ - startRun(workflowId, taskId, context) │ │
│ │ - Create WorkflowRun │ │
│ │ - Save to .veritas-kanban/workflow-runs/<id>/ │ │
│ │ - Broadcast status via WebSocket │ │
│ │ - executeRun() → iterate steps │ │
│ └────────────────────────┬───────────────────────────┘ │
│ │ │
│ │ 3. For each step │
│ ▼ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ WorkflowStepExecutor │ │
│ │ - executeStep(step, run) │ │
│ │ - Render input template (Jinja2) │ │
│ │ - Call OpenClaw sessions_spawn │ │
│ └────────────────────────┬───────────────────────────┘ │
└───────────────────────────┼─────────────────────────────────────────┘
│
│ 4. Spawn agent session
▼
┌─────────────────────────────────────────────────────────────────────┐
│ OpenClaw │
│ - sessions_spawn(agent, prompt, taskId) │
│ - Agent executes (coding, analysis, testing, etc.) │
│ - sessions_wait_for(sessionKey) → result │
└────────────────────────┬────────────────────────────────────────────┘
│
│ 5. Agent completes
▼
┌─────────────────────────────────────────────────────────────────────┐
│ WorkflowStepExecutor │
│ - Parse agent output │
│ - Write to step-outputs/<step-id>.md │
│ - Merge output into run.context │
│ - Return to WorkflowRunService │
└────────────────────────┬────────────────────────────────────────────┘
│
│ 6. Next step or complete
▼
┌─────────────────────────────────────────────────────────────────────┐
│ WorkflowRunService │
│ - Update run.status = 'completed' │
│ - Broadcast status via WebSocket │
│ - Update associated task (if taskId present) │
└────────────────────────┬────────────────────────────────────────────┘
│
│ 7. Real-time status updates
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Frontend (WorkflowRunView) │
│ - Receives WebSocket message: workflow:status │
│ - Updates UI (step progress, badges, duration) │
└─────────────────────────────────────────────────────────────────────┘
7. Integration Points
Tasks
- Workflow run → task creation: When a workflow is started, optionally create a task
- Workflow run → task update: On completion, update task status/deliverables
- Task → workflow trigger: "Run Workflow" button in task detail panel
Squad Chat
Every step posts to squad chat during execution:
// In WorkflowStepExecutor.executeAgentStep()
await squadChatPost({
agent: step.agent,
message: `Starting step: ${step.name}`,
tags: ['workflow', run.workflowId],
model: 'gh-sonnet',
taskTitle: run.taskId ? `Run ${run.id}` : undefined,
});
Time Tracking
Each step run tracks duration — can be aggregated for task time tracking:
const totalDuration = run.steps.reduce((sum, s) => sum + (s.duration || 0), 0);
Telemetry
Emit run events:
// On startRun()
telemetry.emit({
type: 'run.started',
workflowId: run.workflowId,
runId: run.id,
taskId: run.taskId,
agent: 'workflow-engine',
});
// On completion
telemetry.emit({
type: 'run.completed',
workflowId: run.workflowId,
runId: run.id,
taskId: run.taskId,
agent: 'workflow-engine',
durationMs: Date.parse(run.completedAt) - Date.parse(run.startedAt),
success: run.status === 'completed',
});
Progress Files (#108)
Each step reads/writes progress.md for context passing:
// In WorkflowStepExecutor
const progressPath = path.join(this.runsDir, run.id, run.config.progress_file || 'progress.md');
const progress = await fs.readFile(progressPath, 'utf-8').catch(() => '');
run.context.progress = progress;
Acceptance Criteria (#109)
Steps can define acceptance_criteria — executor validates outputs:
if (step.acceptance_criteria) {
for (const criterion of step.acceptance_criteria) {
if (!result.includes(criterion)) {
throw new Error(`Acceptance criterion not met: ${criterion}`);
}
}
}
Future Features
- #110 (tool policies): Agent
rolefield maps to toolPolicy - #111 (fresh sessions):
fresh_sessionflag per step - #112 (loop steps):
type: loopalready designed - #113 (retry/escalation):
on_failpolicy already designed - #114 (dashboard): WorkflowRunList page (next section)
8. Implementation Phases
Phase 1: Core Engine (Week 1-2)
Goal: YAML loading, sequential step execution, basic CLI.
Deliverables:
WorkflowService— load/validate YAMLWorkflowRunService— create/execute runsWorkflowStepExecutor— agent step execution (OpenClaw integration)- Storage:
.veritas-kanban/workflows/,workflow-runs/ - API routes:
GET/POST /api/workflows,POST /api/workflows/:id/runs - CLI command:
vk workflow run <workflow-id> --task=<task-id>
Test: Run feature-dev.yml workflow from CLI, verify step outputs.
Phase 2: Run State Management (Week 3)
Goal: Persistence, resume, WebSocket updates.
Deliverables:
run.jsonstate persistencePOST /api/workflow-runs/:id/resumeendpoint- WebSocket broadcast:
workflow:statusevents - Retry/escalation policies:
on_faillogic - Progress file integration: read/write
progress.md
Test: Start a workflow, kill the server mid-run, restart, resume from last checkpoint.
Phase 3: Frontend (Week 4)
Goal: WorkflowsPage, RunView, navigation.
Deliverables:
WorkflowsPage— list workflows, start runsWorkflowRunView— live step-by-step progressWorkflowRunList— active/completed/blocked runs- "Run Workflow" button in TaskDetailPanel
- Navigation tab: "Workflows"
Test: Run a workflow from the UI, watch live updates, resume a blocked run.
Phase 4: Advanced Features (Week 5-6)
Goal: Parallel steps, loops, gates.
Deliverables:
- Loop step execution:
type: loop - Gate step execution:
type: gate - Parallel step execution:
type: parallel(fan-out/fan-in) - Acceptance criteria validation
- Schema validation for step outputs
Test: Run security-audit.yml (uses loops), verify all fixes applied.
9. Open Questions
For Brad to decide before coding begins:
-
OpenClaw Integration Approach:
- Option A: HTTP API (
POST /sessions/spawn,GET /sessions/:key/wait) - Option B: Direct SDK (
await openclaw.spawn(...)) - Recommendation: Start with HTTP (decoupled), migrate to SDK if performance matters
- Option A: HTTP API (
-
Workflow Editor:
- Visual editor (drag-and-drop steps like GitHub Actions UI)?
- Raw YAML editor with live preview?
- Recommendation: Start with raw YAML (simpler), add visual editor in v3.1
-
Workflow Versioning:
- How to handle workflow version upgrades mid-run?
- Recommendation: Run always uses the version it started with (snapshot YAML in run state)
-
Authentication:
- Should workflows have separate auth (workflow-specific API keys)?
- Recommendation: Reuse existing VK auth (task owner = workflow runner)
-
Concurrency:
- Max concurrent workflow runs globally?
- Max concurrent steps per workflow?
- Recommendation: Start with 1 concurrent workflow, 1 step at a time (serial), parallelize in Phase 4
-
Error Handling:
- Should failed workflows auto-archive the task?
- Recommendation: No — leave task in "blocked" status, require manual review/resume
-
Cost Tracking:
- Track token usage per workflow run?
- Recommendation: Yes — aggregate telemetry from all step sessions
-
Workflow Library:
- Ship with built-in workflows (feature-dev, security-audit, etc.)?
- Recommendation: Yes — seed
.veritas-kanban/workflows/on first run
10. Antfarm Parity Features
10.1 Setup Steps
Problem: Antfarm has a setup phase before the main workflow runs (env prep, dependency install, etc.). VK workflows currently start directly with the first step.
Solution: Add an optional setup section to workflow YAML:
id: feature-dev
name: Feature Development Workflow
version: 1
# ✅ Setup phase (runs before main steps)
setup:
- id: prep-env
name: 'Setup: Prepare environment'
agent: system
input: |
Prepare the development environment.
REPO: {{task.git.worktreePath}}
BRANCH: {{task.git.branch}}
Instructions:
1. cd {{task.git.worktreePath}}
2. git fetch origin
3. git checkout {{task.git.branch}} || git checkout -b {{task.git.branch}}
4. npm install (or yarn install)
5. Run any pre-flight checks
Reply with:
STATUS: done
ENV: <environment summary>
on_fail:
escalate_to: human
timeout: 600
steps:
# Main workflow steps (run after setup completes)
- id: plan
# ...
Executor Logic:
// In WorkflowRunService.executeRun()
// Run setup steps first
if (workflow.setup && workflow.setup.length > 0) {
for (const setupStep of workflow.setup) {
const result = await this.stepExecutor.executeStep(setupStep, run);
run.context[setupStep.id] = result.output;
}
}
// Then run main steps
for (const step of workflow.steps) {
// ...
}
10.2 Watchdogs & Step Timeouts
Problem: Antfarm monitors for stuck/hung steps. VK workflows currently have no timeout enforcement per step.
Solution: Add step-level and workflow-level timeouts with automatic escalation:
// In WorkflowStepExecutor.executeStep()
const timeout = step.timeout || workflow.config?.timeout || 3600; // Default 1 hour
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
reject(new Error(`Step ${step.id} timed out after ${timeout}s`));
}, timeout * 1000);
});
try {
const result = await Promise.race([this.executeAgentStep(step, run), timeoutPromise]);
return result;
} catch (err: any) {
if (err.message.includes('timed out')) {
// Step timed out — handle according to on_fail policy
log.error({ stepId: step.id, timeout }, 'Step timed out');
if (step.on_fail?.escalate_to === 'human') {
// Block workflow
throw new Error(`Step ${step.id} timed out — manual intervention required`);
} else {
// Retry or fail
throw err;
}
} else {
throw err;
}
}
Watchdog Monitoring (optional background job):
// server/src/jobs/workflow-watchdog.ts
import cron from 'node-cron';
import { getWorkflowRunService } from '../services/workflow-run-service.js';
import { createLogger } from '../lib/logger.js';
const log = createLogger('workflow-watchdog');
// Run every 5 minutes
cron.schedule('*/5 * * * *', async () => {
const runService = getWorkflowRunService();
const activeRuns = await runService.listRuns({ status: 'running' });
const now = Date.now();
for (const run of activeRuns) {
const runningStep = run.steps.find((s) => s.status === 'running');
if (!runningStep || !runningStep.startedAt) continue;
const stepDuration = (now - new Date(runningStep.startedAt).getTime()) / 1000;
const workflow = await getWorkflowService().loadWorkflow(run.workflowId);
const stepDef = workflow?.steps.find((s) => s.id === runningStep.stepId);
const timeout = stepDef?.timeout || workflow?.config?.timeout || 7200;
if (stepDuration > timeout) {
log.warn(
{
runId: run.id,
stepId: runningStep.stepId,
duration: stepDuration,
timeout,
},
'Step timeout detected by watchdog'
);
// Mark the step as failed
runningStep.status = 'failed';
runningStep.error = `Timeout (${timeout}s exceeded)`;
runningStep.completedAt = new Date().toISOString();
// Block the workflow
run.status = 'blocked';
run.error = `Step ${runningStep.stepId} timed out`;
await runService.saveRun(run);
broadcastWorkflowStatus(run);
}
}
});
10.3 CLI Parity
Problem: Antfarm has a CLI for running workflows (antfarm run, antfarm status, antfarm resume). VK currently has API-only workflow control.
Solution: Add CLI commands for workflow management:
# List available workflows
vk workflow list
# Show workflow definition
vk workflow show <workflow-id>
# Run a workflow
vk workflow run <workflow-id> --task=<task-id>
# List active/completed runs
vk workflow runs [--status=running|completed|failed|blocked]
# Show run details
vk workflow status <run-id>
# Resume a blocked run
vk workflow resume <run-id> [--context='{"key":"value"}']
# Cancel a running workflow
vk workflow cancel <run-id>
# Cleanup old runs
vk workflow cleanup [--older-than=30d] [--dry-run]
# Validate a workflow YAML
vk workflow lint <workflow-file.yml>
CLI Implementation:
// server/src/cli/workflow.ts
import { Command } from 'commander';
import { getWorkflowService } from '../services/workflow-service.js';
import { getWorkflowRunService } from '../services/workflow-run-service.js';
import { table } from 'table';
import chalk from 'chalk';
const workflowCmd = new Command('workflow');
// List workflows
workflowCmd
.command('list')
.description('List available workflows')
.action(async () => {
const workflows = await getWorkflowService().listWorkflows();
console.log(
table([
['ID', 'Name', 'Version', 'Steps'],
...workflows.map((w) => [w.id, w.name, w.version.toString(), w.steps.length.toString()]),
])
);
});
// Run a workflow
workflowCmd
.command('run <workflow-id>')
.option('--task <task-id>', 'Associate with a task')
.option('--context <json>', 'Initial context (JSON)')
.description('Start a workflow run')
.action(async (workflowId, options) => {
const runService = getWorkflowRunService();
const context = options.context ? JSON.parse(options.context) : undefined;
const run = await runService.startRun(workflowId, options.task, context);
console.log(chalk.green(`✓ Started workflow run: ${run.id}`));
console.log(` Workflow: ${run.workflowId}`);
console.log(` Status: ${run.status}`);
console.log(` Task: ${run.taskId || 'none'}`);
});
// Show run status
workflowCmd
.command('status <run-id>')
.description('Show workflow run status')
.action(async (runId) => {
const runService = getWorkflowRunService();
const run = await runService.getRun(runId);
if (!run) {
console.error(chalk.red(`✗ Run ${runId} not found`));
process.exit(1);
}
console.log(chalk.bold(`Workflow Run: ${run.id}`));
console.log(` Workflow: ${run.workflowId} (v${run.workflowVersion})`);
console.log(` Status: ${run.status}`);
console.log(` Started: ${run.startedAt}`);
if (run.completedAt) {
console.log(` Completed: ${run.completedAt}`);
}
console.log('\nSteps:');
console.log(
table([
['Step ID', 'Status', 'Duration', 'Retries'],
...run.steps.map((s) => [
s.stepId,
s.status,
s.duration ? `${s.duration}s` : '-',
s.retries.toString(),
]),
])
);
});
// Resume a blocked run
workflowCmd
.command('resume <run-id>')
.option('--context <json>', 'Resume context (JSON)')
.description('Resume a blocked workflow run')
.action(async (runId, options) => {
const runService = getWorkflowRunService();
const context = options.context ? JSON.parse(options.context) : undefined;
const run = await runService.resumeRun(runId, context);
console.log(chalk.green(`✓ Resumed workflow run: ${run.id}`));
});
export { workflowCmd };
11. Divergences from Antfarm
| Aspect | Antfarm (Ralph) | Veritas Kanban | Reasoning |
|---|---|---|---|
| Context Model | Ralph loop (shared git history + progress file) | Fresh OpenClaw session per step | VK is multi-project, not single-repo |
| Agent Memory | Git commits + progress.md | Step outputs in workflow-runs/ | Explicit state > implicit git log |
| Retry Mechanism | Explicit verify step + retry loop | Declarative on_fail policy |
More flexible (retry different steps) |
| Storage | In-repo files | .veritas-kanban/ directory |
Separation of concerns |
| Templating | Basic string interpolation | Jinja2 templates | More expressive (loops, filters) |
| Execution Model | CLI-driven (antfarm run) | API + UI-driven | Multi-user, real-time UI |
Appendix: Key Architectural Decisions
1. YAML Over Database
Decision: Workflows are version-controlled YAML files, not database records.
Rationale:
- Git-friendly (diff, merge, revert)
- Portable (copy workflows between VK instances)
- Human-readable (easy to review, audit)
- No schema migration pain
2. Fresh Sessions by Default
Decision: Each step spawns a fresh OpenClaw session unless fresh_session: false.
Rationale:
- Avoids context pollution (agent doesn't carry mistakes forward)
- More deterministic (same step → same result)
- Better error isolation (one step failure doesn't corrupt others)
Trade-off: More token usage (no context reuse). Mitigated by progress files.
3. Jinja2 for Templating
Decision: Use Jinja2 for input templates and expressions.
Rationale:
- Familiar syntax (Python/Ansible community)
- Powerful (loops, filters, conditionals)
- Safe (sandboxed evaluation)
Alternative considered: JavaScript template literals (rejected — security risk)
4. Explicit Retry Policies
Decision: Every step defines on_fail with retry/escalation logic.
Rationale:
- No silent failures
- Workflow author makes the decision (not the engine)
- Different steps need different policies (e.g., critical steps escalate immediately)
5. Step Outputs as Files
Decision: Step outputs written to step-outputs/<step-id>.md, not in-memory only.
Rationale:
- Observable (inspect outputs mid-run)
- Resumable (reload outputs after restart)
- Debuggable (see exactly what each agent produced)
End of Architecture Document
This document should be sufficient for a developer to implement the workflow engine from scratch. Next steps: Brad reviews, approves, then implementation begins with Phase 1.