fix: guard updatedTask null check in task routes

also clean up observations section build warning
This commit is contained in:
V.K. Watson 2026-02-20 01:51:45 -06:00 committed by GitHub
parent e4277b8295
commit 9657e731b6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
48 changed files with 1957 additions and 209 deletions

View file

@ -31,12 +31,15 @@ VERITAS_ADMIN_KEY=
# VERITAS_AUTH_ENABLED=true
# Allow localhost requests to bypass authentication (default: false)
# SECURITY: Keep this disabled unless you explicitly need local bypass for development.
# VERITAS_AUTH_LOCALHOST_BYPASS=false
# Role assigned to localhost-bypass connections: admin | editor | viewer
# VERITAS_AUTH_LOCALHOST_ROLE=viewer
# Role assigned to localhost-bypass connections: admin | agent | read-only
# Recommended (if bypass is enabled): read-only
# VERITAS_AUTH_LOCALHOST_ROLE=read-only
# Additional API keys (comma-separated, format: name:key,name:key)
# Additional API keys (comma-separated, format: name:key:role,name:key:role)
# Example: ci:vk_xxx:agent,readonly-bot:vk_yyy:read-only
# VERITAS_API_KEYS=
# ── Data ─────────────────────────────────────────────────────

View file

@ -40,9 +40,9 @@ jobs:
- name: Type check all packages
run: pnpm typecheck
# ─── Server Tests ────────────────────────────────────────────────
test-server:
name: Server Tests
# ─── Workspace Unit Tests ────────────────────────────────────────
test-workspace:
name: Workspace Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@ -57,11 +57,11 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build shared (server dependency)
- name: Build shared (dependency for workspace tests)
run: pnpm --filter @veritas-kanban/shared build
- name: Run server tests
run: pnpm --filter @veritas-kanban/server test
- name: Run workspace unit tests
run: pnpm test:unit
# ─── Build ───────────────────────────────────────────────────────
build:

View file

@ -5,6 +5,29 @@ All notable changes to Veritas Kanban are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- **Orchestrator Delegation Enforcement** — Full enforcement gate for orchestrator delegation
- Orchestrator agent selector in Settings → Enforcement (dropdown of enabled agents)
- Active/Inactive status badge showing enforcement state
- Warning banner when delegation enabled but no agent selected
- Section auto-disables when delegation toggle is off
- Zod-validated `orchestratorAgent` field (string, max 50 chars)
- `POST /api/agent/delegation-violation` endpoint for violation reporting
- Auto-posts violations to squad chat when squad chat enforcement is enabled
- **Enforcement Gate Toast Notifications** — Enhanced error feedback for all enforcement gates
- Gate-specific titles and actionable guidance for REVIEW_GATE, CLOSING_COMMENTS_REQUIRED, DELIVERABLE_REQUIRED, ORCHESTRATOR_DELEGATION
- 10-second toast duration for enforcement messages (up from 5s)
- BulkActionsBar surfaces gate details on bulk move failures
- **Dashboard Enforcement Indicator** — At-a-glance enforcement status
- Shows active/total gate count with color-coded shield icon (green/amber/gray)
- Individual gate dots (green = active, gray = off)
- Renders in dashboard status bar alongside refresh timestamp
## [3.3.0] - 2026-02-15
### ✨ Highlights

11
demo/.env.example Normal file
View file

@ -0,0 +1,11 @@
# Veritas Kanban Demo — Environment Configuration
# Copy to .env and adjust as needed
# Port the demo UI will be accessible on (host side)
DEMO_PORT=3099
# Admin API key for seeding data
VERITAS_ADMIN_KEY=demo-admin-key-2026
# Set to true to disable auth entirely (easier for demos)
VERITAS_AUTH_ENABLED=false

60
demo/README.md Normal file
View file

@ -0,0 +1,60 @@
# Veritas Kanban — Demo Environment
Spin up a fully populated VK instance with one command. Includes sample tasks, agents, sprints, squad chat, and telemetry data.
## Quick Start
```bash
# From the repo root:
npm run demo
# Or directly:
docker compose -f demo/docker-compose.demo.yml up --build
```
Then open **http://localhost:3099**
## What's Included
The demo seeds realistic data showcasing VK's features:
| Feature | Sample Data |
| -------------- | --------------------------------------------------------------- |
| **Tasks** | 10 tasks across all statuses (open, in-progress, done, blocked) |
| **Agents** | 4 agents (VERITAS, TARS, CASE, Ava) with different statuses |
| **Sprints** | 2 sprints (1 active, 1 completed) with task assignments |
| **Squad Chat** | 6 messages showing agent collaboration |
| **Telemetry** | Run events, token usage, and duration tracking |
## Configuration
Copy `.env.example` to `.env` to customize:
```bash
cp demo/.env.example demo/.env
```
| Variable | Default | Description |
| ---------------------- | --------------------- | -------------------------- |
| `DEMO_PORT` | `3099` | Host port for the UI |
| `VERITAS_ADMIN_KEY` | `demo-admin-key-2026` | API admin key |
| `VERITAS_AUTH_ENABLED` | `false` | Set `true` to require auth |
## Reset Demo Data
```bash
# Stop and remove volumes
docker compose -f demo/docker-compose.demo.yml down -v
# Start fresh
docker compose -f demo/docker-compose.demo.yml up --build
```
## How It Works
1. `docker-compose.demo.yml` builds VK from the repo Dockerfile
2. A lightweight `alpine` sidecar waits for the health check
3. `seed.sh` POSTs demo data via the VK API
4. The sidecar exits; VK keeps running with seeded data
Data persists in a Docker volume (`demo-data`) across restarts. The seed script is idempotent — it skips if tasks already exist.

View file

@ -0,0 +1,53 @@
# =============================================================================
# Veritas Kanban — Demo Environment
# =============================================================================
# One command: docker compose -f demo/docker-compose.demo.yml up --build
# Then open: http://localhost:3099
#
# Automatically seeds demo data on first run via the seed sidecar.
# =============================================================================
services:
vk-demo:
build:
context: ..
dockerfile: Dockerfile
container_name: vk-demo
working_dir: /app/server
ports:
- '${DEMO_PORT:-3099}:3001'
environment:
- NODE_ENV=production
- PORT=3001
- DATA_DIR=/app/data
- VERITAS_ADMIN_KEY=${VERITAS_ADMIN_KEY:-demo-admin-key-2026}
- VERITAS_AUTH_ENABLED=${VERITAS_AUTH_ENABLED:-false}
- VERITAS_AUTH_LOCALHOST_BYPASS=true
- VERITAS_AUTH_LOCALHOST_ROLE=admin
- CORS_ORIGINS=http://localhost:${DEMO_PORT:-3099}
volumes:
- demo-data:/app/data
restart: unless-stopped
healthcheck:
test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost:3001/health']
interval: 10s
timeout: 5s
retries: 5
start_period: 15s
vk-demo-seed:
image: alpine:3.19
container_name: vk-demo-seed
depends_on:
vk-demo:
condition: service_healthy
volumes:
- ./seed.sh:/seed/seed.sh:ro
entrypoint: ['sh', '/seed/seed.sh']
environment:
- API_URL=http://vk-demo:3001
- ADMIN_KEY=${VERITAS_ADMIN_KEY:-demo-admin-key-2026}
volumes:
demo-data:
driver: local

95
demo/seed.sh Executable file
View file

@ -0,0 +1,95 @@
#!/bin/sh
# seed.sh — Populate VK demo instance with sample data via API
# Runs inside alpine/curl container — no python3, just sh + wget
set -e
API="${API_URL:-http://vk-demo:3001}"
KEY="${ADMIN_KEY:-demo-admin-key-2026}"
echo "⏳ Waiting for VK API at $API..."
for i in $(seq 1 30); do
if wget -q --spider "$API/health" 2>/dev/null; then
echo "✅ API is ready"
break
fi
[ "$i" -eq 30 ] && echo "❌ API not ready after 60s" && exit 1
sleep 2
done
# Check if already seeded
EXISTING=$(wget -qO- "$API/api/tasks" 2>/dev/null | grep -o '"id"' | wc -l)
if [ "$EXISTING" -gt 3 ]; then
echo " Already seeded ($EXISTING tasks). Skipping."
exit 0
fi
# Helper
post() {
wget -qO /dev/null --post-data="$2" \
--header="Content-Type: application/json" \
--header="Authorization: Bearer $KEY" \
"$API$1" 2>/dev/null || echo "$1 failed"
}
echo "🌱 Seeding demo data..."
# ── Tasks ─────────────────────────────────────────────────────────────
echo " 📋 Tasks..."
post "/api/tasks" '{"id":"demo_001","title":"Implement WebSocket real-time updates","type":"code","status":"done","priority":"high","project":"veritas-kanban","description":"Add WebSocket support for live board updates across connected clients.","subtasks":[{"id":"sub_001a","title":"Set up ws server","done":true},{"id":"sub_001b","title":"Client reconnection logic","done":true},{"id":"sub_001c","title":"Broadcast task mutations","done":true}],"timeTracking":{"entries":[{"id":"t_001","startTime":"2026-02-10T09:00:00Z","endTime":"2026-02-10T12:30:00Z","duration":12600}],"totalSeconds":12600}}'
post "/api/tasks" '{"id":"demo_002","title":"Build sprint planning dashboard","type":"code","status":"in-progress","priority":"high","project":"veritas-kanban","description":"Create a visual sprint planning view with capacity tracking and velocity charts.","subtasks":[{"id":"sub_002a","title":"Sprint data model","done":true},{"id":"sub_002b","title":"Velocity chart component","done":true},{"id":"sub_002c","title":"Capacity planning UI","done":false},{"id":"sub_002d","title":"Sprint retrospective view","done":false}],"timeTracking":{"entries":[{"id":"t_002","startTime":"2026-02-15T10:00:00Z","endTime":"2026-02-15T14:00:00Z","duration":14400}],"totalSeconds":14400,"isRunning":true}}'
post "/api/tasks" '{"id":"demo_003","title":"Add AI-powered task estimation","type":"research","status":"open","priority":"medium","project":"veritas-kanban","description":"Research and implement story point estimation using historical task data and LLM analysis."}'
post "/api/tasks" '{"id":"demo_004","title":"Fix memory leak in long-running agent sessions","type":"bug","status":"in-progress","priority":"critical","project":"veritas-kanban","description":"Agent sessions running >4 hours accumulate event listeners. Memory grows ~50MB/hr.","subtasks":[{"id":"sub_004a","title":"Profile heap snapshots","done":true},{"id":"sub_004b","title":"Identify listener leak source","done":true},{"id":"sub_004c","title":"Implement cleanup on disconnect","done":false}]}'
post "/api/tasks" '{"id":"demo_005","title":"Docker Compose production deployment guide","type":"documentation","status":"done","priority":"medium","project":"veritas-kanban","description":"Complete deployment guide with Docker Compose, Traefik reverse proxy, and SSL setup.","timeTracking":{"entries":[{"id":"t_005","startTime":"2026-02-12T08:00:00Z","endTime":"2026-02-12T10:00:00Z","duration":7200}],"totalSeconds":7200}}'
post "/api/tasks" '{"id":"demo_006","title":"Integrate GitHub webhook for auto-task creation","type":"code","status":"blocked","priority":"medium","project":"veritas-kanban","description":"Automatically create VK tasks from GitHub issues and PRs. Blocked: waiting on GitHub App approval.","blockedReason":"Waiting on GitHub App review (submitted Feb 14)"}'
post "/api/tasks" '{"id":"demo_007","title":"E2E test suite for critical paths","type":"code","status":"in-progress","priority":"high","project":"veritas-kanban","description":"Playwright test coverage for task CRUD, sprint management, and agent workflows."}'
post "/api/tasks" '{"id":"demo_008","title":"Research CalDAV integration for deadline sync","type":"research","status":"open","priority":"low","project":"veritas-kanban","description":"Investigate syncing task deadlines with calendar apps via CalDAV protocol."}'
post "/api/tasks" '{"id":"demo_009","title":"Audit npm dependencies for vulnerabilities","type":"operations","status":"done","priority":"high","project":"veritas-kanban","description":"Run pnpm audit, update critical packages, document remaining advisories."}'
post "/api/tasks" '{"id":"demo_010","title":"Design dark mode theme tokens","type":"code","status":"open","priority":"low","project":"veritas-kanban","description":"Define CSS custom properties for dark mode. Support system preference detection."}'
# ── Agents ────────────────────────────────────────────────────────────
echo " 🤖 Agents..."
post "/api/agents" '{"name":"VERITAS","status":"idle","model":"claude-sonnet-4-20250514","capabilities":["orchestration","task-management","code-review"],"description":"Primary orchestrator agent"}'
post "/api/agents" '{"name":"TARS","status":"working","model":"gpt-5","currentTask":"demo_002","capabilities":["frontend","react","typescript"],"description":"Frontend specialist"}'
post "/api/agents" '{"name":"CASE","status":"idle","model":"claude-sonnet-4-20250514","capabilities":["backend","api","database"],"description":"Backend engineer"}'
post "/api/agents" '{"name":"Ava","status":"offline","model":"codex","capabilities":["research","analysis","documentation"],"description":"Research and documentation agent"}'
# ── Sprints ───────────────────────────────────────────────────────────
echo " 🏃 Sprints..."
post "/api/sprints" '{"id":"sprint_demo_01","name":"Sprint 14 — Real-time & Polish","status":"active","startDate":"2026-02-10T00:00:00Z","endDate":"2026-02-24T00:00:00Z","goals":["Ship WebSocket real-time updates","Complete sprint planning dashboard","Fix critical memory leak"],"taskIds":["demo_001","demo_002","demo_004","demo_007"]}'
post "/api/sprints" '{"id":"sprint_demo_00","name":"Sprint 13 — Docs & Ops","status":"completed","startDate":"2026-01-27T00:00:00Z","endDate":"2026-02-09T00:00:00Z","goals":["Production deployment guide","Dependency audit","GitHub integration research"],"taskIds":["demo_005","demo_009","demo_006"]}'
# ── Squad Chat ────────────────────────────────────────────────────────
echo " 💬 Squad chat..."
post "/api/chat/squad" '{"agent":"VERITAS","message":"Sprint 14 kicked off. Focus areas: real-time updates, sprint dashboard, and that memory leak fix.","model":"claude-sonnet-4-20250514","tags":["sprint"]}'
post "/api/chat/squad" '{"agent":"TARS","message":"WebSocket implementation complete — all clients get live updates now. Moving to sprint dashboard.","model":"gpt-5","tags":["demo_001"]}'
post "/api/chat/squad" '{"agent":"CASE","message":"Found the memory leak — EventEmitter listeners not cleaned up on agent disconnect. Fix incoming.","model":"claude-sonnet-4-20250514","tags":["demo_004"]}'
post "/api/chat/squad" '{"agent":"VERITAS","message":"Good find CASE. demo_004 is critical path for Sprint 14. Prioritize the fix.","model":"claude-sonnet-4-20250514","tags":["demo_004"]}'
post "/api/chat/squad" '{"agent":"Ava","message":"Completed the Docker deployment guide. Covers Compose, Traefik, SSL, and backup strategies.","model":"codex","tags":["demo_005"]}'
post "/api/chat/squad" '{"agent":"TARS","message":"Sprint dashboard velocity chart is live. Starting capacity planning UI next.","model":"gpt-5","tags":["demo_002"]}'
# ── Telemetry Events ──────────────────────────────────────────────────
echo " 📊 Telemetry..."
post "/api/telemetry/events" '{"type":"run.started","taskId":"demo_001","agent":"TARS"}'
post "/api/telemetry/events" '{"type":"run.completed","taskId":"demo_001","agent":"TARS","durationMs":12600000,"success":true}'
post "/api/telemetry/events" '{"type":"run.tokens","taskId":"demo_001","agent":"TARS","model":"gpt-5","inputTokens":45000,"outputTokens":12000,"cost":0.85}'
post "/api/telemetry/events" '{"type":"run.started","taskId":"demo_004","agent":"CASE"}'
post "/api/telemetry/events" '{"type":"run.completed","taskId":"demo_005","agent":"Ava","durationMs":7200000,"success":true}'
post "/api/telemetry/events" '{"type":"run.tokens","taskId":"demo_005","agent":"Ava","model":"codex","inputTokens":28000,"outputTokens":8500,"cost":0.42}'
post "/api/telemetry/events" '{"type":"run.started","taskId":"demo_002","agent":"TARS"}'
echo ""
echo "✅ Demo seeded! Open http://localhost:${DEMO_PORT:-3099}"

View file

@ -72,4 +72,29 @@ Codify what works (and what burns us) when running Veritas Kanban with humans +
10. **Copy/pasting unvetted external code**
- Run security review (see RF-002) and cite sources.
---
## v3.3.0 Features — Best Practices
11. **Use task dependencies to enforce ordering**
- Set `depends_on` / `blocks` relationships so agents don't start work before prerequisites are complete.
- The API's cycle detection prevents circular chains — trust it and model dependencies accurately.
12. **Leverage crash-recovery checkpointing for long tasks**
- Call `POST /api/tasks/:id/checkpoint` periodically during multi-step agent work.
- Secrets are auto-sanitized — don't worry about leaking credentials in checkpoint state.
- Clear checkpoints after task completion to avoid stale data (`DELETE /api/tasks/:id/checkpoint`).
13. **Capture observations for institutional memory**
- Log decisions, blockers, and insights as observations (`POST /api/observations`).
- Use importance scoring (110) so future agents can filter by significance.
- Search across all tasks with `GET /api/observations/search?query=...` to avoid repeating past mistakes.
14. **Use the agent filter for workload visibility**
- Query `GET /api/tasks?agent=name` to see what each agent is working on before assigning new work.
15. **Use workflows for repeatable multi-agent pipelines**
- If you're doing the same plan→implement→review cycle repeatedly, encode it as a YAML workflow.
- Workflows provide retry policies, gate approvals, and real-time observability that ad-hoc scripts don't.
Stick to these rules and the board stays trustworthy even with dozens of agents in parallel.

View file

@ -92,3 +92,63 @@ For any workflow:
6. **Lessons learned** field updated for systemic knowledge.
Use these recipes as seeds for your own automation playbooks.
---
## 7. Workflow Engine Pipeline (v3.0)
**Goal:** Automate plan → implement → test → review with retry policies.
1. Create `.veritas-kanban/workflows/feature-dev.yml` with planner, developer, and tester agents.
2. Start via API: `POST /api/workflows/feature-dev/runs`
3. Monitor live in the Workflows tab — each step shows status, duration, and output preview.
4. Gate steps block until quality checks pass or a human approves.
See [WORKFLOW-GUIDE.md](WORKFLOW-GUIDE.md) for full YAML examples.
---
## 8. Using Task Dependencies (v3.3)
**Goal:** Ensure backend API is complete before frontend work starts.
1. Create `US-100 "Build REST API"` and `US-101 "Build React UI"`.
2. Set dependency: `US-101` depends_on `US-100`.
3. The dependency badge on `US-101` shows it's blocked until `US-100` is done.
4. Query the full graph: `GET /api/tasks/US-101/dependencies`
---
## 9. Crash-Recovery Checkpointing (v3.3)
**Goal:** Resume long-running agent work after a crash.
```bash
# Save checkpoint mid-work
curl -X POST http://localhost:3001/api/tasks/US-42/checkpoint \
-H "Content-Type: application/json" \
-d '{"state":{"step":3,"completed":["auth","db"],"notes":"Working on API layer"}}'
# After restart, resume from checkpoint
CHECKPOINT=$(curl -s http://localhost:3001/api/tasks/US-42/checkpoint)
# Feed $CHECKPOINT into agent prompt for continuity
# Clean up after completion
curl -X DELETE http://localhost:3001/api/tasks/US-42/checkpoint
```
---
## 10. Observational Memory for Cross-Agent Learning (v3.3)
**Goal:** Capture architectural decisions so future agents don't repeat exploration.
```bash
# Log a decision
curl -X POST http://localhost:3001/api/observations \
-H "Content-Type: application/json" \
-d '{"taskId":"US-42","type":"decision","content":"Chose WebSocket over SSE for real-time updates — lower latency, bidirectional","importance":9}'
# Future agent searches before making the same decision
curl "http://localhost:3001/api/observations/search?query=websocket+vs+sse"
```

View file

@ -14,6 +14,11 @@ Complete feature reference for Veritas Kanban. For a quick overview, see the [RE
- [AI Agent Integration](#ai-agent-integration)
- [PRD-Driven Autonomous Development](#prd-driven-autonomous-development)
- [Multi-Agent System (v2.0)](#multi-agent-system-v200)
- [Squad Chat (v2.0)](#squad-chat-v200)
- [Broadcast Notifications (v2.0)](#broadcast-notifications-v200)
- [Task Deliverables (v2.0)](#task-deliverables-v200)
- [Efficient Polling (v2.0)](#efficient-polling-v200)
- [Approval Delegation (v2.0)](#approval-delegation-v200)
- [Lifecycle Automation (v2.0)](#lifecycle-automation-v200)
- [GitHub Issues Sync](#github-issues-sync)
- [Activity Feed](#activity-feed)
@ -27,6 +32,7 @@ Complete feature reference for Veritas Kanban. For a quick overview, see the [RE
- [API](#api)
- [Notifications](#notifications)
- [Storage & Architecture](#storage--architecture)
- [Reverse Proxy Ready (v2.1.1)](#reverse-proxy-ready-v211)
- [Infrastructure & DevOps](#infrastructure--devops)
- [Testing](#testing)
- [Accessibility](#accessibility)
@ -438,6 +444,134 @@ Automated staleness detection for project documentation with real-time tracking
---
## Squad Chat (v2.0.0)
Real-time agent-to-agent communication channel for multi-agent collaboration.
- **WebSocket-powered chat** — Messages broadcast in real time to all connected clients
- **System lifecycle events** — Automatic events for agent spawned, completed, and failed transitions
- **Model attribution** — Each message tagged with the sending agent's model for provenance tracking
- **Configurable display names** — Agents set custom display names for chat identity
- **Squad Chat Webhook** — Configurable webhooks for external integration; supports generic HTTP and OpenClaw Direct modes
- **OpenClaw Direct gateway wake** — Real-time squad chat notifications pushed to OpenClaw gateway for agent orchestration
- **Searchable history** — Browse and search past squad chat messages
### API Endpoints
| Endpoint | Method | Description |
| ----------------- | ------ | --------------------------- |
| `/api/chat/squad` | POST | Send a squad chat message |
| `/api/chat/squad` | GET | Retrieve squad chat history |
---
## Broadcast Notifications (v2.0.0)
Priority-based persistent notification system with agent-specific delivery and read receipts.
- **Priority levels** — Notifications carry priority (low, normal, high, urgent) for triage
- **Agent-specific delivery** — Target notifications to specific agents or broadcast to all
- **Read receipts** — Track which agents have acknowledged notifications
- **Persistent storage** — Notifications persisted to disk, survive server restarts
- **Notification queue** — Unsent notifications queued for batch delivery
- **Per-event toggles** — Enable/disable notification types in Settings → Notifications
### API Endpoints
| Endpoint | Method | Description |
| ----------------------------- | ------ | --------------------------------------- |
| `/api/notifications` | POST | Create a notification |
| `/api/notifications` | GET | List notifications (filterable) |
| `/api/notifications/:id/read` | POST | Mark notification as read |
| `/api/notifications/pending` | GET | Get unsent notifications (Teams format) |
---
## Task Deliverables (v2.0.0)
First-class deliverable objects attached to tasks with type and status tracking.
- **Deliverable types** — Code, documentation, data, config, test, and custom types
- **Status tracking** — Pending, in-progress, complete, and rejected lifecycle
- **Task association** — Deliverables linked to parent tasks for traceability
- **Structured metadata** — Each deliverable carries type, status, description, and optional file references
- **Enforcement gate**`closingComments` gate can require deliverable summary (≥20 chars) before task completion
### API Endpoints
| Endpoint | Method | Description |
| ---------------------------------- | ------ | ---------------------------- |
| `/api/tasks/:id/deliverables` | GET | List deliverables for a task |
| `/api/tasks/:id/deliverables` | POST | Add a deliverable to a task |
| `/api/tasks/:id/deliverables/:did` | PUT | Update a deliverable |
| `/api/tasks/:id/deliverables/:did` | DELETE | Remove a deliverable |
| `/api/scheduled-deliverables` | GET | View scheduled deliverables |
---
## Efficient Polling (v2.0.0)
Optimized change-detection endpoint for agents that poll instead of using WebSocket.
- **Change feed**`GET /api/changes?since=<ISO timestamp>` returns only tasks modified after the given timestamp
- **ETag support** — Responses include `ETag` headers; clients send `If-None-Match` to receive `304 Not Modified` when nothing changed
- **Minimal payload** — Returns only changed task IDs and their new status, reducing bandwidth
- **Agent-friendly** — Designed for headless agents that cannot maintain WebSocket connections
- **Complements WebSocket** — Use WebSocket for real-time UI updates; use `/api/changes` for lightweight agent polling
### API Endpoints
| Endpoint | Method | Description |
| -------------------------- | ------ | ---------------------------------------------- |
| `/api/changes?since=<ISO>` | GET | Get tasks changed since timestamp (ETag aware) |
---
## Approval Delegation (v2.0.0)
Vacation mode with scoped approval delegation and automatic routing.
- **Delegation rules** — Delegate approval authority to another agent or user for a defined period
- **Scoped delegation** — Restrict delegation to specific projects, task types, or priority levels
- **Automatic routing** — Approval requests automatically routed to the delegate when the primary approver is unavailable
- **Vacation mode** — Mark yourself as unavailable; all approvals reroute to your configured delegate
- **Audit trail** — All delegated approvals logged with both original approver and delegate for accountability
---
## Reverse Proxy Ready (v2.1.1)
Deploy Veritas Kanban behind nginx, Caddy, Traefik, or any reverse proxy.
- **`TRUST_PROXY` environment variable** — Set to `true`, `1`, or a comma-separated list of trusted proxy IPs/CIDRs
- **Correct client IP resolution** — With `TRUST_PROXY` enabled, Express reads the real client IP from `X-Forwarded-For` headers
- **Secure cookies** — When behind a TLS-terminating proxy, session cookies respect `X-Forwarded-Proto`
- **Rate limiting accuracy** — Rate limits apply to the real client IP, not the proxy's IP
- **WebSocket passthrough** — WebSocket connections work through reverse proxies with standard `Upgrade` header forwarding
### Example Configurations
**nginx:**
```nginx
location / {
proxy_pass http://localhost:3001;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
```
**Environment:**
```bash
TRUST_PROXY=true
```
---
## Workflow Engine (v3.0.0)
A deterministic multi-step agent orchestration system for repeatable, observable, and reliable agent execution. Think GitHub Actions for AI agents.
@ -1562,4 +1696,4 @@ Working toward WCAG 2.1 AA compliance.
---
_Last updated: 2026-02-06 · [Back to README](../README.md)_
_Last updated: 2026-02-17 · [Back to README](../README.md)_

View file

@ -244,4 +244,61 @@ System events render as divider lines in the UI — visually distinct from regul
| Time tracking forgotten | Start timer immediately, add manual entry for elapsed time with reason. |
| Reviewer disagrees | Re-open task, create subtasks for fixes, keep cross-model reviewer in the loop. |
---
## Crash-Recovery Checkpointing (v3.3)
For long-running tasks, save agent state periodically so work can resume after crashes:
```bash
# Save checkpoint mid-work (secrets auto-sanitized)
curl -X POST http://localhost:3001/api/tasks/<id>/checkpoint \
-H "Content-Type: application/json" \
-d '{"state":{"current_step":3,"completed":["step1","step2"],"notes":"Working on step 3"}}'
# On restart, check for existing checkpoint
curl http://localhost:3001/api/tasks/<id>/checkpoint
# Clear after task completion
curl -X DELETE http://localhost:3001/api/tasks/<id>/checkpoint
```
**Rules:**
- Save checkpoints every 510 minutes on tasks expected to run >15 minutes.
- Always clear checkpoints after `vk done`.
- Checkpoint payloads are capped at 1MB with 24h auto-expiry.
## Observational Memory (v3.3)
Capture important decisions, blockers, and insights as task observations:
```bash
# Log a decision
curl -X POST http://localhost:3001/api/observations \
-H "Content-Type: application/json" \
-d '{"taskId":"<id>","type":"decision","content":"Chose approach X over Y because...","importance":8}'
# Search observations across all tasks
curl "http://localhost:3001/api/observations/search?query=approach+X"
```
**When to create observations:**
- Architectural or design decisions (type: `decision`, importance: 710)
- Blockers with workaround details (type: `blocker`)
- Surprising findings or gotchas (type: `insight`)
- Context needed for future work (type: `context`)
## Task Dependencies (v3.3)
Before starting a task, check its dependency status:
```bash
# Check dependencies
curl http://localhost:3001/api/tasks/<id>/dependencies
# If upstream blockers are incomplete, don't start — pick another task instead.
```
Follow this SOP and every task stays audit-friendly, searchable, and trustworthy.

View file

@ -121,4 +121,22 @@ See [SQUAD-CHAT-PROTOCOL.md](SQUAD-CHAT-PROTOCOL.md) for full details.
6. **Opus**: Reviews, marks done, updates sprint recap comment.
7. **Opus**: Sets agent status back to idle once all workers complete (`vk agent idle`).
---
## Workflow Engine (v3.0)
For repeatable multi-agent pipelines, consider using the **Workflow Engine** instead of manual orchestration:
- Define pipelines as YAML with sequential, loop, gate, and parallel steps.
- Tool policies restrict what each agent role can do.
- Session isolation prevents context bleed between steps.
- Real-time dashboard shows active runs, success rates, and per-workflow health.
See [WORKFLOW-GUIDE.md](WORKFLOW-GUIDE.md) for full details and example workflows.
**When to use Workflows vs. Manual Orchestration:**
- **Workflows:** Repeatable pipelines you run more than once (feature dev, security audits, release processes).
- **Manual orchestration:** One-off sprint management, ad-hoc task assignment, exploratory work.
Following this SOP keeps human oversight minimal while preserving accountability.

View file

@ -92,4 +92,25 @@ Combine MCP + prompt registry to let Claude act as your PM.
- **Archive page:** Use the full-page Archive (accessible from board navigation) instead of the sidebar for faster search and filtering.
- **Notifications:** Configure Teams/Slack/webhooks once; agents can trigger them via the API.
---
## v3.3.0 Features
| Feature | Quick Usage |
| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| **Task dependencies** | Set `depends_on`/`blocks` in task detail → Dependencies section. API: `GET /api/tasks/:id/dependencies` for the full graph. |
| **Crash-recovery checkpoint** | `POST /api/tasks/:id/checkpoint` to save state; `GET` to resume. Secrets auto-sanitized. 24h expiry. |
| **Observational memory** | `POST /api/observations` with type (decision/blocker/insight/context) + importance (110). Search: `GET /api/observations/search?query=...`. |
| **Agent filter** | `GET /api/tasks?agent=codex` — filter tasks by assigned agent name. |
---
## Workflow Engine (v3.0)
- Define pipelines as YAML in `.veritas-kanban/workflows/`.
- Start runs: `POST /api/workflows/:id/runs`.
- Monitor live in the **Workflows** tab or Dashboard.
- Use tool policies to restrict agent permissions per step.
- See [WORKFLOW-GUIDE.md](WORKFLOW-GUIDE.md) for full details.
Know a trick that belongs here? Add it and mirror to the knowledge base so agents learn it too.

View file

@ -91,10 +91,10 @@ pnpm install
pnpm build
```
If errors persist, check your Node.js version — **Node 20+** is required:
If errors persist, check your Node.js version — **Node 22+** is required:
```bash
node -v # Should be v20.x or higher
node -v # Should be v22.x or higher
```
### `pnpm` not found

184
docs/coolify-integration.md Normal file
View file

@ -0,0 +1,184 @@
# Coolify Integration Guide
Veritas Kanban integrates with DigitalMeld's ops services hosted on [Coolify](https://ops.digitalmeld.cloud). This document covers all available services, integration patterns, and authentication requirements.
## Services Overview
| Service | URL | Purpose |
| ----------------- | ------------------------------------------ | --------------------------------- |
| **Supabase** | `https://supabase.ops.digitalmeld.cloud` | Database, auth, storage, realtime |
| **OpenPanel** | `https://analytics.ops.digitalmeld.cloud` | Web analytics dashboard |
| **OpenPanel API** | `https://opapi.ops.digitalmeld.cloud` | Analytics event ingestion API |
| **n8n** | `https://automation.ops.digitalmeld.cloud` | Workflow automation |
| **Plane** | `https://projects.ops.digitalmeld.cloud` | Project management |
| **Appsmith** | `https://apps.ops.digitalmeld.cloud` | Internal tool builder |
## Integration Patterns
### Supabase (`supabase.ops.digitalmeld.cloud`)
**Integration type:** REST API + Realtime subscriptions
- **Database:** Store/query data via PostgREST (`/rest/v1/`)
- **Auth:** User authentication via `/auth/v1/`
- **Storage:** File uploads via `/storage/v1/`
- **Realtime:** WebSocket subscriptions for live updates
**Authentication:**
- `apikey` header with the project's anon or service-role key
- `Authorization: Bearer <JWT>` for authenticated requests
- Service-role key for server-side operations (bypass RLS)
**VK use cases:**
- Persist task data to a shared Supabase database
- Realtime task update subscriptions across agents
- Store task attachments in Supabase Storage
---
### OpenPanel (`analytics.ops.digitalmeld.cloud` / `opapi.ops.digitalmeld.cloud`)
**Integration type:** Event tracking API
- **Dashboard:** `analytics.ops.digitalmeld.cloud` — view analytics
- **API:** `opapi.ops.digitalmeld.cloud` — ingest events via `POST /track`
**Authentication:**
- Client ID header: `openpanel-client-id: <CLIENT_ID>`
- Optional secret: `openpanel-client-secret: <SECRET>`
**VK use cases:**
- Track task lifecycle events (created, started, completed)
- Agent usage analytics
- Dashboard engagement metrics
---
### n8n (`automation.ops.digitalmeld.cloud`)
**Integration type:** Webhooks + REST API
- **Webhooks:** Trigger n8n workflows via `POST /webhook/<path>`
- **API:** `GET/POST /api/v1/workflows`, `/api/v1/executions`
**Authentication:**
- Webhook endpoints: Usually unauthenticated or with a shared secret in the URL path
- API endpoints: `X-N8N-API-KEY: <API_KEY>` header
- Basic auth if configured
**VK use cases:**
- Trigger automation workflows on task state changes
- Offload calendar checks, transcript processing, notifications
- Orchestrate multi-service workflows (e.g., task → Slack → email)
---
### Plane (`projects.ops.digitalmeld.cloud`)
**Integration type:** REST API
- **API base:** `/api/v1/`
- Workspaces, projects, issues, cycles, modules
**Authentication:**
- API key: `X-API-Key: <PLANE_API_KEY>`
- Or OAuth2 token
**VK use cases:**
- Sync VK tasks ↔ Plane issues (bidirectional)
- Mirror project structure
- Import/export between VK and Plane
---
### Appsmith (`apps.ops.digitalmeld.cloud`)
**Integration type:** Embedded apps + REST API
- **API:** `/api/v1/` for app/page/datasource management
- **Embed:** iframe embedding for custom dashboards
**Authentication:**
- Session-based auth (cookie)
- API key for programmatic access
**VK use cases:**
- Build custom admin dashboards consuming VK API
- Create internal tools for task triage
- Embed Appsmith pages in VK UI
---
## Configuration
Services are configured in VK's `config.json` under the optional `coolify` key:
```json
{
"repos": [...],
"agents": [...],
"coolify": {
"services": {
"supabase": {
"url": "https://supabase.ops.digitalmeld.cloud",
"apiKey": "eyJ..."
},
"openpanel": {
"url": "https://analytics.ops.digitalmeld.cloud",
"apiUrl": "https://opapi.ops.digitalmeld.cloud",
"clientId": "..."
},
"n8n": {
"url": "https://automation.ops.digitalmeld.cloud",
"apiKey": "..."
},
"plane": {
"url": "https://projects.ops.digitalmeld.cloud",
"apiKey": "..."
},
"appsmith": {
"url": "https://apps.ops.digitalmeld.cloud",
"apiKey": "..."
}
}
}
}
```
## Health Check
`GET /api/integrations/status` returns the status of all configured Coolify services:
```json
{
"data": {
"supabase": { "status": "up", "responseTimeMs": 142 },
"openpanel": { "status": "up", "responseTimeMs": 89 },
"n8n": { "status": "down", "responseTimeMs": 5000, "error": "timeout" },
"plane": { "status": "unconfigured" },
"appsmith": { "status": "unconfigured" }
}
}
```
Status values: `up` | `down` | `unconfigured`
## Future Work
Deep integrations will be implemented as separate tasks:
- **Supabase sync:** Real-time task replication
- **OpenPanel tracking:** Automatic event emission from VK
- **n8n webhooks:** Task lifecycle → workflow triggers
- **Plane sync:** Bidirectional issue synchronization
- **Appsmith dashboards:** Embedded analytics views

View file

@ -0,0 +1,162 @@
# Orchestrator Delegation Enforcement
Prevents orchestrator agents from directly implementing work — they must delegate to sub-agents.
## Overview
When enabled, orchestrator delegation enforcement ensures the designated orchestrator agent acts as a **coordinator**, not an implementer. The orchestrator should spawn sub-agents for hands-on work (code edits, file changes, multi-step implementations) rather than doing it directly.
This enforces a separation of concerns: the orchestrator plans, prioritizes, and delegates; sub-agents execute.
## Configuration
**Settings → Enforcement → Orchestrator Delegation**
| Setting | Type | Default | Description |
| ------------------------ | ------- | ------- | ----------------------------------------------------- |
| `orchestratorDelegation` | boolean | `false` | Enable/disable delegation enforcement |
| `orchestratorAgent` | string | `""` | Designated orchestrator agent name (e.g. `"veritas"`) |
### UI Configuration
1. Navigate to **Settings → Enforcement** tab
2. Scroll to the **Orchestrator Delegation** section (below the divider)
3. Toggle **Enable Delegation Enforcement**
4. Select the **Orchestrator Agent** from the dropdown (populated from enabled agents in Settings → Agents)
The section shows a status badge:
- **Active** (green shield) — enforcement enabled AND an agent is selected
- **Inactive** (gray shield) — enforcement disabled or no agent selected
> ⚠️ If delegation is enabled but no agent is selected, a warning banner appears: enforcement won't take effect until an agent is chosen.
## How It Works
### Violation Reporting API
Agents (or tooling) report delegation violations via:
```
POST /api/agent/delegation-violation
```
**Request body:**
```json
{
"agent": "veritas",
"action": "file_edit",
"taskId": "task_123",
"details": "Directly edited server/src/routes/tasks.ts"
}
```
| Field | Required | Description |
| --------- | -------- | ----------------------------------------------------------------------- |
| `agent` | ✅ | Agent name reporting the violation |
| `action` | ✅ | What the agent did (e.g. `file_edit`, `code_change`, `multi_step_work`) |
| `taskId` | ❌ | Associated task ID |
| `details` | ❌ | Additional context |
**Response (enforcement enabled):**
```json
{
"success": true,
"enforced": true,
"message": "Delegation violation logged for veritas: file_edit"
}
```
**Response (enforcement disabled):**
```json
{
"success": true,
"enforced": false,
"message": "Delegation enforcement is disabled"
}
```
### Server-Side Behavior
When a violation is reported and enforcement is enabled:
1. **Logs a warning**`Orchestrator delegation violation: {agent} performed {action} directly`
2. **Posts to squad chat** (if squad chat enforcement is also enabled) — a `⚠️ Delegation Violation` message from the `ENFORCEMENT` agent
3. Returns `enforced: true` so the caller knows the violation was recorded
### Toast Notifications (Client-Side)
When task operations are blocked by enforcement gates, the UI shows enhanced toast notifications:
| Gate Code | Title | Guidance |
| ------------------------- | ---------------------- | ----------------------------------------------------------------------------------- |
| `ORCHESTRATOR_DELEGATION` | 🤖 Delegation Required | Orchestrator should delegate this work to a sub-agent instead of doing it directly. |
Toast notifications for enforcement gates display for **10 seconds** (vs 5s for normal errors) to ensure visibility.
### Dashboard Enforcement Indicator
The dashboard displays an **Enforcement Indicator** showing all active gates at a glance:
- Shows `{active}/{total}` gate count (e.g. `3/6`)
- Color-coded: green (all active), amber (partial), gray (none)
- Individual gate dots: green = active, gray = off
- Delegation appears as the "Delegation" dot in the indicator
## Examples
### ❌ What Gets Blocked (Violations)
- Orchestrator directly editing source code files
- Orchestrator performing multi-step implementation work
- Orchestrator making code changes instead of spawning a sub-agent
### ✅ What's Allowed
- Orchestrator reading files for context
- Orchestrator planning and creating tasks
- Orchestrator spawning sub-agents for implementation
- Orchestrator reviewing completed work
- Orchestrator updating documentation (non-code)
- Any non-orchestrator agent doing direct work (enforcement only applies to the designated orchestrator)
## Relationship to Other Enforcement Gates
Orchestrator delegation is one of six enforcement gates in Veritas Kanban:
| Gate | Purpose |
| --------------------------- | --------------------------------------------- |
| **Squad Chat** | Auto-post task lifecycle events |
| **Review Gate** | Require 4×10 review scores before completion |
| **Closing Comments** | Require deliverable summary before completion |
| **Auto Telemetry** | Emit run events on status changes |
| **Auto Time Tracking** | Auto-start/stop timers on status changes |
| **Orchestrator Delegation** | Warn when orchestrator works directly |
All gates are independently toggleable. Orchestrator delegation is unique in that it also requires selecting a specific agent — without that selection, enforcement has no effect even when toggled on.
When squad chat enforcement is also enabled, delegation violations automatically post to squad chat for team visibility.
## Data Model
In `shared/src/types/config.types.ts`:
```typescript
export interface EnforcementSettings {
squadChat: boolean;
reviewGate: boolean;
closingComments: boolean;
autoTelemetry: boolean;
autoTimeTracking: boolean;
orchestratorDelegation: boolean;
orchestratorAgent?: string;
}
```
Validated server-side via Zod schema (`server/src/schemas/feature-settings-schema.ts`):
- `orchestratorDelegation`: `z.boolean().optional()`
- `orchestratorAgent`: `z.string().max(50).optional()`

View file

@ -0,0 +1,216 @@
# n8n Offload Candidates — Veritas Kanban
> **Audit date:** 2026-02-18
> **n8n instance:** `https://automation.ops.digitalmeld.cloud`
> **Auditor:** vk-n8n-audit (automated)
## Summary
Veritas Kanban's server has **16 identified workflows** running in-process that could potentially be offloaded to n8n. Of these, **6 are quick wins** (easy feasibility, high priority) that would reduce server complexity and improve observability with minimal effort.
**Key benefits of offloading:**
- Reduces VK server memory/CPU footprint
- Gives workflows visual debugging, retry logic, and monitoring via n8n UI
- Decouples background processing from the main Express process
- Enables non-developer workflow editing
---
## Workflow Inventory
| # | Workflow | Source File | Current Impl | n8n Feasibility | Priority | Recommended n8n Trigger | Quick Win? |
| --- | ------------------------------------------------------------- | ----------------------------------- | ------------------------------------------ | --------------- | ---------- | --------------------------------------- | ---------- |
| 1 | [GitHub Issues Sync (polling)](#1-github-issues-sync) | `github-sync-service.ts` | `setInterval` polling (5 min default) | **Easy** | **High** | Schedule (5 min cron) | ✅ |
| 2 | [Stale Agent Cleanup](#2-stale-agent-cleanup) | `agent-registry-service.ts` | `setInterval` (configurable) | **Easy** | **High** | Schedule (every 2 min) | ✅ |
| 3 | [Telemetry Retention Cleanup](#3-telemetry-retention-cleanup) | `telemetry-service.ts` | On-init (startup only) | **Easy** | **High** | Schedule (daily at 3 AM) | ✅ |
| 4 | [Failure Alert Dispatch](#4-failure-alert-dispatch) | `failure-alert-service.ts` | In-process (event-driven) | **Easy** | **High** | Webhook (from VK telemetry POST) | ✅ |
| 5 | [Clawdbot Webhook Relay](#5-clawdbot-webhook-relay) | `clawdbot-webhook-service.ts` | Fire-and-forget HTTP POST + retry | **Easy** | **Medium** | Webhook (from VK task events) | ✅ |
| 6 | [Squad Webhook Relay](#6-squad-webhook-relay) | `squad-webhook-service.ts` | Fire-and-forget HTTP POST | **Easy** | **Medium** | Webhook (from VK squad chat POST) | ✅ |
| 7 | [Transition Hook Webhooks](#7-transition-hook-webhooks) | `transition-hooks-service.ts` | In-process `fetch()` on task status change | **Easy** | **Medium** | Webhook (from VK transition event) | |
| 8 | [Lifecycle Hook Executor](#8-lifecycle-hook-executor) | `lifecycle-hooks-service.ts` | In-process, configurable actions | **Medium** | **Medium** | Webhook (from VK lifecycle event) | |
| 9 | [Hook Service (task state webhooks)](#9-hook-service) | `hook-service.ts` | In-process `setTimeout` + HTTP POST | **Easy** | **Low** | Webhook (from VK task change) | |
| 10 | [Daily Digest Generation](#10-daily-digest-generation) | `digest-service.ts` | On-demand (API call) | **Medium** | **High** | Schedule (daily at 7 AM) + HTTP Request | |
| 11 | [Doc Freshness Checks](#11-doc-freshness-checks) | `doc-freshness-service.ts` | On-demand (API call) | **Medium** | **Medium** | Schedule (daily) | |
| 12 | [Scheduled Deliverables](#12-scheduled-deliverables) | `scheduled-deliverables-service.ts` | Metadata-only (no executor) | **Medium** | **Low** | Schedule (per-deliverable cron) | |
| 13 | [PDF Report Generation](#13-pdf-report-generation) | `pdf-report-service.ts` | On-demand (API call) | **Hard** | **Low** | Webhook (on-demand) | |
| 14 | [Notification Dispatch](#14-notification-dispatch) | `notification-service.ts` | In-process file-based | **Medium** | **Medium** | Webhook (from VK mention/assignment) | |
| 15 | [Broadcast → WebSocket + Webhook](#15-broadcast-service) | `broadcast-service.ts` | In-process WS broadcast | **Hard** | **Low** | N/A (needs WS server) | |
| 16 | [Prometheus Metrics Collection](#16-prometheus-metrics) | `metrics/prometheus.ts` | `setInterval` (event loop lag) | **Hard** | **Low** | N/A (runtime introspection) | |
---
## Detailed Analysis
### 1. GitHub Issues Sync
**File:** `server/src/services/github-sync-service.ts`
**Current:** `setInterval` polls GitHub Issues via `gh` CLI every 5 minutes. Bidirectional sync (import issues → tasks, push status back).
**n8n approach:** Schedule trigger → GitHub node (native) → HTTP Request to VK API to create/update tasks. Outbound: VK webhook → n8n → GitHub node to update issues.
**Benefit:** Native GitHub node in n8n has OAuth, pagination, error handling built in. Eliminates `gh` CLI dependency. Visual retry/error handling.
### 2. Stale Agent Cleanup
**File:** `server/src/services/agent-registry-service.ts`
**Current:** `setInterval` checks for agents that haven't sent a heartbeat and marks them offline.
**n8n approach:** Schedule trigger (every 2 min) → HTTP Request GET `/api/agents/registry` → Filter stale → HTTP Request PATCH to mark offline.
**Benefit:** Trivial to implement. Removes background timer from server process.
### 3. Telemetry Retention Cleanup
**File:** `server/src/services/telemetry-service.ts`
**Current:** `cleanupOldEvents()` runs once on startup. Deletes/compresses telemetry files older than retention period (default 30 days). Compresses files older than 7 days.
**n8n approach:** Schedule trigger (daily 3 AM) → Execute Command node (or HTTP Request to a new VK cleanup endpoint).
**Benefit:** Runs reliably on schedule instead of only at restart. Adds observability (can see last cleanup run, duration, files removed).
### 4. Failure Alert Dispatch
**File:** `server/src/services/failure-alert-service.ts`
**Current:** Called inline when telemetry events are ingested. Checks if `run.error` or `run.completed` with `success:false`, deduplicates (5 min window), sends notification.
**n8n approach:** VK POSTs failure events to n8n webhook → Dedup (n8n Function node with static data) → Microsoft Teams node for notification.
**Benefit:** Decouples alerting from telemetry ingestion. Can easily add Slack, email, PagerDuty channels without touching VK code.
### 5. Clawdbot Webhook Relay
**File:** `server/src/services/clawdbot-webhook-service.ts`
**Current:** Fire-and-forget HTTP POST to configured webhook URL on task/chat events. Single retry after 2s on failure. HMAC-SHA256 signing.
**n8n approach:** VK emits to n8n webhook → n8n delivers to Clawdbot gateway with retry logic, signing, and dead-letter handling.
**Benefit:** n8n provides exponential backoff, error logging, and replay. Current retry logic is basic (1 retry, 2s delay).
### 6. Squad Webhook Relay
**File:** `server/src/services/squad-webhook-service.ts`
**Current:** Fires HTTP webhooks (generic or OpenClaw wake) when squad messages are posted. HMAC-SHA256 signing. 5s timeout.
**n8n approach:** VK POSTs squad message to n8n webhook → n8n routes to configured destination (generic webhook or OpenClaw gateway).
**Benefit:** Same as #5 — better retry, observability, and multi-destination routing.
### 7. Transition Hook Webhooks
**File:** `server/src/services/transition-hooks-service.ts`
**Current:** On task status change, executes configured actions including `send-webhook` (direct `fetch()` call to configured URLs).
**n8n approach:** VK POSTs transition event to n8n → n8n evaluates rules → routes to appropriate webhook destinations.
**Benefit:** Move webhook routing logic out of VK. n8n can handle complex routing, transformations, and retries.
### 8. Lifecycle Hook Executor
**File:** `server/src/services/lifecycle-hooks-service.ts`
**Current:** Configurable hooks on task lifecycle events (created, started, blocked, done, etc.). Actions: notify, log, verify checklist, emit telemetry, webhook, custom.
**n8n approach:** VK emits lifecycle event to n8n webhook → n8n workflow with Switch node routes to appropriate action chains.
**Benefit:** Complex hook chains become visual workflows. Non-developers can modify hook behavior.
### 9. Hook Service (task state webhooks)
**File:** `server/src/services/hook-service.ts`
**Current:** Fires webhook POST and squad chat notifications on task state changes. Uses `setTimeout` for async execution.
**n8n approach:** Same pattern as #7/#8 — VK emits event, n8n handles delivery.
**Benefit:** Consolidate with #7 and #8 into a single "task event router" n8n workflow.
### 10. Daily Digest Generation
**File:** `server/src/services/digest-service.ts`
**Current:** Generates daily digest summaries (tasks, runs, tokens, issues). Called on-demand via API — no automatic schedule.
**n8n approach:** Schedule trigger (7 AM daily) → HTTP Request to VK digest API → Microsoft Teams node to post summary.
**Benefit:** Automated daily delivery without relying on agent heartbeat/cron. Currently depends on VERITAS agent to trigger.
### 11. Doc Freshness Checks
**File:** `server/src/services/doc-freshness-service.ts`
**Current:** Tracks document review dates and computes freshness scores. On-demand only.
**n8n approach:** Schedule trigger (daily) → HTTP Request to VK doc freshness API → Filter stale docs → Notification.
**Benefit:** Automated stale doc alerts without manual checking.
### 12. Scheduled Deliverables
**File:** `server/src/services/scheduled-deliverables-service.ts`
**Current:** Stores deliverable metadata (schedule, last run, next run) but has **no built-in executor**. External agents must poll and execute.
**n8n approach:** Each deliverable becomes an n8n workflow with its own schedule trigger.
**Benefit:** n8n becomes the actual executor, replacing the need for agent polling. Each deliverable gets its own visual workflow.
### 13. PDF Report Generation
**File:** `server/src/services/pdf-report-service.ts`
**Current:** Generates branded HTML reports from markdown. On-demand via API.
**n8n approach:** Webhook trigger → HTTP Request to VK report API → file delivery.
**Difficulty:** HTML generation logic is tightly coupled to VK's template system. Would need to replicate or keep calling VK API.
### 14. Notification Dispatch
**File:** `server/src/services/notification-service.ts`
**Current:** File-based notification storage with @mention parsing. Tracks delivery status.
**n8n approach:** VK emits notification event to n8n → n8n routes to Teams/email/push.
**Benefit:** Multi-channel delivery without VK code changes. Currently notifications are only stored in files.
### 15. Broadcast Service
**File:** `server/src/services/broadcast-service.ts`
**Current:** WebSocket broadcast to connected clients + webhook relay.
**n8n approach:** Not practical — WebSocket server must stay in-process.
**Note:** The webhook relay portion (calls to `clawdbot-webhook-service`) could be offloaded (see #5).
### 16. Prometheus Metrics Collection
**File:** `server/src/services/metrics/prometheus.ts`
**Current:** `setInterval` measures event loop lag for Prometheus metrics.
**n8n approach:** Not practical — requires runtime introspection of the Node.js process.
---
## Quick Wins (Recommended First Wave)
These 6 workflows can move to n8n with **minimal VK code changes** (mostly just adding a webhook POST where events already fire):
| # | Workflow | Effort | Impact |
| --- | --------------------------- | ------ | ----------------------------------------------------------- |
| 1 | GitHub Issues Sync | ~2h | Eliminates `gh` CLI polling, adds native GitHub integration |
| 2 | Stale Agent Cleanup | ~30min | Removes `setInterval` from server |
| 3 | Telemetry Retention Cleanup | ~1h | Reliable daily cleanup vs. startup-only |
| 4 | Failure Alert Dispatch | ~1h | Multi-channel alerting, better dedup |
| 5 | Clawdbot Webhook Relay | ~1h | Proper retry/dead-letter handling |
| 6 | Squad Webhook Relay | ~30min | Same as above |
**Total estimated effort: ~6 hours**
### Implementation Pattern
For most offloads, the pattern is:
1. **VK side:** Add a single webhook POST to the n8n instance when the event occurs (or keep existing event and just route to n8n)
2. **n8n side:** Create workflow with Webhook trigger → processing nodes → delivery
3. **VK cleanup:** Remove the in-process `setInterval`/`setTimeout`/`fetch` logic
4. **Config:** Store n8n webhook URLs in VK settings (already has webhook URL config support)
### Consolidation Opportunity
Workflows **#5, #6, #7, #8, #9** are all variations of "on task/chat event → deliver webhook." These could be consolidated into a **single n8n "Event Router" workflow**:
```
Webhook Trigger (all VK events)
→ Switch Node (by event type)
→ Branch: task.changed → Clawdbot gateway
→ Branch: squad.message → OpenClaw wake / generic webhook
→ Branch: task.transition → configured webhook URLs
→ Branch: lifecycle.* → hook action chains
```
This would replace 5 separate VK services with 1 n8n workflow.
---
## Not Recommended for n8n
| Workflow | Reason |
| ------------------------------- | ----------------------------------------- |
| WebSocket Broadcast (#15) | Must be in-process (real-time WS) |
| Prometheus Event Loop Lag (#16) | Runtime introspection, not offloadable |
| Task Service file watcher | Core data layer, must stay in-process |
| Circuit Breaker logic | Per-request middleware, latency-sensitive |
---
## Next Steps
1. **Create n8n webhook endpoints** for the 6 quick-win workflows
2. **Add VK config** for n8n webhook URL (similar to existing `VERITAS_WEBHOOK_URL`)
3. **Implement quick wins** in priority order: #3 (cleanup), #4 (alerts), #2 (stale agents), #1 (GitHub sync), #5/#6 (relays)
4. **Phase 2:** Consolidate event routing (#5-#9) into single n8n workflow
5. **Phase 3:** Automate digest (#10) and doc freshness (#11) on schedule

View file

@ -262,6 +262,19 @@ Check that `VERITAS_AUTH_LOCALHOST_BYPASS=true` is set, or provide an API key.
## Changelog
- **v3.3.0** (2026-02-15): Task intelligence security hardening
- Crash-recovery checkpointing with auto-sanitization of 20+ secret patterns plus regex value detection
- XSS prevention in observational memory via `sanitizeCommentText()`
- DFS cycle detection in task dependencies prevents infinite loop attacks
- Input sanitization on agent filter (trim + 100 char cap)
- Zod validation on all dependency and checkpoint routes
- **v3.0.0** (2026-02-09): Workflow engine security
- ReDoS protection on regex acceptance criteria
- Expression injection prevention in template evaluator
- Parallel DoS limits (max 50 concurrent sub-steps)
- Gate approval authentication and permission checks
- RBAC with ACL files for workflow access control
- Audit logging of all workflow changes
- **v2.0.0** (2026-02-06): Multi-agent security
- Agent permission levels (Intern/Specialist/Lead) with enforcement
- Agent registry with heartbeat-based liveness tracking

View file

@ -20,7 +20,7 @@
"lint:fix": "eslint . --fix",
"typecheck": "pnpm -r typecheck",
"test": "vitest run",
"test:unit": "pnpm -r test",
"test:unit": "pnpm -r --workspace-concurrency=1 test",
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed",
"test:e2e:ui": "playwright test --ui",
@ -30,7 +30,8 @@
"audit": "pnpm audit --prod",
"audit:all": "pnpm audit",
"seed": "bash scripts/seed.sh",
"prepare": "husky"
"prepare": "husky",
"demo": "docker compose -f demo/docker-compose.demo.yml up --build"
},
"devDependencies": {
"@playwright/test": "^1.58.0",
@ -58,7 +59,9 @@
},
"pnpm": {
"overrides": {
"hono": ">=4.11.7"
"hono": ">=4.11.7",
"ajv": "^8.18.0",
"qs": "^6.14.2"
}
},
"repository": {

81
pnpm-lock.yaml generated
View file

@ -6,6 +6,8 @@ settings:
overrides:
hono: '>=4.11.7'
ajv: ^8.18.0
qs: ^6.14.2
importers:
.:
@ -103,8 +105,8 @@ importers:
specifier: workspace:*
version: link:../shared
ajv:
specifier: ^8.17.1
version: 8.17.1
specifier: ^8.18.0
version: 8.18.0
bcrypt:
specifier: ^6.0.0
version: 6.0.0
@ -2909,21 +2911,15 @@ packages:
integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==,
}
peerDependencies:
ajv: ^8.0.0
ajv: ^8.18.0
peerDependenciesMeta:
ajv:
optional: true
ajv@6.12.6:
ajv@8.18.0:
resolution:
{
integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==,
}
ajv@8.17.1:
resolution:
{
integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==,
integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==,
}
ansi-escapes@7.2.0:
@ -4438,12 +4434,6 @@ packages:
}
engines: { node: '>=8.6.0' }
fast-json-stable-stringify@2.1.0:
resolution:
{
integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==,
}
fast-levenshtein@2.0.6:
resolution:
{
@ -5418,12 +5408,6 @@ packages:
integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==,
}
json-schema-traverse@0.4.1:
resolution:
{
integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==,
}
json-schema-traverse@1.0.0:
resolution:
{
@ -6740,10 +6724,10 @@ packages:
}
engines: { node: '>=6' }
qs@6.14.1:
qs@6.15.0:
resolution:
{
integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==,
integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==,
}
engines: { node: '>=0.6' }
@ -7983,12 +7967,6 @@ packages:
peerDependencies:
browserslist: '>= 4.21.0'
uri-js@4.4.1:
resolution:
{
integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==,
}
use-callback-ref@1.3.3:
resolution:
{
@ -8800,7 +8778,7 @@ snapshots:
'@eslint/eslintrc@3.3.3':
dependencies:
ajv: 6.12.6
ajv: 8.18.0
debug: 4.4.3
espree: 10.4.0
globals: 14.0.0
@ -8908,8 +8886,8 @@ snapshots:
'@modelcontextprotocol/sdk@1.26.0(zod@3.25.76)':
dependencies:
'@hono/node-server': 1.19.9(hono@4.11.7)
ajv: 8.17.1
ajv-formats: 3.0.1(ajv@8.17.1)
ajv: 8.18.0
ajv-formats: 3.0.1(ajv@8.18.0)
content-type: 1.0.5
cors: 2.8.6
cross-spawn: 7.0.6
@ -9953,18 +9931,11 @@ snapshots:
agent-base@7.1.4: {}
ajv-formats@3.0.1(ajv@8.17.1):
ajv-formats@3.0.1(ajv@8.18.0):
optionalDependencies:
ajv: 8.17.1
ajv: 8.18.0
ajv@6.12.6:
dependencies:
fast-deep-equal: 3.1.3
fast-json-stable-stringify: 2.1.0
json-schema-traverse: 0.4.1
uri-js: 4.4.1
ajv@8.17.1:
ajv@8.18.0:
dependencies:
fast-deep-equal: 3.1.3
fast-uri: 3.1.0
@ -10184,7 +10155,7 @@ snapshots:
http-errors: 2.0.1
iconv-lite: 0.4.24
on-finished: 2.4.1
qs: 6.14.1
qs: 6.15.0
raw-body: 2.5.3
type-is: 1.6.18
unpipe: 1.0.0
@ -10199,7 +10170,7 @@ snapshots:
http-errors: 2.0.1
iconv-lite: 0.7.2
on-finished: 2.4.1
qs: 6.14.1
qs: 6.15.0
raw-body: 3.0.2
type-is: 2.0.1
transitivePeerDependencies:
@ -10882,7 +10853,7 @@ snapshots:
'@humanwhocodes/module-importer': 1.0.1
'@humanwhocodes/retry': 0.4.3
'@types/estree': 1.0.8
ajv: 6.12.6
ajv: 8.18.0
chalk: 4.1.2
cross-spawn: 7.0.6
debug: 4.4.3
@ -10992,7 +10963,7 @@ snapshots:
parseurl: 1.3.3
path-to-regexp: 0.1.12
proxy-addr: 2.0.7
qs: 6.14.1
qs: 6.15.0
range-parser: 1.2.1
safe-buffer: 5.2.1
send: 0.19.2
@ -11027,7 +10998,7 @@ snapshots:
once: 1.4.0
parseurl: 1.3.3
proxy-addr: 2.0.7
qs: 6.14.1
qs: 6.15.0
range-parser: 1.2.1
router: 2.2.0
send: 1.2.1
@ -11061,8 +11032,6 @@ snapshots:
merge2: 1.4.1
micromatch: 4.0.8
fast-json-stable-stringify@2.1.0: {}
fast-levenshtein@2.0.6: {}
fast-safe-stringify@2.1.1: {}
@ -11647,8 +11616,6 @@ snapshots:
json-buffer@3.0.1: {}
json-schema-traverse@0.4.1: {}
json-schema-traverse@1.0.0: {}
json-schema-typed@8.0.2: {}
@ -12551,7 +12518,7 @@ snapshots:
punycode@2.3.1: {}
qs@6.14.1:
qs@6.15.0:
dependencies:
side-channel: 1.1.0
@ -13192,7 +13159,7 @@ snapshots:
formidable: 3.5.4
methods: 1.1.2
mime: 2.6.0
qs: 6.14.1
qs: 6.15.0
transitivePeerDependencies:
- supports-color
@ -13490,10 +13457,6 @@ snapshots:
escalade: 3.2.0
picocolors: 1.1.1
uri-js@4.4.1:
dependencies:
punycode: 2.3.1
use-callback-ref@1.3.3(@types/react@19.2.9)(react@19.2.3):
dependencies:
react: 19.2.3

View file

@ -10,7 +10,7 @@
"start": "node dist/index.js",
"typecheck": "tsc --noEmit",
"lint": "eslint src --ext .ts",
"test": "vitest run",
"test": "VERITAS_DISABLE_WATCHERS=1 vitest run",
"test:watch": "vitest",
"clean": "rm -rf dist",
"reset-password": "tsx src/scripts/reset-password.ts"

View file

@ -173,7 +173,7 @@ describe('envSchema', () => {
expect(result.data.LOG_LEVEL).toBe('info');
expect(result.data.VERITAS_AUTH_ENABLED).toBe(true);
expect(result.data.VERITAS_AUTH_LOCALHOST_BYPASS).toBe(false);
expect(result.data.VERITAS_AUTH_LOCALHOST_ROLE).toBe('viewer');
expect(result.data.VERITAS_AUTH_LOCALHOST_ROLE).toBe('read-only');
expect(result.data.VERITAS_API_KEYS).toBe('');
expect(result.data.RATE_LIMIT_MAX).toBe(300);
expect(result.data.CSP_REPORT_ONLY).toBe(false);
@ -203,6 +203,31 @@ describe('envSchema', () => {
});
});
describe('localhost auth role enum', () => {
it('should accept canonical roles', () => {
for (const role of ['admin', 'agent', 'read-only']) {
const result = envSchema.safeParse({
VERITAS_ADMIN_KEY: 'test-key',
VERITAS_AUTH_LOCALHOST_ROLE: role,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.VERITAS_AUTH_LOCALHOST_ROLE).toBe(role);
}
}
});
it('should reject legacy role names', () => {
for (const role of ['editor', 'viewer']) {
const result = envSchema.safeParse({
VERITAS_ADMIN_KEY: 'test-key',
VERITAS_AUTH_LOCALHOST_ROLE: role,
});
expect(result.success).toBe(false);
}
});
});
describe('boolean string coercion', () => {
it('should coerce "true" to true', () => {
const result = envSchema.safeParse({

View file

@ -373,7 +373,7 @@ describe('Auth Middleware', () => {
expect(req.auth?.role).toBe('admin');
});
it('should extract API key from query parameter', () => {
it('should reject API key in HTTP query parameter (headers only)', () => {
process.env.VERITAS_ADMIN_KEY = 'query-key';
const req = mockRequest({
query: { api_key: 'query-key' },
@ -384,8 +384,8 @@ describe('Auth Middleware', () => {
const next = mockNext();
authenticate(req, res, next);
expect(next).toHaveBeenCalled();
expect(req.auth?.role).toBe('admin');
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(401);
});
});

View file

@ -0,0 +1,80 @@
import express from 'express';
import request from 'supertest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
vi.mock('../../config/security.js', () => ({
getSecurityConfig: vi.fn(() => ({
authEnabled: false,
passwordHash: null,
jwtSecret: 'test-secret-key',
})),
getJwtSecret: vi.fn(() => 'test-secret-key'),
getValidJwtSecrets: vi.fn(() => ['test-secret-key']),
}));
import { authenticate, authorizeWrite } from '../../middleware/auth.js';
describe('API write authorization integration', () => {
const originalEnv = { ...process.env };
beforeEach(() => {
process.env = {
...originalEnv,
NODE_ENV: 'test',
VERITAS_AUTH_ENABLED: 'true',
VERITAS_AUTH_LOCALHOST_BYPASS: 'false',
VERITAS_API_KEYS: 'readonly:ro-key:read-only,writer:agent-key:agent',
};
});
afterEach(() => {
process.env = { ...originalEnv };
});
function createApp() {
const app = express();
app.use(express.json());
app.use('/api', authenticate, authorizeWrite);
app.get('/api/probe', (_req, res) => {
res.status(200).json({ ok: true });
});
app.post('/api/probe', (_req, res) => {
res.status(201).json({ created: true });
});
return app;
}
it('allows read-only key to perform GET', async () => {
const app = createApp();
await request(app).get('/api/probe').set('X-API-Key', 'ro-key').expect(200);
});
it('denies read-only key from POST mutation', async () => {
const app = createApp();
const response = await request(app)
.post('/api/probe')
.set('X-API-Key', 'ro-key')
.send({ name: 'test' })
.expect(403);
expect(response.body).toMatchObject({
code: 'WRITE_FORBIDDEN',
message: 'Write access denied',
});
});
it('allows agent key to perform POST mutation', async () => {
const app = createApp();
await request(app)
.post('/api/probe')
.set('X-API-Key', 'agent-key')
.send({ name: 'test' })
.expect(201);
});
});

View file

@ -0,0 +1,94 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import express from 'express';
import request from 'supertest';
const { mockLookup } = vi.hoisted(() => ({
mockLookup: vi.fn(),
}));
vi.mock('node:dns/promises', () => ({
lookup: mockLookup,
}));
const { mockGetConfig } = vi.hoisted(() => ({
mockGetConfig: vi.fn(),
}));
vi.mock('../../services/config-service.js', () => ({
ConfigService: function () {
return {
getConfig: mockGetConfig,
};
},
}));
import { integrationsRoutes } from '../../routes/integrations.js';
describe('integrations routes', () => {
let app: express.Express;
beforeEach(() => {
vi.clearAllMocks();
mockLookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
app = express();
app.use('/api/integrations', integrationsRoutes);
});
it('blocks localhost/private targets (SSRF guard)', async () => {
mockGetConfig.mockResolvedValue({
coolify: {
services: {
n8n: { url: 'http://127.0.0.1:5678', token: '' },
},
},
});
const res = await request(app).get('/api/integrations/status');
expect(res.status).toBe(200);
expect(res.body.data.n8n.status).toBe('down');
expect(res.body.data.n8n.error).toBe('blocked host');
});
it('blocks DNS resolutions that point to private addresses', async () => {
mockLookup.mockResolvedValue([{ address: '10.0.0.4', family: 4 }]);
mockGetConfig.mockResolvedValue({
coolify: {
services: {
n8n: { url: 'https://example.com', token: '' },
},
},
});
const fetchSpy = vi.spyOn(globalThis, 'fetch');
const res = await request(app).get('/api/integrations/status');
expect(res.status).toBe(200);
expect(res.body.data.n8n.status).toBe('down');
expect(res.body.data.n8n.error).toBe('blocked host');
expect(fetchSpy).not.toHaveBeenCalled();
fetchSpy.mockRestore();
});
it('marks service up when reachable', async () => {
mockGetConfig.mockResolvedValue({
coolify: {
services: {
n8n: { url: 'https://example.com', token: '' },
},
},
});
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true } as Response);
const res = await request(app).get('/api/integrations/status');
expect(res.status).toBe(200);
expect(res.body.data.n8n.status).toBe('up');
expect(mockLookup).toHaveBeenCalledWith('example.com', { all: true });
expect(fetchSpy).toHaveBeenCalledWith(
'https://example.com/',
expect.objectContaining({ method: 'HEAD', redirect: 'manual' })
);
fetchSpy.mockRestore();
});
});

View file

@ -212,6 +212,20 @@ describe('TelemetryService', () => {
});
});
describe('getBulkTaskEvents', () => {
it('should respect per-task limit guardrails', async () => {
for (let i = 0; i < 8; i++) {
await service.emit<TaskTelemetryEvent>({ type: 'task.created', taskId: 'task_1' });
}
await service.emit<TaskTelemetryEvent>({ type: 'task.created', taskId: 'task_2' });
const eventsByTask = await service.getBulkTaskEvents(['task_1', 'task_2'], 3);
expect(eventsByTask.get('task_1')?.length).toBe(3);
expect(eventsByTask.get('task_2')?.length).toBe(1);
});
});
describe('clear', () => {
it('should delete all event files', async () => {
await service.emit<TaskTelemetryEvent>({ type: 'task.created', taskId: 'task_1' });

View file

@ -67,9 +67,12 @@ export const envSchema = z.object({
VERITAS_AUTH_LOCALHOST_BYPASS: booleanString.default('false'),
/** Role assigned to localhost-bypass connections */
VERITAS_AUTH_LOCALHOST_ROLE: z.enum(['admin', 'editor', 'viewer']).optional().default('viewer'),
VERITAS_AUTH_LOCALHOST_ROLE: z
.enum(['admin', 'agent', 'read-only'])
.optional()
.default('read-only'),
/** Comma-separated additional API keys (format: name:key,name:key) */
/** Comma-separated additional API keys (format: name:key:role,name:key:role) */
VERITAS_API_KEYS: z.string().optional().default(''),
// ── Data ────────────────────────────────────────────────────────────

View file

@ -37,6 +37,7 @@ import { requestTimeout } from './middleware/request-timeout.js';
import {
authenticate,
authorize,
authorizeWrite,
authenticateWebSocket,
validateWebSocketOrigin,
getAuthStatus,
@ -422,6 +423,12 @@ app.use('/api', apiRateLimit);
// Apply authentication to all API routes (except /api/auth which is handled above)
app.use('/api', authenticate);
// ============================================
// Authorization: write access enforcement
// Read-only roles can perform only GET/HEAD/OPTIONS on API routes.
// ============================================
app.use('/api', authorizeWrite);
// ============================================
// API Versioning Middleware
// Sets X-API-Version response header and validates requested version

View file

@ -177,7 +177,12 @@ function isLocalhostRequest(req: Request | IncomingMessage): boolean {
);
}
function extractApiKey(req: Request | IncomingMessage): string | null {
function extractApiKey(
req: Request | IncomingMessage,
options: { allowQueryParam?: boolean } = {}
): string | null {
const { allowQueryParam = false } = options;
// Check Authorization header (Bearer token)
const authHeader = req.headers.authorization;
if (authHeader?.startsWith('Bearer ')) {
@ -190,16 +195,9 @@ function extractApiKey(req: Request | IncomingMessage): string | null {
return apiKeyHeader;
}
// Check query parameter (for WebSocket connections)
if ('query' in req && typeof req.query === 'object' && req.query !== null) {
const query = req.query as Record<string, unknown>;
if (typeof query.api_key === 'string') {
return query.api_key;
}
}
// For IncomingMessage (WebSocket), parse URL
if ('url' in req && typeof req.url === 'string') {
// Optional fallback for WebSocket clients only.
// HTTP requests must use headers, not query parameters.
if (allowQueryParam && 'url' in req && typeof req.url === 'string') {
try {
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
const apiKey = url.searchParams.get('api_key');
@ -300,7 +298,7 @@ export function authenticate(req: AuthenticatedRequest, res: Response, next: Nex
}
// 2. Check API key (agents/services)
const apiKey = extractApiKey(req);
const apiKey = extractApiKey(req, { allowQueryParam: false });
if (apiKey) {
const validation = validateApiKey(apiKey, config);
if (validation.valid) {
@ -326,7 +324,7 @@ export function authenticate(req: AuthenticatedRequest, res: Response, next: Nex
details: {
hint: passwordAuthEnabled
? 'Please log in or provide an API key'
: 'Provide API key via Authorization header (Bearer <key>), X-API-Key header, or api_key query parameter',
: 'Provide API key via Authorization header (Bearer <key>) or X-API-Key header',
},
});
}
@ -454,8 +452,8 @@ export function authenticateWebSocket(req: IncomingMessage): WebSocketAuthResult
}
}
// 2. Check API key
const apiKey = extractApiKey(req);
// 2. Check API key (headers; query-param fallback for WS only)
const apiKey = extractApiKey(req, { allowQueryParam: true });
if (apiKey) {
const validation = validateApiKey(apiKey, config);
if (validation.valid) {
@ -483,7 +481,7 @@ export function authenticateWebSocket(req: IncomingMessage): WebSocketAuthResult
isLocalhost,
error: passwordAuthEnabled
? 'Authentication required. Please log in.'
: 'Authentication required. Provide api_key query parameter.',
: 'Authentication required. Provide API key via Authorization or X-API-Key header (WebSocket also supports api_key query parameter).',
};
}

View file

@ -0,0 +1,153 @@
/**
* Integrations Status Route
*
* GET /api/integrations/status Health check for configured Coolify services.
* Pings each configured service and returns up/down/unconfigured status with response time.
*/
import { Router } from 'express';
import { lookup } from 'node:dns/promises';
import { isIP } from 'node:net';
import { ConfigService } from '../services/config-service.js';
import { asyncHandler } from '../middleware/async-handler.js';
import { createLogger } from '../lib/logger.js';
import type { CoolifyServiceConfig, CoolifyServicesConfig } from '@veritas-kanban/shared';
const log = createLogger('integrations');
const router = Router();
const configService = new ConfigService();
const SERVICE_NAMES = ['supabase', 'openpanel', 'n8n', 'plane', 'appsmith'] as const;
type ServiceName = (typeof SERVICE_NAMES)[number];
/** Timeout for health check pings (ms) */
const PING_TIMEOUT_MS = 5_000;
interface ServiceStatus {
status: 'up' | 'down' | 'unconfigured';
responseTimeMs?: number;
error?: string;
}
const BLOCKED_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
function isPrivateIpv4(hostname: string): boolean {
const parts = hostname.split('.').map((p) => Number(p));
if (parts.length !== 4 || parts.some((n) => Number.isNaN(n) || n < 0 || n > 255)) return false;
const [a, b] = parts;
return (
a === 10 ||
a === 127 ||
a === 0 ||
a === 169 ||
(a === 100 && b >= 64 && b <= 127) || // carrier-grade NAT
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168)
);
}
function isPrivateIpv6(hostname: string): boolean {
const normalized = hostname.toLowerCase();
return (
normalized === '::1' ||
normalized.startsWith('fc') ||
normalized.startsWith('fd') ||
normalized.startsWith('fe80')
);
}
function isBlockedIp(hostname: string): boolean {
const ipVersion = isIP(hostname);
if (ipVersion === 4) return isPrivateIpv4(hostname);
if (ipVersion === 6) return isPrivateIpv6(hostname);
return false;
}
async function validateServiceUrl(
url: string
): Promise<{ ok: true; href: string } | { ok: false; reason: string }> {
try {
const parsed = new URL(url);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return { ok: false, reason: 'unsupported protocol' };
}
const host = parsed.hostname.toLowerCase();
if (BLOCKED_HOSTS.has(host) || isBlockedIp(host) || host.endsWith('.local')) {
return { ok: false, reason: 'blocked host' };
}
// Prevent DNS rebinding/indirection to private addresses.
const resolutions = await lookup(host, { all: true });
if (resolutions.some((entry) => isBlockedIp(entry.address))) {
return { ok: false, reason: 'blocked host' };
}
return { ok: true, href: parsed.href };
} catch {
return { ok: false, reason: 'invalid url' };
}
}
/**
* Ping a service URL and return its status.
*/
async function pingService(service: CoolifyServiceConfig): Promise<ServiceStatus> {
const validated = await validateServiceUrl(service.url);
if (!validated.ok) {
return { status: 'down', error: validated.reason };
}
const start = performance.now();
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), PING_TIMEOUT_MS);
try {
const response = await fetch(validated.href, {
method: 'HEAD',
signal: controller.signal,
redirect: 'manual',
});
const responseTimeMs = Math.round(performance.now() - start);
// Any response (even 401/403) means the service is up
return { status: 'up', responseTimeMs };
} catch (err: unknown) {
const responseTimeMs = Math.round(performance.now() - start);
const message = err instanceof Error ? err.message : 'unknown error';
return {
status: 'down',
responseTimeMs,
error: message.includes('abort') ? 'timeout' : message,
};
} finally {
clearTimeout(timeout);
}
}
// GET /api/integrations/status
router.get(
'/status',
asyncHandler(async (_req, res) => {
const config = await configService.getConfig();
const services = config.coolify?.services ?? ({} as CoolifyServicesConfig);
const results: Record<string, ServiceStatus> = {};
// Ping all configured services in parallel
const checks = SERVICE_NAMES.map(async (name: ServiceName) => {
const svc = services[name];
if (!svc?.url) {
results[name] = { status: 'unconfigured' };
return;
}
results[name] = await pingService(svc);
});
await Promise.all(checks);
log.debug({ results }, 'Integration status check complete');
res.json({ data: results });
})
);
export { router as integrationsRoutes };

View file

@ -1141,7 +1141,10 @@ router.post(
}
const { depends_on, blocks } = input;
const targetId = depends_on || blocks;
const targetId = depends_on ?? blocks;
if (!targetId) {
throw new ValidationError('Must provide either depends_on or blocks');
}
const type: 'depends_on' | 'blocks' = depends_on ? 'depends_on' : 'blocks';
if (!targetId) {
throw new ValidationError('Either depends_on or blocks is required');
@ -1445,6 +1448,10 @@ router.post(
// Update task with checkpoint
const updatedTask = await taskService.updateTask(taskId, { checkpoint });
if (!updatedTask) {
return res.status(404).json({ error: 'Task update failed - task not found' });
}
// Broadcast change
broadcastTaskChange('updated', updatedTask.id);
@ -1512,6 +1519,10 @@ router.delete(
// Clear checkpoint by setting it to undefined
const updatedTask = await taskService.updateTask(taskId, { checkpoint: undefined });
if (!updatedTask) {
return res.status(404).json({ error: 'Task update failed - task not found' });
}
// Broadcast change
broadcastTaskChange('updated', updatedTask.id);

View file

@ -150,9 +150,9 @@ router.post(
validate({ body: TelemetryBulkQuerySchema }),
asyncHandler(async (req: ValidatedRequest<unknown, unknown, TelemetryBulkQuery>, res) => {
const telemetry = getTelemetryService();
const { taskIds } = req.validated.body!;
const { taskIds, perTaskLimit } = req.validated.body!;
const eventsMap = await telemetry.getBulkTaskEvents(taskIds);
const eventsMap = await telemetry.getBulkTaskEvents(taskIds, perTaskLimit);
// Convert Map to plain object for JSON response
const result: Record<string, AnyTelemetryEvent[]> = {};

View file

@ -72,6 +72,7 @@ import lessonsRoutes from '../lessons.js';
import delegationRoutes from '../delegation.js';
import { workflowRoutes } from '../workflows.js';
import toolPolicyRoutes from '../tool-policies.js';
import { integrationsRoutes } from '../integrations.js';
const v1Router: IRouter = Router();
@ -159,5 +160,6 @@ v1Router.use('/lessons', lessonsRoutes);
v1Router.use('/delegation', delegationRoutes);
v1Router.use('/workflows', workflowRoutes);
v1Router.use('/tool-policies', toolPolicyRoutes);
v1Router.use('/integrations', integrationsRoutes);
export { v1Router };

View file

@ -144,6 +144,7 @@ const EnforcementSettingsSchema = z
autoTelemetry: z.boolean().optional(),
autoTimeTracking: z.boolean().optional(),
orchestratorDelegation: z.boolean().optional(),
orchestratorAgent: z.string().max(50).optional(),
})
.strict()
.optional();

View file

@ -60,6 +60,7 @@ export const TelemetryCountQuerySchema = z.object({
*/
export const TelemetryBulkQuerySchema = z.object({
taskIds: z.array(TaskIdSchema).min(1).max(100),
perTaskLimit: z.coerce.number().int().positive().max(1000).optional().default(200),
});
export type TelemetryEventsQuery = z.infer<typeof TelemetryEventsQuerySchema>;

View file

@ -183,6 +183,7 @@ export class TelemetryService {
const { type, since, until, taskId, project, limit } = options;
const types = type ? (Array.isArray(type) ? type : [type]) : null;
const effectiveLimit = Math.min(Math.max(limit ?? 1000, 1), 10_000);
// Determine which files to read based on date range
const files = await this.getEventFiles(since, until);
@ -209,9 +210,9 @@ export class TelemetryService {
// Sort by timestamp (newest first)
events.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
// Apply limit after sort
if (limit && events.length > limit) {
return events.slice(0, limit);
// Apply limit after sort (guardrail default prevents unbounded reads)
if (events.length > effectiveLimit) {
return events.slice(0, effectiveLimit);
}
return events;
@ -228,41 +229,46 @@ export class TelemetryService {
* Get events for multiple tasks at once (batch query)
* Returns a map of taskId -> events[]
*/
async getBulkTaskEvents(taskIds: string[]): Promise<Map<string, AnyTelemetryEvent[]>> {
async getBulkTaskEvents(
taskIds: string[],
perTaskLimit: number = 200
): Promise<Map<string, AnyTelemetryEvent[]>> {
if (taskIds.length === 0) {
return new Map();
}
await this.init();
// Get all recent event files (last 90 days should cover most use cases)
const effectivePerTaskLimit = Math.min(Math.max(perTaskLimit, 1), 1000);
const files = await this.getEventFiles();
// Read all events
let allEvents: AnyTelemetryEvent[] = [];
for (const file of files) {
const fileEvents = await this.readEventFile(file);
allEvents.push(...fileEvents);
}
// Create a Set for O(1) lookup
const taskIdSet = new Set(taskIds);
// Group events by taskId
// Group events by taskId with bounded buffers to cap memory use
const result = new Map<string, AnyTelemetryEvent[]>();
for (const taskId of taskIds) {
result.set(taskId, []);
}
for (const event of allEvents) {
if (event.taskId && taskIdSet.has(event.taskId)) {
result.get(event.taskId)!.push(event);
}
for (const file of files) {
await this.streamEventFile(file, (event) => {
if (!event.taskId || !taskIdSet.has(event.taskId)) return;
const bucket = result.get(event.taskId);
if (!bucket) return;
if (bucket.length < effectivePerTaskLimit) {
bucket.push(event);
}
});
}
// Sort events within each task by timestamp (newest first)
for (const [, events] of result) {
events.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
if (events.length > effectivePerTaskLimit) {
events.length = effectivePerTaskLimit;
}
}
return result;

View file

@ -11,6 +11,7 @@
import fs from 'node:fs';
import type { FSWatcher } from 'node:fs';
import { EventEmitter } from 'node:events';
import { access } from 'node:fs/promises';
// ---------------------------------------------------------------------------
@ -27,7 +28,23 @@ export const renameSync = fs.renameSync;
// Watcher primitives (used by task-service, config-service cache invalidation)
// ---------------------------------------------------------------------------
export const watch = fs.watch;
function createNoopWatcher(): FSWatcher {
const emitter = new EventEmitter();
return Object.assign(emitter, {
close: () => {
emitter.removeAllListeners();
return undefined;
},
}) as unknown as FSWatcher;
}
export function watch(...args: Parameters<typeof fs.watch>): FSWatcher {
if (process.env.VERITAS_DISABLE_WATCHERS === '1') {
return createNoopWatcher();
}
return fs.watch(...args);
}
export type { FSWatcher };
// ---------------------------------------------------------------------------

View file

@ -119,6 +119,32 @@ export const DEFAULT_ROUTING_CONFIG: AgentRoutingConfig = {
maxRetries: 1,
};
// ============ Coolify Integration Types ============
/** Configuration for an individual Coolify-hosted service */
export interface CoolifyServiceConfig {
url: string;
apiKey?: string;
/** Additional API URL (e.g., OpenPanel has separate dashboard + API URLs) */
apiUrl?: string;
/** Client ID for services that use client-based auth (e.g., OpenPanel) */
clientId?: string;
}
/** All Coolify services that VK can integrate with */
export interface CoolifyServicesConfig {
supabase?: CoolifyServiceConfig;
openpanel?: CoolifyServiceConfig;
n8n?: CoolifyServiceConfig;
plane?: CoolifyServiceConfig;
appsmith?: CoolifyServiceConfig;
}
/** Top-level Coolify configuration */
export interface CoolifyConfig {
services: CoolifyServicesConfig;
}
export interface AppConfig {
repos: RepoConfig[];
agents: AgentConfig[];
@ -126,6 +152,7 @@ export interface AppConfig {
agentRouting?: AgentRoutingConfig;
telemetry?: TelemetryConfig;
features?: FeatureSettings;
coolify?: CoolifyConfig;
}
// ============ Feature Settings Types ============
@ -233,6 +260,7 @@ export interface EnforcementSettings {
autoTelemetry: boolean; // Emit run.started/run.completed on status changes
autoTimeTracking: boolean; // Auto-start/stop timers on status changes
orchestratorDelegation: boolean; // Warn when orchestrator does implementation work instead of delegating
orchestratorAgent?: string; // The designated orchestrator agent name (e.g. "veritas")
}
/** Individual hook configuration */
@ -401,6 +429,7 @@ export const DEFAULT_FEATURE_SETTINGS: FeatureSettings = {
autoTelemetry: false,
autoTimeTracking: false,
orchestratorDelegation: false,
orchestratorAgent: '',
},
hooks: {
enabled: false, // Disabled by default

View file

@ -8,10 +8,15 @@ import { AgentStatusIndicator } from '@/components/shared/AgentStatusIndicator';
// ── Mocks ────────────────────────────────────────────────────
// Mock useGlobalAgentStatus hook — allows us to control the returned data
const mockGlobalAgentStatus = vi.fn();
vi.mock('@/hooks/useGlobalAgentStatus', () => ({
useGlobalAgentStatus: () => mockGlobalAgentStatus(),
// Mock realtime status hook — allows us to control returned data
const mockRealtimeAgentStatus = vi.fn();
vi.mock('@/hooks/useAgentStatus', () => ({
useRealtimeAgentStatus: () => mockRealtimeAgentStatus(),
}));
// Mock WebSocket context used by indicator
vi.mock('@/contexts/WebSocketContext', () => ({
useWebSocketStatus: () => ({ isConnected: true }),
}));
// Mock the activity API to avoid real requests
@ -44,26 +49,22 @@ afterEach(() => {
// ── Tests ────────────────────────────────────────────────────
describe('AgentStatusIndicator', () => {
it('shows loading state when data is not yet available', () => {
mockGlobalAgentStatus.mockReturnValue({
data: undefined,
isLoading: true,
error: null,
});
it('falls back to idle state when data is not yet available', () => {
mockRealtimeAgentStatus.mockReturnValue(undefined);
renderIndicator();
expect(screen.getByText('Loading...')).toBeDefined();
const button = screen.getByRole('status');
expect(button.getAttribute('aria-label')).toContain('Agent status: Idle');
});
it('shows idle state with gray dot', () => {
mockGlobalAgentStatus.mockReturnValue({
data: {
status: 'idle',
subAgentCount: 0,
lastUpdated: new Date().toISOString(),
},
isLoading: false,
error: null,
mockRealtimeAgentStatus.mockReturnValue({
status: 'idle',
subAgentCount: 0,
activeAgents: [],
lastUpdated: new Date().toISOString(),
isConnected: true,
isStale: false,
});
renderIndicator();
@ -76,16 +77,15 @@ describe('AgentStatusIndicator', () => {
});
it('shows working state with active task title', () => {
mockGlobalAgentStatus.mockReturnValue({
data: {
status: 'working',
subAgentCount: 0,
activeTask: 'task-1',
activeTaskTitle: 'Build Feature',
lastUpdated: new Date().toISOString(),
},
isLoading: false,
error: null,
mockRealtimeAgentStatus.mockReturnValue({
status: 'working',
subAgentCount: 0,
activeAgents: [],
activeTask: 'task-1',
activeTaskTitle: 'Build Feature',
lastUpdated: new Date().toISOString(),
isConnected: true,
isStale: false,
});
renderIndicator();
@ -95,14 +95,13 @@ describe('AgentStatusIndicator', () => {
});
it('shows sub-agents state with count', () => {
mockGlobalAgentStatus.mockReturnValue({
data: {
status: 'sub-agent',
subAgentCount: 3,
lastUpdated: new Date().toISOString(),
},
isLoading: false,
error: null,
mockRealtimeAgentStatus.mockReturnValue({
status: 'sub-agent',
subAgentCount: 3,
activeAgents: [],
lastUpdated: new Date().toISOString(),
isConnected: true,
isStale: false,
});
renderIndicator();
@ -111,15 +110,14 @@ describe('AgentStatusIndicator', () => {
});
it('shows error state', () => {
mockGlobalAgentStatus.mockReturnValue({
data: {
status: 'error',
subAgentCount: 0,
error: 'Agent crashed',
lastUpdated: new Date().toISOString(),
},
isLoading: false,
error: null,
mockRealtimeAgentStatus.mockReturnValue({
status: 'error',
subAgentCount: 0,
activeAgents: [],
error: 'Agent crashed',
lastUpdated: new Date().toISOString(),
isConnected: true,
isStale: false,
});
renderIndicator();
@ -128,10 +126,14 @@ describe('AgentStatusIndicator', () => {
});
it('shows error state when query itself fails', () => {
mockGlobalAgentStatus.mockReturnValue({
data: undefined,
isLoading: false,
error: new Error('Network failure'),
mockRealtimeAgentStatus.mockReturnValue({
status: 'error',
subAgentCount: 0,
activeAgents: [],
error: 'Network failure',
lastUpdated: new Date().toISOString(),
isConnected: true,
isStale: false,
});
renderIndicator();
@ -140,14 +142,13 @@ describe('AgentStatusIndicator', () => {
});
it('shows thinking state', () => {
mockGlobalAgentStatus.mockReturnValue({
data: {
status: 'thinking',
subAgentCount: 0,
lastUpdated: new Date().toISOString(),
},
isLoading: false,
error: null,
mockRealtimeAgentStatus.mockReturnValue({
status: 'thinking',
subAgentCount: 0,
activeAgents: [],
lastUpdated: new Date().toISOString(),
isConnected: true,
isStale: false,
});
renderIndicator();

View file

@ -43,6 +43,17 @@ vi.mock('@/hooks/useBoardDragDrop', () => ({
}),
}));
vi.mock('@/hooks/useAgentStatus', () => ({
useRealtimeAgentStatus: () => ({
status: 'idle',
subAgentCount: 0,
activeAgents: [],
lastUpdated: new Date().toISOString(),
isConnected: true,
isStale: false,
}),
}));
vi.mock('@/hooks/useKeyboard', () => ({
useKeyboard: () => ({
selectedTaskId: null,
@ -65,6 +76,12 @@ vi.mock('@/hooks/useFeatureSettings', () => ({
showSprintBadges: true,
showDoneMetrics: false,
},
budget: {
enabled: false,
monthlyTokenLimit: 1_000_000,
monthlyCostLimit: 100,
warningThreshold: 0.8,
},
},
}),
}));
@ -201,10 +218,10 @@ describe('KanbanBoard', () => {
mockUseTasks = () => ({ data: mockTasks, isLoading: false, error: null });
renderBoard();
expect(screen.getByText('To Do')).toBeDefined();
expect(screen.getByText('In Progress')).toBeDefined();
expect(screen.getByText('Blocked')).toBeDefined();
expect(screen.getByText('Done')).toBeDefined();
expect(screen.getAllByText('To Do').length).toBeGreaterThan(0);
expect(screen.getAllByText('In Progress').length).toBeGreaterThan(0);
expect(screen.getAllByText('Blocked').length).toBeGreaterThan(0);
expect(screen.getAllByText('Done').length).toBeGreaterThan(0);
});
it('renders empty board when no tasks', () => {

View file

@ -136,10 +136,13 @@ export function BulkActionsBar({ tasks }: BulkActionsBarProps) {
clearSelection();
setMoveTarget(null);
} catch (error) {
const err = error as Error & { details?: Array<{ code: string; message: string }> };
const gateDetail = err.details?.[0];
toast({
variant: 'destructive',
title: 'Move Failed',
description: 'Failed to move selected tasks.',
title: gateDetail ? `⚠️ Enforcement: ${gateDetail.code}` : 'Move Failed',
description: gateDetail?.message || 'Failed to move selected tasks.',
duration: gateDetail ? 10000 : 5000,
});
} finally {
setIsProcessing(false);

View file

@ -30,6 +30,7 @@ import { ActivityClock } from './ActivityClock';
import { HourlyActivityChart } from './HourlyActivityChart';
import { WallTimeToggle } from './WallTimeToggle';
import { SessionMetrics } from './SessionMetrics';
import { EnforcementIndicator } from './EnforcementIndicator';
// Trend indicator component
// direction: 'up' always means improvement, 'down' means decline (from backend)
@ -198,10 +199,13 @@ export function Dashboard() {
onExportClick={() => setExportDialogOpen(true)}
/>
{/* Updated timestamp */}
<div className="flex items-center justify-end gap-2 text-xs text-muted-foreground -mt-2">
<RefreshCw className={cn('h-3 w-3', isFetching && 'animate-spin')} />
{isFetching ? 'Refreshing...' : `Updated ${new Date(dataUpdatedAt).toLocaleTimeString()}`}
{/* Status bar: enforcement indicator + updated timestamp */}
<div className="flex items-center justify-between -mt-2">
<EnforcementIndicator />
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<RefreshCw className={cn('h-3 w-3', isFetching && 'animate-spin')} />
{isFetching ? 'Refreshing...' : `Updated ${new Date(dataUpdatedAt).toLocaleTimeString()}`}
</div>
</div>
{/* Export Dialog */}

View file

@ -0,0 +1,77 @@
import { useFeatureSettings } from '@/hooks/useFeatureSettings';
import { DEFAULT_FEATURE_SETTINGS } from '@veritas-kanban/shared';
import { Shield, ShieldCheck, ShieldX } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* Dashboard indicator showing which enforcement gates are currently active.
* Provides at-a-glance visibility into process enforcement state.
*/
export function EnforcementIndicator() {
const { settings, isLoading } = useFeatureSettings();
if (isLoading) return null;
const enforcement = settings.enforcement ?? DEFAULT_FEATURE_SETTINGS.enforcement;
const gates = [
{ key: 'reviewGate', label: 'Review Gate', active: enforcement.reviewGate ?? false },
{
key: 'closingComments',
label: 'Closing Comments',
active: enforcement.closingComments ?? false,
},
{ key: 'squadChat', label: 'Squad Chat', active: enforcement.squadChat ?? false },
{ key: 'autoTelemetry', label: 'Auto Telemetry', active: enforcement.autoTelemetry ?? false },
{
key: 'autoTimeTracking',
label: 'Time Tracking',
active: enforcement.autoTimeTracking ?? false,
},
{
key: 'orchestratorDelegation',
label: 'Delegation',
active: enforcement.orchestratorDelegation ?? false,
},
];
const activeCount = gates.filter((g) => g.active).length;
const totalCount = gates.length;
return (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg border bg-card text-card-foreground">
<div className="flex items-center gap-1.5">
{activeCount === 0 ? (
<ShieldX className="h-4 w-4 text-muted-foreground" />
) : activeCount === totalCount ? (
<ShieldCheck className="h-4 w-4 text-green-500" />
) : (
<Shield className="h-4 w-4 text-amber-500" />
)}
<span className="text-xs font-medium text-muted-foreground">Enforcement</span>
<span
className={cn(
'text-xs font-semibold tabular-nums',
activeCount === 0 && 'text-muted-foreground',
activeCount > 0 && activeCount < totalCount && 'text-amber-500',
activeCount === totalCount && 'text-green-500'
)}
>
{activeCount}/{totalCount}
</span>
</div>
<div className="flex gap-1">
{gates.map((gate) => (
<div
key={gate.key}
title={`${gate.label}: ${gate.active ? 'Active' : 'Off'}`}
className={cn(
'h-1.5 w-1.5 rounded-full transition-colors',
gate.active ? 'bg-green-500' : 'bg-muted-foreground/30'
)}
/>
))}
</div>
</div>
);
}

View file

@ -1,12 +1,23 @@
import { useFeatureSettings, useDebouncedFeatureUpdate } from '@/hooks/useFeatureSettings';
import { useConfig } from '@/hooks/useConfig';
import { DEFAULT_FEATURE_SETTINGS } from '@veritas-kanban/shared';
import { ToggleRow, SectionHeader, SaveIndicator } from '../shared';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { ToggleRow, SettingRow, SectionHeader, SaveIndicator } from '../shared';
import { Shield, ShieldCheck, Bot } from 'lucide-react';
import { cn } from '@/lib/utils';
export function EnforcementTab() {
const { settings } = useFeatureSettings();
const { debouncedUpdate, isPending } = useDebouncedFeatureUpdate();
const { data: config } = useConfig();
const updateEnforcement = (key: string, value: boolean) => {
const updateEnforcement = (key: string, value: boolean | string) => {
debouncedUpdate({ enforcement: { [key]: value } });
};
@ -17,6 +28,10 @@ export function EnforcementTab() {
};
const enforcement = settings.enforcement ?? DEFAULT_FEATURE_SETTINGS.enforcement;
const agents = config?.agents ?? [];
const enabledAgents = agents.filter((a) => a.enabled);
const orchestratorAgent = enforcement.orchestratorAgent || '';
const delegationActive = enforcement.orchestratorDelegation && !!orchestratorAgent;
return (
<div className="space-y-4">
@ -79,13 +94,85 @@ export function EnforcementTab() {
checked={enforcement.autoTimeTracking ?? false}
onCheckedChange={(v) => updateEnforcement('autoTimeTracking', v)}
/>
</div>
</div>
<div className="border-t my-6" />
{/* Orchestrator Delegation Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium text-foreground">Orchestrator Delegation</h3>
{delegationActive ? (
<div className="flex items-center gap-1 px-2 py-0.5 rounded-full bg-green-500/10 text-green-600 dark:text-green-400">
<ShieldCheck className="h-3 w-3" />
<span className="text-xs font-medium">Active</span>
</div>
) : (
<div className="flex items-center gap-1 px-2 py-0.5 rounded-full bg-muted text-muted-foreground">
<Shield className="h-3 w-3" />
<span className="text-xs font-medium">Inactive</span>
</div>
)}
</div>
<div className="text-xs text-muted-foreground bg-muted/50 rounded-md px-3 py-2">
<p>
<strong>What is orchestrator delegation?</strong> When enabled, the designated
orchestrator agent is expected to coordinate work by delegating tasks to sub-agents
rather than doing implementation work directly. VK will warn when the orchestrator
starts doing hands-on work instead of delegating.
</p>
</div>
<div className="divide-y">
<ToggleRow
label="Orchestrator Delegation"
label="Enable Delegation Enforcement"
description="Warn when orchestrator does work instead of delegating"
checked={enforcement.orchestratorDelegation ?? false}
onCheckedChange={(v) => updateEnforcement('orchestratorDelegation', v)}
/>
<div
className={cn(!enforcement.orchestratorDelegation && 'opacity-50 pointer-events-none')}
>
<SettingRow
label="Orchestrator Agent"
description="The agent designated as the orchestrator / coordinator"
>
<div className="flex items-center gap-2">
{orchestratorAgent && <Bot className="h-4 w-4 text-primary" />}
<Select
value={orchestratorAgent || '__none__'}
onValueChange={(v) =>
updateEnforcement('orchestratorAgent', v === '__none__' ? '' : v)
}
>
<SelectTrigger className="w-[180px] h-8">
<SelectValue placeholder="Select agent..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">
<span className="text-muted-foreground">None selected</span>
</SelectItem>
{enabledAgents.map((a) => (
<SelectItem key={a.type} value={a.type}>
{a.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</SettingRow>
</div>
</div>
{enforcement.orchestratorDelegation && !orchestratorAgent && (
<div className="text-xs text-amber-600 dark:text-amber-400 bg-amber-500/10 rounded-md px-3 py-2">
Delegation enforcement is enabled but no orchestrator agent is selected. Select an
agent above for enforcement to take effect.
</div>
)}
</div>
</div>
);

View file

@ -21,8 +21,10 @@ interface DependenciesSectionProps {
onBlockedByChange: (blockedBy: string[] | undefined) => void;
}
export function DependenciesSection({ task, onBlockedByChange }: DependenciesSectionProps) {
void onBlockedByChange;
export function DependenciesSection({
task,
onBlockedByChange: _onBlockedByChange,
}: DependenciesSectionProps) {
const { data: allTasks } = useTasks();
const [isAddingDependsOn, setIsAddingDependsOn] = useState(false);
const [isAddingBlocks, setIsAddingBlocks] = useState(false);

View file

@ -58,14 +58,11 @@ const TYPE_COLORS: Record<ObservationType, string> = {
function ObservationItem({
observation,
taskId,
onDelete,
}: {
observation: Observation;
taskId: string;
onDelete: (observationId: string) => Promise<void>;
}) {
void taskId;
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const handleDelete = async () => {
@ -258,12 +255,7 @@ export function ObservationsSection({
.slice()
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
.map((obs) => (
<ObservationItem
key={obs.id}
observation={obs}
taskId={task.id}
onDelete={onDeleteObservation}
/>
<ObservationItem key={obs.id} observation={obs} onDelete={onDeleteObservation} />
))}
</div>
</div>

View file

@ -53,7 +53,13 @@ async function fetchBudgetMetrics(
export function useBudgetMetrics(project?: string) {
const { settings } = useFeatureSettings();
const { enabled, monthlyTokenLimit, monthlyCostLimit, warningThreshold } = settings.budget;
const budget = settings.budget ?? {
enabled: false,
monthlyTokenLimit: 1_000_000,
monthlyCostLimit: 100,
warningThreshold: 0.8,
};
const { enabled, monthlyTokenLimit, monthlyCostLimit, warningThreshold } = budget;
const { isConnected } = useWebSocketStatus();
return useQuery({

View file

@ -136,20 +136,37 @@ export function useUpdateTask() {
if (details && details.length > 0) {
const gateError = details[0];
// Map gate codes to user-friendly titles
const gateNames: Record<string, string> = {
REVIEW_GATE: '🔒 Review Gate',
CLOSING_COMMENTS_REQUIRED: '💬 Closing Comments Required',
DELIVERABLE_REQUIRED: '📦 Deliverable Required',
// Map gate codes to user-friendly titles and actionable guidance
const gateInfo: Record<string, { title: string; guidance: string }> = {
REVIEW_GATE: {
title: '🔒 Review Gate Blocked',
guidance: 'Add all four review scores (10/10/10/10) before completing this task.',
},
CLOSING_COMMENTS_REQUIRED: {
title: '💬 Closing Comments Required',
guidance:
'Add a review comment with a deliverable summary (≥20 chars) before completing.',
},
DELIVERABLE_REQUIRED: {
title: '📦 Deliverable Required',
guidance: 'Attach at least one deliverable before marking this task as done.',
},
ORCHESTRATOR_DELEGATION: {
title: '🤖 Delegation Required',
guidance:
'Orchestrator should delegate this work to a sub-agent instead of doing it directly.',
},
};
const title = gateNames[gateError.code] || '⚠️ Validation Error';
const info = gateInfo[gateError.code];
const title = info?.title || '⚠️ Enforcement Gate';
const guidance = info?.guidance || '';
toast({
title,
description: gateError.message,
description: `${gateError.message}${guidance ? `\n\n→ ${guidance}` : ''}`,
variant: 'destructive',
duration: 8000, // Longer duration for enforcement messages
duration: 10000, // Longer duration for enforcement messages
});
} else {
// Generic error fallback