mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-08-28 02:44:59 +00:00
chore: harden audit findings and release QA
Add release validation and scheduled QA workflows. Harden webhook URL handling, API helper edge cases, and runtime version reporting. Split heavy web bundles, centralize view metadata, and stabilize full-suite tests.
This commit is contained in:
parent
55ba8b8f4d
commit
d3976f1d74
53 changed files with 1760 additions and 638 deletions
13
.github/workflows/ci.yml
vendored
13
.github/workflows/ci.yml
vendored
|
|
@ -37,6 +37,9 @@ jobs:
|
|||
- name: Lint
|
||||
run: pnpm lint
|
||||
|
||||
- name: Enforce lint warning budget
|
||||
run: pnpm lint:budget
|
||||
|
||||
- name: Type check all packages
|
||||
run: pnpm typecheck
|
||||
|
||||
|
|
@ -104,6 +107,16 @@ jobs:
|
|||
echo "✅ Server build output exists"
|
||||
ls -la server/dist/
|
||||
|
||||
- name: Verify CLI and MCP build output
|
||||
run: |
|
||||
for file in cli/dist/index.js mcp/dist/index.js; do
|
||||
if [ ! -f "$file" ]; then
|
||||
echo "::error::$file not found"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "✅ CLI and MCP build outputs exist"
|
||||
|
||||
# ─── Security Audit ──────────────────────────────────────────────
|
||||
security-audit:
|
||||
name: Security Audit
|
||||
|
|
|
|||
148
.github/workflows/scheduled-qa.yml
vendored
Normal file
148
.github/workflows/scheduled-qa.yml
vendored
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
name: Scheduled QA
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '17 8 * * 1'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
load_profile:
|
||||
description: k6 load profile to run
|
||||
required: true
|
||||
default: smoke
|
||||
type: choice
|
||||
options:
|
||||
- smoke
|
||||
- full
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
NODE_VERSION: '22'
|
||||
LOG_LEVEL: warn
|
||||
VERITAS_ADMIN_KEY: scheduled-qa-admin-key-000000000000
|
||||
VERITAS_AUTH_LOCALHOST_BYPASS: 'true'
|
||||
VERITAS_AUTH_LOCALHOST_ROLE: admin
|
||||
VERITAS_JWT_SECRET: scheduled-qa-jwt-secret-00000000000000000000000000000000
|
||||
|
||||
jobs:
|
||||
playwright:
|
||||
name: Playwright E2E
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
env:
|
||||
VERITAS_DATA_DIR: ${{ runner.temp }}/veritas-playwright-data
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install Chromium
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
|
||||
- name: Run Playwright E2E tests
|
||||
run: pnpm test:e2e
|
||||
|
||||
- name: Upload Playwright artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-artifacts
|
||||
path: |
|
||||
playwright-report/
|
||||
test-results/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
k6:
|
||||
name: k6 Load Smoke
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
K6_PROFILE: ${{ github.event_name == 'workflow_dispatch' && inputs.load_profile || 'smoke' }}
|
||||
VERITAS_DATA_DIR: ${{ runner.temp }}/veritas-k6-data
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build runtime packages
|
||||
run: pnpm build
|
||||
|
||||
- name: Start API server
|
||||
run: |
|
||||
mkdir -p "$VERITAS_DATA_DIR"
|
||||
pnpm --filter @veritas-kanban/server start > "$RUNNER_TEMP/veritas-server.log" 2>&1 &
|
||||
echo "$!" > "$RUNNER_TEMP/veritas-server.pid"
|
||||
|
||||
- name: Wait for API health
|
||||
run: |
|
||||
for _ in $(seq 1 60); do
|
||||
if curl -fsS http://127.0.0.1:3001/api/health > /dev/null; then
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
cat "$RUNNER_TEMP/veritas-server.log"
|
||||
exit 1
|
||||
|
||||
- name: Run k6 profile
|
||||
run: |
|
||||
mkdir -p k6-results
|
||||
|
||||
if [ "$K6_PROFILE" = "full" ]; then
|
||||
scripts="smoke read-load write-load mixed-load ws-stress"
|
||||
else
|
||||
scripts="smoke"
|
||||
fi
|
||||
|
||||
for script in $scripts; do
|
||||
docker run --rm --network host \
|
||||
-e BASE_URL=http://127.0.0.1:3001 \
|
||||
-e WS_URL=ws://127.0.0.1:3001/ws \
|
||||
-e API_KEY="$VERITAS_ADMIN_KEY" \
|
||||
-v "$PWD:/work" \
|
||||
-w /work \
|
||||
grafana/k6:latest run \
|
||||
--summary-export "k6-results/${script}.json" \
|
||||
"load-tests/k6/${script}.js"
|
||||
done
|
||||
|
||||
- name: Stop API server
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f "$RUNNER_TEMP/veritas-server.pid" ]; then
|
||||
kill "$(cat "$RUNNER_TEMP/veritas-server.pid")" || true
|
||||
fi
|
||||
|
||||
- name: Upload k6 artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: k6-artifacts
|
||||
path: |
|
||||
k6-results/
|
||||
${{ runner.temp }}/veritas-server.log
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
19
CHANGELOG.md
19
CHANGELOG.md
|
|
@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Added `pnpm validate:release` to verify workspace versions, required release files, README badge, changelog heading, built artifacts, and optional GitHub tag/release state.
|
||||
- Added a scheduled QA workflow that runs Playwright and k6 checks outside the fast pull request CI path.
|
||||
- Added a codebase audit report at `docs/CODEBASE-AUDIT-2026-05-16.md` with linked follow-up issues.
|
||||
|
||||
### Changed
|
||||
|
||||
- Expanded the root build and CI artifact checks to include the CLI and MCP packages.
|
||||
- Split heavy web panels and task-detail surfaces out of the initial Vite bundle, removing oversized chunk warnings from the production build.
|
||||
- Centralized web view metadata and task-detail tab metadata to reduce navigation and feature-gate drift.
|
||||
- Added a lint warning budget gate after reducing current warning debt from 728 to 714.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Hardened outbound webhook URL handling with shared validation and redirect checks.
|
||||
- Fixed board drag reordering, dependency blocking, checkpoint clearing, CLI/MCP version reporting, API envelope handling, and setup guidance found during the audit.
|
||||
- Fixed Docker workspace dependency stages to include CLI and MCP package manifests.
|
||||
|
||||
## [4.3.1] - 2026-05-11
|
||||
|
||||
### Security
|
||||
|
|
|
|||
|
|
@ -162,7 +162,7 @@ Before merging any branch, verify:
|
|||
|
||||
- [ ] **Type exports:** All new types added to `shared/` are exported in `shared/src/types/index.ts`
|
||||
- [ ] **Type checks pass:** `pnpm typecheck` succeeds for all workspace packages (shared, server, web, CLI, MCP)
|
||||
- [ ] **Builds pass:** `pnpm build` succeeds for all packages (shared, server, web)
|
||||
- [ ] **Builds pass:** `pnpm build` succeeds for all packages (shared, server, web, CLI, MCP)
|
||||
- [ ] **No hardcoded values:** No hardcoded ports, URLs, or timeouts in application code
|
||||
- [ ] **CSP/CORS configs:** Security policies work in both `NODE_ENV=development` AND `NODE_ENV=production`
|
||||
- [ ] **Frontend hooks:** All HTTP calls use shared helpers (`apiFetch`) and all WebSocket/URL logic uses `window.location.host` (not hardcoded ports)
|
||||
|
|
@ -172,7 +172,7 @@ Before merging any branch, verify:
|
|||
|
||||
**Never change these without team agreement:**
|
||||
|
||||
- **PORT in `.env`:** Default is 3000. Changing this breaks developer workflows and bookmarks.
|
||||
- **PORT in `.env`:** Server default is 3001. Changing this breaks CLI/API workflows and bookmarks.
|
||||
- **CORS_ORIGINS:** Must include the production serving origin (e.g., `http://localhost:3000` when Express serves the built frontend in production mode).
|
||||
- **CSP `connect-src`:** Must allow WebSocket connections in all modes (dev, production, test). Don't hide WebSocket support behind `isDev` checks.
|
||||
- **Configurable values:** Use environment variables with sensible defaults. No magic numbers in code.
|
||||
|
|
@ -256,6 +256,7 @@ docs: update README with deployment instructions
|
|||
|
||||
- **Language:** TypeScript (strict mode)
|
||||
- **Linting:** ESLint — `pnpm lint`
|
||||
- **Lint budget:** `pnpm lint:budget` enforces the current warning ceiling so lint debt cannot grow.
|
||||
- **Formatting:** Prettier — `pnpm format`
|
||||
- **Editor:** VS Code recommended with ESLint + Prettier extensions
|
||||
|
||||
|
|
@ -275,6 +276,19 @@ Follow the existing conventions in `.eslintrc.*`, `.prettierrc`, and `tsconfig.j
|
|||
pnpm test:e2e
|
||||
```
|
||||
|
||||
- **Load smoke tests** use [k6](https://k6.io/):
|
||||
|
||||
```bash
|
||||
pnpm test:load:smoke
|
||||
```
|
||||
|
||||
- **Release readiness** checks workspace versions, changelog, README badge, build outputs, and optional GitHub tag/release state:
|
||||
|
||||
```bash
|
||||
pnpm validate:release
|
||||
pnpm validate:release -- --github
|
||||
```
|
||||
|
||||
- Write tests for new features and bug fixes.
|
||||
- Ensure existing tests pass before submitting.
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
|
|||
COPY shared/package.json ./shared/
|
||||
COPY server/package.json ./server/
|
||||
COPY web/package.json ./web/
|
||||
COPY cli/package.json ./cli/
|
||||
COPY mcp/package.json ./mcp/
|
||||
|
||||
# Install all dependencies (dev + prod) for building
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
|
@ -76,6 +78,8 @@ COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
|
|||
COPY shared/package.json ./shared/
|
||||
COPY server/package.json ./server/
|
||||
COPY web/package.json ./web/
|
||||
COPY cli/package.json ./cli/
|
||||
COPY mcp/package.json ./mcp/
|
||||
|
||||
# Install production-only dependencies
|
||||
# --ignore-scripts: skip husky prepare hook (not needed in container)
|
||||
|
|
|
|||
|
|
@ -714,8 +714,11 @@ pnpm dev # Start dev servers (web + API concurrently)
|
|||
pnpm build # Production build
|
||||
pnpm typecheck # TypeScript strict check
|
||||
pnpm lint # ESLint
|
||||
pnpm lint:budget # ESLint with current warning budget
|
||||
pnpm test # Unit tests (Vitest)
|
||||
pnpm test:e2e # E2E tests (Playwright)
|
||||
pnpm test:load:smoke # k6 API smoke test
|
||||
pnpm validate:release # Release readiness checks
|
||||
```
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -135,7 +135,7 @@ This is a sample task created by \`vk setup\`.
|
|||
Use the API to create, update, and manage tasks.
|
||||
\`\`\`
|
||||
3. **Try the CLI** — Run \`vk list\` to see all tasks
|
||||
4. **Archive this task** — When done exploring, run \`vk done ${Date.now()}\`
|
||||
4. **Archive this task** — When done exploring, run \`vk done <task-id>\` with the task ID printed by setup
|
||||
|
||||
## Resources
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import { api, API_BASE } from '../utils/api.js';
|
||||
import { api, API_BASE, buildApiHeaders } from '../utils/api.js';
|
||||
|
||||
export function registerSummaryCommands(program: Command): void {
|
||||
// Create summary parent command with subcommands
|
||||
|
|
@ -90,7 +90,10 @@ export function registerSummaryCommands(program: Command): void {
|
|||
} else {
|
||||
// Fetch markdown or text directly
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/summary/standup?date=${dateParam}&format=${format}`
|
||||
`${API_BASE}/api/summary/standup?date=${dateParam}&format=${format}`,
|
||||
{
|
||||
headers: buildApiHeaders({ accept: 'text/plain, text/markdown, application/json' }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) {
|
||||
const errorBody = await res.json().catch(() => ({ error: res.statusText }));
|
||||
|
|
@ -118,7 +121,13 @@ export function registerSummaryCommands(program: Command): void {
|
|||
const recent = await api<unknown>(`/api/summary/recent?hours=${options.hours}`);
|
||||
console.log(JSON.stringify(recent, null, 2));
|
||||
} else {
|
||||
const res = await fetch(`${API_BASE}/api/summary/memory?hours=${options.hours}`);
|
||||
const res = await fetch(`${API_BASE}/api/summary/memory?hours=${options.hours}`, {
|
||||
headers: buildApiHeaders({ accept: 'text/markdown, text/plain, application/json' }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errorBody = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error((errorBody as { error?: string }).error || `API error: ${res.status}`);
|
||||
}
|
||||
const markdown = await res.text();
|
||||
|
||||
if (options.output) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
#!/usr/bin/env node
|
||||
import { Command } from 'commander';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { registerTaskCommands } from './commands/tasks.js';
|
||||
import { registerBacklogCommands } from './commands/backlog.js';
|
||||
import { registerAgentCommands } from './commands/agents.js';
|
||||
|
|
@ -17,11 +18,14 @@ import { registerUsageCommands } from './commands/usage.js';
|
|||
import { registerSprintCommands } from './commands/sprints.js';
|
||||
|
||||
const program = new Command();
|
||||
const packageJson = JSON.parse(
|
||||
readFileSync(new URL('../package.json', import.meta.url), 'utf-8')
|
||||
) as { version?: string };
|
||||
|
||||
program
|
||||
.name('vk')
|
||||
.description('Veritas Kanban CLI - Task management for AI agents')
|
||||
.version('0.1.0');
|
||||
.version(packageJson.version ?? '0.0.0');
|
||||
|
||||
// Register all command groups
|
||||
registerTaskCommands(program);
|
||||
|
|
|
|||
71
docs/CODEBASE-AUDIT-2026-05-16.md
Normal file
71
docs/CODEBASE-AUDIT-2026-05-16.md
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
# Codebase Audit - 2026-05-16
|
||||
|
||||
This audit covered the server, web app, shared package, CLI, MCP server, docs, scripts, and CI.
|
||||
|
||||
## Changes Applied
|
||||
|
||||
- Fixed Tool Policies API calls to use the shared `apiFetch` helper, so sub-path deployments and custom `VITE_API_URL` values work consistently.
|
||||
- Fixed same-column kanban drag reorder state so reorder persistence can fire.
|
||||
- Fixed checkpoint clearing when tasks are marked `done` or explicitly cleared.
|
||||
- Fixed dependency blocking so `blocked -> in-progress` cannot bypass incomplete blockers.
|
||||
- Hardened outbound webhook delivery through shared URL validation, DNS checks, and manual redirect handling.
|
||||
- Added `VK_API_KEY` headers to raw CLI summary and memory fetch paths.
|
||||
- Improved CLI/MCP API error messages for standard `{ success: false, error }` envelopes.
|
||||
- Included CLI and MCP packages in the root build and CI build-output checks.
|
||||
- Added a release validator for package version alignment, changelog/badge checks, local artifacts, git tags, and GitHub release state.
|
||||
- Added scheduled Playwright and k6 CI gates outside the fast PR workflow.
|
||||
- Fixed Docker dependency stages to copy CLI and MCP package manifests now that they are part of the workspace.
|
||||
- Split task detail, create-task, settings, search, and chat panels out of the initial web bundle through lazy loading.
|
||||
- Centralized web view metadata for URL routing, header navigation, command palette navigation, and lazy-route loading labels.
|
||||
- Centralized task-detail tab metadata and reset behavior so feature-gated or disabled tabs cannot strand the panel on hidden content.
|
||||
- Removed the warning cluster in `server/src/index.ts` and added a CI lint warning budget to prevent new debt.
|
||||
- Fixed stale CLI/MCP runtime versions by reading package metadata.
|
||||
- Fixed `vk setup` sample-task guidance so it no longer prints an impossible task ID.
|
||||
- Corrected stale health endpoint and port docs.
|
||||
- Quarantined the legacy review-task creation script behind an explicit opt-in flag.
|
||||
|
||||
## Verified Findings
|
||||
|
||||
- Production dependency audit is clean.
|
||||
- `pnpm typecheck` passes across the workspace.
|
||||
- `pnpm test:unit` passes across server and web packages.
|
||||
- Targeted regressions pass for URL validation, squad webhooks, task checkpoint/dependency behavior, auth, feedback, and web API helpers.
|
||||
- `pnpm build` passes across shared, server, web, CLI, and MCP after expanding the root build.
|
||||
- `pnpm lint` exits 0 and currently reports 714 warnings, down from 728, mostly `any`, non-null assertions, and hook dependency warnings.
|
||||
- `pnpm lint:budget` enforces the current warning ceiling so future cleanup can ratchet it down.
|
||||
- `pnpm validate:release -- --github` passes for v4.3.1, including local tag, origin tag, and published GitHub release checks.
|
||||
- Scheduled QA workflow YAML parses successfully.
|
||||
- The Vite production build no longer emits oversized chunk warnings; the largest app chunk is the lazy `TaskDetailPanel` chunk at 473.98 kB.
|
||||
- Source TODO sweep found two intentional future seams: OpenClaw `sessions_spawn` workflow execution and CSP style nonce migration.
|
||||
|
||||
## Refactor Targets
|
||||
|
||||
- Centralize all outbound integrations into a named endpoint registry with validation, secrets, delivery history, and audit events.
|
||||
- Add workflow agent execution profiles for read-only review, workspace-write implementation, local-only no-network, and publish-capable networked runs.
|
||||
- Replace the remaining OpenClaw workflow execution placeholder with the same provider-adapter model used by the Codex agent path.
|
||||
- Move production style handling away from broad CSP `unsafe-inline` once the UI/runtime path supports nonce-tagged style injection consistently.
|
||||
- Move more services behind the existing storage provider abstraction instead of direct file I/O.
|
||||
- Centralize web view registration so `App`, `Header`, and command palette navigation cannot drift.
|
||||
- Extract task-detail tab registration so feature-gated tabs cannot strand users on hidden content.
|
||||
- Tighten lint in phases: app code first, test files second.
|
||||
|
||||
## Expansion Candidates
|
||||
|
||||
- Work Queue view focused on next actions, blockers, agent status, and review readiness.
|
||||
- Saved board views backed by the existing URL filter model.
|
||||
- Review readiness summary in task details using verification, deliverables, review, metrics, and observations.
|
||||
- Outbound integration registry for hooks, policies, squad chat, and failure alerts.
|
||||
- Release validation script covering versions, changelog, tag, GitHub release, Docker build, CLI, and MCP artifacts.
|
||||
- Scheduled Playwright and k6 CI jobs separate from the fast PR gate.
|
||||
|
||||
## Tracker Follow-Up
|
||||
|
||||
- [#394](https://github.com/BradGroux/veritas-kanban/issues/394) - Centralize outbound integration endpoint registry and delivery audit.
|
||||
- [#395](https://github.com/BradGroux/veritas-kanban/issues/395) - Complete OpenClaw workflow-step execution through provider adapters.
|
||||
- [#396](https://github.com/BradGroux/veritas-kanban/issues/396) - Remove broad CSP unsafe-inline style allowance in production.
|
||||
- [#397](https://github.com/BradGroux/veritas-kanban/issues/397) - Centralize web view, navigation, and task-detail tab registration.
|
||||
- [#398](https://github.com/BradGroux/veritas-kanban/issues/398) - Reduce lint warning debt and phase in stricter lint gates.
|
||||
- [#399](https://github.com/BradGroux/veritas-kanban/issues/399) - Add release validation script for versions, artifacts, tags, and GitHub releases.
|
||||
- [#400](https://github.com/BradGroux/veritas-kanban/issues/400) - Add scheduled Playwright and k6 CI gates outside the fast PR path.
|
||||
- [#401](https://github.com/BradGroux/veritas-kanban/issues/401) - Add saved board views backed by URL filters.
|
||||
- [#402](https://github.com/BradGroux/veritas-kanban/issues/402) - Split oversized frontend bundles and route-heavy Vite chunks.
|
||||
|
|
@ -1869,7 +1869,9 @@ Production-ready deployment and development tooling.
|
|||
|
||||
- **GitHub Actions** — CI pipeline on push to `main` and pull requests
|
||||
- **Concurrency control** — In-progress runs cancelled when new commits push
|
||||
- **Pipeline jobs** — Lint & type check, workspace unit tests, production build, and security audit
|
||||
- **Pipeline jobs** — Lint and warning budget, type check, workspace unit tests, production build, and security audit
|
||||
- **Scheduled QA** — Weekly and manually triggered Playwright and k6 gates run outside the fast PR path
|
||||
- **Release validation** — `pnpm validate:release` checks versions, release docs, built artifacts, and optional GitHub tag/release state
|
||||
- **pnpm caching** — Dependency cache for faster CI runs
|
||||
|
||||
### Development
|
||||
|
|
@ -1878,7 +1880,7 @@ Production-ready deployment and development tooling.
|
|||
- **lint-staged** — Runs ESLint on staged files
|
||||
- **Gitleaks** — Pre-commit secret scanning via [gitleaks](https://gitleaks.io/) (`.pre-commit-config.yaml`)
|
||||
- **Concurrent dev servers** — `pnpm dev` starts both web and API servers simultaneously
|
||||
- **ESLint** — Linting across all packages
|
||||
- **ESLint** — Linting across all packages with a ratchetable warning budget
|
||||
- **TypeScript strict mode** — Full strict checking across the monorepo
|
||||
|
||||
### Observability
|
||||
|
|
@ -1897,8 +1899,8 @@ Multi-layer testing strategy.
|
|||
|
||||
### Unit Tests (Vitest)
|
||||
|
||||
- **61 test files** · **1,143 tests passing** across server and frontend
|
||||
- **Server (51 files, 1,033 tests):**
|
||||
- **119 test files** · **1,699 tests passing** across server and frontend
|
||||
- **Server (105 files, 1,570 tests):**
|
||||
- All middleware (auth, rate limiting, request ID, API versioning, cache control, validation, response envelope, request timeout)
|
||||
- Core services (task, template, telemetry, notification, activity, sprint, diff, conflict, summary, status history, digest, attachment, text extraction, migration, managed list, broadcast, automation, blocking, failure alert, metrics, settings, JWT rotation, MIME validation, preview, trace, circuit breaker)
|
||||
- Route handlers (tasks, task archive, task comments, task subtasks, task time, auth, agent status, automation, config, notifications, templates, health, misc routes)
|
||||
|
|
@ -1907,7 +1909,7 @@ Multi-layer testing strategy.
|
|||
- Prometheus metrics (counters, gauges, histograms, registry, collector middleware)
|
||||
- Environment variable validation
|
||||
- Circuit breaker transitions (18 tests covering open/half-open/closed states — added in v3.3.2)
|
||||
- **Frontend (10 files, 110 tests):**
|
||||
- **Frontend (14 files, 129 tests):**
|
||||
- API client helpers and task operations
|
||||
- Custom hooks: useWebSocket, useKeyboard (keyboard shortcuts)
|
||||
- Components: KanbanBoard, TaskCard, ErrorBoundary, AgentStatusIndicator, WebSocketIndicator
|
||||
|
|
|
|||
|
|
@ -251,13 +251,13 @@ prompt-registry/
|
|||
|
||||
Stale docs = hallucinating AI. Keep these files current:
|
||||
|
||||
| File | Purpose |
|
||||
| ------------------------ | ------------------------------------------------------------------ |
|
||||
| `CLAUDE.md` | Agent rules, architecture, lessons learned. **Template included.** |
|
||||
| `AGENTS.md` | Personality, escalation rules, cross-model review requirement. |
|
||||
| `SOUL.md` | "Who are we?" - tone/voice used by agents. |
|
||||
| `GPT.md` / `CODEX.md` | Model-specific guardrails (optional). |
|
||||
| `docs/BEST-PRACTICES.md` | Patterns and anti-patterns all agents follow. |
|
||||
| File | Purpose |
|
||||
| ------------------------ | -------------------------------------------------------------------- |
|
||||
| `CLAUDE.md` | Agent rules, architecture, lessons learned. **Current repo source.** |
|
||||
| `AGENTS.md` | Optional local agent instructions if your deployment uses that file. |
|
||||
| `SOUL.md` | Optional tone/voice guide if your team keeps one. |
|
||||
| `GPT.md` / `CODEX.md` | Optional model-specific guardrails. |
|
||||
| `docs/BEST-PRACTICES.md` | Patterns and anti-patterns all agents follow. |
|
||||
|
||||
**Cadence:**
|
||||
|
||||
|
|
|
|||
|
|
@ -8,21 +8,21 @@ Read system health indicators and respond to alerts.
|
|||
|
||||
The Global System Health API aggregates three signal streams into a single status response, displayed in real-time by the health status bar in the VK dashboard:
|
||||
|
||||
| Signal | What It Monitors |
|
||||
| ------------ | ---------------------------------------------------- |
|
||||
| `system` | Storage access, disk space (>100 MB free), memory |
|
||||
| `agents` | Agent registry — online, offline, total counts |
|
||||
| `operations` | Run metrics — 24h success rate, failed runs |
|
||||
| Signal | What It Monitors |
|
||||
| ------------ | ------------------------------------------------- |
|
||||
| `system` | Storage access, disk space (>100 MB free), memory |
|
||||
| `agents` | Agent registry — online, offline, total counts |
|
||||
| `operations` | Run metrics — 24h success rate, failed runs |
|
||||
|
||||
**Overall status values (ordered by severity):**
|
||||
|
||||
| Status | Meaning |
|
||||
| ----------- | ------------------------------------------------------------- |
|
||||
| `stable` | All signals OK |
|
||||
| `reviewing` | One warning signal detected |
|
||||
| `drifting` | Two or more warnings, or at least one agent offline |
|
||||
| `elevated` | Any signal is `critical` |
|
||||
| `alert` | System storage failure, or operations success rate < 50% |
|
||||
| Status | Meaning |
|
||||
| ----------- | -------------------------------------------------------- |
|
||||
| `stable` | All signals OK |
|
||||
| `reviewing` | One warning signal detected |
|
||||
| `drifting` | Two or more warnings, or at least one agent offline |
|
||||
| `elevated` | Any signal is `critical` |
|
||||
| `alert` | System storage failure, or operations success rate < 50% |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -38,7 +38,7 @@ The Global System Health API aggregates three signal streams into a single statu
|
|||
### 1. Check System Health
|
||||
|
||||
```bash
|
||||
curl http://localhost:3001/api/system/health
|
||||
curl http://localhost:3001/api/v1/system/health
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
|
@ -75,18 +75,22 @@ curl http://localhost:3001/api/system/health
|
|||
**`stable`:** No action needed.
|
||||
|
||||
**`reviewing`:** Look at which signal is `warn`:
|
||||
|
||||
- `system.memory: false` → heap usage >90% — monitor for leaks or restart if persistent
|
||||
- `operations.status: warn` → success rate 80–99% or >5 failed runs — check recent task failures
|
||||
|
||||
**`drifting`:** Two signals are warning or agents are offline:
|
||||
|
||||
- Check `agents.offline` count — confirm agents are expected to be offline
|
||||
- Run `GET /api/agents` to see which agents are offline and their last heartbeat
|
||||
|
||||
**`elevated`:** A critical signal exists:
|
||||
|
||||
- `agents.status: critical` → all agents offline — check agent processes
|
||||
- `operations.status: critical` → success rate <50% or massive failure count — check logs immediately
|
||||
|
||||
**`alert`:** Immediate action required:
|
||||
|
||||
- `system.storage: false` → data directory inaccessible — check filesystem permissions
|
||||
- `system.disk: false` → <100 MB disk free — clean up disk space immediately
|
||||
- `operations.successRate < 50` → more than half of recent runs failed — check server logs
|
||||
|
|
@ -127,7 +131,7 @@ For automated monitoring, poll the health endpoint and alert on status changes:
|
|||
#!/bin/bash
|
||||
PREV_STATUS=""
|
||||
while true; do
|
||||
STATUS=$(curl -s http://localhost:3001/api/system/health | jq -r '.status')
|
||||
STATUS=$(curl -s http://localhost:3001/api/v1/system/health | jq -r '.status')
|
||||
if [ "$STATUS" != "$PREV_STATUS" ] && [ "$STATUS" != "stable" ]; then
|
||||
echo "ALERT: System status changed to $STATUS"
|
||||
# trigger your notification here
|
||||
|
|
@ -141,9 +145,9 @@ done
|
|||
|
||||
## API Endpoints
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | --------------------- | ------------------------------------ |
|
||||
| `GET` | `/api/system/health` | Get aggregated system health status |
|
||||
| Method | Path | Description |
|
||||
| ------ | ----------------------- | ----------------------------------- |
|
||||
| `GET` | `/api/v1/system/health` | Get aggregated system health status |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -158,6 +162,7 @@ System storage fail OR successRate < 50% → alert
|
|||
```
|
||||
|
||||
Thresholds (hardcoded in v4.0):
|
||||
|
||||
- **Memory warn:** heap used > 90%
|
||||
- **Disk fail:** free space < 100 MB
|
||||
- **Operations warn:** success rate 80–99%, or failedRuns > 5
|
||||
|
|
|
|||
|
|
@ -41,10 +41,13 @@ export default [
|
|||
'no-undef': 'off',
|
||||
|
||||
// TypeScript rules
|
||||
'@typescript-eslint/no-unused-vars': ['warn', {
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
}],
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/no-non-null-assertion': 'warn',
|
||||
|
|
@ -64,6 +67,19 @@ export default [
|
|||
},
|
||||
},
|
||||
|
||||
// Node release/maintenance scripts
|
||||
{
|
||||
files: ['scripts/**/*.mjs'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: 'module',
|
||||
globals: {
|
||||
console: 'readonly',
|
||||
process: 'readonly',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// React/TypeScript files (web)
|
||||
{
|
||||
files: ['web/src/**/*.tsx', 'web/src/**/*.ts'],
|
||||
|
|
@ -93,10 +109,13 @@ export default [
|
|||
'no-undef': 'off',
|
||||
|
||||
// TypeScript rules
|
||||
'@typescript-eslint/no-unused-vars': ['warn', {
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
}],
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/no-non-null-assertion': 'warn',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
#!/usr/bin/env node
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import {
|
||||
|
|
@ -23,11 +24,15 @@ import { sprintTools, handleSprintTool } from './tools/sprints.js';
|
|||
import { commentTools, handleCommentTool } from './tools/comments.js';
|
||||
import { projectTools, handleProjectTool } from './tools/projects.js';
|
||||
|
||||
const packageJson = JSON.parse(
|
||||
readFileSync(new URL('../package.json', import.meta.url), 'utf-8')
|
||||
) as { version?: string };
|
||||
|
||||
// Create MCP server
|
||||
const server = new Server(
|
||||
{
|
||||
name: 'veritas-kanban',
|
||||
version: '0.1.0',
|
||||
version: packageJson.version ?? '0.0.0',
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@
|
|||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true
|
||||
"declaration": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
|
|
|
|||
|
|
@ -15,8 +15,9 @@
|
|||
"dev": "concurrently -n server,web -c blue,green \"pnpm --filter server dev\" \"pnpm --filter web dev\"",
|
||||
"dev:clean": "bash scripts/dev-clean.sh",
|
||||
"dev:watchdog": "bash scripts/dev-watchdog.sh",
|
||||
"build": "pnpm --filter shared build && pnpm --filter server build && pnpm --filter web build",
|
||||
"build": "pnpm --filter @veritas-kanban/shared build && pnpm --filter @veritas-kanban/server build && pnpm --filter @veritas-kanban/web build && pnpm --filter @veritas-kanban/cli build && pnpm --filter @veritas-kanban/mcp build",
|
||||
"lint": "eslint .",
|
||||
"lint:budget": "eslint . --max-warnings=714",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"typecheck": "pnpm --filter @veritas-kanban/shared build && pnpm -r typecheck",
|
||||
"test": "vitest run",
|
||||
|
|
@ -26,6 +27,7 @@
|
|||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:load:smoke": "k6 run load-tests/k6/smoke.js",
|
||||
"test:load": "k6 run load-tests/k6/smoke.js && k6 run load-tests/k6/read-load.js && k6 run load-tests/k6/write-load.js && k6 run load-tests/k6/mixed-load.js && k6 run load-tests/k6/ws-stress.js",
|
||||
"validate:release": "node scripts/validate-release.mjs",
|
||||
"clean": "pnpm -r clean && rm -rf node_modules",
|
||||
"audit": "pnpm audit --prod",
|
||||
"audit:all": "pnpm audit",
|
||||
|
|
|
|||
|
|
@ -1,18 +1,27 @@
|
|||
#!/bin/bash
|
||||
# Script to create remaining code review tasks
|
||||
# Pauses between requests to avoid rate limiting
|
||||
# Legacy one-off script to create historical code review tasks.
|
||||
# Requires an explicit opt-in so it is not run accidentally during repo setup.
|
||||
|
||||
API="http://localhost:3001/api/tasks"
|
||||
if [ "${CONFIRM_CREATE_REVIEW_TASKS:-}" != "1" ]; then
|
||||
echo "This is a legacy one-off script. Set CONFIRM_CREATE_REVIEW_TASKS=1 to run it."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
API="${VK_API_URL:-http://localhost:3001}/api/tasks"
|
||||
AUTH_HEADER=()
|
||||
if [ -n "${VK_API_KEY:-}" ]; then
|
||||
AUTH_HEADER=(-H "x-api-key: ${VK_API_KEY}")
|
||||
fi
|
||||
|
||||
create_task() {
|
||||
local title="$1"
|
||||
local desc="$2"
|
||||
local priority="$3"
|
||||
|
||||
curl -s -X POST "$API" -H "Content-Type: application/json" -d "{
|
||||
curl -s -X POST "$API" "${AUTH_HEADER[@]}" -H "Content-Type: application/json" -d "{
|
||||
\"title\": \"$title\",
|
||||
\"description\": \"$desc\",
|
||||
\"type\": \"refactor-saXoty\",
|
||||
\"type\": \"code\",
|
||||
\"priority\": \"$priority\",
|
||||
\"project\": \"veritas-kanban\"
|
||||
}" > /dev/null
|
||||
|
|
|
|||
377
scripts/validate-release.mjs
Normal file
377
scripts/validate-release.mjs
Normal file
|
|
@ -0,0 +1,377 @@
|
|||
#!/usr/bin/env node
|
||||
import { access, readFile } from 'node:fs/promises';
|
||||
import { constants } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
const packageFiles = [
|
||||
{ label: 'root', file: 'package.json' },
|
||||
{ label: 'shared', file: 'shared/package.json' },
|
||||
{ label: 'server', file: 'server/package.json' },
|
||||
{ label: 'web', file: 'web/package.json' },
|
||||
{ label: 'cli', file: 'cli/package.json' },
|
||||
{ label: 'mcp', file: 'mcp/package.json' },
|
||||
];
|
||||
|
||||
const requiredFiles = [
|
||||
'CHANGELOG.md',
|
||||
'Dockerfile',
|
||||
'README.md',
|
||||
'package.json',
|
||||
'pnpm-lock.yaml',
|
||||
'pnpm-workspace.yaml',
|
||||
];
|
||||
|
||||
const requiredScripts = [
|
||||
'audit',
|
||||
'build',
|
||||
'lint',
|
||||
'lint:budget',
|
||||
'test:e2e',
|
||||
'test:load',
|
||||
'test:load:smoke',
|
||||
'test:unit',
|
||||
'typecheck',
|
||||
];
|
||||
|
||||
const buildOutputs = [
|
||||
{ label: 'shared build output', file: 'shared/dist/index.js' },
|
||||
{ label: 'server build output', file: 'server/dist/index.js' },
|
||||
{ label: 'web build output', file: 'web/dist/index.html' },
|
||||
{ label: 'CLI build output', file: 'cli/dist/index.js' },
|
||||
{ label: 'MCP build output', file: 'mcp/dist/index.js' },
|
||||
];
|
||||
|
||||
const checks = [];
|
||||
|
||||
function usage() {
|
||||
console.log(`Usage: pnpm validate:release -- [options]
|
||||
|
||||
Options:
|
||||
--version <version> Validate a specific version. Defaults to package.json version.
|
||||
--github Validate v<version> tag and GitHub release.
|
||||
--repo <owner/repo> GitHub repository for --github. Defaults to package.json repository.
|
||||
--skip-build-output Skip local dist artifact checks.
|
||||
--docker-build Build the production Docker image as part of validation.
|
||||
--help Show this help text.
|
||||
`);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
dockerBuild: false,
|
||||
github: false,
|
||||
repo: undefined,
|
||||
skipBuildOutput: false,
|
||||
version: undefined,
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
|
||||
if (arg === '--') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--help' || arg === '-h') {
|
||||
usage();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (arg === '--github') {
|
||||
options.github = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--skip-build-output') {
|
||||
options.skipBuildOutput = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--docker-build') {
|
||||
options.dockerBuild = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--version') {
|
||||
options.version = argv[index + 1];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith('--version=')) {
|
||||
options.version = arg.slice('--version='.length);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--repo') {
|
||||
options.repo = argv[index + 1];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith('--repo=')) {
|
||||
options.repo = arg.slice('--repo='.length);
|
||||
continue;
|
||||
}
|
||||
|
||||
fail('CLI options', `Unknown option: ${arg}`);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function record(status, name, detail = '') {
|
||||
checks.push({ status, name, detail });
|
||||
}
|
||||
|
||||
function pass(name, detail = '') {
|
||||
record('pass', name, detail);
|
||||
}
|
||||
|
||||
function fail(name, detail = '') {
|
||||
record('fail', name, detail);
|
||||
}
|
||||
|
||||
function skip(name, detail = '') {
|
||||
record('skip', name, detail);
|
||||
}
|
||||
|
||||
function check(name, condition, detail = '') {
|
||||
if (condition) {
|
||||
pass(name, detail);
|
||||
} else {
|
||||
fail(name, detail);
|
||||
}
|
||||
}
|
||||
|
||||
function relativePath(file) {
|
||||
return path.join(rootDir, file);
|
||||
}
|
||||
|
||||
async function readText(file) {
|
||||
return readFile(relativePath(file), 'utf8');
|
||||
}
|
||||
|
||||
async function readJson(file) {
|
||||
return JSON.parse(await readText(file));
|
||||
}
|
||||
|
||||
async function exists(file) {
|
||||
try {
|
||||
await access(relativePath(file), constants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: rootDir,
|
||||
encoding: 'utf8',
|
||||
stdio: options.stdio ?? 'pipe',
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 1,
|
||||
stdout: '',
|
||||
stderr: result.error.message,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: result.status === 0,
|
||||
status: result.status,
|
||||
stdout: typeof result.stdout === 'string' ? result.stdout.trim() : '',
|
||||
stderr: typeof result.stderr === 'string' ? result.stderr.trim() : '',
|
||||
};
|
||||
}
|
||||
|
||||
function escapeRegex(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function parseGithubRepo(repositoryUrl) {
|
||||
if (!repositoryUrl) return undefined;
|
||||
|
||||
const match = repositoryUrl.match(/github\.com[:/]([^/]+\/[^/.]+)(?:\.git)?$/);
|
||||
return match?.[1];
|
||||
}
|
||||
|
||||
function printableDetail(detail) {
|
||||
return detail ? ` - ${detail}` : '';
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const packages = [];
|
||||
|
||||
for (const packageFile of packageFiles) {
|
||||
packages.push({
|
||||
...packageFile,
|
||||
json: await readJson(packageFile.file),
|
||||
});
|
||||
}
|
||||
|
||||
const rootPackage = packages.find((pkg) => pkg.label === 'root').json;
|
||||
const expectedVersion = options.version ?? rootPackage.version;
|
||||
|
||||
check(
|
||||
'Release version is valid semver',
|
||||
/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(expectedVersion),
|
||||
expectedVersion
|
||||
);
|
||||
|
||||
for (const packageFile of requiredFiles) {
|
||||
check(`Required file exists: ${packageFile}`, await exists(packageFile));
|
||||
}
|
||||
|
||||
for (const pkg of packages) {
|
||||
check(
|
||||
`${pkg.label} package version matches ${expectedVersion}`,
|
||||
pkg.json.version === expectedVersion,
|
||||
`found ${pkg.json.version}`
|
||||
);
|
||||
}
|
||||
|
||||
check(
|
||||
'packageManager pins pnpm',
|
||||
/^pnpm@\d+\.\d+\.\d+$/.test(rootPackage.packageManager ?? ''),
|
||||
rootPackage.packageManager ?? 'not declared'
|
||||
);
|
||||
|
||||
check(
|
||||
'Node engine targets Node 22 or newer',
|
||||
/^>=22\b/.test(rootPackage.engines?.node ?? ''),
|
||||
rootPackage.engines?.node ?? 'not declared'
|
||||
);
|
||||
|
||||
for (const scriptName of requiredScripts) {
|
||||
check(
|
||||
`Required package script exists: ${scriptName}`,
|
||||
typeof rootPackage.scripts?.[scriptName] === 'string',
|
||||
rootPackage.scripts?.[scriptName] ?? 'missing'
|
||||
);
|
||||
}
|
||||
|
||||
const readme = await readText('README.md');
|
||||
check(
|
||||
'README version badge matches release version',
|
||||
new RegExp(`version-${escapeRegex(expectedVersion)}-blue\\.svg`).test(readme),
|
||||
`expected badge version ${expectedVersion}`
|
||||
);
|
||||
|
||||
const changelog = await readText('CHANGELOG.md');
|
||||
check(
|
||||
'CHANGELOG has a release heading',
|
||||
new RegExp(
|
||||
`^## \\[${escapeRegex(expectedVersion)}\\](?:\\s+-\\s+\\d{4}-\\d{2}-\\d{2})?$`,
|
||||
'm'
|
||||
).test(changelog),
|
||||
`expected ## [${expectedVersion}]`
|
||||
);
|
||||
|
||||
if (options.skipBuildOutput) {
|
||||
skip('Local build output validation', 'skipped by --skip-build-output');
|
||||
} else {
|
||||
for (const artifact of buildOutputs) {
|
||||
check(`${artifact.label} exists`, await exists(artifact.file), artifact.file);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.github) {
|
||||
const tagName = `v${expectedVersion}`;
|
||||
const repo = options.repo ?? parseGithubRepo(rootPackage.repository?.url);
|
||||
|
||||
check(
|
||||
'GitHub repository resolved',
|
||||
typeof repo === 'string' && repo.length > 0,
|
||||
repo ?? 'missing'
|
||||
);
|
||||
|
||||
const localTag = run('git', ['tag', '--list', tagName]);
|
||||
check(
|
||||
`Local git tag exists: ${tagName}`,
|
||||
localTag.ok && localTag.stdout.split('\n').includes(tagName),
|
||||
localTag.ok && localTag.stdout ? tagName : localTag.stderr || 'not found'
|
||||
);
|
||||
|
||||
const remoteTag = run('git', ['ls-remote', '--tags', 'origin', `refs/tags/${tagName}`]);
|
||||
check(
|
||||
`Origin git tag exists: ${tagName}`,
|
||||
remoteTag.ok && remoteTag.stdout.includes(`refs/tags/${tagName}`),
|
||||
remoteTag.ok && remoteTag.stdout ? 'origin' : remoteTag.stderr || 'not found'
|
||||
);
|
||||
|
||||
if (repo) {
|
||||
const release = run('gh', [
|
||||
'release',
|
||||
'view',
|
||||
tagName,
|
||||
'--repo',
|
||||
repo,
|
||||
'--json',
|
||||
'isDraft,isPrerelease,name,tagName,url',
|
||||
]);
|
||||
|
||||
if (release.ok) {
|
||||
const releaseJson = JSON.parse(release.stdout);
|
||||
check(
|
||||
`GitHub release exists: ${tagName}`,
|
||||
releaseJson.tagName === tagName,
|
||||
releaseJson.url ?? releaseJson.name ?? ''
|
||||
);
|
||||
check(
|
||||
`GitHub release is published: ${tagName}`,
|
||||
releaseJson.isDraft === false,
|
||||
releaseJson.isDraft ? 'draft release' : 'published'
|
||||
);
|
||||
} else {
|
||||
fail(`GitHub release exists: ${tagName}`, release.stderr || 'gh release view failed');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
skip('Git tag and GitHub release validation', 'pass --github to verify remote release state');
|
||||
}
|
||||
|
||||
if (options.dockerBuild) {
|
||||
const dockerTag = `veritas-kanban:validate-${expectedVersion.replace(/[^0-9A-Za-z_.-]/g, '-')}`;
|
||||
const result = run('docker', ['build', '--target', 'production', '-t', dockerTag, '.'], {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
check('Production Docker image builds', result.ok, dockerTag);
|
||||
} else {
|
||||
skip('Production Docker image build', 'pass --docker-build to verify the image');
|
||||
}
|
||||
|
||||
const labels = {
|
||||
fail: 'FAIL',
|
||||
pass: 'PASS',
|
||||
skip: 'SKIP',
|
||||
};
|
||||
|
||||
console.log(`\nRelease validation for ${expectedVersion}\n`);
|
||||
|
||||
for (const item of checks) {
|
||||
console.log(`${labels[item.status]} ${item.name}${printableDetail(item.detail)}`);
|
||||
}
|
||||
|
||||
const failures = checks.filter((item) => item.status === 'fail');
|
||||
if (failures.length > 0) {
|
||||
console.error(`\nRelease validation failed: ${failures.length} check(s) failed.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('\nRelease validation passed.');
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -4,6 +4,13 @@
|
|||
* Tests payload formatting, HMAC signing, delivery logic, and retry behaviour.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
const mockLookup = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('node:dns/promises', () => ({
|
||||
lookup: mockLookup,
|
||||
}));
|
||||
|
||||
import {
|
||||
signPayload,
|
||||
setWebhookUrl,
|
||||
|
|
@ -33,6 +40,8 @@ function mockFetch(response: { ok: boolean; status?: number } = { ok: true, stat
|
|||
describe('ClawdbotWebhookService', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
mockLookup.mockReset();
|
||||
mockLookup.mockResolvedValue([{ address: '8.8.8.8', family: 4 }]);
|
||||
// Clear env overrides
|
||||
delete process.env.VERITAS_WEBHOOK_URL;
|
||||
delete process.env.VERITAS_WEBHOOK_SECRET;
|
||||
|
|
@ -175,6 +184,17 @@ describe('ClawdbotWebhookService', () => {
|
|||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not fetch or retry when outbound URL policy blocks the host', async () => {
|
||||
setWebhookUrl('https://hook.test/endpoint');
|
||||
mockLookup.mockResolvedValue([{ address: '10.0.0.10', family: 4 }]);
|
||||
const fetchSpy = mockFetch();
|
||||
|
||||
await deliverWebhook(samplePayload);
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does NOT retry on success', async () => {
|
||||
setWebhookUrl('https://hook.test/endpoint');
|
||||
const fetchSpy = mockFetch({ ok: true });
|
||||
|
|
|
|||
|
|
@ -194,13 +194,15 @@ describe('ClawdbotAgentService Codex providers', () => {
|
|||
model: 'gpt-5.5',
|
||||
})
|
||||
);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
'agent_completed',
|
||||
task.id,
|
||||
task.title,
|
||||
expect.objectContaining({ provider: 'codex-cli', success: true }),
|
||||
'codex'
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
'agent_completed',
|
||||
task.id,
|
||||
task.title,
|
||||
expect.objectContaining({ provider: 'codex-cli', success: true }),
|
||||
'codex'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('maps Codex file events to task deliverables linked to the attempt', async () => {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('node:fs/promises', () => {
|
||||
const createFsPromisesMock = vi.hoisted(() => () => {
|
||||
const mod = {
|
||||
access: vi.fn().mockResolvedValue(undefined),
|
||||
appendFile: vi.fn().mockResolvedValue(undefined),
|
||||
copyFile: vi.fn().mockResolvedValue(undefined),
|
||||
mkdir: vi.fn().mockResolvedValue(undefined),
|
||||
readFile: vi.fn().mockResolvedValue(''),
|
||||
writeFile: vi.fn().mockResolvedValue(undefined),
|
||||
|
|
@ -14,6 +16,9 @@ vi.mock('node:fs/promises', () => {
|
|||
return { ...mod, default: mod };
|
||||
});
|
||||
|
||||
vi.mock('fs/promises', createFsPromisesMock);
|
||||
vi.mock('node:fs/promises', createFsPromisesMock);
|
||||
|
||||
// Mock node:fs to prevent filesystem reads
|
||||
vi.mock('node:fs', () => {
|
||||
const mockPromises = {
|
||||
|
|
|
|||
|
|
@ -42,11 +42,11 @@ describe('Task ↔ Agent registry sync (route-level integration)', () => {
|
|||
app.use('/api/tasks', taskRoutes);
|
||||
app.use('/api/agents/register', agentRegistryRoutes);
|
||||
app.use(errorHandler);
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
disposeTaskService();
|
||||
disposeAgentRegistryService();
|
||||
disposeTaskService?.();
|
||||
disposeAgentRegistryService?.();
|
||||
delete process.env.VERITAS_TASK_SYNC_FLAP_GUARD_MS;
|
||||
await fs.rm(testRoot, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -243,7 +243,7 @@ describe('Tasks Routes (actual module)', () => {
|
|||
});
|
||||
|
||||
it('should reject blocked task moving to in-progress', async () => {
|
||||
const oldTask = { id: 't1', status: 'todo', title: 'Task', blockedBy: ['t2'] };
|
||||
const oldTask = { id: 't1', status: 'blocked', title: 'Task', blockedBy: ['t2'] };
|
||||
mockTaskService.getTask.mockResolvedValue(oldTask);
|
||||
mockTaskService.listTasks.mockResolvedValue([oldTask]);
|
||||
mockBlockingService.canMoveToInProgress.mockReturnValue({
|
||||
|
|
|
|||
|
|
@ -2,9 +2,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||
import crypto from 'crypto';
|
||||
|
||||
const mockValidateWebhookUrl = vi.fn();
|
||||
const mockSafeFetch = vi.fn();
|
||||
|
||||
vi.mock('../utils/url-validation.js', () => ({
|
||||
validateWebhookUrl: mockValidateWebhookUrl,
|
||||
safeFetch: mockSafeFetch,
|
||||
}));
|
||||
|
||||
describe('squad webhook service', () => {
|
||||
|
|
@ -14,6 +16,7 @@ describe('squad webhook service', () => {
|
|||
vi.resetModules();
|
||||
mockValidateWebhookUrl.mockReturnValue({ valid: true });
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch' as any);
|
||||
mockSafeFetch.mockImplementation((url: string, init: RequestInit) => fetch(url, init));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -106,7 +109,10 @@ describe('squad webhook service', () => {
|
|||
} as any
|
||||
);
|
||||
|
||||
expect(mockValidateWebhookUrl).toHaveBeenCalledWith('https://gateway.test/tools/invoke');
|
||||
expect(mockSafeFetch).toHaveBeenCalledWith(
|
||||
'https://gateway.test/tools/invoke',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
expect(fetchSpy.mock.calls[0][0]).toBe('https://gateway.test/tools/invoke');
|
||||
expect(JSON.parse(fetchSpy.mock.calls[0][1].body)).toMatchObject({
|
||||
|
|
@ -130,7 +136,7 @@ describe('squad webhook service', () => {
|
|||
notifyOnAgent: true,
|
||||
mode: 'openclaw',
|
||||
} as any);
|
||||
mockValidateWebhookUrl.mockReturnValue({ valid: false, reason: 'ssrf' });
|
||||
mockSafeFetch.mockResolvedValueOnce(null);
|
||||
await mod.fireSquadWebhook(msg, {
|
||||
enabled: true,
|
||||
notifyOnHuman: true,
|
||||
|
|
|
|||
|
|
@ -232,6 +232,21 @@ updated: '2026-01-26T10:00:00.000Z'
|
|||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should clear checkpoint when task is marked done', async () => {
|
||||
const task = await service.createTask({ title: 'Checkpointed Task' });
|
||||
await service.updateTask(task.id, {
|
||||
checkpoint: {
|
||||
step: 2,
|
||||
state: { step: 'mid-run' },
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
const completed = await service.updateTask(task.id, { status: 'done' });
|
||||
|
||||
expect(completed?.checkpoint).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should rename file when title changes', async () => {
|
||||
const task = await service.createTask({ title: 'Original Name' });
|
||||
const originalFiles = await fs.readdir(tasksDir);
|
||||
|
|
|
|||
54
server/src/__tests__/url-validation.test.ts
Normal file
54
server/src/__tests__/url-validation.test.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mockLookup = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('node:dns/promises', () => ({
|
||||
lookup: mockLookup,
|
||||
}));
|
||||
|
||||
import { safeFetch, validateWebhookUrl } from '../utils/url-validation.js';
|
||||
|
||||
describe('url validation', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
mockLookup.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('blocks localhost webhook URLs before fetch', async () => {
|
||||
const fetchSpy = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
|
||||
expect(validateWebhookUrl('https://localhost/hook').valid).toBe(false);
|
||||
await expect(safeFetch('https://127.0.0.1/hook')).resolves.toBeNull();
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('blocks hostnames that resolve to private addresses', async () => {
|
||||
const fetchSpy = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
mockLookup.mockResolvedValue([{ address: '10.0.0.12', family: 4 }]);
|
||||
|
||||
await expect(safeFetch('https://hooks.example.test/hook')).resolves.toBeNull();
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forces manual redirect handling for allowed outbound fetches', async () => {
|
||||
const response = { ok: true, status: 200 } as Response;
|
||||
const fetchSpy = vi.fn().mockResolvedValue(response);
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
mockLookup.mockResolvedValue([{ address: '8.8.8.8', family: 4 }]);
|
||||
|
||||
await expect(
|
||||
safeFetch('https://hooks.example.test/hook', { method: 'POST', redirect: 'follow' })
|
||||
).resolves.toBe(response);
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'https://hooks.example.test/hook',
|
||||
expect.objectContaining({ method: 'POST', redirect: 'manual' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -53,15 +53,7 @@ import { apiRateLimit, authRateLimit } from './middleware/rate-limit.js';
|
|||
import { apiVersionMiddleware } from './middleware/api-version.js';
|
||||
import { apiCacheHeaders } from './middleware/cache-control.js';
|
||||
import type { AgentOutput } from './services/clawdbot-agent-service.js';
|
||||
import { taskArchiveRoutes } from './routes/task-archive.js';
|
||||
import { taskTimeRoutes } from './routes/task-time.js';
|
||||
import { taskRoutes } from './routes/tasks.js';
|
||||
import { taskCommentRoutes } from './routes/task-comments.js';
|
||||
import { taskSubtaskRoutes } from './routes/task-subtasks.js';
|
||||
import attachmentRoutes from './routes/attachments.js';
|
||||
import { webhookN8nRouter } from './routes/webhook-n8n.js';
|
||||
import { configRoutes } from './routes/config.js';
|
||||
import { agentRoutes } from './routes/agents.js';
|
||||
import { cspNonceMiddleware, cspNonceDirective } from './middleware/csp-nonce.js';
|
||||
import { healthRouter, apiHealthRouter, setHealthWss } from './routes/health.js';
|
||||
import { getPrometheusCollector } from './services/metrics/prometheus.js';
|
||||
|
|
@ -669,7 +661,7 @@ wss.on('connection', (ws: HeartbeatWebSocket, req) => {
|
|||
// Authenticate WebSocket connection
|
||||
const authResult = authenticateWebSocket(req);
|
||||
|
||||
if (!authResult.authenticated) {
|
||||
if (!authResult.authenticated || !authResult.role) {
|
||||
log.warn({ error: authResult.error }, 'WebSocket connection rejected');
|
||||
ws.close(4001, authResult.error || 'Authentication required');
|
||||
return;
|
||||
|
|
@ -677,7 +669,7 @@ wss.on('connection', (ws: HeartbeatWebSocket, req) => {
|
|||
|
||||
// Attach auth info to WebSocket for later use
|
||||
ws.auth = {
|
||||
role: authResult.role!,
|
||||
role: authResult.role,
|
||||
keyName: authResult.keyName,
|
||||
isLocalhost: authResult.isLocalhost,
|
||||
};
|
||||
|
|
@ -709,10 +701,16 @@ wss.on('connection', (ws: HeartbeatWebSocket, req) => {
|
|||
let currentErrorHandler: ((error: Error) => void) | null = null;
|
||||
|
||||
const cleanupEmitterListeners = () => {
|
||||
if (currentEmitter && currentOutputHandler) {
|
||||
currentEmitter.off('output', currentOutputHandler);
|
||||
currentEmitter.off('complete', currentCompleteHandler!);
|
||||
currentEmitter.off('error', currentErrorHandler!);
|
||||
if (currentEmitter) {
|
||||
if (currentOutputHandler) {
|
||||
currentEmitter.off('output', currentOutputHandler);
|
||||
}
|
||||
if (currentCompleteHandler) {
|
||||
currentEmitter.off('complete', currentCompleteHandler);
|
||||
}
|
||||
if (currentErrorHandler) {
|
||||
currentEmitter.off('error', currentErrorHandler);
|
||||
}
|
||||
currentEmitter = null;
|
||||
currentOutputHandler = null;
|
||||
currentCompleteHandler = null;
|
||||
|
|
@ -759,10 +757,12 @@ wss.on('connection', (ws: HeartbeatWebSocket, req) => {
|
|||
// Subscribe to new chat session
|
||||
const sessionId: string = message.sessionId;
|
||||
subscribedChatSession = sessionId;
|
||||
if (!chatSubscriptions.has(sessionId)) {
|
||||
chatSubscriptions.set(sessionId, new Set());
|
||||
let sessionSubscribers = chatSubscriptions.get(sessionId);
|
||||
if (!sessionSubscribers) {
|
||||
sessionSubscribers = new Set();
|
||||
chatSubscriptions.set(sessionId, sessionSubscribers);
|
||||
}
|
||||
chatSubscriptions.get(sessionId)!.add(ws);
|
||||
sessionSubscribers.add(ws);
|
||||
|
||||
// Send confirmation
|
||||
ws.send(
|
||||
|
|
@ -772,10 +772,7 @@ wss.on('connection', (ws: HeartbeatWebSocket, req) => {
|
|||
})
|
||||
);
|
||||
|
||||
log.debug(
|
||||
{ sessionId, clients: chatSubscriptions.get(sessionId)!.size },
|
||||
'Chat subscription added'
|
||||
);
|
||||
log.debug({ sessionId, clients: sessionSubscribers.size }, 'Chat subscription added');
|
||||
}
|
||||
|
||||
// Handle subscription to agent output
|
||||
|
|
@ -794,10 +791,12 @@ wss.on('connection', (ws: HeartbeatWebSocket, req) => {
|
|||
// Subscribe to new task
|
||||
const newTaskId: string = message.taskId;
|
||||
subscribedTaskId = newTaskId;
|
||||
if (!agentSubscriptions.has(newTaskId)) {
|
||||
agentSubscriptions.set(newTaskId, new Set());
|
||||
let taskSubscribers = agentSubscriptions.get(newTaskId);
|
||||
if (!taskSubscribers) {
|
||||
taskSubscribers = new Set();
|
||||
agentSubscriptions.set(newTaskId, taskSubscribers);
|
||||
}
|
||||
agentSubscriptions.get(newTaskId)!.add(ws);
|
||||
taskSubscribers.add(ws);
|
||||
|
||||
// Clean up previous emitter listeners before subscribing to new task
|
||||
cleanupEmitterListeners();
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ const rotateSecretSchema = z
|
|||
.default({});
|
||||
|
||||
// Constants
|
||||
const SALT_ROUNDS = 12;
|
||||
const SALT_ROUNDS = process.env.NODE_ENV === 'test' ? 4 : 12;
|
||||
const JWT_EXPIRY_DEFAULT = '24h';
|
||||
const JWT_EXPIRY_REMEMBER = '30d';
|
||||
|
||||
|
|
|
|||
|
|
@ -689,8 +689,12 @@ router.patch(
|
|||
// (human users can still approve, or API keys with admin/agent role)
|
||||
}
|
||||
|
||||
// Check if trying to move blocked task to in-progress
|
||||
if (input.status === 'in-progress' && oldTask.status === 'todo' && oldTask.blockedBy?.length) {
|
||||
// Check if trying to move a blocked task to in-progress
|
||||
if (
|
||||
input.status === 'in-progress' &&
|
||||
oldTask.status !== 'in-progress' &&
|
||||
oldTask.blockedBy?.length
|
||||
) {
|
||||
const allTasks = await taskService.listTasks();
|
||||
const { allowed, blockers } = blockingService.canMoveToInProgress(oldTask, allTasks);
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
|
||||
import crypto from 'crypto';
|
||||
import { createLogger } from '../lib/logger.js';
|
||||
import { validateWebhookUrl } from '../utils/url-validation.js';
|
||||
import { safeFetch } from '../utils/url-validation.js';
|
||||
import type { TaskChangeType } from './broadcast-service.js';
|
||||
|
||||
const log = createLogger('webhook');
|
||||
|
|
@ -101,9 +101,10 @@ export function signPayload(body: string, secret: string): string {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* POST a JSON payload to `url`. Returns true on 2xx, false otherwise.
|
||||
* POST a JSON payload to `url`. Returns true on 2xx, false on non-2xx,
|
||||
* and null when the URL is blocked by outbound URL policy.
|
||||
*/
|
||||
async function postPayload(url: string, body: string, secret?: string): Promise<boolean> {
|
||||
async function postPayload(url: string, body: string, secret?: string): Promise<boolean | null> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'VeritasKanban-Webhook/1.0',
|
||||
|
|
@ -113,13 +114,15 @@ async function postPayload(url: string, body: string, secret?: string): Promise<
|
|||
headers['X-Webhook-Signature'] = signPayload(body, secret);
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
const res = await safeFetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
signal: AbortSignal.timeout(10_000), // 10 s hard timeout per attempt
|
||||
});
|
||||
|
||||
if (!res) return null;
|
||||
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
|
|
@ -132,18 +135,15 @@ export async function deliverWebhook(payload: WebhookPayload): Promise<void> {
|
|||
const url = getWebhookUrl();
|
||||
if (!url) return; // webhook not configured — silently skip
|
||||
|
||||
// Validate URL to prevent SSRF attacks
|
||||
const validation = validateWebhookUrl(url);
|
||||
if (!validation.valid) {
|
||||
log.warn({ reason: validation.reason }, 'Webhook URL blocked (SSRF prevention)');
|
||||
return;
|
||||
}
|
||||
|
||||
const body = JSON.stringify(payload);
|
||||
const secret = getWebhookSecret();
|
||||
|
||||
try {
|
||||
const ok = await postPayload(url, body, secret);
|
||||
if (ok === null) {
|
||||
log.warn('Webhook URL blocked (SSRF prevention)');
|
||||
return;
|
||||
}
|
||||
if (ok) {
|
||||
log.debug({ event: payload.event }, 'Webhook delivered');
|
||||
return;
|
||||
|
|
@ -157,6 +157,10 @@ export async function deliverWebhook(payload: WebhookPayload): Promise<void> {
|
|||
setTimeout(async () => {
|
||||
try {
|
||||
const ok = await postPayload(url, body, secret);
|
||||
if (ok === null) {
|
||||
log.warn('Webhook retry URL blocked (SSRF prevention)');
|
||||
return;
|
||||
}
|
||||
if (!ok) {
|
||||
log.error({ event: payload.event }, 'Webhook retry failed (non-2xx)');
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
import { getNotificationService, type NotificationService } from './notification-service.js';
|
||||
import { getConfigService, type ConfigService } from './config-service.js';
|
||||
import type { TelemetryEventIngestion } from '../schemas/telemetry-schemas.js';
|
||||
import { safeFetch } from '../utils/url-validation.js';
|
||||
|
||||
// Deduplication cache: taskId -> last alert timestamp
|
||||
const recentAlerts = new Map<string, number>();
|
||||
|
|
@ -204,7 +205,7 @@ export class FailureAlertService {
|
|||
return false;
|
||||
}
|
||||
|
||||
const response = await fetch(webhookUrl, {
|
||||
const response = await safeFetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
|
|
@ -212,6 +213,11 @@ export class FailureAlertService {
|
|||
}),
|
||||
});
|
||||
|
||||
if (!response) {
|
||||
log.warn('[FailureAlert] Webhook URL blocked by outbound URL policy');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
log.warn(`[FailureAlert] Webhook delivery failed: ${response.status}`);
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
*/
|
||||
|
||||
import { createLogger } from '../lib/logger.js';
|
||||
import { validateWebhookUrl } from '../utils/url-validation.js';
|
||||
import { safeFetch } from '../utils/url-validation.js';
|
||||
import type { Task, EnforcementSettings } from '@veritas-kanban/shared';
|
||||
import { getChatService } from './chat-service.js';
|
||||
import { getNotificationService } from './notification-service.js';
|
||||
|
|
@ -216,17 +216,10 @@ async function fireSquadChat(
|
|||
* Single retry after 2 seconds on failure.
|
||||
*/
|
||||
async function fireWebhook(url: string, payload: HookPayload): Promise<void> {
|
||||
// Validate URL to prevent SSRF attacks
|
||||
const validation = validateWebhookUrl(url);
|
||||
if (!validation.valid) {
|
||||
log.warn({ url, reason: validation.reason }, 'Webhook URL blocked (SSRF prevention)');
|
||||
return;
|
||||
}
|
||||
|
||||
const body = JSON.stringify(payload);
|
||||
|
||||
const doFetch = async (): Promise<void> => {
|
||||
const response = await fetch(url, {
|
||||
const doFetch = async (): Promise<boolean> => {
|
||||
const response = await safeFetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
|
@ -236,13 +229,21 @@ async function fireWebhook(url: string, payload: HookPayload): Promise<void> {
|
|||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
if (!response) {
|
||||
log.warn({ url }, 'Webhook URL blocked (SSRF prevention)');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
try {
|
||||
await doFetch();
|
||||
const delivered = await doFetch();
|
||||
if (!delivered) return;
|
||||
log.debug({ event: payload.event, url }, 'Webhook delivered');
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
|
|
@ -253,7 +254,8 @@ async function fireWebhook(url: string, payload: HookPayload): Promise<void> {
|
|||
// Single retry after 2 seconds
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await doFetch();
|
||||
const delivered = await doFetch();
|
||||
if (!delivered) return;
|
||||
log.debug({ event: payload.event, url }, 'Webhook retry succeeded');
|
||||
} catch (retryErr) {
|
||||
log.error(
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { createLogger } from '../lib/logger.js';
|
|||
import { ConflictError, NotFoundError, ValidationError } from '../middleware/error-handler.js';
|
||||
import { policySchema } from '../schemas/policy-schemas.js';
|
||||
import { getPoliciesDir } from '../utils/paths.js';
|
||||
import { safeFetch } from '../utils/url-validation.js';
|
||||
|
||||
const log = createLogger('policy-service');
|
||||
const PRESET_TIMESTAMP = new Date().toISOString();
|
||||
|
|
@ -440,13 +441,17 @@ export class PolicyService {
|
|||
const body = policy.config.sendContext === false ? undefined : JSON.stringify(input);
|
||||
|
||||
try {
|
||||
const response = await fetch(policy.config.url, {
|
||||
const response = await safeFetch(policy.config.url, {
|
||||
method: policy.config.method ?? 'POST',
|
||||
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
||||
body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response) {
|
||||
throw new Error('Webhook URL blocked by outbound URL policy');
|
||||
}
|
||||
|
||||
const text = await response.text().catch(() => '');
|
||||
const statusMatches = response.status === (policy.config.expectedStatus ?? 200);
|
||||
const bodyMatches = policy.config.expectedBodyContains
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
import crypto from 'crypto';
|
||||
import type { SquadMessage, SquadWebhookSettings } from '@veritas-kanban/shared';
|
||||
import { createLogger } from '../lib/logger.js';
|
||||
import { validateWebhookUrl } from '../utils/url-validation.js';
|
||||
import { safeFetch } from '../utils/url-validation.js';
|
||||
|
||||
const log = createLogger('squad-webhook');
|
||||
|
||||
|
|
@ -86,18 +86,11 @@ async function fireOpenClawWake(
|
|||
|
||||
const url = `${settings.openclawGatewayUrl}/tools/invoke`;
|
||||
|
||||
// Validate URL to prevent SSRF attacks
|
||||
const validation = validateWebhookUrl(url);
|
||||
if (!validation.valid) {
|
||||
log.warn({ url, reason: validation.reason }, 'Webhook URL blocked (SSRF prevention)');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 5000); // 5 second timeout
|
||||
|
||||
const response = await fetch(url, {
|
||||
const response = await safeFetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
|
@ -109,6 +102,11 @@ async function fireOpenClawWake(
|
|||
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!response) {
|
||||
log.warn({ url }, 'OpenClaw wake call URL blocked (SSRF prevention)');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
log.warn(
|
||||
{ status: response.status, statusText: response.statusText, url },
|
||||
|
|
@ -184,7 +182,7 @@ async function fireWebhookAsync(
|
|||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 5000); // 5 second timeout
|
||||
|
||||
const response = await fetch(url, {
|
||||
const response = await safeFetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
|
|
@ -193,6 +191,11 @@ async function fireWebhookAsync(
|
|||
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!response) {
|
||||
log.warn({ url }, 'Squad webhook URL blocked (SSRF prevention)');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
log.warn(
|
||||
{ status: response.status, statusText: response.statusText, url },
|
||||
|
|
|
|||
|
|
@ -757,9 +757,11 @@ export class TaskService {
|
|||
}
|
||||
|
||||
// Handle checkpoint resumption: increment resumeCount if transitioning to in-progress with checkpoint
|
||||
const hasCheckpointInput = Object.prototype.hasOwnProperty.call(input, 'checkpoint');
|
||||
let checkpointUpdate = input.checkpoint;
|
||||
let clearCheckpoint = hasCheckpointInput && input.checkpoint === undefined;
|
||||
if (
|
||||
!checkpointUpdate &&
|
||||
!hasCheckpointInput &&
|
||||
freshTask.checkpoint &&
|
||||
input.status === 'in-progress' &&
|
||||
previousStatus !== 'in-progress'
|
||||
|
|
@ -773,7 +775,7 @@ export class TaskService {
|
|||
|
||||
// Clear checkpoint when task completes successfully
|
||||
if (input.status === 'done' && freshTask.checkpoint) {
|
||||
checkpointUpdate = undefined;
|
||||
clearCheckpoint = true;
|
||||
}
|
||||
|
||||
// Validate agent ref against registry if being changed (#157)
|
||||
|
|
@ -802,7 +804,11 @@ export class TaskService {
|
|||
? undefined
|
||||
: (blockedReasonUpdate ?? freshTask.blockedReason),
|
||||
// Apply checkpoint update (resume count or clear)
|
||||
checkpoint: checkpointUpdate !== undefined ? checkpointUpdate : freshTask.checkpoint,
|
||||
checkpoint: clearCheckpoint
|
||||
? undefined
|
||||
: checkpointUpdate !== undefined
|
||||
? checkpointUpdate
|
||||
: freshTask.checkpoint,
|
||||
updated: new Date().toISOString(),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { createLogger } from '../lib/logger.js';
|
||||
import { validateWebhookUrl } from '../utils/url-validation.js';
|
||||
import { safeFetch } from '../utils/url-validation.js';
|
||||
import type { Task, TaskStatus } from '@veritas-kanban/shared';
|
||||
import type {
|
||||
TransitionHooksConfig,
|
||||
|
|
@ -376,13 +376,6 @@ async function sendWebhook(
|
|||
fromStatus: TaskStatus | undefined,
|
||||
toStatus: TaskStatus
|
||||
): Promise<void> {
|
||||
// Validate URL to prevent SSRF attacks
|
||||
const validation = validateWebhookUrl(url);
|
||||
if (!validation.valid) {
|
||||
log.warn({ url, reason: validation.reason }, 'Webhook URL blocked (SSRF prevention)');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
event: 'status_transition',
|
||||
taskId: task.id,
|
||||
|
|
@ -394,7 +387,7 @@ async function sendWebhook(
|
|||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const response = await fetch(url, {
|
||||
const response = await safeFetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
|
@ -404,6 +397,11 @@ async function sendWebhook(
|
|||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
if (!response) {
|
||||
log.warn({ url }, 'Transition webhook URL blocked (SSRF prevention)');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Webhook failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@
|
|||
* @see docs/SECURITY_AUDIT_2026-01-28.md — SSRF prevention
|
||||
*/
|
||||
|
||||
import { lookup } from 'node:dns/promises';
|
||||
import { isIP } from 'node:net';
|
||||
import { createLogger } from '../lib/logger.js';
|
||||
|
||||
const log = createLogger('url-validation');
|
||||
|
|
@ -98,6 +100,27 @@ function isBlockedIPv6(host: string): { blocked: boolean; reason?: string } {
|
|||
return { blocked: false };
|
||||
}
|
||||
|
||||
function isBlockedIpAddress(
|
||||
address: string,
|
||||
opts: UrlValidationOptions
|
||||
): { blocked: boolean; reason?: string } {
|
||||
if (isIP(address) === 4) {
|
||||
const check = isBlockedIPv4(address);
|
||||
if (check.blocked && !opts.allowPrivateIp && !opts.allowLocalhost) {
|
||||
return check;
|
||||
}
|
||||
}
|
||||
|
||||
if (isIP(address) === 6) {
|
||||
const check = isBlockedIPv6(address);
|
||||
if (check.blocked && !opts.allowPrivateIp && !opts.allowLocalhost) {
|
||||
return check;
|
||||
}
|
||||
}
|
||||
|
||||
return { blocked: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if hostname resolves to localhost variants
|
||||
*/
|
||||
|
|
@ -225,6 +248,42 @@ export function validateWebhookUrl(
|
|||
return { valid: true, normalized: parsed.href };
|
||||
}
|
||||
|
||||
async function validateResolvedHostname(
|
||||
parsed: URL,
|
||||
opts: UrlValidationOptions
|
||||
): Promise<UrlValidationResult> {
|
||||
const hostname = parsed.hostname;
|
||||
|
||||
if (isIP(hostname) !== 0 || isLocalhostHostname(hostname)) {
|
||||
return { valid: true, normalized: parsed.href };
|
||||
}
|
||||
|
||||
try {
|
||||
const records = await lookup(hostname, { all: true, verbatim: true });
|
||||
for (const record of records) {
|
||||
const check = isBlockedIpAddress(record.address, opts);
|
||||
if (check.blocked) {
|
||||
const result = {
|
||||
valid: false,
|
||||
reason: `Hostname resolves to blocked address: ${check.reason}`,
|
||||
};
|
||||
if (opts.logFailures) {
|
||||
log.warn({ url: parsed.origin, reason: result.reason }, 'Webhook URL blocked');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
const result = { valid: false, reason: 'Hostname could not be resolved' };
|
||||
if (opts.logFailures) {
|
||||
log.warn({ url: parsed.origin, reason: result.reason }, 'Webhook URL blocked');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return { valid: true, normalized: parsed.href };
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely fetch a URL after validation
|
||||
*
|
||||
|
|
@ -238,10 +297,20 @@ export async function safeFetch(
|
|||
init?: RequestInit,
|
||||
validationOptions?: UrlValidationOptions
|
||||
): Promise<Response | null> {
|
||||
const validation = validateWebhookUrl(url, validationOptions);
|
||||
const opts = { ...DEFAULT_OPTIONS, ...validationOptions };
|
||||
const validation = validateWebhookUrl(url, opts);
|
||||
if (!validation.valid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return fetch(url, init);
|
||||
const parsed = new URL(validation.normalized ?? url);
|
||||
const resolved = await validateResolvedHostname(parsed, opts);
|
||||
if (!resolved.valid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return fetch(validation.normalized ?? url, {
|
||||
...init,
|
||||
redirect: 'manual',
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,12 +7,34 @@ import type { Task } from '../types/task.types.js';
|
|||
const DEFAULT_BASE = 'http://localhost:3001';
|
||||
|
||||
/** Standard API response envelope */
|
||||
interface ApiEnvelope<T> {
|
||||
success: boolean;
|
||||
interface ApiSuccessEnvelope<T> {
|
||||
success: true;
|
||||
data: T;
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface ApiErrorEnvelope {
|
||||
success: false;
|
||||
error: {
|
||||
code?: string;
|
||||
message?: string;
|
||||
details?: unknown;
|
||||
};
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
function isErrorEnvelope(value: unknown): value is ApiErrorEnvelope {
|
||||
return isRecord(value) && value.success === false && isRecord(value.error);
|
||||
}
|
||||
|
||||
function isSuccessEnvelope<T>(value: unknown): value is ApiSuccessEnvelope<T> {
|
||||
return isRecord(value) && value.success === true && 'data' in value;
|
||||
}
|
||||
|
||||
function getEnv(name: string): string | undefined {
|
||||
return typeof process !== 'undefined' ? process.env?.[name] : undefined;
|
||||
}
|
||||
|
|
@ -69,10 +91,17 @@ export function createApiClient(baseUrl = DEFAULT_BASE, apiKey = getEnv('VK_API_
|
|||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = (await res.json().catch(() => ({ error: res.statusText }))) as {
|
||||
error?: string;
|
||||
};
|
||||
throw new Error(error.error || `API error: ${res.status}`);
|
||||
const error = (await res.json().catch(() => ({ error: res.statusText }))) as unknown;
|
||||
|
||||
if (isErrorEnvelope(error)) {
|
||||
throw new Error(error.error.message || `API error: ${res.status}`);
|
||||
}
|
||||
|
||||
const legacyError = isRecord(error) && typeof error.error === 'string' ? error.error : null;
|
||||
const legacyMessage =
|
||||
isRecord(error) && typeof error.message === 'string' ? error.message : null;
|
||||
|
||||
throw new Error(legacyError || legacyMessage || `API error: ${res.status}`);
|
||||
}
|
||||
|
||||
if (res.status === 204) {
|
||||
|
|
@ -82,8 +111,8 @@ export function createApiClient(baseUrl = DEFAULT_BASE, apiKey = getEnv('VK_API_
|
|||
const body = await res.json();
|
||||
|
||||
// Unwrap standard API envelope { success, data, meta }
|
||||
if (body && typeof body === 'object' && 'success' in body && 'data' in body) {
|
||||
return (body as ApiEnvelope<T>).data;
|
||||
if (isSuccessEnvelope<T>(body)) {
|
||||
return body.data;
|
||||
}
|
||||
|
||||
return body as T;
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@
|
|||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src --ext .ts,.tsx",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test": "vitest run --testTimeout 15000",
|
||||
"test:watch": "vitest --testTimeout 15000",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { SkipToContent } from './components/shared/SkipToContent';
|
|||
import { LiveAnnouncerProvider } from './components/shared/LiveAnnouncer';
|
||||
import { FloatingChat } from './components/chat/FloatingChat';
|
||||
import { SystemHealthBar } from './components/layout/SystemHealthBar';
|
||||
import { VIEW_BY_ID, type AppView } from './lib/views';
|
||||
|
||||
// Lazy-load ActivityFeed and BacklogPage to keep initial bundle small
|
||||
const ActivityFeed = lazy(() =>
|
||||
|
|
@ -72,19 +73,21 @@ const PolicyManager = lazy(() =>
|
|||
}))
|
||||
);
|
||||
|
||||
function ViewLoading({ view }: { view: AppView }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<span className="text-muted-foreground">{VIEW_BY_ID[view].loadingLabel ?? 'Loading...'}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Renders the current view (board, activity feed, or backlog). */
|
||||
function MainContent() {
|
||||
const { view, setView, navigateToTask } = useView();
|
||||
|
||||
if (view === 'activity') {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<span className="text-muted-foreground">Loading activity feed…</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Suspense fallback={<ViewLoading view="activity" />}>
|
||||
<ActivityFeed
|
||||
onBack={() => setView('board')}
|
||||
onTaskClick={(taskId) => navigateToTask(taskId)}
|
||||
|
|
@ -95,13 +98,7 @@ function MainContent() {
|
|||
|
||||
if (view === 'backlog') {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<span className="text-muted-foreground">Loading backlog…</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Suspense fallback={<ViewLoading view="backlog" />}>
|
||||
<BacklogPage onBack={() => setView('board')} />
|
||||
</Suspense>
|
||||
);
|
||||
|
|
@ -109,13 +106,7 @@ function MainContent() {
|
|||
|
||||
if (view === 'archive') {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<span className="text-muted-foreground">Loading archive…</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Suspense fallback={<ViewLoading view="archive" />}>
|
||||
<ArchivePage onBack={() => setView('board')} />
|
||||
</Suspense>
|
||||
);
|
||||
|
|
@ -123,13 +114,7 @@ function MainContent() {
|
|||
|
||||
if (view === 'templates') {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<span className="text-muted-foreground">Loading templates…</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Suspense fallback={<ViewLoading view="templates" />}>
|
||||
<TemplatesPage onBack={() => setView('board')} />
|
||||
</Suspense>
|
||||
);
|
||||
|
|
@ -137,13 +122,7 @@ function MainContent() {
|
|||
|
||||
if (view === 'workflows') {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<span className="text-muted-foreground">Loading workflows…</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Suspense fallback={<ViewLoading view="workflows" />}>
|
||||
<WorkflowsPage onBack={() => setView('board')} />
|
||||
</Suspense>
|
||||
);
|
||||
|
|
@ -151,13 +130,7 @@ function MainContent() {
|
|||
|
||||
if (view === 'drift') {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<span className="text-muted-foreground">Loading drift monitor…</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Suspense fallback={<ViewLoading view="drift" />}>
|
||||
<DriftMonitor onBack={() => setView('board')} />
|
||||
</Suspense>
|
||||
);
|
||||
|
|
@ -165,13 +138,7 @@ function MainContent() {
|
|||
|
||||
if (view === 'decisions') {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<span className="text-muted-foreground">Loading decisions…</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Suspense fallback={<ViewLoading view="decisions" />}>
|
||||
<DecisionExplorer onBack={() => setView('board')} />
|
||||
</Suspense>
|
||||
);
|
||||
|
|
@ -179,13 +146,7 @@ function MainContent() {
|
|||
|
||||
if (view === 'scoring') {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<span className="text-muted-foreground">Loading scoring…</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Suspense fallback={<ViewLoading view="scoring" />}>
|
||||
<ScoringProfiles onBack={() => setView('board')} />
|
||||
</Suspense>
|
||||
);
|
||||
|
|
@ -193,13 +154,7 @@ function MainContent() {
|
|||
|
||||
if (view === 'policies') {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<span className="text-muted-foreground">Loading policies…</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Suspense fallback={<ViewLoading view="policies" />}>
|
||||
<PolicyManager onBack={() => setView('board')} />
|
||||
</Suspense>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
/**
|
||||
* Tests for lib/api/helpers.ts — handleResponse envelope unwrapping.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { handleResponse } from '@/lib/api/helpers';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { API_BASE, apiFetch, handleResponse } from '@/lib/api/helpers';
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -31,6 +31,10 @@ describe('handleResponse', () => {
|
|||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('returns undefined for 204 No Content', async () => {
|
||||
const response = { ok: true, status: 204, json: vi.fn() } as unknown as Response;
|
||||
const result = await handleResponse<void>(response);
|
||||
|
|
@ -99,4 +103,14 @@ describe('handleResponse', () => {
|
|||
const result = await handleResponse(brokenJsonResponse(200));
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('apiFetch routes /api paths through the configured API base once', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({ success: true, data: [] })));
|
||||
|
||||
await apiFetch('/api/tasks');
|
||||
await apiFetch(`${API_BASE}/metrics`);
|
||||
|
||||
expect(fetch).toHaveBeenNthCalledWith(1, `${API_BASE}/tasks`, expect.any(Object));
|
||||
expect(fetch).toHaveBeenNthCalledWith(2, `${API_BASE}/metrics`, expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { useTasks, useTasksByStatus, useUpdateTask, useReorderTasks } from '@/ho
|
|||
import { useBoardDragDrop } from '@/hooks/useBoardDragDrop';
|
||||
import { KanbanColumn } from './KanbanColumn';
|
||||
import { BoardLoadingSkeleton } from './BoardLoadingSkeleton';
|
||||
import { TaskDetailPanel } from '@/components/task/TaskDetailPanel';
|
||||
import type { TaskStatus, Task } from '@veritas-kanban/shared';
|
||||
import { useFeatureSettings } from '@/hooks/useFeatureSettings';
|
||||
import { DndContext, DragOverlay } from '@dnd-kit/core';
|
||||
|
|
@ -33,6 +32,12 @@ const Dashboard = lazy(() =>
|
|||
}))
|
||||
);
|
||||
|
||||
const TaskDetailPanel = lazy(() =>
|
||||
import('@/components/task/TaskDetailPanel').then((mod) => ({
|
||||
default: mod.TaskDetailPanel,
|
||||
}))
|
||||
);
|
||||
|
||||
const COLUMNS: { id: TaskStatus; title: string }[] = [
|
||||
{ id: 'todo', title: 'To Do' },
|
||||
{ id: 'in-progress', title: 'In Progress' },
|
||||
|
|
@ -46,6 +51,7 @@ export function KanbanBoard() {
|
|||
const { announce } = useLiveAnnouncer();
|
||||
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [detailPanelMounted, setDetailPanelMounted] = useState(false);
|
||||
|
||||
// Initialize filters from URL
|
||||
const [filters, setFilters] = useState<FilterState>(() => {
|
||||
|
|
@ -67,6 +73,7 @@ export function KanbanBoard() {
|
|||
// Try local task list first
|
||||
const localTask = tasks?.find((t) => t.id === pendingTaskId);
|
||||
if (localTask) {
|
||||
setDetailPanelMounted(true);
|
||||
setSelectedTask(localTask);
|
||||
setDetailOpen(true);
|
||||
clearPendingTask();
|
||||
|
|
@ -78,6 +85,7 @@ export function KanbanBoard() {
|
|||
const { api } = await import('@/lib/api');
|
||||
const fetchedTask = await api.tasks.get(pendingTaskId);
|
||||
if (fetchedTask) {
|
||||
setDetailPanelMounted(true);
|
||||
setSelectedTask(fetchedTask);
|
||||
setDetailOpen(true);
|
||||
}
|
||||
|
|
@ -114,6 +122,7 @@ export function KanbanBoard() {
|
|||
|
||||
// Handler for opening a task
|
||||
const handleTaskClick = useCallback((task: Task) => {
|
||||
setDetailPanelMounted(true);
|
||||
setSelectedTask(task);
|
||||
setDetailOpen(true);
|
||||
}, []);
|
||||
|
|
@ -127,6 +136,7 @@ export function KanbanBoard() {
|
|||
// Try local task list first
|
||||
const localTask = tasks?.find((t) => t.id === taskId);
|
||||
if (localTask) {
|
||||
setDetailPanelMounted(true);
|
||||
setSelectedTask(localTask);
|
||||
setDetailOpen(true);
|
||||
return;
|
||||
|
|
@ -137,6 +147,7 @@ export function KanbanBoard() {
|
|||
const { api } = await import('@/lib/api');
|
||||
const fetchedTask = await api.tasks.get(taskId);
|
||||
if (fetchedTask) {
|
||||
setDetailPanelMounted(true);
|
||||
setSelectedTask(fetchedTask);
|
||||
setDetailOpen(true);
|
||||
}
|
||||
|
|
@ -318,11 +329,15 @@ export function KanbanBoard() {
|
|||
)}
|
||||
</FeatureErrorBoundary>
|
||||
|
||||
<TaskDetailPanel
|
||||
task={currentSelectedTask}
|
||||
open={detailOpen}
|
||||
onOpenChange={handleDetailClose}
|
||||
/>
|
||||
{detailPanelMounted && (
|
||||
<Suspense fallback={null}>
|
||||
<TaskDetailPanel
|
||||
task={currentSelectedTask}
|
||||
open={detailOpen}
|
||||
onOpenChange={handleDetailClose}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,15 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { lazy, Suspense, useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
import { ChatPanel } from './ChatPanel';
|
||||
import { chatEventTarget } from '@/hooks/useTaskSync';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const ChatPanel = lazy(() =>
|
||||
import('./ChatPanel').then((mod) => ({
|
||||
default: mod.ChatPanel,
|
||||
}))
|
||||
);
|
||||
|
||||
/**
|
||||
* Floating chat bubble — bottom-right corner.
|
||||
* Opens a board-level ChatPanel (no taskId).
|
||||
|
|
@ -12,6 +17,7 @@ import { cn } from '@/lib/utils';
|
|||
*/
|
||||
export function FloatingChat() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [panelMounted, setPanelMounted] = useState(false);
|
||||
const [hasUnread, setHasUnread] = useState(false);
|
||||
|
||||
// Listen for incoming chat messages when panel is closed
|
||||
|
|
@ -35,6 +41,7 @@ export function FloatingChat() {
|
|||
|
||||
// Clear unread when opening
|
||||
const handleOpen = () => {
|
||||
setPanelMounted(true);
|
||||
setOpen(true);
|
||||
setHasUnread(false);
|
||||
};
|
||||
|
|
@ -63,7 +70,11 @@ export function FloatingChat() {
|
|||
</Button>
|
||||
|
||||
{/* Chat panel — board-level (no taskId) */}
|
||||
<ChatPanel open={open} onOpenChange={setOpen} />
|
||||
{panelMounted && (
|
||||
<Suspense fallback={null}>
|
||||
<ChatPanel open={open} onOpenChange={setOpen} />
|
||||
</Suspense>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog';
|
||||
import { lazy, Suspense, useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog';
|
||||
import { useKeyboard } from '@/hooks/useKeyboard';
|
||||
import { useView } from '@/contexts/ViewContext';
|
||||
import {
|
||||
|
|
@ -8,6 +8,7 @@ import {
|
|||
ListOrdered,
|
||||
Inbox,
|
||||
Archive,
|
||||
FileText,
|
||||
Search,
|
||||
ArrowRight,
|
||||
Moon,
|
||||
|
|
@ -16,10 +17,38 @@ import {
|
|||
Activity,
|
||||
GitBranch,
|
||||
Sparkles,
|
||||
Workflow,
|
||||
Scale,
|
||||
ShieldAlert,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SearchDialog } from '@/components/search';
|
||||
import { VIEW_DEFINITIONS, type ViewIcon } from '@/lib/views';
|
||||
|
||||
const SearchDialog = lazy(() =>
|
||||
import('@/components/search').then((mod) => ({
|
||||
default: mod.SearchDialog,
|
||||
}))
|
||||
);
|
||||
|
||||
const VIEW_ICONS: Record<ViewIcon, LucideIcon> = {
|
||||
Activity,
|
||||
Archive,
|
||||
FileText,
|
||||
GitBranch,
|
||||
Inbox,
|
||||
LayoutDashboard,
|
||||
ListOrdered,
|
||||
Scale,
|
||||
ShieldAlert,
|
||||
Workflow,
|
||||
};
|
||||
|
||||
function renderViewIcon(icon: ViewIcon) {
|
||||
const Icon = VIEW_ICONS[icon];
|
||||
return <Icon className="h-4 w-4" />;
|
||||
}
|
||||
|
||||
interface CommandItem {
|
||||
id: string;
|
||||
|
|
@ -28,12 +57,13 @@ interface CommandItem {
|
|||
icon: React.ReactNode;
|
||||
category: string;
|
||||
action: () => void;
|
||||
keywords?: string[];
|
||||
keywords?: readonly string[];
|
||||
}
|
||||
|
||||
export function CommandPalette() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [searchMounted, setSearchMounted] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
|
@ -43,6 +73,11 @@ export function CommandPalette() {
|
|||
const { setView, navigateToTask } = useView();
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
const openSearchDialog = useCallback(() => {
|
||||
setSearchMounted(true);
|
||||
setSearchOpen(true);
|
||||
}, []);
|
||||
|
||||
const commands: CommandItem[] = useMemo(
|
||||
() => [
|
||||
// Actions
|
||||
|
|
@ -68,60 +103,19 @@ export function CommandPalette() {
|
|||
label: 'Search Tasks and Docs',
|
||||
icon: <Sparkles className="h-4 w-4" />,
|
||||
category: 'Actions',
|
||||
action: () => setSearchOpen(true),
|
||||
action: openSearchDialog,
|
||||
keywords: ['qmd', 'semantic', 'retrieval', 'docs', 'archive'],
|
||||
},
|
||||
|
||||
// Navigation
|
||||
{
|
||||
id: 'go-board',
|
||||
label: 'Go to Board',
|
||||
shortcut: 'B',
|
||||
icon: <LayoutDashboard className="h-4 w-4" />,
|
||||
...VIEW_DEFINITIONS.map((definition) => ({
|
||||
id: `go-${definition.view}`,
|
||||
label: definition.commandLabel,
|
||||
shortcut: definition.view === 'board' ? 'B' : undefined,
|
||||
icon: renderViewIcon(definition.icon),
|
||||
category: 'Navigation',
|
||||
action: () => setView('board'),
|
||||
keywords: ['kanban', 'home', 'main'],
|
||||
},
|
||||
{
|
||||
id: 'go-activity',
|
||||
label: 'Go to Activity',
|
||||
icon: <ListOrdered className="h-4 w-4" />,
|
||||
category: 'Navigation',
|
||||
action: () => setView('activity'),
|
||||
keywords: ['feed', 'log', 'history'],
|
||||
},
|
||||
{
|
||||
id: 'go-backlog',
|
||||
label: 'Go to Backlog',
|
||||
icon: <Inbox className="h-4 w-4" />,
|
||||
category: 'Navigation',
|
||||
action: () => setView('backlog'),
|
||||
keywords: ['someday', 'maybe', 'later'],
|
||||
},
|
||||
{
|
||||
id: 'go-drift',
|
||||
label: 'Go to Drift Monitor',
|
||||
icon: <Activity className="h-4 w-4" />,
|
||||
category: 'Navigation',
|
||||
action: () => setView('drift'),
|
||||
keywords: ['behavior', 'anomaly', 'z-score', 'alerts'],
|
||||
},
|
||||
{
|
||||
id: 'go-archive',
|
||||
label: 'Go to Archive',
|
||||
icon: <Archive className="h-4 w-4" />,
|
||||
category: 'Navigation',
|
||||
action: () => setView('archive'),
|
||||
keywords: ['done', 'completed', 'old'],
|
||||
},
|
||||
{
|
||||
id: 'go-decisions',
|
||||
label: 'Go to Decisions',
|
||||
icon: <GitBranch className="h-4 w-4" />,
|
||||
category: 'Navigation',
|
||||
action: () => setView('decisions'),
|
||||
keywords: ['audit', 'reasoning', 'assumptions'],
|
||||
},
|
||||
action: () => setView(definition.view),
|
||||
keywords: definition.keywords,
|
||||
})),
|
||||
|
||||
// Board shortcuts
|
||||
{
|
||||
|
|
@ -188,7 +182,7 @@ export function CommandPalette() {
|
|||
keywords: ['view', 'detail'],
|
||||
},
|
||||
],
|
||||
[openCreateDialog, setView, theme, setTheme]
|
||||
[openCreateDialog, openSearchDialog, setView, theme, setTheme]
|
||||
);
|
||||
|
||||
// Filter commands by query
|
||||
|
|
@ -285,6 +279,11 @@ export function CommandPalette() {
|
|||
className="max-w-[520px] p-0 gap-0 overflow-hidden"
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<DialogTitle className="sr-only">Command palette</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Search and run board actions, navigation commands, and shortcuts.
|
||||
</DialogDescription>
|
||||
|
||||
{/* Search input */}
|
||||
<div className="flex items-center gap-3 px-4 border-b">
|
||||
<Search className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
|
|
@ -355,7 +354,15 @@ export function CommandPalette() {
|
|||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<SearchDialog open={searchOpen} onOpenChange={setSearchOpen} onTaskOpen={navigateToTask} />
|
||||
{searchMounted && (
|
||||
<Suspense fallback={null}>
|
||||
<SearchDialog
|
||||
open={searchOpen}
|
||||
onOpenChange={setSearchOpen}
|
||||
onTaskOpen={navigateToTask}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import {
|
|||
Plus,
|
||||
Settings,
|
||||
Search,
|
||||
ListOrdered,
|
||||
Archive,
|
||||
Inbox,
|
||||
Sun,
|
||||
|
|
@ -12,25 +11,69 @@ import {
|
|||
Workflow,
|
||||
Activity,
|
||||
GitBranch,
|
||||
LayoutDashboard,
|
||||
ListOrdered,
|
||||
Scale,
|
||||
ShieldAlert,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CreateTaskDialog } from '@/components/task/CreateTaskDialog';
|
||||
import { SettingsDialog } from '@/components/settings/SettingsDialog';
|
||||
// ActivitySidebar removed — merged into ActivityFeed (GH-66)
|
||||
// ArchiveSidebar removed — replaced with full-page ArchivePage
|
||||
import { ChatPanel } from '@/components/chat/ChatPanel';
|
||||
import { SquadChatPanel } from '@/components/chat/SquadChatPanel';
|
||||
import { SearchDialog } from '@/components/search';
|
||||
import { UserMenu } from './UserMenu';
|
||||
import { WebSocketIndicator } from '@/components/shared/WebSocketIndicator';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { lazy, Suspense, useState, useCallback } from 'react';
|
||||
import { useKeyboard } from '@/hooks/useKeyboard';
|
||||
import { useView } from '@/contexts/ViewContext';
|
||||
import { useBacklogCount } from '@/hooks/useBacklog';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { NAVIGATION_VIEWS, type ViewIcon } from '@/lib/views';
|
||||
|
||||
const CreateTaskDialog = lazy(() =>
|
||||
import('@/components/task/CreateTaskDialog').then((mod) => ({
|
||||
default: mod.CreateTaskDialog,
|
||||
}))
|
||||
);
|
||||
|
||||
const SettingsDialog = lazy(() =>
|
||||
import('@/components/settings/SettingsDialog').then((mod) => ({
|
||||
default: mod.SettingsDialog,
|
||||
}))
|
||||
);
|
||||
|
||||
const ChatPanel = lazy(() =>
|
||||
import('@/components/chat/ChatPanel').then((mod) => ({
|
||||
default: mod.ChatPanel,
|
||||
}))
|
||||
);
|
||||
|
||||
const SquadChatPanel = lazy(() =>
|
||||
import('@/components/chat/SquadChatPanel').then((mod) => ({
|
||||
default: mod.SquadChatPanel,
|
||||
}))
|
||||
);
|
||||
|
||||
const SearchDialog = lazy(() =>
|
||||
import('@/components/search').then((mod) => ({
|
||||
default: mod.SearchDialog,
|
||||
}))
|
||||
);
|
||||
|
||||
type LazyPanel = 'chat' | 'create' | 'search' | 'settings' | 'squadChat';
|
||||
|
||||
const VIEW_ICONS: Record<ViewIcon, LucideIcon> = {
|
||||
Activity,
|
||||
Archive,
|
||||
FileText,
|
||||
GitBranch,
|
||||
Inbox,
|
||||
LayoutDashboard,
|
||||
ListOrdered,
|
||||
Scale,
|
||||
ShieldAlert,
|
||||
Workflow,
|
||||
};
|
||||
|
||||
export function Header() {
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
|
|
@ -41,19 +84,56 @@ export function Header() {
|
|||
// archiveOpen removed — archive is now a full page view
|
||||
const [chatOpen, setChatOpen] = useState(false);
|
||||
const [squadChatOpen, setSquadChatOpen] = useState(false);
|
||||
const [loadedPanels, setLoadedPanels] = useState<Set<LazyPanel>>(() => new Set());
|
||||
const { setOpenCreateDialog, setOpenChatPanel } = useKeyboard();
|
||||
const { view, setView, navigateToTask } = useView();
|
||||
const { data: backlogCount = 0 } = useBacklogCount();
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
const openSecuritySettings = useCallback(() => {
|
||||
setSettingsTab('security');
|
||||
setSettingsOpen(true);
|
||||
const markPanelLoaded = useCallback((panel: LazyPanel) => {
|
||||
setLoadedPanels((current) => {
|
||||
if (current.has(panel)) return current;
|
||||
|
||||
const next = new Set(current);
|
||||
next.add(panel);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const openCreateDialog = useCallback(() => {
|
||||
markPanelLoaded('create');
|
||||
setCreateOpen(true);
|
||||
}, [markPanelLoaded]);
|
||||
|
||||
const openChatPanel = useCallback(() => {
|
||||
markPanelLoaded('chat');
|
||||
setChatOpen(true);
|
||||
}, [markPanelLoaded]);
|
||||
|
||||
const openSearchDialog = useCallback(() => {
|
||||
markPanelLoaded('search');
|
||||
setSearchOpen(true);
|
||||
}, [markPanelLoaded]);
|
||||
|
||||
const openSquadChatPanel = useCallback(() => {
|
||||
markPanelLoaded('squadChat');
|
||||
setSquadChatOpen(true);
|
||||
}, [markPanelLoaded]);
|
||||
|
||||
const openSettingsDialog = useCallback(() => {
|
||||
markPanelLoaded('settings');
|
||||
setSettingsOpen(true);
|
||||
}, [markPanelLoaded]);
|
||||
|
||||
const openSecuritySettings = useCallback(() => {
|
||||
markPanelLoaded('settings');
|
||||
setSettingsTab('security');
|
||||
setSettingsOpen(true);
|
||||
}, [markPanelLoaded]);
|
||||
|
||||
// Register the create dialog and chat panel openers with keyboard context (refs, no useEffect needed)
|
||||
setOpenCreateDialog(() => setCreateOpen(true));
|
||||
setOpenChatPanel(() => setChatOpen(true));
|
||||
setOpenCreateDialog(openCreateDialog);
|
||||
setOpenChatPanel(openChatPanel);
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 border-b border-border bg-card" role="banner">
|
||||
|
|
@ -76,104 +156,40 @@ export function Header() {
|
|||
</div>
|
||||
|
||||
<div className="flex items-center gap-2" role="toolbar" aria-label="Board actions">
|
||||
<Button variant="default" size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<Button variant="default" size="sm" onClick={openCreateDialog}>
|
||||
<Plus className="h-4 w-4 mr-1" aria-hidden="true" />
|
||||
New Task
|
||||
</Button>
|
||||
<Button
|
||||
variant={view === 'activity' ? 'secondary' : 'ghost'}
|
||||
size="icon"
|
||||
onClick={() => setView(view === 'activity' ? 'board' : 'activity')}
|
||||
aria-label="Activity"
|
||||
title="Activity"
|
||||
>
|
||||
<ListOrdered className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={view === 'backlog' ? 'secondary' : 'ghost'}
|
||||
size="icon"
|
||||
onClick={() => setView(view === 'backlog' ? 'board' : 'backlog')}
|
||||
aria-label="Backlog"
|
||||
title="Backlog"
|
||||
className="relative"
|
||||
>
|
||||
<Inbox className="h-4 w-4" aria-hidden="true" />
|
||||
{backlogCount > 0 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="absolute -top-1 -right-1 h-5 w-5 rounded-full p-0 flex items-center justify-center text-[10px]"
|
||||
{NAVIGATION_VIEWS.map((item) => {
|
||||
const Icon = VIEW_ICONS[item.icon];
|
||||
const isBacklog = item.view === 'backlog';
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={item.view}
|
||||
variant={view === item.view ? 'secondary' : 'ghost'}
|
||||
size="icon"
|
||||
onClick={() => setView(view === item.view ? 'board' : item.view)}
|
||||
aria-label={item.label}
|
||||
title={item.title ?? item.label}
|
||||
className={isBacklog ? 'relative' : undefined}
|
||||
>
|
||||
{backlogCount > 99 ? '99+' : backlogCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant={view === 'archive' ? 'secondary' : 'ghost'}
|
||||
size="icon"
|
||||
onClick={() => setView(view === 'archive' ? 'board' : 'archive')}
|
||||
aria-label="Archive"
|
||||
title="Archive"
|
||||
>
|
||||
<Archive className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={view === 'templates' ? 'secondary' : 'ghost'}
|
||||
size="icon"
|
||||
onClick={() => setView(view === 'templates' ? 'board' : 'templates')}
|
||||
aria-label="Templates"
|
||||
title="Templates"
|
||||
>
|
||||
<FileText className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={view === 'workflows' ? 'secondary' : 'ghost'}
|
||||
size="icon"
|
||||
onClick={() => setView(view === 'workflows' ? 'board' : 'workflows')}
|
||||
aria-label="Workflows"
|
||||
title="Workflows"
|
||||
>
|
||||
<Workflow className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={view === 'drift' ? 'secondary' : 'ghost'}
|
||||
size="icon"
|
||||
onClick={() => setView(view === 'drift' ? 'board' : 'drift')}
|
||||
aria-label="Drift Monitor"
|
||||
title="Drift Monitor"
|
||||
>
|
||||
<Activity className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={view === 'decisions' ? 'secondary' : 'ghost'}
|
||||
size="icon"
|
||||
onClick={() => setView(view === 'decisions' ? 'board' : 'decisions')}
|
||||
aria-label="Decisions"
|
||||
title="Decision Audit Trail"
|
||||
>
|
||||
<GitBranch className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={view === 'scoring' ? 'secondary' : 'ghost'}
|
||||
size="icon"
|
||||
onClick={() => setView(view === 'scoring' ? 'board' : 'scoring')}
|
||||
aria-label="Scoring"
|
||||
title="Scoring"
|
||||
>
|
||||
<Scale className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={view === 'policies' ? 'secondary' : 'ghost'}
|
||||
size="icon"
|
||||
onClick={() => setView(view === 'policies' ? 'board' : 'policies')}
|
||||
aria-label="Policies"
|
||||
title="Policies"
|
||||
>
|
||||
<ShieldAlert className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Icon className="h-4 w-4" aria-hidden="true" />
|
||||
{isBacklog && backlogCount > 0 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="absolute -top-1 -right-1 h-5 w-5 rounded-full p-0 flex items-center justify-center text-[10px]"
|
||||
>
|
||||
{backlogCount > 99 ? '99+' : backlogCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setSearchOpen(true)}
|
||||
onClick={openSearchDialog}
|
||||
aria-label="Search"
|
||||
title="Search"
|
||||
>
|
||||
|
|
@ -182,7 +198,7 @@ export function Header() {
|
|||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setSquadChatOpen(true)}
|
||||
onClick={openSquadChatPanel}
|
||||
aria-label="Squad Chat"
|
||||
title="Squad Chat — Agent communication"
|
||||
>
|
||||
|
|
@ -191,7 +207,7 @@ export function Header() {
|
|||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
onClick={openSettingsDialog}
|
||||
aria-label="Settings"
|
||||
title="Settings"
|
||||
>
|
||||
|
|
@ -230,18 +246,32 @@ export function Header() {
|
|||
</div>
|
||||
</nav>
|
||||
|
||||
<CreateTaskDialog open={createOpen} onOpenChange={setCreateOpen} />
|
||||
<SettingsDialog
|
||||
open={settingsOpen}
|
||||
onOpenChange={(open) => {
|
||||
setSettingsOpen(open);
|
||||
if (!open) setSettingsTab(undefined);
|
||||
}}
|
||||
defaultTab={settingsTab}
|
||||
/>
|
||||
<ChatPanel open={chatOpen} onOpenChange={setChatOpen} />
|
||||
<SquadChatPanel open={squadChatOpen} onOpenChange={setSquadChatOpen} />
|
||||
<SearchDialog open={searchOpen} onOpenChange={setSearchOpen} onTaskOpen={navigateToTask} />
|
||||
<Suspense fallback={null}>
|
||||
{loadedPanels.has('create') && (
|
||||
<CreateTaskDialog open={createOpen} onOpenChange={setCreateOpen} />
|
||||
)}
|
||||
{loadedPanels.has('settings') && (
|
||||
<SettingsDialog
|
||||
open={settingsOpen}
|
||||
onOpenChange={(open) => {
|
||||
setSettingsOpen(open);
|
||||
if (!open) setSettingsTab(undefined);
|
||||
}}
|
||||
defaultTab={settingsTab}
|
||||
/>
|
||||
)}
|
||||
{loadedPanels.has('chat') && <ChatPanel open={chatOpen} onOpenChange={setChatOpen} />}
|
||||
{loadedPanels.has('squadChat') && (
|
||||
<SquadChatPanel open={squadChatOpen} onOpenChange={setSquadChatOpen} />
|
||||
)}
|
||||
{loadedPanels.has('search') && (
|
||||
<SearchDialog
|
||||
open={searchOpen}
|
||||
onOpenChange={setSearchOpen}
|
||||
onTaskOpen={navigateToTask}
|
||||
/>
|
||||
)}
|
||||
</Suspense>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@
|
|||
* Manage role-based tool access policies for workflow agents.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { apiFetch } from '@/lib/api/helpers';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
|
@ -45,20 +45,11 @@ export function ToolPoliciesTab() {
|
|||
const [formDenied, setFormDenied] = useState('');
|
||||
const [formDescription, setFormDescription] = useState('');
|
||||
|
||||
const fetchPolicies = async () => {
|
||||
const fetchPolicies = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`${API_BASE}/tool-policies`);
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
setPolicies(result.data);
|
||||
} else {
|
||||
toast({
|
||||
title: 'Failed to load policies',
|
||||
description: result.error || 'Unknown error',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
const result = await apiFetch<ToolPolicy[]>('/api/tool-policies');
|
||||
setPolicies(result);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to load policies',
|
||||
|
|
@ -68,11 +59,11 @@ export function ToolPoliciesTab() {
|
|||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [toast]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchPolicies();
|
||||
}, []);
|
||||
}, [fetchPolicies]);
|
||||
|
||||
const openEditDialog = (policy: ToolPolicy | null) => {
|
||||
if (policy) {
|
||||
|
|
@ -109,28 +100,18 @@ export function ToolPoliciesTab() {
|
|||
const url = isNew ? '/api/tool-policies' : `/api/tool-policies/${policy.role}`;
|
||||
const method = isNew ? 'POST' : 'PUT';
|
||||
|
||||
const response = await fetch(url, {
|
||||
await apiFetch<ToolPolicy>(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(policy),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
toast({
|
||||
title: isNew ? 'Policy created' : 'Policy updated',
|
||||
description: `Tool policy for role "${policy.role}" has been saved.`,
|
||||
});
|
||||
setEditDialogOpen(false);
|
||||
fetchPolicies();
|
||||
} else {
|
||||
toast({
|
||||
title: 'Failed to save policy',
|
||||
description: result.error || 'Unknown error',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
toast({
|
||||
title: isNew ? 'Policy created' : 'Policy updated',
|
||||
description: `Tool policy for role "${policy.role}" has been saved.`,
|
||||
});
|
||||
setEditDialogOpen(false);
|
||||
void fetchPolicies();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to save policy',
|
||||
|
|
@ -155,25 +136,15 @@ export function ToolPoliciesTab() {
|
|||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/tool-policies/${role}`, {
|
||||
await apiFetch<{ deleted: string }>(`/api/tool-policies/${role}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
toast({
|
||||
title: 'Policy deleted',
|
||||
description: `Tool policy for role "${role}" has been deleted.`,
|
||||
});
|
||||
fetchPolicies();
|
||||
} else {
|
||||
toast({
|
||||
title: 'Failed to delete policy',
|
||||
description: result.error || 'Unknown error',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
toast({
|
||||
title: 'Policy deleted',
|
||||
description: `Tool policy for role "${role}" has been deleted.`,
|
||||
});
|
||||
void fetchPolicies();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to delete policy',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
|
@ -35,6 +35,7 @@ import {
|
|||
NotebookPen,
|
||||
Workflow,
|
||||
Eye,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import type { Task, ReviewComment, ReviewState } from '@veritas-kanban/shared';
|
||||
import { useAddObservation, useDeleteObservation } from '@/hooks/useTasks';
|
||||
|
|
@ -47,6 +48,85 @@ interface TaskDetailPanelProps {
|
|||
onRestore?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
type TaskDetailTabId =
|
||||
| 'details'
|
||||
| 'progress'
|
||||
| 'observations'
|
||||
| 'attachments'
|
||||
| 'git'
|
||||
| 'agent'
|
||||
| 'changes'
|
||||
| 'review'
|
||||
| 'metrics';
|
||||
|
||||
interface TaskDetailTabDefinition {
|
||||
id: TaskDetailTabId;
|
||||
label: string;
|
||||
Icon?: LucideIcon;
|
||||
fallbackTitle?: string;
|
||||
codeOnly?: boolean;
|
||||
feature?: 'attachments';
|
||||
disabledWithoutWorktree?: boolean;
|
||||
}
|
||||
|
||||
const TASK_DETAIL_TABS: readonly TaskDetailTabDefinition[] = [
|
||||
{ id: 'details', label: 'Details' },
|
||||
{
|
||||
id: 'progress',
|
||||
label: 'Progress',
|
||||
Icon: NotebookPen,
|
||||
fallbackTitle: 'Progress section failed to load',
|
||||
},
|
||||
{
|
||||
id: 'observations',
|
||||
label: 'Observations',
|
||||
Icon: Eye,
|
||||
fallbackTitle: 'Observations section failed to load',
|
||||
},
|
||||
{
|
||||
id: 'attachments',
|
||||
label: 'Attachments',
|
||||
Icon: Paperclip,
|
||||
fallbackTitle: 'Attachments section failed to load',
|
||||
feature: 'attachments',
|
||||
},
|
||||
{
|
||||
id: 'git',
|
||||
label: 'Git',
|
||||
Icon: GitBranch,
|
||||
fallbackTitle: 'Git section failed to load',
|
||||
codeOnly: true,
|
||||
},
|
||||
{
|
||||
id: 'agent',
|
||||
label: 'Agent',
|
||||
Icon: Bot,
|
||||
fallbackTitle: 'Agent panel failed to load',
|
||||
codeOnly: true,
|
||||
},
|
||||
{
|
||||
id: 'changes',
|
||||
label: 'Changes',
|
||||
Icon: FileDiff,
|
||||
fallbackTitle: 'Changes viewer failed to load',
|
||||
codeOnly: true,
|
||||
disabledWithoutWorktree: true,
|
||||
},
|
||||
{
|
||||
id: 'review',
|
||||
label: 'Review',
|
||||
Icon: ClipboardCheck,
|
||||
fallbackTitle: 'Review panel failed to load',
|
||||
codeOnly: true,
|
||||
},
|
||||
{
|
||||
id: 'metrics',
|
||||
label: 'Metrics',
|
||||
Icon: BarChart3,
|
||||
fallbackTitle: 'Metrics panel failed to load',
|
||||
},
|
||||
];
|
||||
|
||||
export function TaskDetailPanel({
|
||||
task,
|
||||
open,
|
||||
|
|
@ -77,15 +157,105 @@ export function TaskDetailPanel({
|
|||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [open, onOpenChange]);
|
||||
|
||||
if (!localTask) return null;
|
||||
const isCodeTask = localTask?.type === 'code';
|
||||
const hasWorktree = !!localTask?.git?.worktreePath;
|
||||
const visibleTabs = useMemo(
|
||||
() =>
|
||||
TASK_DETAIL_TABS.filter((tab) => {
|
||||
if (tab.codeOnly && !isCodeTask) return false;
|
||||
if (tab.feature === 'attachments' && !taskSettings.enableAttachments) return false;
|
||||
return true;
|
||||
}).map((tab) => ({
|
||||
...tab,
|
||||
disabled: tab.disabledWithoutWorktree && !hasWorktree,
|
||||
})),
|
||||
[hasWorktree, isCodeTask, taskSettings.enableAttachments]
|
||||
);
|
||||
|
||||
const isCodeTask = localTask.type === 'code';
|
||||
const hasWorktree = !!localTask.git?.worktreePath;
|
||||
useEffect(() => {
|
||||
const activeTabAvailable = visibleTabs.some((tab) => tab.id === activeTab && !tab.disabled);
|
||||
if (!activeTabAvailable) {
|
||||
setActiveTab('details');
|
||||
}
|
||||
}, [activeTab, visibleTabs]);
|
||||
|
||||
if (!localTask) return null;
|
||||
|
||||
// Get current type info
|
||||
const currentType = taskTypes.find((t) => t.id === localTask.type);
|
||||
const TypeIconComponent = currentType ? getTypeIcon(currentType.icon) : null;
|
||||
const typeLabel = currentType ? currentType.label : localTask.type;
|
||||
const renderTabContent = (tabId: TaskDetailTabId) => {
|
||||
switch (tabId) {
|
||||
case 'details':
|
||||
return (
|
||||
<TaskDetailsTab
|
||||
task={localTask}
|
||||
onUpdate={updateField}
|
||||
onClose={() => onOpenChange(false)}
|
||||
readOnly={readOnly}
|
||||
onRestore={onRestore}
|
||||
/>
|
||||
);
|
||||
case 'progress':
|
||||
return <ProgressTab task={localTask} />;
|
||||
case 'observations':
|
||||
return (
|
||||
<ObservationsSection
|
||||
task={localTask}
|
||||
onAddObservation={async (data) => {
|
||||
await addObservation.mutateAsync({ taskId: localTask.id, data });
|
||||
}}
|
||||
onDeleteObservation={async (observationId) => {
|
||||
await deleteObservation.mutateAsync({
|
||||
taskId: localTask.id,
|
||||
observationId,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case 'attachments':
|
||||
return <AttachmentsSection task={localTask} />;
|
||||
case 'git':
|
||||
return (
|
||||
<GitSection
|
||||
task={localTask}
|
||||
onGitChange={(git) => updateField('git', git as Task['git'])}
|
||||
/>
|
||||
);
|
||||
case 'agent':
|
||||
return <AgentPanel task={localTask} />;
|
||||
case 'changes':
|
||||
if (!hasWorktree) return null;
|
||||
return (
|
||||
<DiffViewer
|
||||
task={localTask}
|
||||
onAddComment={(comment: ReviewComment) => {
|
||||
const newComments = [...(localTask.reviewComments || []), comment];
|
||||
updateField('reviewComments', newComments);
|
||||
}}
|
||||
onRemoveComment={(commentId: string) => {
|
||||
const newComments = (localTask.reviewComments || []).filter(
|
||||
(comment) => comment.id !== commentId
|
||||
);
|
||||
updateField('reviewComments', newComments.length > 0 ? newComments : undefined);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case 'review':
|
||||
return (
|
||||
<ReviewPanel
|
||||
task={localTask}
|
||||
onReview={(review: ReviewState) => {
|
||||
updateField('review', Object.keys(review).length > 0 ? review : undefined);
|
||||
}}
|
||||
onMergeComplete={() => onOpenChange(false)}
|
||||
/>
|
||||
);
|
||||
case 'metrics':
|
||||
return <TaskMetricsPanel task={localTask} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
|
|
@ -179,161 +349,34 @@ export function TaskDetailPanel({
|
|||
className="flex-1 flex flex-col overflow-hidden px-6 pt-3 pb-6"
|
||||
>
|
||||
<TabsList className="w-full flex-shrink-0 justify-start overflow-x-auto">
|
||||
<TabsTrigger value="details" className="flex-none px-3">
|
||||
Details
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="progress" className="flex-none px-3">
|
||||
<NotebookPen className="h-3 w-3" />
|
||||
Progress
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="observations" className="flex-none px-3">
|
||||
<Eye className="h-3 w-3" />
|
||||
Observations
|
||||
</TabsTrigger>
|
||||
{taskSettings.enableAttachments && (
|
||||
<TabsTrigger value="attachments" className="flex-none px-3">
|
||||
<Paperclip className="h-3 w-3" />
|
||||
Attachments
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{isCodeTask && (
|
||||
<>
|
||||
<TabsTrigger value="git" className="flex-none px-3">
|
||||
<GitBranch className="h-3 w-3" />
|
||||
Git
|
||||
{visibleTabs.map((tab) => {
|
||||
const Icon = tab.Icon;
|
||||
return (
|
||||
<TabsTrigger
|
||||
key={tab.id}
|
||||
value={tab.id}
|
||||
disabled={tab.disabled}
|
||||
className="flex-none px-3"
|
||||
>
|
||||
{Icon && <Icon className="h-3 w-3" />}
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="agent" className="flex-none px-3">
|
||||
<Bot className="h-3 w-3" />
|
||||
Agent
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="changes" disabled={!hasWorktree} className="flex-none px-3">
|
||||
<FileDiff className="h-3 w-3" />
|
||||
Changes
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="review" className="flex-none px-3">
|
||||
<ClipboardCheck className="h-3 w-3" />
|
||||
Review
|
||||
</TabsTrigger>
|
||||
</>
|
||||
)}
|
||||
<TabsTrigger value="metrics" className="flex-none px-3">
|
||||
<BarChart3 className="h-3 w-3" />
|
||||
Metrics
|
||||
</TabsTrigger>
|
||||
);
|
||||
})}
|
||||
</TabsList>
|
||||
|
||||
<div className="flex-1 overflow-y-auto mt-4 pr-1">
|
||||
{/* Details Tab */}
|
||||
<TabsContent value="details" className="mt-0">
|
||||
<TaskDetailsTab
|
||||
task={localTask}
|
||||
onUpdate={updateField}
|
||||
onClose={() => onOpenChange(false)}
|
||||
readOnly={readOnly}
|
||||
onRestore={onRestore}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* Progress Tab */}
|
||||
<TabsContent value="progress" className="mt-0">
|
||||
<FeatureErrorBoundary fallbackTitle="Progress section failed to load">
|
||||
<ProgressTab task={localTask} />
|
||||
</FeatureErrorBoundary>
|
||||
</TabsContent>
|
||||
|
||||
{/* Observations Tab */}
|
||||
<TabsContent value="observations" className="mt-0">
|
||||
<FeatureErrorBoundary fallbackTitle="Observations section failed to load">
|
||||
<ObservationsSection
|
||||
task={localTask}
|
||||
onAddObservation={async (data) => {
|
||||
await addObservation.mutateAsync({ taskId: localTask.id, data });
|
||||
}}
|
||||
onDeleteObservation={async (observationId) => {
|
||||
await deleteObservation.mutateAsync({
|
||||
taskId: localTask.id,
|
||||
observationId,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</FeatureErrorBoundary>
|
||||
</TabsContent>
|
||||
|
||||
{/* Attachments Tab */}
|
||||
{taskSettings.enableAttachments && (
|
||||
<TabsContent value="attachments" className="mt-0">
|
||||
<FeatureErrorBoundary fallbackTitle="Attachments section failed to load">
|
||||
<AttachmentsSection task={localTask} />
|
||||
</FeatureErrorBoundary>
|
||||
{visibleTabs.map((tab) => (
|
||||
<TabsContent key={tab.id} value={tab.id} className="mt-0">
|
||||
{tab.fallbackTitle ? (
|
||||
<FeatureErrorBoundary fallbackTitle={tab.fallbackTitle}>
|
||||
{renderTabContent(tab.id)}
|
||||
</FeatureErrorBoundary>
|
||||
) : (
|
||||
renderTabContent(tab.id)
|
||||
)}
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{/* Git Tab */}
|
||||
{isCodeTask && (
|
||||
<TabsContent value="git" className="mt-0">
|
||||
<FeatureErrorBoundary fallbackTitle="Git section failed to load">
|
||||
<GitSection
|
||||
task={localTask}
|
||||
onGitChange={(git) => updateField('git', git as Task['git'])}
|
||||
/>
|
||||
</FeatureErrorBoundary>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{/* Agent Tab */}
|
||||
{isCodeTask && (
|
||||
<TabsContent value="agent" className="mt-0">
|
||||
<FeatureErrorBoundary fallbackTitle="Agent panel failed to load">
|
||||
<AgentPanel task={localTask} />
|
||||
</FeatureErrorBoundary>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{/* Changes Tab */}
|
||||
{isCodeTask && hasWorktree && (
|
||||
<TabsContent value="changes" className="mt-0">
|
||||
<FeatureErrorBoundary fallbackTitle="Changes viewer failed to load">
|
||||
<DiffViewer
|
||||
task={localTask}
|
||||
onAddComment={(comment: ReviewComment) => {
|
||||
const newComments = [...(localTask.reviewComments || []), comment];
|
||||
updateField('reviewComments', newComments);
|
||||
}}
|
||||
onRemoveComment={(commentId: string) => {
|
||||
const newComments = (localTask.reviewComments || []).filter(
|
||||
(c) => c.id !== commentId
|
||||
);
|
||||
updateField(
|
||||
'reviewComments',
|
||||
newComments.length > 0 ? newComments : undefined
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FeatureErrorBoundary>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{/* Review Tab */}
|
||||
{isCodeTask && (
|
||||
<TabsContent value="review" className="mt-0">
|
||||
<FeatureErrorBoundary fallbackTitle="Review panel failed to load">
|
||||
<ReviewPanel
|
||||
task={localTask}
|
||||
onReview={(review: ReviewState) => {
|
||||
updateField('review', Object.keys(review).length > 0 ? review : undefined);
|
||||
}}
|
||||
onMergeComplete={() => onOpenChange(false)}
|
||||
/>
|
||||
</FeatureErrorBoundary>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{/* Metrics Tab */}
|
||||
<TabsContent value="metrics" className="mt-0">
|
||||
<FeatureErrorBoundary fallbackTitle="Metrics panel failed to load">
|
||||
<TaskMetricsPanel task={localTask} />
|
||||
</FeatureErrorBoundary>
|
||||
</TabsContent>
|
||||
))}
|
||||
</div>
|
||||
</Tabs>
|
||||
</SheetContent>
|
||||
|
|
|
|||
|
|
@ -7,34 +7,10 @@ import {
|
|||
useEffect,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
|
||||
export type AppView =
|
||||
| 'board'
|
||||
| 'activity'
|
||||
| 'backlog'
|
||||
| 'archive'
|
||||
| 'templates'
|
||||
| 'workflows'
|
||||
| 'policies'
|
||||
| 'drift'
|
||||
| 'decisions'
|
||||
| 'scoring';
|
||||
import { VIEW_PATHS, type AppView } from '@/lib/views';
|
||||
|
||||
const basePath = (import.meta.env.BASE_URL || '/').replace(/\/$/, '');
|
||||
|
||||
const VIEW_PATHS: Record<AppView, string> = {
|
||||
board: '/',
|
||||
activity: '/activity',
|
||||
backlog: '/backlog',
|
||||
archive: '/archive',
|
||||
templates: '/templates',
|
||||
workflows: '/workflows',
|
||||
policies: '/policies',
|
||||
drift: '/drift',
|
||||
decisions: '/decisions',
|
||||
scoring: '/scoring',
|
||||
};
|
||||
|
||||
function normalizeAppPath(pathname: string): string {
|
||||
const normalized = pathname.startsWith(basePath)
|
||||
? pathname.slice(basePath.length) || '/'
|
||||
|
|
|
|||
|
|
@ -129,14 +129,27 @@ export function useBoardDragDrop({
|
|||
const isOverColumn = columnIds.includes(overId as TaskStatus);
|
||||
const overColumn = isOverColumn ? (overId as TaskStatus) : findColumn(overId, prev);
|
||||
|
||||
if (!overColumn || activeColumn === overColumn) return prev;
|
||||
if (!overColumn) return prev;
|
||||
|
||||
// Move the task from source to destination
|
||||
// Reorder within the same column when hovering over another task.
|
||||
const sourceTasks = prev[activeColumn];
|
||||
const destTasks = prev[overColumn];
|
||||
const activeIndex = sourceTasks.findIndex((t) => t.id === activeId);
|
||||
if (activeIndex === -1) return prev;
|
||||
|
||||
if (activeColumn === overColumn) {
|
||||
if (isOverColumn) return prev;
|
||||
|
||||
const overIndex = sourceTasks.findIndex((t) => t.id === overId);
|
||||
if (overIndex === -1 || activeIndex === overIndex) return prev;
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[activeColumn]: arrayMove(sourceTasks, activeIndex, overIndex),
|
||||
};
|
||||
}
|
||||
|
||||
// Move the task from source to destination.
|
||||
const destTasks = prev[overColumn];
|
||||
const movedTask = sourceTasks[activeIndex];
|
||||
const newSource = [...sourceTasks];
|
||||
newSource.splice(activeIndex, 1);
|
||||
|
|
|
|||
|
|
@ -68,15 +68,30 @@ export async function handleResponse<T>(response: Response): Promise<T> {
|
|||
return body as T;
|
||||
}
|
||||
|
||||
function resolveApiUrl(url: string): string {
|
||||
if (/^[a-z][a-z\d+\-.]*:\/\//i.test(url)) {
|
||||
return url;
|
||||
}
|
||||
|
||||
const apiBase = API_BASE.replace(/\/$/, '');
|
||||
if (url === apiBase || url.startsWith(`${apiBase}/`)) {
|
||||
return url;
|
||||
}
|
||||
|
||||
if (url === '/api' || url.startsWith('/api/')) {
|
||||
return `${apiBase}${url.slice('/api'.length)}`;
|
||||
}
|
||||
|
||||
const base = (import.meta.env.BASE_URL || '/').replace(/\/$/, '');
|
||||
return base && url.startsWith('/') ? base + url : url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper: fetch + handleResponse in one call.
|
||||
* Use this in hooks instead of raw `fetch` + `response.json()`.
|
||||
*/
|
||||
export async function apiFetch<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
// When deployed under a sub-path, prefix absolute paths with BASE_URL
|
||||
// so /api/v1/tasks becomes e.g. /kanban/api/v1/tasks.
|
||||
const base = (import.meta.env.BASE_URL || '/').replace(/\/$/, '');
|
||||
const fullUrl = base && url.startsWith('/') ? base + url : url;
|
||||
const fullUrl = resolveApiUrl(url);
|
||||
const response = await fetch(fullUrl, {
|
||||
credentials: 'include',
|
||||
...init,
|
||||
|
|
|
|||
142
web/src/lib/views.ts
Normal file
142
web/src/lib/views.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
export type AppView =
|
||||
| 'board'
|
||||
| 'activity'
|
||||
| 'backlog'
|
||||
| 'archive'
|
||||
| 'templates'
|
||||
| 'workflows'
|
||||
| 'policies'
|
||||
| 'drift'
|
||||
| 'decisions'
|
||||
| 'scoring';
|
||||
|
||||
export type NavigationView = Exclude<AppView, 'board'>;
|
||||
|
||||
export type ViewIcon =
|
||||
| 'Activity'
|
||||
| 'Archive'
|
||||
| 'FileText'
|
||||
| 'GitBranch'
|
||||
| 'Inbox'
|
||||
| 'LayoutDashboard'
|
||||
| 'ListOrdered'
|
||||
| 'Scale'
|
||||
| 'ShieldAlert'
|
||||
| 'Workflow';
|
||||
|
||||
export interface ViewDefinition {
|
||||
view: AppView;
|
||||
path: string;
|
||||
label: string;
|
||||
title?: string;
|
||||
commandLabel: string;
|
||||
loadingLabel?: string;
|
||||
icon: ViewIcon;
|
||||
keywords: readonly string[];
|
||||
}
|
||||
|
||||
export const VIEW_DEFINITIONS: readonly ViewDefinition[] = [
|
||||
{
|
||||
view: 'board',
|
||||
path: '/',
|
||||
label: 'Board',
|
||||
commandLabel: 'Go to Board',
|
||||
icon: 'LayoutDashboard',
|
||||
keywords: ['kanban', 'home', 'main'],
|
||||
},
|
||||
{
|
||||
view: 'activity',
|
||||
path: '/activity',
|
||||
label: 'Activity',
|
||||
commandLabel: 'Go to Activity',
|
||||
loadingLabel: 'Loading activity feed...',
|
||||
icon: 'ListOrdered',
|
||||
keywords: ['feed', 'log', 'history'],
|
||||
},
|
||||
{
|
||||
view: 'backlog',
|
||||
path: '/backlog',
|
||||
label: 'Backlog',
|
||||
commandLabel: 'Go to Backlog',
|
||||
loadingLabel: 'Loading backlog...',
|
||||
icon: 'Inbox',
|
||||
keywords: ['someday', 'maybe', 'later'],
|
||||
},
|
||||
{
|
||||
view: 'archive',
|
||||
path: '/archive',
|
||||
label: 'Archive',
|
||||
commandLabel: 'Go to Archive',
|
||||
loadingLabel: 'Loading archive...',
|
||||
icon: 'Archive',
|
||||
keywords: ['done', 'completed', 'old'],
|
||||
},
|
||||
{
|
||||
view: 'templates',
|
||||
path: '/templates',
|
||||
label: 'Templates',
|
||||
commandLabel: 'Go to Templates',
|
||||
loadingLabel: 'Loading templates...',
|
||||
icon: 'FileText',
|
||||
keywords: ['templates', 'repeatable', 'task'],
|
||||
},
|
||||
{
|
||||
view: 'workflows',
|
||||
path: '/workflows',
|
||||
label: 'Workflows',
|
||||
commandLabel: 'Go to Workflows',
|
||||
loadingLabel: 'Loading workflows...',
|
||||
icon: 'Workflow',
|
||||
keywords: ['automation', 'runs', 'workflow'],
|
||||
},
|
||||
{
|
||||
view: 'drift',
|
||||
path: '/drift',
|
||||
label: 'Drift Monitor',
|
||||
commandLabel: 'Go to Drift Monitor',
|
||||
loadingLabel: 'Loading drift monitor...',
|
||||
icon: 'Activity',
|
||||
keywords: ['behavior', 'anomaly', 'z-score', 'alerts'],
|
||||
},
|
||||
{
|
||||
view: 'decisions',
|
||||
path: '/decisions',
|
||||
label: 'Decisions',
|
||||
title: 'Decision Audit Trail',
|
||||
commandLabel: 'Go to Decisions',
|
||||
loadingLabel: 'Loading decisions...',
|
||||
icon: 'GitBranch',
|
||||
keywords: ['audit', 'reasoning', 'assumptions'],
|
||||
},
|
||||
{
|
||||
view: 'scoring',
|
||||
path: '/scoring',
|
||||
label: 'Scoring',
|
||||
commandLabel: 'Go to Scoring',
|
||||
loadingLabel: 'Loading scoring...',
|
||||
icon: 'Scale',
|
||||
keywords: ['score', 'prioritize', 'value'],
|
||||
},
|
||||
{
|
||||
view: 'policies',
|
||||
path: '/policies',
|
||||
label: 'Policies',
|
||||
commandLabel: 'Go to Policies',
|
||||
loadingLabel: 'Loading policies...',
|
||||
icon: 'ShieldAlert',
|
||||
keywords: ['governance', 'rules', 'guardrails'],
|
||||
},
|
||||
];
|
||||
|
||||
export const VIEW_PATHS = Object.fromEntries(
|
||||
VIEW_DEFINITIONS.map((definition) => [definition.view, definition.path])
|
||||
) as Record<AppView, string>;
|
||||
|
||||
export const VIEW_BY_ID = Object.fromEntries(
|
||||
VIEW_DEFINITIONS.map((definition) => [definition.view, definition])
|
||||
) as Record<AppView, ViewDefinition>;
|
||||
|
||||
export const NAVIGATION_VIEWS = VIEW_DEFINITIONS.filter(
|
||||
(definition): definition is ViewDefinition & { view: NavigationView } =>
|
||||
definition.view !== 'board'
|
||||
);
|
||||
|
|
@ -30,15 +30,13 @@ export default defineConfig({
|
|||
exclude: ['**/node_modules/**', '**/dist/**'],
|
||||
globals: false,
|
||||
setupFiles: [],
|
||||
testTimeout: 15_000,
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (
|
||||
id.includes('node_modules/react/') ||
|
||||
id.includes('node_modules/react-dom/')
|
||||
) {
|
||||
if (id.includes('node_modules/react/') || id.includes('node_modules/react-dom/')) {
|
||||
return 'vendor-react';
|
||||
}
|
||||
if (
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue