mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-08-28 02:44:59 +00:00
Add ceremony enforcement gates (#750)
This commit is contained in:
parent
13fc8ac083
commit
b9a648afe7
22 changed files with 1379 additions and 32 deletions
|
|
@ -49,13 +49,14 @@
|
|||
36. [Tool Policies](#tool-policies)
|
||||
37. [Watcher Continuation Policies](#watcher-continuation-policies)
|
||||
38. [Traces](#traces)
|
||||
39. [Governance Decision Traces](#governance-decision-traces-apigovernancetraces)
|
||||
40. [Audit](#audit)
|
||||
41. [Maintenance Center](#maintenance-center-apiv1maintenance)
|
||||
42. [Common Workflows](#common-workflows)
|
||||
43. [Versioning & Deprecation](#versioning--deprecation)
|
||||
44. [Rate Limits](#rate-limits)
|
||||
45. [Additional Endpoint Groups](#additional-endpoint-groups)
|
||||
39. [Ceremony Requirements](#ceremony-requirements-apiceremonies)
|
||||
40. [Governance Decision Traces](#governance-decision-traces-apigovernancetraces)
|
||||
41. [Audit](#audit)
|
||||
42. [Maintenance Center](#maintenance-center-apiv1maintenance)
|
||||
43. [Common Workflows](#common-workflows)
|
||||
44. [Versioning & Deprecation](#versioning--deprecation)
|
||||
45. [Rate Limits](#rate-limits)
|
||||
46. [Additional Endpoint Groups](#additional-endpoint-groups)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -545,10 +546,18 @@ PATCH /api/settings/features # Toggle feature flags
|
|||
{
|
||||
"darkMode": true,
|
||||
"squadChat": true,
|
||||
"analyticsEnabled": true
|
||||
"analyticsEnabled": true,
|
||||
"enforcement": {
|
||||
"ceremonyDesignReview": "block",
|
||||
"ceremonyFailureRetrospective": "warn"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Ceremony enforcement modes are `off`, `warn`, or `block`. `block` prevents task
|
||||
completion until the matching ceremony is completed; `warn` records a pending
|
||||
ceremony and governance trace without blocking completion.
|
||||
|
||||
---
|
||||
|
||||
## Lifecycle Hooks
|
||||
|
|
@ -3003,6 +3012,7 @@ These endpoints follow the same auth/error patterns documented above:
|
|||
| `/api/lessons` | Lessons learned |
|
||||
| `/api/delegation` | Task delegation |
|
||||
| `/api/workflows` | Workflow engine ([details](API-WORKFLOWS.md)) |
|
||||
| `/api/ceremonies` | Design-review and failure-retrospective requirements |
|
||||
| `/api/tool-policies` | Tool access policies |
|
||||
| `/api/sandbox-policies` | Agent sandbox policy presets |
|
||||
| `/api/integrations` | External integrations, outbound delivery audit, and human reply adapters |
|
||||
|
|
@ -3217,9 +3227,71 @@ Task-launched review sessions capture independent participant responses, ordered
|
|||
|
||||
---
|
||||
|
||||
### Ceremony Requirements (`/api/ceremonies`)
|
||||
|
||||
Ceremony requirements are durable review records created by enforcement gates or
|
||||
operators. They link back to tasks, runs, workflows, pull requests, or CI runs.
|
||||
|
||||
| Method | Path | Description | Permissions |
|
||||
| ------ | ------------------------------ | --------------------------------------- | ---------------- |
|
||||
| `GET` | `/api/ceremonies` | List ceremony requirements | `workflow:read` |
|
||||
| `POST` | `/api/ceremonies` | Create a ceremony requirement | `workflow:write` |
|
||||
| `POST` | `/api/ceremonies/:id/complete` | Complete a pending ceremony requirement | `workflow:write` |
|
||||
|
||||
#### List Ceremony Requirements
|
||||
|
||||
```
|
||||
GET /api/ceremonies?status=pending&kind=design_review&taskId=task_123&limit=20
|
||||
```
|
||||
|
||||
Query params: `status`, `kind`, `taskId`, `limit`.
|
||||
|
||||
#### Create Ceremony Requirement
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "design_review",
|
||||
"enforcementMode": "block",
|
||||
"reason": "Task coordinates multiple agents.",
|
||||
"target": { "taskId": "task_20260626_review" },
|
||||
"trigger": "manual",
|
||||
"requiredArtifacts": ["decision-packet", "risk-list", "action-items"]
|
||||
}
|
||||
```
|
||||
|
||||
#### Complete Ceremony Requirement
|
||||
|
||||
```json
|
||||
{
|
||||
"completedBy": "brad",
|
||||
"artifacts": [
|
||||
{
|
||||
"kind": "decision-packet",
|
||||
"title": "Design review notes",
|
||||
"body": "Reviewed scope, risks, rollback, and follow-up actions."
|
||||
}
|
||||
],
|
||||
"actionItems": [
|
||||
{
|
||||
"title": "Track hardening follow-up",
|
||||
"priority": "high",
|
||||
"issueUrl": "https://github.com/BradGroux/veritas-kanban/issues/123"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`completedBy` defaults to the authenticated actor when omitted. Completion keeps
|
||||
the record for audit and satisfies future blocking evaluations for the same task
|
||||
and ceremony kind.
|
||||
|
||||
---
|
||||
|
||||
### Governance Decision Traces (`/api/governance/traces`)
|
||||
|
||||
Inspect policy, tool-policy, sandbox-policy, budget-policy, agent-permission, routing, and workflow-gate decisions with evaluated rules, matched rules, remediation, and redacted raw detail.
|
||||
Inspect policy, tool-policy, sandbox-policy, budget-policy, agent-permission,
|
||||
routing, workflow-gate, and ceremony decisions with evaluated rules, matched
|
||||
rules, remediation, and redacted raw detail.
|
||||
|
||||
#### List Governance Traces
|
||||
|
||||
|
|
@ -3229,7 +3301,7 @@ GET /api/governance/traces
|
|||
|
||||
Query params: `kind`, `outcome`, `agent`, `taskId`, `actionType`, `startTime`, `endTime`, `limit`.
|
||||
|
||||
`kind` values: `policy`, `tool-policy`, `sandbox-policy`, `budget-policy`, `agent-permission`, `routing`, `workflow-gate`.
|
||||
`kind` values: `policy`, `tool-policy`, `sandbox-policy`, `budget-policy`, `agent-permission`, `routing`, `workflow-gate`, `ceremony`.
|
||||
|
||||
`outcome` values: `allowed`, `warned`, `blocked`, `approval-required`, `routed`, `fallback`, `skipped`.
|
||||
|
||||
|
|
|
|||
|
|
@ -1073,18 +1073,31 @@ Real-time monitoring for workflow execution.
|
|||
|
||||
## Enforcement Gates
|
||||
|
||||
Structural quality gates that prevent workflow violations. Six gates shipped in v3.1, all disabled by default.
|
||||
Structural quality gates that prevent workflow violations. The original six gates shipped in v3.1, all disabled by default. Ceremony gates add off/warn/block controls for review and retrospective records.
|
||||
|
||||
### Available Gates
|
||||
|
||||
| Gate | What It Enforces |
|
||||
| ------------------------ | ----------------------------------------------------------------------- |
|
||||
| `squadChat` | Agents must post to squad chat at every major step |
|
||||
| `reviewGate` | Code tasks must pass 4×10 review scoring before completion |
|
||||
| `closingComments` | Tasks require a deliverable summary (≥20 chars) before moving to Done |
|
||||
| `autoTelemetry` | Automatic telemetry event emission on task transitions |
|
||||
| `autoTimeTracking` | Automatic time tracking start/stop on status changes |
|
||||
| `orchestratorDelegation` | Orchestrator agent must delegate work to sub-agents, not do it directly |
|
||||
| Gate | What It Enforces |
|
||||
| ------------------------------ | ----------------------------------------------------------------------- |
|
||||
| `squadChat` | Agents must post to squad chat at every major step |
|
||||
| `reviewGate` | Code tasks must pass 4×10 review scoring before completion |
|
||||
| `closingComments` | Tasks require a deliverable summary (≥20 chars) before moving to Done |
|
||||
| `autoTelemetry` | Automatic telemetry event emission on task transitions |
|
||||
| `autoTimeTracking` | Automatic time tracking start/stop on status changes |
|
||||
| `orchestratorDelegation` | Orchestrator agent must delegate work to sub-agents, not do it directly |
|
||||
| `ceremonyDesignReview` | Design-review ceremony for critical, review-mode, or multi-agent tasks |
|
||||
| `ceremonyFailureRetrospective` | Retrospective ceremony after blocked work or failed attempts |
|
||||
|
||||
### Ceremony Enforcement
|
||||
|
||||
Ceremony gates create durable review records instead of letting risky or failed work move to Done without a trace.
|
||||
|
||||
- **Off/warn/block modes** — Each ceremony gate can be disabled, advisory, or blocking
|
||||
- **Design-review targeting** — Applies to multi-agent tasks, critical tasks, and `strategy`, `eng-review`, or `paranoid-review` run modes
|
||||
- **Failure retrospective targeting** — Applies to blocked tasks, blocked reasons, and failed attempts
|
||||
- **Durable queue** — Pending and completed ceremonies live at `/api/ceremonies` with target links, required artifacts, participants, and action items
|
||||
- **Governance traces** — Warned and blocked evaluations record `ceremony` traces under `/api/governance/traces`
|
||||
- **Settings visibility** — Settings -> Enforcement exposes both ceremony modes and the latest pending ceremony queue
|
||||
|
||||
### Orchestrator Delegation Enforcement
|
||||
|
||||
|
|
|
|||
|
|
@ -6,14 +6,16 @@ gates are **disabled by default** and must be explicitly enabled via the Setting
|
|||
|
||||
## Available Gates
|
||||
|
||||
| Gate | Behavior | Default |
|
||||
| ------------------------ | ----------------------------------------------------------------------------- | ------- |
|
||||
| `squadChat` | Auto-post task lifecycle events to squad chat | `false` |
|
||||
| `reviewGate` | Blocks completion unless all four `reviewScores` are `10` (4x10 review gate). | `false` |
|
||||
| `closingComments` | Blocks completion unless at least one review comment has ≥20 characters. | `false` |
|
||||
| `autoTelemetry` | Auto-emits `run.started`/`run.completed` on status changes. | `false` |
|
||||
| `autoTimeTracking` | Auto-starts/stops task timers when status changes. | `false` |
|
||||
| `orchestratorDelegation` | Warn when orchestrator does implementation work instead of delegating | `false` |
|
||||
| Gate | Behavior | Default |
|
||||
| ------------------------------ | --------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| `squadChat` | Auto-post task lifecycle events to squad chat | `false` |
|
||||
| `reviewGate` | Blocks completion unless all four `reviewScores` are `10` (4x10 review gate). | `false` |
|
||||
| `closingComments` | Blocks completion unless at least one review comment has ≥20 characters. | `false` |
|
||||
| `autoTelemetry` | Auto-emits `run.started`/`run.completed` on status changes. | `false` |
|
||||
| `autoTimeTracking` | Auto-starts/stops task timers when status changes. | `false` |
|
||||
| `orchestratorDelegation` | Warn when orchestrator does implementation work instead of delegating | `false` |
|
||||
| `ceremonyDesignReview` | Requires a design-review ceremony for high-risk or multi-agent task completion. Values: `off`, `warn`, `block`. | `off` |
|
||||
| `ceremonyFailureRetrospective` | Requires a retrospective ceremony after blocked work or failed attempts. Values: `off`, `warn`, `block`. | `off` |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -67,6 +69,19 @@ curl -X PATCH http://localhost:3001/api/settings/features \
|
|||
}'
|
||||
```
|
||||
|
||||
### Enable ceremony gates
|
||||
|
||||
```bash
|
||||
curl -X PATCH http://localhost:3001/api/settings/features \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"enforcement": {
|
||||
"ceremonyDesignReview": "block",
|
||||
"ceremonyFailureRetrospective": "warn"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### Disable all enforcement gates
|
||||
|
||||
```bash
|
||||
|
|
@ -79,7 +94,9 @@ curl -X PATCH http://localhost:3001/api/settings/features \
|
|||
"closingComments": false,
|
||||
"autoTelemetry": false,
|
||||
"autoTimeTracking": false,
|
||||
"orchestratorDelegation": false
|
||||
"orchestratorDelegation": false,
|
||||
"ceremonyDesignReview": "off",
|
||||
"ceremonyFailureRetrospective": "off"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
|
@ -266,6 +283,50 @@ Task US-42 started by VERITAS
|
|||
|
||||
---
|
||||
|
||||
### 7. ceremonyDesignReview and ceremonyFailureRetrospective
|
||||
|
||||
**What they do:** Create auditable ceremony requirements before task completion when the task shape says human or cross-agent review is needed.
|
||||
|
||||
**Triggered on:**
|
||||
|
||||
- `ceremonyDesignReview`: completing a task with multiple agents, `critical` priority, or review-heavy run modes (`strategy`, `eng-review`, `paranoid-review`)
|
||||
- `ceremonyFailureRetrospective`: completing work that was blocked or has failed attempts
|
||||
|
||||
**Behavior:**
|
||||
|
||||
- `off`: no requirement is created
|
||||
- `warn`: a pending ceremony is created, a governance trace is recorded, and completion continues
|
||||
- `block`: a pending ceremony is created, a governance trace is recorded, and completion is blocked until the matching ceremony is completed
|
||||
|
||||
**Ceremony records:** Stored under `/api/ceremonies` with required artifacts, participants, action items, and target links back to the task/run/workflow.
|
||||
|
||||
**Complete a ceremony:**
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3001/api/ceremonies/ceremony_123/complete \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"completedBy": "brad",
|
||||
"artifacts": [
|
||||
{
|
||||
"kind": "decision-packet",
|
||||
"title": "Design review notes",
|
||||
"body": "Reviewed scope, risks, rollback, and follow-up actions."
|
||||
}
|
||||
],
|
||||
"actionItems": [
|
||||
{
|
||||
"title": "Track follow-up hardening issue",
|
||||
"priority": "high"
|
||||
}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
**Use case:** Multi-agent and failed-run work should not silently disappear into Done. Ceremony gates leave a durable review or retrospective record, with governance traces explaining whether the gate warned or blocked.
|
||||
|
||||
---
|
||||
|
||||
## For AI Agents: How to Interact with Enforcement Gates
|
||||
|
||||
If you're an autonomous agent interacting with the Veritas Kanban API, here's how to handle enforcement gates gracefully.
|
||||
|
|
@ -287,7 +348,9 @@ curl http://localhost:3001/api/settings/features | jq '.data.enforcement'
|
|||
"closingComments": true,
|
||||
"autoTelemetry": false,
|
||||
"autoTimeTracking": false,
|
||||
"orchestratorDelegation": false
|
||||
"orchestratorDelegation": false,
|
||||
"ceremonyDesignReview": "block",
|
||||
"ceremonyFailureRetrospective": "warn"
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -297,6 +360,8 @@ curl http://localhost:3001/api/settings/features | jq '.data.enforcement'
|
|||
- `closingComments: true` → You must have at least one comment ≥20 characters
|
||||
- `autoTelemetry: false` → You are responsible for emitting `run.*` events yourself
|
||||
- `autoTimeTracking: false` → You must manually start/stop timers
|
||||
- `ceremonyDesignReview: block` → Complete any pending design-review ceremony before marking risky work done
|
||||
- `ceremonyFailureRetrospective: warn` → Expect a pending retrospective record after blocked or failed work
|
||||
|
||||
### 2. Pre-Flight Checks
|
||||
|
||||
|
|
|
|||
116
server/src/__tests__/ceremony-service.test.ts
Normal file
116
server/src/__tests__/ceremony-service.test.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { Task } from '@veritas-kanban/shared';
|
||||
import { CeremonyService } from '../services/ceremony-service.js';
|
||||
|
||||
function task(overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id: 'task_20260626_ceremony',
|
||||
title: 'Ceremony task',
|
||||
description: '',
|
||||
type: 'code',
|
||||
status: 'in-progress',
|
||||
priority: 'medium',
|
||||
created: '2026-06-26T12:00:00.000Z',
|
||||
updated: '2026-06-26T12:00:00.000Z',
|
||||
...overrides,
|
||||
} as Task;
|
||||
}
|
||||
|
||||
function service() {
|
||||
const audit = vi.fn().mockResolvedValue(undefined);
|
||||
const record = vi.fn().mockResolvedValue({ id: 'govtrace_1' });
|
||||
return {
|
||||
audit,
|
||||
record,
|
||||
service: new CeremonyService({
|
||||
persist: false,
|
||||
audit,
|
||||
governanceTraceService: { record } as never,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe('CeremonyService', () => {
|
||||
it('creates, lists, and completes ceremony requirements with artifacts', async () => {
|
||||
const { audit, service: ceremonyService } = service();
|
||||
|
||||
const requirement = await ceremonyService.create({
|
||||
kind: 'design_review',
|
||||
enforcementMode: 'block',
|
||||
reason: 'Task coordinates multiple agents.',
|
||||
target: { taskId: 'task_20260626_design' },
|
||||
trigger: 'task.completion',
|
||||
});
|
||||
|
||||
expect(requirement).toMatchObject({
|
||||
kind: 'design_review',
|
||||
status: 'pending',
|
||||
enforcementMode: 'block',
|
||||
requiredArtifacts: ['decision-packet', 'risk-list', 'action-items'],
|
||||
});
|
||||
expect(await ceremonyService.list({ status: 'pending' })).toHaveLength(1);
|
||||
|
||||
const completed = await ceremonyService.complete(requirement.id, {
|
||||
completedBy: 'brad',
|
||||
artifacts: [
|
||||
{
|
||||
kind: 'decision-packet',
|
||||
title: 'Review notes',
|
||||
body: 'Reviewed scope, risks, and rollback path.',
|
||||
},
|
||||
],
|
||||
actionItems: [{ title: 'Add follow-up hardening task', priority: 'high' }],
|
||||
});
|
||||
|
||||
expect(completed.status).toBe('completed');
|
||||
expect(completed.artifacts[0]).toMatchObject({ title: 'Review notes' });
|
||||
expect(completed.artifacts[0].createdAt).toBeDefined();
|
||||
expect(completed.actionItems[0]).toMatchObject({ priority: 'high' });
|
||||
expect(audit).toHaveBeenCalledWith(expect.objectContaining({ action: 'ceremony.completed' }));
|
||||
});
|
||||
|
||||
it('blocks risky task completion until the matching design review is completed', async () => {
|
||||
const { record, service: ceremonyService } = service();
|
||||
const riskyTask = task({ agents: ['codex', 'claude-code'] });
|
||||
|
||||
const blocked = await ceremonyService.evaluateTaskCompletion(riskyTask, {
|
||||
ceremonyDesignReview: 'block',
|
||||
});
|
||||
|
||||
expect(blocked.allowed).toBe(false);
|
||||
expect(blocked.mode).toBe('block');
|
||||
expect(blocked.pending).toHaveLength(1);
|
||||
expect(blocked.blockedReasons[0]).toMatch(/Design review required/);
|
||||
expect(record).toHaveBeenCalledWith(expect.objectContaining({ kind: 'ceremony' }));
|
||||
|
||||
const secondEvaluation = await ceremonyService.evaluateTaskCompletion(riskyTask, {
|
||||
ceremonyDesignReview: 'block',
|
||||
});
|
||||
expect(secondEvaluation.pending).toHaveLength(1);
|
||||
expect(await ceremonyService.list({ status: 'pending' })).toHaveLength(1);
|
||||
|
||||
await ceremonyService.complete(blocked.pending[0].id, { completedBy: 'brad' });
|
||||
const allowed = await ceremonyService.evaluateTaskCompletion(riskyTask, {
|
||||
ceremonyDesignReview: 'block',
|
||||
});
|
||||
|
||||
expect(allowed.allowed).toBe(true);
|
||||
expect(allowed.pending).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('warns instead of blocking for failure retrospectives in warn mode', async () => {
|
||||
const { service: ceremonyService } = service();
|
||||
const blockedTask = task({
|
||||
status: 'blocked',
|
||||
blockedReason: { category: 'technical-snag', note: 'CI failed repeatedly.' },
|
||||
});
|
||||
|
||||
const evaluation = await ceremonyService.evaluateTaskCompletion(blockedTask, {
|
||||
ceremonyFailureRetrospective: 'warn',
|
||||
});
|
||||
|
||||
expect(evaluation.allowed).toBe(true);
|
||||
expect(evaluation.mode).toBe('warn');
|
||||
expect(evaluation.warnings[0]).toMatch(/Failure retrospective required/);
|
||||
});
|
||||
});
|
||||
|
|
@ -115,6 +115,75 @@ describe('Enforcement gates', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('blocks completion when a required ceremony is pending', async () => {
|
||||
vi.spyOn(ConfigService.prototype, 'getFeatureSettings').mockResolvedValue(
|
||||
buildSettings({ enforcement: { ceremonyDesignReview: 'block' } }) as any
|
||||
);
|
||||
const ceremonyService = {
|
||||
evaluateTaskCompletion: vi.fn().mockResolvedValue({
|
||||
allowed: false,
|
||||
mode: 'block',
|
||||
pending: [
|
||||
{
|
||||
id: 'ceremony_1',
|
||||
kind: 'design_review',
|
||||
title: 'Design review required before completion',
|
||||
reason: 'Task is high-risk, multi-agent, or review-mode work.',
|
||||
requiredArtifacts: ['decision-packet', 'risk-list', 'action-items'],
|
||||
dueAt: '2026-06-27T12:00:00.000Z',
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
blockedReasons: [
|
||||
'Design review required before completion: Task is high-risk, multi-agent, or review-mode work.',
|
||||
],
|
||||
}),
|
||||
};
|
||||
service = new TaskService({ tasksDir, archiveDir, ceremonyService: ceremonyService as any });
|
||||
|
||||
const task = await service.createTask({ title: 'Ceremony blocked', type: 'code' });
|
||||
|
||||
await expect(service.updateTask(task.id, { status: 'done' })).rejects.toThrow(
|
||||
/Ceremony Enforcement:/
|
||||
);
|
||||
expect(ceremonyService.evaluateTaskCompletion).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: task.id, status: 'done' }),
|
||||
expect.objectContaining({ ceremonyDesignReview: 'block' })
|
||||
);
|
||||
});
|
||||
|
||||
it('allows completion when ceremony enforcement only warns', async () => {
|
||||
vi.spyOn(ConfigService.prototype, 'getFeatureSettings').mockResolvedValue(
|
||||
buildSettings({ enforcement: { ceremonyDesignReview: 'warn' } }) as any
|
||||
);
|
||||
const ceremonyService = {
|
||||
evaluateTaskCompletion: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
mode: 'warn',
|
||||
pending: [
|
||||
{
|
||||
id: 'ceremony_1',
|
||||
kind: 'design_review',
|
||||
title: 'Design review required before completion',
|
||||
reason: 'Task is high-risk, multi-agent, or review-mode work.',
|
||||
requiredArtifacts: ['decision-packet', 'risk-list', 'action-items'],
|
||||
},
|
||||
],
|
||||
warnings: [
|
||||
'Design review required before completion: Task is high-risk, multi-agent, or review-mode work.',
|
||||
],
|
||||
blockedReasons: [],
|
||||
}),
|
||||
};
|
||||
service = new TaskService({ tasksDir, archiveDir, ceremonyService: ceremonyService as any });
|
||||
|
||||
const task = await service.createTask({ title: 'Ceremony warned', type: 'code' });
|
||||
const updated = await service.updateTask(task.id, { status: 'done' });
|
||||
|
||||
expect(updated.status).toBe('done');
|
||||
expect(ceremonyService.evaluateTaskCompletion).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('skips enforcement when enforcement settings are missing', async () => {
|
||||
vi.spyOn(ConfigService.prototype, 'getFeatureSettings').mockResolvedValue({
|
||||
...DEFAULT_FEATURE_SETTINGS,
|
||||
|
|
|
|||
125
server/src/__tests__/routes/ceremonies.test.ts
Normal file
125
server/src/__tests__/routes/ceremonies.test.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import express, { type NextFunction, type Request, type Response } from 'express';
|
||||
import request from 'supertest';
|
||||
|
||||
const { mockCeremonyService } = vi.hoisted(() => ({
|
||||
mockCeremonyService: {
|
||||
list: vi.fn(),
|
||||
create: vi.fn(),
|
||||
complete: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../services/ceremony-service.js', () => ({
|
||||
getCeremonyService: () => mockCeremonyService,
|
||||
}));
|
||||
|
||||
import { ceremonyRoutes } from '../../routes/ceremonies.js';
|
||||
|
||||
interface TestAuthRequest extends Request {
|
||||
auth?: { role: string; userId?: string; permissions: string[] };
|
||||
}
|
||||
|
||||
interface TestError extends Error {
|
||||
statusCode?: number;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
const requirement = {
|
||||
id: 'ceremony_1',
|
||||
kind: 'design_review',
|
||||
status: 'pending',
|
||||
enforcementMode: 'block',
|
||||
title: 'Design review required before completion',
|
||||
reason: 'Task is high-risk, multi-agent, or review-mode work.',
|
||||
target: { taskId: 'task_20260626_ceremony' },
|
||||
trigger: 'task.completion',
|
||||
participants: [{ role: 'coordinator' }],
|
||||
requiredArtifacts: ['decision-packet', 'risk-list', 'action-items'],
|
||||
artifacts: [],
|
||||
actionItems: [],
|
||||
createdAt: '2026-06-26T12:00:00.000Z',
|
||||
updatedAt: '2026-06-26T12:00:00.000Z',
|
||||
};
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req: TestAuthRequest, _res: Response, next: NextFunction) => {
|
||||
req.auth = { role: 'agent', userId: 'brad', permissions: ['workflow:write'] };
|
||||
next();
|
||||
});
|
||||
app.use('/api/ceremonies', ceremonyRoutes);
|
||||
app.use((err: TestError, _req: Request, res: Response, _next: NextFunction) => {
|
||||
res.status(err.statusCode || 500).json({ code: err.code || 'ERROR', message: err.message });
|
||||
});
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('ceremony routes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockCeremonyService.list.mockResolvedValue([requirement]);
|
||||
mockCeremonyService.create.mockResolvedValue(requirement);
|
||||
mockCeremonyService.complete.mockResolvedValue({ ...requirement, status: 'completed' });
|
||||
});
|
||||
|
||||
it('lists ceremony requirements with validated filters', async () => {
|
||||
const res = await request(createApp()).get('/api/ceremonies?status=pending&limit=5');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body[0].id).toBe('ceremony_1');
|
||||
expect(mockCeremonyService.list).toHaveBeenCalledWith({ status: 'pending', limit: 5 });
|
||||
});
|
||||
|
||||
it('creates ceremony requirements', async () => {
|
||||
const res = await request(createApp())
|
||||
.post('/api/ceremonies')
|
||||
.send({
|
||||
kind: 'design_review',
|
||||
enforcementMode: 'block',
|
||||
reason: 'Task coordinates multiple agents.',
|
||||
target: { taskId: 'task_20260626_ceremony' },
|
||||
trigger: 'manual',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.id).toBe('ceremony_1');
|
||||
expect(mockCeremonyService.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ kind: 'design_review', enforcementMode: 'block' })
|
||||
);
|
||||
});
|
||||
|
||||
it('completes ceremony requirements using the authenticated actor by default', async () => {
|
||||
const res = await request(createApp())
|
||||
.post('/api/ceremonies/ceremony_1/complete')
|
||||
.send({
|
||||
artifacts: [
|
||||
{
|
||||
kind: 'decision-packet',
|
||||
title: 'Decision packet',
|
||||
body: 'Reviewed the risk list and rollback plan.',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('completed');
|
||||
expect(mockCeremonyService.complete).toHaveBeenCalledWith(
|
||||
'ceremony_1',
|
||||
expect.objectContaining({ completedBy: 'brad' })
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects invalid ceremony targets', async () => {
|
||||
const res = await request(createApp()).post('/api/ceremonies').send({
|
||||
kind: 'design_review',
|
||||
reason: 'Missing target.',
|
||||
target: {},
|
||||
trigger: 'manual',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(mockCeremonyService.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -18,6 +18,14 @@ describe('shared API permission metadata', () => {
|
|||
).toEqual(['workflow:execute']);
|
||||
});
|
||||
|
||||
it('keeps ceremony API under workflow read and write permissions', () => {
|
||||
expect(getApiPermissionRequirement('/api/ceremonies').permissions).toEqual(['workflow:read']);
|
||||
expect(
|
||||
getApiPermissionRequirement('/api/ceremonies/ceremony_1/complete', { method: 'POST' })
|
||||
.permissions
|
||||
).toEqual(['workflow:write']);
|
||||
});
|
||||
|
||||
it('keeps diff reads task-read scoped', () => {
|
||||
expect(getApiPermissionRequirement('/api/diff/task_1/full').permissions).toEqual(['task:read']);
|
||||
});
|
||||
|
|
|
|||
149
server/src/routes/ceremonies.ts
Normal file
149
server/src/routes/ceremonies.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import { Router, type Router as RouterType } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { asyncHandler } from '../middleware/async-handler.js';
|
||||
import type { AuthenticatedRequest } from '../middleware/auth.js';
|
||||
import { ValidationError } from '../middleware/error-handler.js';
|
||||
import { getCeremonyService } from '../services/ceremony-service.js';
|
||||
|
||||
const router: RouterType = Router();
|
||||
|
||||
const ceremonyKindSchema = z.enum(['design_review', 'failure_retrospective']);
|
||||
const ceremonyStatusSchema = z.enum(['pending', 'completed', 'cancelled']);
|
||||
const ceremonyModeSchema = z.enum(['off', 'warn', 'block']);
|
||||
const participantRoleSchema = z.enum([
|
||||
'coordinator',
|
||||
'implementer',
|
||||
'reviewer',
|
||||
'security-owner',
|
||||
'qa-owner',
|
||||
'human-approver',
|
||||
]);
|
||||
const artifactKindSchema = z.enum([
|
||||
'decision-packet',
|
||||
'risk-list',
|
||||
'retrospective',
|
||||
'action-items',
|
||||
'github-issues',
|
||||
]);
|
||||
|
||||
const targetSchema = z
|
||||
.object({
|
||||
taskId: z.string().min(1).optional(),
|
||||
runId: z.string().min(1).optional(),
|
||||
workflowId: z.string().min(1).optional(),
|
||||
prUrl: z.string().url().optional(),
|
||||
ciUrl: z.string().url().optional(),
|
||||
})
|
||||
.refine((target) => Object.values(target).some(Boolean), {
|
||||
message: 'At least one ceremony target identifier is required',
|
||||
});
|
||||
|
||||
const participantSchema = z.object({
|
||||
role: participantRoleSchema,
|
||||
name: z.string().min(1).optional(),
|
||||
agent: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
const createCeremonySchema = z.object({
|
||||
kind: ceremonyKindSchema,
|
||||
enforcementMode: ceremonyModeSchema.optional(),
|
||||
title: z.string().min(1).max(200).optional(),
|
||||
reason: z.string().min(1).max(2000),
|
||||
target: targetSchema,
|
||||
trigger: z.string().min(1).max(120),
|
||||
dueAt: z.string().datetime().optional(),
|
||||
participants: z.array(participantSchema).max(20).optional(),
|
||||
requiredArtifacts: z.array(artifactKindSchema).max(12).optional(),
|
||||
});
|
||||
|
||||
const completeCeremonySchema = z.object({
|
||||
completedBy: z.string().min(1).optional(),
|
||||
artifacts: z
|
||||
.array(
|
||||
z.object({
|
||||
kind: artifactKindSchema,
|
||||
title: z.string().min(1).max(200),
|
||||
body: z.string().min(1).max(10000),
|
||||
url: z.string().url().optional(),
|
||||
createdAt: z.string().datetime().optional(),
|
||||
})
|
||||
)
|
||||
.max(20)
|
||||
.optional(),
|
||||
actionItems: z
|
||||
.array(
|
||||
z.object({
|
||||
title: z.string().min(1).max(200),
|
||||
assignee: z.string().min(1).optional(),
|
||||
priority: z.enum(['low', 'medium', 'high', 'critical']).optional(),
|
||||
dueAt: z.string().datetime().optional(),
|
||||
taskId: z.string().min(1).optional(),
|
||||
issueUrl: z.string().url().optional(),
|
||||
createdAt: z.string().datetime().optional(),
|
||||
})
|
||||
)
|
||||
.max(50)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const listQuerySchema = z.object({
|
||||
status: ceremonyStatusSchema.optional(),
|
||||
kind: ceremonyKindSchema.optional(),
|
||||
taskId: z.string().min(1).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(500).optional(),
|
||||
});
|
||||
|
||||
function parseOrThrow<T>(schema: z.ZodType<T>, value: unknown): T {
|
||||
try {
|
||||
return schema.parse(value);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
throw new ValidationError('Validation failed', error.issues);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function actorFromRequest(req: AuthenticatedRequest): string {
|
||||
return (
|
||||
req.auth?.userId ||
|
||||
req.auth?.tokenName ||
|
||||
req.auth?.keyName ||
|
||||
req.auth?.clientId ||
|
||||
req.auth?.deviceId ||
|
||||
req.auth?.role ||
|
||||
'operator'
|
||||
);
|
||||
}
|
||||
|
||||
router.get(
|
||||
'/',
|
||||
asyncHandler(async (req, res) => {
|
||||
const query = parseOrThrow(listQuerySchema, req.query);
|
||||
res.json(await getCeremonyService().list(query));
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/',
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = parseOrThrow(createCeremonySchema, req.body);
|
||||
const requirement = await getCeremonyService().create(body);
|
||||
res.status(201).json(requirement);
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:id/complete',
|
||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
||||
const id = String(req.params.id);
|
||||
const body = parseOrThrow(completeCeremonySchema, req.body);
|
||||
const requirement = await getCeremonyService().complete(id, {
|
||||
...body,
|
||||
completedBy: body.completedBy ?? actorFromRequest(req),
|
||||
});
|
||||
res.json(requirement);
|
||||
})
|
||||
);
|
||||
|
||||
export { router as ceremonyRoutes };
|
||||
|
|
@ -16,6 +16,7 @@ const traceListQuerySchema = z.object({
|
|||
'agent-permission',
|
||||
'routing',
|
||||
'workflow-gate',
|
||||
'ceremony',
|
||||
])
|
||||
.optional(),
|
||||
outcome: z
|
||||
|
|
|
|||
|
|
@ -118,6 +118,7 @@ import transitionHooksRoutes from '../transition-hooks.js';
|
|||
import lessonsRoutes from '../lessons.js';
|
||||
import delegationRoutes from '../delegation.js';
|
||||
import { workflowRoutes } from '../workflows.js';
|
||||
import { ceremonyRoutes } from '../ceremonies.js';
|
||||
import toolPolicyRoutes from '../tool-policies.js';
|
||||
import policyRoutes from '../policies.js';
|
||||
import { integrationsRoutes } from '../integrations.js';
|
||||
|
|
@ -232,6 +233,7 @@ v1Router.use('/audit', adminAccess, auditRoutes);
|
|||
v1Router.use('/lessons', taskReadAccess, lessonsRoutes);
|
||||
v1Router.use('/delegation', delegationAccess, delegationRoutes);
|
||||
v1Router.use('/workflows', workflowAccess, workflowRoutes);
|
||||
v1Router.use('/ceremonies', workflowAccess, ceremonyRoutes);
|
||||
v1Router.use('/scheduler', schedulerAccess, schedulerRoutes);
|
||||
v1Router.use('/queue-monitors', queueMonitorAccess, queueMonitorRoutes);
|
||||
v1Router.use('/watcher-policies', watcherPolicyAccess, watcherPolicyRoutes);
|
||||
|
|
|
|||
|
|
@ -220,6 +220,8 @@ const EnforcementSettingsSchema = z
|
|||
autoTimeTracking: z.boolean().optional(),
|
||||
orchestratorDelegation: z.boolean().optional(),
|
||||
orchestratorAgent: z.string().max(50).optional(),
|
||||
ceremonyDesignReview: z.enum(['off', 'warn', 'block']).optional(),
|
||||
ceremonyFailureRetrospective: z.enum(['off', 'warn', 'block']).optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
|
|
|
|||
416
server/src/services/ceremony-service.ts
Normal file
416
server/src/services/ceremony-service.ts
Normal file
|
|
@ -0,0 +1,416 @@
|
|||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { nanoid } from 'nanoid';
|
||||
import type {
|
||||
CeremonyActionItem,
|
||||
CeremonyArtifact,
|
||||
CeremonyArtifactKind,
|
||||
CeremonyEnforcementMode,
|
||||
CeremonyEvaluationResult,
|
||||
CeremonyKind,
|
||||
CeremonyParticipant,
|
||||
CeremonyRequirement,
|
||||
CeremonyStatus,
|
||||
CompleteCeremonyRequirementInput,
|
||||
CreateCeremonyRequirementInput,
|
||||
EnforcementSettings,
|
||||
Task,
|
||||
} from '@veritas-kanban/shared';
|
||||
import { auditLog, type AuditEvent } from './audit-service.js';
|
||||
import {
|
||||
getGovernanceTraceService,
|
||||
type GovernanceTraceService,
|
||||
} from './governance-trace-service.js';
|
||||
import { withFileLock } from './file-lock.js';
|
||||
import { ConflictError, NotFoundError } from '../middleware/error-handler.js';
|
||||
import { ensureWithinBase, validatePathSegment } from '../utils/sanitize.js';
|
||||
import { getRuntimeDir } from '../utils/paths.js';
|
||||
|
||||
const MAX_REQUIREMENTS = 1000;
|
||||
|
||||
interface CeremonyState {
|
||||
version: 1;
|
||||
requirements: CeremonyRequirement[];
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CeremonyServiceOptions {
|
||||
storageDir?: string;
|
||||
persist?: boolean;
|
||||
audit?: (event: AuditEvent) => Promise<void>;
|
||||
governanceTraceService?: GovernanceTraceService;
|
||||
}
|
||||
|
||||
export interface CeremonyListFilters {
|
||||
status?: CeremonyStatus;
|
||||
kind?: CeremonyKind;
|
||||
taskId?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function defaultParticipants(kind: CeremonyKind): CeremonyParticipant[] {
|
||||
if (kind === 'design_review') {
|
||||
return [
|
||||
{ role: 'coordinator' },
|
||||
{ role: 'implementer' },
|
||||
{ role: 'reviewer' },
|
||||
{ role: 'qa-owner' },
|
||||
];
|
||||
}
|
||||
return [{ role: 'coordinator' }, { role: 'implementer' }, { role: 'reviewer' }];
|
||||
}
|
||||
|
||||
function defaultArtifacts(kind: CeremonyKind): CeremonyArtifactKind[] {
|
||||
return kind === 'design_review'
|
||||
? ['decision-packet', 'risk-list', 'action-items']
|
||||
: ['retrospective', 'action-items'];
|
||||
}
|
||||
|
||||
function defaultTitle(kind: CeremonyKind): string {
|
||||
return kind === 'design_review' ? 'Design review required' : 'Failure retrospective required';
|
||||
}
|
||||
|
||||
function normalizeMode(mode?: CeremonyEnforcementMode): CeremonyEnforcementMode {
|
||||
return mode ?? 'warn';
|
||||
}
|
||||
|
||||
function dueInHours(hours: number): string {
|
||||
return new Date(Date.now() + hours * 60 * 60 * 1000).toISOString();
|
||||
}
|
||||
|
||||
export class CeremonyService {
|
||||
private readonly storageDir: string;
|
||||
private readonly persist: boolean;
|
||||
private readonly audit: (event: AuditEvent) => Promise<void>;
|
||||
private readonly governanceTraceService: GovernanceTraceService;
|
||||
private loaded = false;
|
||||
private state: CeremonyState = this.emptyState();
|
||||
|
||||
constructor(options: CeremonyServiceOptions = {}) {
|
||||
this.storageDir = options.storageDir ?? path.join(getRuntimeDir(), 'ceremonies');
|
||||
this.persist = options.persist ?? process.env.VITEST !== 'true';
|
||||
this.audit = options.audit ?? auditLog;
|
||||
this.governanceTraceService = options.governanceTraceService ?? getGovernanceTraceService();
|
||||
}
|
||||
|
||||
async list(filters: CeremonyListFilters = {}): Promise<CeremonyRequirement[]> {
|
||||
await this.ensureLoaded();
|
||||
const limit = Math.max(1, Math.min(Math.floor(filters.limit ?? 100), MAX_REQUIREMENTS));
|
||||
return this.state.requirements
|
||||
.filter((requirement) => !filters.status || requirement.status === filters.status)
|
||||
.filter((requirement) => !filters.kind || requirement.kind === filters.kind)
|
||||
.filter((requirement) => !filters.taskId || requirement.target.taskId === filters.taskId)
|
||||
.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt))
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
async create(input: CreateCeremonyRequirementInput): Promise<CeremonyRequirement> {
|
||||
await this.ensureLoaded();
|
||||
const existing = this.findOpenRequirement(input.kind, input.target.taskId, input.target.runId);
|
||||
if (existing) return existing;
|
||||
|
||||
const timestamp = nowIso();
|
||||
const requirement: CeremonyRequirement = {
|
||||
id: `ceremony_${Date.now()}_${nanoid(6)}`,
|
||||
kind: input.kind,
|
||||
status: 'pending',
|
||||
enforcementMode: normalizeMode(input.enforcementMode),
|
||||
title: input.title ?? defaultTitle(input.kind),
|
||||
reason: input.reason,
|
||||
target: input.target,
|
||||
trigger: input.trigger,
|
||||
dueAt: input.dueAt,
|
||||
participants: input.participants ?? defaultParticipants(input.kind),
|
||||
requiredArtifacts: input.requiredArtifacts ?? defaultArtifacts(input.kind),
|
||||
artifacts: [],
|
||||
actionItems: [],
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
|
||||
this.state.requirements.push(requirement);
|
||||
if (this.state.requirements.length > MAX_REQUIREMENTS) {
|
||||
this.state.requirements = this.state.requirements.slice(-MAX_REQUIREMENTS);
|
||||
}
|
||||
await this.saveState();
|
||||
await this.auditChange('ceremony.created', requirement);
|
||||
return requirement;
|
||||
}
|
||||
|
||||
async complete(
|
||||
id: string,
|
||||
input: CompleteCeremonyRequirementInput
|
||||
): Promise<CeremonyRequirement> {
|
||||
validatePathSegment(id);
|
||||
await this.ensureLoaded();
|
||||
const requirement = this.findById(id);
|
||||
if (!requirement) throw new NotFoundError('Ceremony requirement not found');
|
||||
if (requirement.status !== 'pending') {
|
||||
throw new ConflictError('Ceremony requirement is not pending');
|
||||
}
|
||||
|
||||
const timestamp = nowIso();
|
||||
requirement.status = 'completed';
|
||||
requirement.completedAt = timestamp;
|
||||
requirement.completedBy = input.completedBy;
|
||||
requirement.updatedAt = timestamp;
|
||||
requirement.artifacts = [
|
||||
...requirement.artifacts,
|
||||
...(input.artifacts ?? []).map<CeremonyArtifact>((artifact) => ({
|
||||
...artifact,
|
||||
createdAt: artifact.createdAt ?? timestamp,
|
||||
})),
|
||||
];
|
||||
requirement.actionItems = [
|
||||
...requirement.actionItems,
|
||||
...(input.actionItems ?? []).map<CeremonyActionItem>((item) => ({
|
||||
...item,
|
||||
createdAt: item.createdAt ?? timestamp,
|
||||
})),
|
||||
];
|
||||
|
||||
await this.saveState();
|
||||
await this.auditChange('ceremony.completed', requirement);
|
||||
return requirement;
|
||||
}
|
||||
|
||||
async evaluateTaskCompletion(
|
||||
task: Task,
|
||||
enforcement?: Partial<EnforcementSettings>
|
||||
): Promise<CeremonyEvaluationResult> {
|
||||
await this.ensureLoaded();
|
||||
const required: CeremonyRequirement[] = [];
|
||||
|
||||
const designMode = enforcement?.ceremonyDesignReview ?? 'off';
|
||||
if (
|
||||
designMode !== 'off' &&
|
||||
this.taskNeedsDesignReview(task) &&
|
||||
!this.hasCompletedRequirement('design_review', task.id)
|
||||
) {
|
||||
required.push(
|
||||
await this.create({
|
||||
kind: 'design_review',
|
||||
enforcementMode: designMode,
|
||||
title: 'Design review required before completion',
|
||||
reason: 'Task is high-risk, multi-agent, or review-mode work.',
|
||||
target: { taskId: task.id },
|
||||
trigger: 'task.completion',
|
||||
dueAt: dueInHours(24),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const retroMode = enforcement?.ceremonyFailureRetrospective ?? 'off';
|
||||
if (
|
||||
retroMode !== 'off' &&
|
||||
this.taskNeedsFailureRetrospective(task) &&
|
||||
!this.hasCompletedRequirement('failure_retrospective', task.id)
|
||||
) {
|
||||
required.push(
|
||||
await this.create({
|
||||
kind: 'failure_retrospective',
|
||||
enforcementMode: retroMode,
|
||||
title: 'Failure retrospective required before completion',
|
||||
reason: 'Task has blocked status or failed run attempts.',
|
||||
target: { taskId: task.id },
|
||||
trigger: 'task.completion',
|
||||
dueAt: dueInHours(24),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const pending = required.filter((requirement) => requirement.status === 'pending');
|
||||
const blocking = pending.filter((requirement) => requirement.enforcementMode === 'block');
|
||||
const warnings = pending
|
||||
.filter((requirement) => requirement.enforcementMode === 'warn')
|
||||
.map((requirement) => `${requirement.title}: ${requirement.reason}`);
|
||||
const blockedReasons = blocking.map(
|
||||
(requirement) => `${requirement.title}: ${requirement.reason}`
|
||||
);
|
||||
const mode: CeremonyEnforcementMode =
|
||||
blocking.length > 0 ? 'block' : warnings.length > 0 ? 'warn' : 'off';
|
||||
|
||||
if (pending.length > 0) {
|
||||
await this.recordEvaluationTrace(task, pending, mode);
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: blocking.length === 0,
|
||||
mode,
|
||||
pending,
|
||||
warnings,
|
||||
blockedReasons,
|
||||
};
|
||||
}
|
||||
|
||||
private taskNeedsDesignReview(task: Task): boolean {
|
||||
return (
|
||||
(task.agents?.length ?? 0) > 1 ||
|
||||
task.priority === 'critical' ||
|
||||
task.runMode === 'strategy' ||
|
||||
task.runMode === 'eng-review' ||
|
||||
task.runMode === 'paranoid-review'
|
||||
);
|
||||
}
|
||||
|
||||
private taskNeedsFailureRetrospective(task: Task): boolean {
|
||||
return (
|
||||
task.status === 'blocked' ||
|
||||
Boolean(task.blockedReason?.note) ||
|
||||
task.attempt?.status === 'failed' ||
|
||||
(task.attempts ?? []).some((attempt) => attempt.status === 'failed')
|
||||
);
|
||||
}
|
||||
|
||||
private async recordEvaluationTrace(
|
||||
task: Task,
|
||||
pending: CeremonyRequirement[],
|
||||
mode: CeremonyEnforcementMode
|
||||
): Promise<void> {
|
||||
await this.governanceTraceService.record({
|
||||
kind: 'ceremony',
|
||||
outcome: mode === 'block' ? 'blocked' : 'warned',
|
||||
title: mode === 'block' ? 'Ceremony gate blocked completion' : 'Ceremony gate warned',
|
||||
summary: `${pending.length} ceremony requirement(s) pending for ${task.id}.`,
|
||||
remediation: 'Complete the required ceremony artifacts or change enforcement mode.',
|
||||
subject: { taskId: task.id, actionType: 'task.complete' },
|
||||
evaluatedRules: pending.map((requirement) => ({
|
||||
id: requirement.id,
|
||||
label: requirement.title,
|
||||
type: requirement.kind,
|
||||
status: 'matched',
|
||||
outcome: requirement.enforcementMode === 'block' ? 'blocked' : 'warned',
|
||||
message: requirement.reason,
|
||||
})),
|
||||
matchedRules: pending.map((requirement) => ({
|
||||
id: requirement.id,
|
||||
label: requirement.title,
|
||||
type: requirement.kind,
|
||||
status: 'matched',
|
||||
outcome: requirement.enforcementMode === 'block' ? 'blocked' : 'warned',
|
||||
message: requirement.reason,
|
||||
})),
|
||||
steps: pending.map((requirement) => ({
|
||||
id: requirement.id,
|
||||
label: requirement.title,
|
||||
status: 'matched',
|
||||
message: requirement.reason,
|
||||
details: {
|
||||
kind: requirement.kind,
|
||||
enforcementMode: requirement.enforcementMode,
|
||||
requiredArtifacts: requirement.requiredArtifacts,
|
||||
},
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
private findOpenRequirement(
|
||||
kind: CeremonyKind,
|
||||
taskId?: string,
|
||||
runId?: string
|
||||
): CeremonyRequirement | undefined {
|
||||
return this.state.requirements.find(
|
||||
(requirement) =>
|
||||
requirement.kind === kind &&
|
||||
requirement.status === 'pending' &&
|
||||
requirement.target.taskId === taskId &&
|
||||
requirement.target.runId === runId
|
||||
);
|
||||
}
|
||||
|
||||
private hasCompletedRequirement(kind: CeremonyKind, taskId?: string, runId?: string): boolean {
|
||||
return this.state.requirements.some(
|
||||
(requirement) =>
|
||||
requirement.kind === kind &&
|
||||
requirement.status === 'completed' &&
|
||||
requirement.target.taskId === taskId &&
|
||||
requirement.target.runId === runId
|
||||
);
|
||||
}
|
||||
|
||||
private findById(id: string): CeremonyRequirement | undefined {
|
||||
return this.state.requirements.find((requirement) => requirement.id === id);
|
||||
}
|
||||
|
||||
private async ensureLoaded(): Promise<void> {
|
||||
if (this.loaded) return;
|
||||
if (!this.persist) {
|
||||
this.loaded = true;
|
||||
return;
|
||||
}
|
||||
|
||||
await fs.mkdir(this.storageDir, { recursive: true });
|
||||
try {
|
||||
const raw = await fs.readFile(this.statePath, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as Partial<CeremonyState>;
|
||||
this.state = {
|
||||
version: 1,
|
||||
requirements: Array.isArray(parsed.requirements)
|
||||
? (parsed.requirements as CeremonyRequirement[])
|
||||
: [],
|
||||
updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : nowIso(),
|
||||
};
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
||||
this.state = this.emptyState();
|
||||
}
|
||||
this.loaded = true;
|
||||
}
|
||||
|
||||
private async saveState(): Promise<void> {
|
||||
this.state.updatedAt = nowIso();
|
||||
if (!this.persist) return;
|
||||
await fs.mkdir(this.storageDir, { recursive: true });
|
||||
await withFileLock(this.statePath, async () => {
|
||||
await fs.writeFile(this.statePath, JSON.stringify(this.state, null, 2), 'utf-8');
|
||||
});
|
||||
}
|
||||
|
||||
private get statePath(): string {
|
||||
const filePath = path.join(this.storageDir, 'requirements.json');
|
||||
ensureWithinBase(this.storageDir, filePath);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
private emptyState(): CeremonyState {
|
||||
return { version: 1, requirements: [], updatedAt: nowIso() };
|
||||
}
|
||||
|
||||
private async auditChange(action: string, requirement: CeremonyRequirement): Promise<void> {
|
||||
await this.audit({
|
||||
action,
|
||||
actor: requirement.completedBy ?? 'system',
|
||||
resource: requirement.id,
|
||||
details: {
|
||||
kind: requirement.kind,
|
||||
status: requirement.status,
|
||||
enforcementMode: requirement.enforcementMode,
|
||||
target: requirement.target,
|
||||
requiredArtifacts: requirement.requiredArtifacts,
|
||||
actionItems: requirement.actionItems.map((item) => ({
|
||||
title: item.title,
|
||||
taskId: item.taskId,
|
||||
issueUrl: item.issueUrl,
|
||||
assignee: item.assignee,
|
||||
priority: item.priority,
|
||||
dueAt: item.dueAt,
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let ceremonyService: CeremonyService | null = null;
|
||||
|
||||
export function getCeremonyService(): CeremonyService {
|
||||
ceremonyService ??= new CeremonyService();
|
||||
return ceremonyService;
|
||||
}
|
||||
|
||||
export function resetCeremonyServiceForTests(): void {
|
||||
ceremonyService = null;
|
||||
}
|
||||
|
|
@ -45,6 +45,7 @@ import {
|
|||
type TaskIdentityLocation,
|
||||
type TaskIdentityScanSource,
|
||||
} from './task-identity-diagnostics.js';
|
||||
import { getCeremonyService, type CeremonyService } from './ceremony-service.js';
|
||||
|
||||
const log = createLogger('task-cache');
|
||||
const TASK_SYNC_CONTEXT: TaskSyncContext = createTaskSyncToken('task-service');
|
||||
|
|
@ -96,6 +97,7 @@ export interface TaskServiceOptions {
|
|||
sqliteDatabase?: SqliteDatabase;
|
||||
sqliteConnectionOptions?: SqliteConnectionOptions;
|
||||
configService?: Pick<ConfigService, 'getFeatureSettings'>;
|
||||
ceremonyService?: Pick<CeremonyService, 'evaluateTaskCompletion'>;
|
||||
}
|
||||
|
||||
/** Ignore file-watcher events within this window after our own writes */
|
||||
|
|
@ -110,6 +112,7 @@ export class TaskService {
|
|||
private sqliteTasks: SqliteTaskRepository | null = null;
|
||||
private sqliteMutationQueue: Promise<unknown> = Promise.resolve();
|
||||
private configService: Pick<ConfigService, 'getFeatureSettings'>;
|
||||
private ceremonyService: Pick<CeremonyService, 'evaluateTaskCompletion'>;
|
||||
|
||||
// ============ In-Memory Cache ============
|
||||
private cache: Map<string, Task> = new Map();
|
||||
|
|
@ -128,6 +131,7 @@ export class TaskService {
|
|||
options.backlogDir ?? (options.tasksDir || options.archiveDir ? null : getTasksBacklogDir());
|
||||
this.telemetry = options.telemetryService || getTelemetryService();
|
||||
this.configService = options.configService ?? new ConfigService();
|
||||
this.ceremonyService = options.ceremonyService ?? getCeremonyService();
|
||||
const storageType =
|
||||
options.storageType ?? (process.env.VERITAS_STORAGE === 'sqlite' ? 'sqlite' : 'file');
|
||||
|
||||
|
|
@ -967,6 +971,34 @@ export class TaskService {
|
|||
? undefined
|
||||
: (blockedReasonUpdate ?? freshTask.blockedReason),
|
||||
};
|
||||
|
||||
if (input.status === 'done' && settings.enforcement) {
|
||||
const ceremonyEvaluation = await this.ceremonyService.evaluateTaskCompletion(
|
||||
previewTask,
|
||||
settings.enforcement
|
||||
);
|
||||
if (!ceremonyEvaluation.allowed) {
|
||||
const detailMessage = `Ceremony Enforcement: ${ceremonyEvaluation.blockedReasons.join(
|
||||
' '
|
||||
)}`;
|
||||
throw new ValidationError(detailMessage, [
|
||||
{
|
||||
code: 'CEREMONY_REQUIRED',
|
||||
message: detailMessage,
|
||||
path: ['status'],
|
||||
details: ceremonyEvaluation.pending.map((requirement) => ({
|
||||
id: requirement.id,
|
||||
kind: requirement.kind,
|
||||
title: requirement.title,
|
||||
reason: requirement.reason,
|
||||
requiredArtifacts: requirement.requiredArtifacts,
|
||||
dueAt: requirement.dueAt,
|
||||
})),
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
const validation = await validateTransition(previewTask, previousStatus, input.status);
|
||||
if (!validation.allowed) {
|
||||
throw new ValidationError(
|
||||
|
|
|
|||
96
shared/src/types/ceremony.types.ts
Normal file
96
shared/src/types/ceremony.types.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import type { TaskPriority } from './task.types.js';
|
||||
|
||||
export type CeremonyKind = 'design_review' | 'failure_retrospective';
|
||||
export type CeremonyStatus = 'pending' | 'completed' | 'cancelled';
|
||||
export type CeremonyEnforcementMode = 'off' | 'warn' | 'block';
|
||||
export type CeremonyParticipantRole =
|
||||
| 'coordinator'
|
||||
| 'implementer'
|
||||
| 'reviewer'
|
||||
| 'security-owner'
|
||||
| 'qa-owner'
|
||||
| 'human-approver';
|
||||
export type CeremonyArtifactKind =
|
||||
| 'decision-packet'
|
||||
| 'risk-list'
|
||||
| 'retrospective'
|
||||
| 'action-items'
|
||||
| 'github-issues';
|
||||
|
||||
export interface CeremonyTarget {
|
||||
taskId?: string;
|
||||
runId?: string;
|
||||
workflowId?: string;
|
||||
prUrl?: string;
|
||||
ciUrl?: string;
|
||||
}
|
||||
|
||||
export interface CeremonyParticipant {
|
||||
role: CeremonyParticipantRole;
|
||||
name?: string;
|
||||
agent?: string;
|
||||
}
|
||||
|
||||
export interface CeremonyArtifact {
|
||||
kind: CeremonyArtifactKind;
|
||||
title: string;
|
||||
body: string;
|
||||
url?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CeremonyActionItem {
|
||||
title: string;
|
||||
assignee?: string;
|
||||
priority?: TaskPriority;
|
||||
dueAt?: string;
|
||||
taskId?: string;
|
||||
issueUrl?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CeremonyRequirement {
|
||||
id: string;
|
||||
kind: CeremonyKind;
|
||||
status: CeremonyStatus;
|
||||
enforcementMode: CeremonyEnforcementMode;
|
||||
title: string;
|
||||
reason: string;
|
||||
target: CeremonyTarget;
|
||||
trigger: string;
|
||||
dueAt?: string;
|
||||
participants: CeremonyParticipant[];
|
||||
requiredArtifacts: CeremonyArtifactKind[];
|
||||
artifacts: CeremonyArtifact[];
|
||||
actionItems: CeremonyActionItem[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
completedAt?: string;
|
||||
completedBy?: string;
|
||||
}
|
||||
|
||||
export interface CreateCeremonyRequirementInput {
|
||||
kind: CeremonyKind;
|
||||
enforcementMode?: CeremonyEnforcementMode;
|
||||
title?: string;
|
||||
reason: string;
|
||||
target: CeremonyTarget;
|
||||
trigger: string;
|
||||
dueAt?: string;
|
||||
participants?: CeremonyParticipant[];
|
||||
requiredArtifacts?: CeremonyArtifactKind[];
|
||||
}
|
||||
|
||||
export interface CompleteCeremonyRequirementInput {
|
||||
completedBy: string;
|
||||
artifacts?: Array<Omit<CeremonyArtifact, 'createdAt'> & { createdAt?: string }>;
|
||||
actionItems?: Array<Omit<CeremonyActionItem, 'createdAt'> & { createdAt?: string }>;
|
||||
}
|
||||
|
||||
export interface CeremonyEvaluationResult {
|
||||
allowed: boolean;
|
||||
mode: CeremonyEnforcementMode;
|
||||
pending: CeremonyRequirement[];
|
||||
warnings: string[];
|
||||
blockedReasons: string[];
|
||||
}
|
||||
|
|
@ -329,6 +329,8 @@ export interface EnforcementSettings {
|
|||
autoTimeTracking: boolean; // Auto-start/stop timers on status changes
|
||||
orchestratorDelegation: boolean; // Warn when orchestrator does implementation work instead of delegating
|
||||
orchestratorAgent?: string; // The designated orchestrator agent name (e.g. "veritas")
|
||||
ceremonyDesignReview: 'off' | 'warn' | 'block'; // Require design review ceremonies for risky/multi-agent tasks
|
||||
ceremonyFailureRetrospective: 'off' | 'warn' | 'block'; // Require retrospectives after blocked or failed runs
|
||||
}
|
||||
|
||||
/** Individual hook configuration */
|
||||
|
|
@ -534,6 +536,8 @@ export const DEFAULT_FEATURE_SETTINGS: FeatureSettings = {
|
|||
autoTimeTracking: false,
|
||||
orchestratorDelegation: false,
|
||||
orchestratorAgent: '',
|
||||
ceremonyDesignReview: 'off',
|
||||
ceremonyFailureRetrospective: 'off',
|
||||
},
|
||||
hooks: {
|
||||
enabled: false, // Disabled by default
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ export type GovernanceTraceKind =
|
|||
| 'budget-policy'
|
||||
| 'agent-permission'
|
||||
| 'routing'
|
||||
| 'workflow-gate';
|
||||
| 'workflow-gate'
|
||||
| 'ceremony';
|
||||
|
||||
export type GovernanceTraceOutcome =
|
||||
| 'allowed'
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export * from './websocket.types.js';
|
|||
export * from './managed-list.types.js';
|
||||
export * from './chat.types.js';
|
||||
export * from './communication-adapter.types.js';
|
||||
export * from './ceremony.types.js';
|
||||
export * from './transition-hooks.types.js';
|
||||
export * from './delegation.types.js';
|
||||
export * from './changes.types.js';
|
||||
|
|
|
|||
|
|
@ -257,6 +257,7 @@ const ROUTE_PERMISSIONS: RoutePermissionConfig[] = [
|
|||
},
|
||||
],
|
||||
},
|
||||
{ prefix: '/api/ceremonies', read: 'workflow:read', write: 'workflow:write' },
|
||||
{
|
||||
prefix: '/api/scheduler',
|
||||
read: 'workflow:read',
|
||||
|
|
|
|||
|
|
@ -154,6 +154,24 @@ const mocks = vi.hoisted(() => ({
|
|||
createdAt: '2026-06-04T08:00:00.000Z',
|
||||
updatedAt: '2026-06-04T08:00:00.000Z',
|
||||
})),
|
||||
ceremonies: vi.fn(async () => [
|
||||
{
|
||||
id: 'ceremony_1',
|
||||
kind: 'design_review',
|
||||
status: 'pending',
|
||||
enforcementMode: 'warn',
|
||||
title: 'Design review required before completion',
|
||||
reason: 'Task is high-risk, multi-agent, or review-mode work.',
|
||||
target: { taskId: 'task_20260626_demo' },
|
||||
trigger: 'task.completion',
|
||||
participants: [{ role: 'coordinator' }],
|
||||
requiredArtifacts: ['decision-packet', 'risk-list', 'action-items'],
|
||||
artifacts: [],
|
||||
actionItems: [],
|
||||
createdAt: '2026-06-04T08:00:00.000Z',
|
||||
updatedAt: '2026-06-04T08:00:00.000Z',
|
||||
},
|
||||
]),
|
||||
settings: {
|
||||
board: {},
|
||||
tasks: {},
|
||||
|
|
@ -182,6 +200,8 @@ const mocks = vi.hoisted(() => ({
|
|||
closingComments: false,
|
||||
autoTelemetry: false,
|
||||
autoTimeTracking: false,
|
||||
ceremonyDesignReview: 'warn',
|
||||
ceremonyFailureRetrospective: 'block',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
|
@ -208,6 +228,9 @@ vi.mock('@/lib/api', () => ({
|
|||
testCommunicationAdapter: mocks.testCommunicationAdapter,
|
||||
disconnectCommunicationAdapter: mocks.disconnectCommunicationAdapter,
|
||||
},
|
||||
ceremonies: {
|
||||
list: mocks.ceremonies,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
|
|
@ -295,10 +318,17 @@ describe('Settings tab Mantine controls', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('renders Enforcement agent selection through direct Mantine Select', () => {
|
||||
it('renders Enforcement ceremony and agent selection through direct Mantine Select', async () => {
|
||||
const { container } = renderWithProviders(<EnforcementTab />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('combobox', { name: 'Design Review Ceremony Enforcement' })
|
||||
).toBeDefined();
|
||||
expect(
|
||||
screen.getByRole('combobox', { name: 'Failure Retrospective Ceremony Enforcement' })
|
||||
).toBeDefined();
|
||||
expect(screen.getByRole('combobox', { name: 'Orchestrator Agent' })).toBeDefined();
|
||||
expect(await screen.findByText('Design review required before completion')).toBeDefined();
|
||||
expect(container.querySelector('.mantine-Select-root')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,15 +1,48 @@
|
|||
import { useFeatureSettings, useDebouncedFeatureUpdate } from '@/hooks/useFeatureSettings';
|
||||
import { useConfig } from '@/hooks/useConfig';
|
||||
import { DEFAULT_FEATURE_SETTINGS } from '@veritas-kanban/shared';
|
||||
import { api } from '@/lib/api';
|
||||
import {
|
||||
DEFAULT_FEATURE_SETTINGS,
|
||||
type CeremonyEnforcementMode,
|
||||
type CeremonyRequirement,
|
||||
} from '@veritas-kanban/shared';
|
||||
import { Select } from '@mantine/core';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ToggleRow, SettingRow, SectionHeader, SaveIndicator } from '../shared';
|
||||
import { Shield, ShieldCheck, Bot } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const ceremonyModeOptions: Array<{ value: CeremonyEnforcementMode; label: string }> = [
|
||||
{ value: 'off', label: 'Off' },
|
||||
{ value: 'warn', label: 'Warn' },
|
||||
{ value: 'block', label: 'Block' },
|
||||
];
|
||||
|
||||
function formatCeremonyKind(kind: CeremonyRequirement['kind']): string {
|
||||
return kind === 'design_review' ? 'Design review' : 'Failure retrospective';
|
||||
}
|
||||
|
||||
function formatCeremonyTarget(requirement: CeremonyRequirement): string {
|
||||
const { target } = requirement;
|
||||
return (
|
||||
target.taskId ||
|
||||
target.runId ||
|
||||
target.workflowId ||
|
||||
target.prUrl ||
|
||||
target.ciUrl ||
|
||||
'workspace'
|
||||
);
|
||||
}
|
||||
|
||||
export function EnforcementTab() {
|
||||
const { settings } = useFeatureSettings();
|
||||
const { debouncedUpdate, isPending } = useDebouncedFeatureUpdate();
|
||||
const { data: config } = useConfig();
|
||||
const { data: pendingCeremonies = [] } = useQuery({
|
||||
queryKey: ['ceremonies', 'pending', 'settings'],
|
||||
queryFn: () => api.ceremonies.list({ status: 'pending', limit: 5 }),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const updateEnforcement = (key: string, value: boolean | string) => {
|
||||
debouncedUpdate({ enforcement: { [key]: value } });
|
||||
|
|
@ -61,6 +94,66 @@ export function EnforcementTab() {
|
|||
onCheckedChange={(v) => updateEnforcement('closingComments', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t pt-3 space-y-3">
|
||||
<SettingRow
|
||||
label="Design Review Ceremony"
|
||||
description="Require review artifacts before completing high-risk or multi-agent tasks"
|
||||
>
|
||||
<Select
|
||||
value={enforcement.ceremonyDesignReview ?? 'off'}
|
||||
onChange={(value) =>
|
||||
updateEnforcement(
|
||||
'ceremonyDesignReview',
|
||||
(value ?? 'off') as CeremonyEnforcementMode
|
||||
)
|
||||
}
|
||||
data={ceremonyModeOptions}
|
||||
aria-label="Design Review Ceremony Enforcement"
|
||||
allowDeselect={false}
|
||||
size="xs"
|
||||
w={140}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label="Failure Retrospective Ceremony"
|
||||
description="Require retrospective artifacts after blocked work or failed attempts"
|
||||
>
|
||||
<Select
|
||||
value={enforcement.ceremonyFailureRetrospective ?? 'off'}
|
||||
onChange={(value) =>
|
||||
updateEnforcement(
|
||||
'ceremonyFailureRetrospective',
|
||||
(value ?? 'off') as CeremonyEnforcementMode
|
||||
)
|
||||
}
|
||||
data={ceremonyModeOptions}
|
||||
aria-label="Failure Retrospective Ceremony Enforcement"
|
||||
allowDeselect={false}
|
||||
size="xs"
|
||||
w={140}
|
||||
/>
|
||||
</SettingRow>
|
||||
<div className="rounded-md bg-muted/50 px-3 py-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-xs font-medium text-foreground">Pending ceremonies</span>
|
||||
<span className="text-xs text-muted-foreground">{pendingCeremonies.length}</span>
|
||||
</div>
|
||||
{pendingCeremonies.length > 0 ? (
|
||||
<div className="mt-2 space-y-2">
|
||||
{pendingCeremonies.map((requirement) => (
|
||||
<div key={requirement.id} className="text-xs">
|
||||
<div className="font-medium text-foreground">{requirement.title}</div>
|
||||
<div className="text-muted-foreground">
|
||||
{formatCeremonyKind(requirement.kind)} - {formatCeremonyTarget(requirement)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-1 text-xs text-muted-foreground">No pending ceremonies</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
48
web/src/lib/api/ceremonies.ts
Normal file
48
web/src/lib/api/ceremonies.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import type {
|
||||
CeremonyKind,
|
||||
CeremonyRequirement,
|
||||
CeremonyStatus,
|
||||
CompleteCeremonyRequirementInput,
|
||||
CreateCeremonyRequirementInput,
|
||||
} from '@veritas-kanban/shared';
|
||||
import { apiFetch } from './helpers';
|
||||
|
||||
export interface CeremonyListFilters {
|
||||
status?: CeremonyStatus;
|
||||
kind?: CeremonyKind;
|
||||
taskId?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
function toQuery(filters: CeremonyListFilters = {}): string {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.status) params.set('status', filters.status);
|
||||
if (filters.kind) params.set('kind', filters.kind);
|
||||
if (filters.taskId) params.set('taskId', filters.taskId);
|
||||
if (filters.limit !== undefined) params.set('limit', String(filters.limit));
|
||||
|
||||
const query = params.toString();
|
||||
return query ? `?${query}` : '';
|
||||
}
|
||||
|
||||
export const ceremoniesApi = {
|
||||
list: async (filters: CeremonyListFilters = {}): Promise<CeremonyRequirement[]> =>
|
||||
apiFetch<CeremonyRequirement[]>(`/api/ceremonies${toQuery(filters)}`),
|
||||
|
||||
create: async (input: CreateCeremonyRequirementInput): Promise<CeremonyRequirement> =>
|
||||
apiFetch<CeremonyRequirement>('/api/ceremonies', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
|
||||
complete: async (
|
||||
id: string,
|
||||
input: CompleteCeremonyRequirementInput
|
||||
): Promise<CeremonyRequirement> =>
|
||||
apiFetch<CeremonyRequirement>(`/api/ceremonies/${encodeURIComponent(id)}/complete`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
};
|
||||
|
|
@ -32,6 +32,7 @@ import { runSessionsApi } from './run-sessions';
|
|||
import { workspaceCapabilitiesApi } from './workspace-capabilities';
|
||||
import { schedulerApi } from './scheduler';
|
||||
import { queueMonitorsApi } from './queue-monitors';
|
||||
import { ceremoniesApi } from './ceremonies';
|
||||
|
||||
// Assemble the full API object (matches original structure exactly)
|
||||
export const api = {
|
||||
|
|
@ -75,6 +76,7 @@ export const api = {
|
|||
workspaceCapabilities: workspaceCapabilitiesApi,
|
||||
scheduler: schedulerApi,
|
||||
queueMonitors: queueMonitorsApi,
|
||||
ceremonies: ceremoniesApi,
|
||||
};
|
||||
|
||||
export type {
|
||||
|
|
@ -88,6 +90,7 @@ export type {
|
|||
} from './search';
|
||||
|
||||
export type { WorkProductExportFormat, WorkProductExportOptions } from './work-products';
|
||||
export type { CeremonyListFilters } from './ceremonies';
|
||||
export type { TraceStatus } from './traces';
|
||||
export type { SqlitePortabilityReport } from './maintenance';
|
||||
export type {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue