mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-08-28 02:44:59 +00:00
fix: guard updatedTask null check in task routes
also clean up observations section build warning
This commit is contained in:
parent
e4277b8295
commit
9657e731b6
48 changed files with 1957 additions and 209 deletions
|
|
@ -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 ─────────────────────────────────────────────────────
|
||||
|
|
|
|||
12
.github/workflows/ci.yml
vendored
12
.github/workflows/ci.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
23
CHANGELOG.md
23
CHANGELOG.md
|
|
@ -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
11
demo/.env.example
Normal 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
60
demo/README.md
Normal 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.
|
||||
53
demo/docker-compose.demo.yml
Normal file
53
demo/docker-compose.demo.yml
Normal 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
95
demo/seed.sh
Executable 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}"
|
||||
|
|
@ -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 (1–10) 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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
```
|
||||
|
|
|
|||
136
docs/FEATURES.md
136
docs/FEATURES.md
|
|
@ -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)_
|
||||
|
|
|
|||
|
|
@ -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 5–10 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: 7–10)
|
||||
- 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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 (1–10). 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.
|
||||
|
|
|
|||
|
|
@ -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
184
docs/coolify-integration.md
Normal 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
|
||||
162
docs/features/orchestrator-delegation.md
Normal file
162
docs/features/orchestrator-delegation.md
Normal 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()`
|
||||
216
docs/n8n-offload-candidates.md
Normal file
216
docs/n8n-offload-candidates.md
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
81
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
94
server/src/__tests__/routes/integrations.test.ts
Normal file
94
server/src/__tests__/routes/integrations.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
153
server/src/routes/integrations.ts
Normal file
153
server/src/routes/integrations.ts
Normal 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 };
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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[]> = {};
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
77
web/src/components/dashboard/EnforcementIndicator.tsx
Normal file
77
web/src/components/dashboard/EnforcementIndicator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue