mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-10-09 05:07:53 +00:00
Merge remote-tracking branch 'origin/main' into feat/system-appearance-1541
This commit is contained in:
commit
6d11c1b6ee
59 changed files with 2019 additions and 491 deletions
|
|
@ -2,7 +2,6 @@
|
|||
cli/src/__tests__/snapshot.test.ts:generic-api-key:220
|
||||
|
||||
# API documentation contains non-functional response examples.
|
||||
docs/API-REFERENCE.md:generic-api-key:1043
|
||||
docs/API-WORKFLOWS.md:generic-api-key:1460
|
||||
|
||||
# Operator documentation uses placeholders in curl authentication examples.
|
||||
|
|
|
|||
133
desktop/src/main/__tests__/connection-redirects.test.ts
Normal file
133
desktop/src/main/__tests__/connection-redirects.test.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import { createServer } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createDesktopBridgeHandlers } from '../bridge.js';
|
||||
|
||||
vi.mock('node:dns/promises', () => ({
|
||||
lookup: vi.fn(async () => [{ address: '93.184.216.34', family: 4 }]),
|
||||
}));
|
||||
const transport = globalThis.fetch;
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
const handlers = () => createDesktopBridgeHandlers({} as never, {} as never, true, '6.1.7');
|
||||
|
||||
async function fixture(
|
||||
run: (origin: string, hits: string[]) => Promise<void>,
|
||||
location?: string,
|
||||
statusCode = 302
|
||||
) {
|
||||
const hits: string[] = [];
|
||||
const server = createServer((request, response) => {
|
||||
hits.push(`${request.method} ${request.url}`);
|
||||
if (request.url?.startsWith('/redirect')) {
|
||||
response.writeHead(statusCode, { Location: location ?? '/trap' });
|
||||
response.end();
|
||||
} else {
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
response.end(
|
||||
JSON.stringify(
|
||||
request.url?.includes('exchange')
|
||||
? { secret: 'synthetic-session' }
|
||||
: { authenticated: true }
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
try {
|
||||
await run(`http://127.0.0.1:${(server.address() as AddressInfo).port}`, hits);
|
||||
} finally {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
}
|
||||
|
||||
describe('remote connection transport policy', () => {
|
||||
it.each([301, 302, 303, 307, 308])(
|
||||
'does not follow a %s redirect for status, credentials, or pairing',
|
||||
async (code) => {
|
||||
for (const auth of [
|
||||
{},
|
||||
{ serverToken: 'synthetic-token' },
|
||||
{ pairingPayload: 'synthetic-code' },
|
||||
]) {
|
||||
await fixture(
|
||||
async (origin, hits) => {
|
||||
// Map only the selected synthetic HTTPS origin to the local fixture.
|
||||
// Node fetch itself handles the response and redirect policy.
|
||||
vi.stubGlobal('fetch', (_url: URL, init: RequestInit) =>
|
||||
transport(`${origin}/redirect`, init)
|
||||
);
|
||||
const result = await handlers().validateConnectionConfig({
|
||||
mode: 'remote',
|
||||
serverUrl: 'https://remote.example',
|
||||
...auth,
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
expect(hits).toEqual([`${'pairingPayload' in auth ? 'POST' : 'GET'} /redirect`]);
|
||||
},
|
||||
undefined,
|
||||
code
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
it.each([
|
||||
'http://example.test/next',
|
||||
'http://127.0.0.1/next',
|
||||
'http://10.0.0.1/next',
|
||||
'http://169.254.169.254/next',
|
||||
])('rejects redirect target %s without issuing a second request', async (location) => {
|
||||
for (const auth of [{}, { pairingPayload: 'synthetic-code' }]) {
|
||||
await fixture(
|
||||
async (origin, hits) => {
|
||||
vi.stubGlobal('fetch', (_url: URL, init: RequestInit) => {
|
||||
// Fail before transport if the guard regresses: never contact a real target.
|
||||
expect(init.redirect).toBe('error');
|
||||
return transport(`${origin}/redirect`, init);
|
||||
});
|
||||
const result = await handlers().validateConnectionConfig({
|
||||
mode: 'remote',
|
||||
serverUrl: 'https://remote.example',
|
||||
...auth,
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
expect(hits).toHaveLength(1);
|
||||
},
|
||||
location,
|
||||
307
|
||||
);
|
||||
}
|
||||
});
|
||||
it('preserves direct status, bearer authentication, and pairing followed by auth', async () => {
|
||||
await fixture(async (origin, hits) => {
|
||||
const requests: RequestInit[] = [];
|
||||
vi.stubGlobal('fetch', (url: URL, init: RequestInit) => {
|
||||
requests.push(init);
|
||||
return transport(new URL(url.pathname, origin), init);
|
||||
});
|
||||
for (const auth of [
|
||||
{},
|
||||
{ serverToken: 'synthetic-token' },
|
||||
{ pairingPayload: 'synthetic-code' },
|
||||
]) {
|
||||
await expect(
|
||||
handlers().validateConnectionConfig({
|
||||
mode: 'remote',
|
||||
serverUrl: 'https://remote.example',
|
||||
...auth,
|
||||
})
|
||||
).resolves.toMatchObject({ valid: true, errors: [] });
|
||||
}
|
||||
expect(hits).toEqual([
|
||||
'GET /api/auth/status',
|
||||
'GET /api/auth/context',
|
||||
'POST /api/auth/device-pairing/exchange',
|
||||
'GET /api/auth/context',
|
||||
]);
|
||||
expect(requests[1].headers).toEqual({ Authorization: 'Bearer synthetic-token' });
|
||||
expect(requests[2].body).toBe(JSON.stringify({ code: 'synthetic-code' }));
|
||||
expect(requests[3].headers).toEqual({ Authorization: 'Bearer synthetic-session' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -114,6 +114,7 @@ async function validateRemoteConnection(
|
|||
try {
|
||||
const response = await fetch(statusUrl, {
|
||||
method: 'GET',
|
||||
redirect: 'error',
|
||||
headers: serverToken ? { Authorization: `Bearer ${serverToken}` } : undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
|
@ -161,6 +162,7 @@ async function exchangeRemotePairingPayload(
|
|||
try {
|
||||
const response = await fetch(exchangeUrl, {
|
||||
method: 'POST',
|
||||
redirect: 'error',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(parseRemotePairingPayload(pairingPayload)),
|
||||
signal: controller.signal,
|
||||
|
|
|
|||
|
|
@ -316,6 +316,8 @@ PATCH /api/tasks/:id
|
|||
|
||||
**Body**: Partial task fields to update (title, description, status, priority, assignee, etc.).
|
||||
|
||||
Managed attempt state is owned by the run lifecycle APIs. Generic task updates reject an `attempt` replacement when the current attempt contains runtime, launch, admission, supervision, or other server-owned evidence. Ordinary task fields remain editable. Historical attempts containing only the legacy editor fields remain editable through this endpoint.
|
||||
|
||||
**Headers**:
|
||||
|
||||
```http
|
||||
|
|
@ -1040,7 +1042,7 @@ POST /api/auth/login
|
|||
|
||||
```json
|
||||
{
|
||||
"token": "eyJhbGciOiJIUzI1NiIs...",
|
||||
"token": "<jwt-token>",
|
||||
"role": "admin",
|
||||
"expiresIn": "24h"
|
||||
}
|
||||
|
|
@ -2405,7 +2407,7 @@ Mounted at `/api/agents`.
|
|||
POST /api/agents/route
|
||||
```
|
||||
|
||||
Accepts either a task ID or ad-hoc metadata:
|
||||
Accepts either a task ID or ad-hoc metadata: Metadata `subtaskCount` must be an integer from 0 through 500; it is evaluated as a count without creating task records.
|
||||
|
||||
**By task ID**:
|
||||
|
||||
|
|
@ -3900,6 +3902,8 @@ Mounted at `/api/cost-prediction`.
|
|||
POST /api/cost-prediction/predict
|
||||
```
|
||||
|
||||
Metadata `subtaskCount` accepts integers from 0 through 500. Existing tasks are evaluated using their stored subtask count.
|
||||
|
||||
**By task ID**:
|
||||
|
||||
```json
|
||||
|
|
@ -6461,3 +6465,17 @@ No query params required.
|
|||
_For workflow engine endpoints, see [API-WORKFLOWS.md](API-WORKFLOWS.md)._
|
||||
_For MCP server tools, see [MCP Server Guide](mcp/README.md)._
|
||||
_For agent workflow SOPs, see [SOP-agent-task-workflow.md](SOP-agent-task-workflow.md)._
|
||||
|
||||
## Notification pagination and storage
|
||||
|
||||
`GET /api/notifications` accepts `limit` (integer 1–1000) and `offset` (nonnegative integer), alongside the existing `agent`, `undelivered`, and `taskId` filters. Omit pagination to retain the existing all-results response. Invalid pagination returns HTTP 400. Responses remain arrays, ordered newest first with notification ID as the stable tie-breaker.
|
||||
|
||||
SQLite applies filtering and pagination in the query, aggregates notification statistics in SQL, and updates delivery state by row ID. Subscription creation preserves an existing subscription's original reason and timestamp. The file backend keeps the same operation semantics with locked, atomic file updates; its read and write costs still depend on retained history. No database migration is required.
|
||||
|
||||
Run the disposable SQLite delivery benchmark from the repository root:
|
||||
|
||||
```bash
|
||||
pnpm --filter @veritas-kanban/server exec tsx scripts/benchmark-notifications.ts
|
||||
```
|
||||
|
||||
It seeds 100, 1,000, and 10,000 public-safe notifications in temporary databases, reports cold delivery latency and 25 warm samples per size, and removes only its temporary fixtures. `changedRowsPerWrite` should remain `[1]`. Timings depend on hardware and current host load; compare matching environments and use the row-change count as the deterministic regression check.
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ The Kanban board is the central interface — a drag-and-drop workspace that ref
|
|||
- **Mobile shell controls** — Compact navigation uses bounded labels and full accessible names; Board Chat stays fixed above the bottom navigation and device safe area
|
||||
- **Resizable Workbench** — Board Chat and Squad Chat open in one bounded right-side dock, preserve the active conversation when switching channels, and clamp their width to keep the application shell recoverable
|
||||
- **Bulk operations** — Select multiple tasks to move, archive, or delete in batch; select-all toggle
|
||||
- **Keyboard shortcuts** — Navigate tasks (j/k, arrows), open (Enter), close (Esc), create (c), move to column (1-4), help (?)
|
||||
- **Keyboard shortcuts** — Navigate visible tasks in saved board order (j/k, arrows), focus and reveal the selected card, open (Enter), close (Esc), create (c), move to configured column (1-9), help (?)
|
||||
- **Loading skeleton** — Shimmer placeholders while the board loads
|
||||
- **Blocked column** — Dedicated column for blocked tasks with categorized reasons (waiting on feedback, technical snag, prerequisite, other)
|
||||
- **Comments** — Add, edit, and delete comments on tasks with author attribution and relative timestamps
|
||||
|
|
@ -2082,6 +2082,8 @@ Optimizations spanning server, frontend, and data lifecycle.
|
|||
|
||||
## Settings & Customization
|
||||
|
||||
Settings edits are batched across sections and continue saving when you switch tabs or close Settings. If a save fails, the changes remain available in the current authenticated session and Settings shows a Retry action. A failed save is never labelled Saved.
|
||||
|
||||
Modular settings system with focused, permission-aware sections.
|
||||
|
||||

|
||||
|
|
|
|||
67
server/scripts/benchmark-notifications.ts
Normal file
67
server/scripts/benchmark-notifications.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { performance } from 'node:perf_hooks';
|
||||
import { createTestSqliteDatabase } from '../src/storage/sqlite/test-helpers.js';
|
||||
import { SqliteNotificationRepository } from '../src/storage/sqlite/notification-repository.js';
|
||||
import { NotificationService } from '../src/services/notification-service.js';
|
||||
const entries = [];
|
||||
for (const count of [100, 1000, 10000]) {
|
||||
const fixture = createTestSqliteDatabase();
|
||||
fixture.database.open();
|
||||
const repo = new SqliteNotificationRepository(fixture.database);
|
||||
const rows = Array.from({ length: count }, (_, i) => ({
|
||||
id: `benchmark-${String(i).padStart(5, '0')}`,
|
||||
taskId: 'benchmark',
|
||||
targetAgent: 'test',
|
||||
fromAgent: 'fixture',
|
||||
content: 'Public-safe benchmark notification',
|
||||
type: 'mention',
|
||||
delivered: false,
|
||||
createdAt: new Date(1700000000000 + i).toISOString(),
|
||||
}));
|
||||
repo.saveNotifications(rows);
|
||||
const service = new NotificationService({
|
||||
storageType: 'sqlite',
|
||||
sqliteDatabase: fixture.database,
|
||||
dataDir: fixture.rootDir,
|
||||
});
|
||||
const db = fixture.database.getConnection();
|
||||
const changes = () => Number(db.prepare('SELECT total_changes() AS n').get()?.n);
|
||||
const before = changes();
|
||||
const coldStart = performance.now();
|
||||
await service.markDelivered(rows[0].id);
|
||||
const coldMs = performance.now() - coldStart;
|
||||
const coldChangedRows = changes() - before;
|
||||
const durations = [];
|
||||
const touched = [];
|
||||
for (let i = 1; i <= 25; i++) {
|
||||
const startChanges = changes();
|
||||
const start = performance.now();
|
||||
await service.markDelivered(rows[i].id);
|
||||
durations.push(performance.now() - start);
|
||||
touched.push(changes() - startChanges);
|
||||
}
|
||||
durations.sort((a, b) => a - b);
|
||||
entries.push({
|
||||
count,
|
||||
coldMs,
|
||||
coldChangedRows,
|
||||
warmMedianMs: durations[12],
|
||||
warmP95Ms: durations[23],
|
||||
changedRowsPerWrite: [...new Set(touched)],
|
||||
});
|
||||
service.dispose();
|
||||
fixture.cleanup();
|
||||
}
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
node: process.version,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
operation: 'NotificationService.markDelivered',
|
||||
warmSamples: 25,
|
||||
entries,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
|
@ -462,7 +462,7 @@ describe('AgentRoutingService', () => {
|
|||
id: 'high-code',
|
||||
name: 'High-priority code owner',
|
||||
enabled: true,
|
||||
match: { type: 'code', priority: 'high' },
|
||||
match: { type: 'code', priority: 'high', minSubtasks: 5 },
|
||||
memberId: 'ops-lead',
|
||||
},
|
||||
],
|
||||
|
|
@ -472,6 +472,7 @@ describe('AgentRoutingService', () => {
|
|||
const result = await service.resolveAgentWithTrace({
|
||||
type: 'code',
|
||||
priority: 'high',
|
||||
subtaskCount: 5,
|
||||
});
|
||||
|
||||
expect(result.result.agent).toBe('amp');
|
||||
|
|
@ -695,6 +696,36 @@ describe('AgentRoutingService', () => {
|
|||
expect(result.rule).toBe('complex');
|
||||
});
|
||||
|
||||
it('routes scalar counts like real subtask collections', async () => {
|
||||
const config = structuredClone(BASE_CONFIG);
|
||||
requireRouting(config).rules = [
|
||||
{
|
||||
id: 'complex',
|
||||
name: 'Complex tasks',
|
||||
match: { minSubtasks: 5 },
|
||||
agent: 'amp',
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
mockGetConfig.mockResolvedValue(config);
|
||||
expect(
|
||||
(await service.resolveAgent({ type: 'feature', priority: 'medium', subtaskCount: 5 })).rule
|
||||
).toBe('complex');
|
||||
expect(
|
||||
(await service.resolveAgent({ type: 'feature', priority: 'medium', subtaskCount: 4 })).rule
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(
|
||||
await service.resolveAgent({
|
||||
type: 'feature',
|
||||
priority: 'medium',
|
||||
subtasks: [],
|
||||
subtaskCount: 5,
|
||||
})
|
||||
).rule
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does NOT match when subtasks below threshold', async () => {
|
||||
const config = structuredClone(BASE_CONFIG);
|
||||
requireRouting(config).rules = [
|
||||
|
|
|
|||
23
server/src/__tests__/cost-prediction-count.test.ts
Normal file
23
server/src/__tests__/cost-prediction-count.test.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
vi.mock('../services/telemetry-service.js', () => ({
|
||||
getTelemetryService: () => ({ getEvents: async () => [] }),
|
||||
}));
|
||||
vi.mock('../services/task-service.js', () => ({ getTaskService: () => ({}) }));
|
||||
import { getCostPredictionService } from '../services/cost-prediction-service.js';
|
||||
|
||||
describe('cost prediction subtask counts', () => {
|
||||
it.each([0, 1, 2, 3, 5, 6, 500])('preserves prediction factors for count %i', async (count) => {
|
||||
const service = getCostPredictionService();
|
||||
const scalar = await service.predict({ subtaskCount: count });
|
||||
const stored = await service.predict({ subtasks: Array.from({ length: count }) });
|
||||
expect(scalar.factors).toEqual(stored.factors);
|
||||
expect(scalar.estimatedCost).toBe(stored.estimatedCost);
|
||||
});
|
||||
|
||||
it('uses actual task subtasks when both representations are present', async () => {
|
||||
const service = getCostPredictionService();
|
||||
const actual = await service.predict({ subtasks: [], subtaskCount: 500 });
|
||||
const empty = await service.predict({ subtasks: [] });
|
||||
expect(actual.factors).toEqual(empty.factors);
|
||||
});
|
||||
});
|
||||
|
|
@ -3,37 +3,28 @@
|
|||
* Tests @mention parsing, notification creation, delivery tracking, and thread subscriptions.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { parseMentions } from '../services/notification-service.js';
|
||||
import { parseMentions, NotificationService } from '../services/notification-service.js';
|
||||
import {
|
||||
createTestSqliteDatabase,
|
||||
type TestSqliteDatabase,
|
||||
} from '../storage/sqlite/test-helpers.js';
|
||||
|
||||
// Persistence behavior is exercised separately against real repository files.
|
||||
vi.mock('../storage/notification-file-repository.js', () => ({
|
||||
NotificationFileRepository: class {
|
||||
async loadNotifications() {
|
||||
return [];
|
||||
}
|
||||
async loadSubscriptions() {
|
||||
return [];
|
||||
}
|
||||
async saveNotifications() {}
|
||||
async saveSubscriptions() {}
|
||||
},
|
||||
}));
|
||||
|
||||
const { getNotificationService } = await import('../services/notification-service.js');
|
||||
import type { NotificationService } from '../services/notification-service.js';
|
||||
|
||||
describe('NotificationService', () => {
|
||||
describe.each(['file', 'sqlite'] as const)('NotificationService (%s)', (storageType) => {
|
||||
let service: NotificationService;
|
||||
let fixture: TestSqliteDatabase;
|
||||
|
||||
beforeEach(() => {
|
||||
service = getNotificationService();
|
||||
// Reset internal state
|
||||
(service as any).notifications = [];
|
||||
(service as any).subscriptions = [];
|
||||
(service as any).loaded = true;
|
||||
fixture = createTestSqliteDatabase();
|
||||
service = new NotificationService({
|
||||
storageType,
|
||||
dataDir: fixture.rootDir,
|
||||
sqliteDatabase: fixture.database,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
service.dispose();
|
||||
fixture.cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -383,7 +383,7 @@ describe('admin-only governance routes', () => {
|
|||
type: 'feature',
|
||||
priority: 'medium',
|
||||
project: undefined,
|
||||
subtasks: undefined,
|
||||
subtaskCount: undefined,
|
||||
},
|
||||
{ requiredRuntimeCapabilities: undefined }
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import type { AuthPermission, AuthenticatedRequest } from '../../middleware/auth.js';
|
||||
import { diffAccess } from '../../routes/v1/permissions.js';
|
||||
import { errorHandler } from '../../middleware/error-handler.js';
|
||||
|
||||
const { mockDiffService, mockCodexReviewService } = vi.hoisted(() => ({
|
||||
|
|
@ -73,3 +75,39 @@ describe('Codex review route', () => {
|
|||
expect(mockCodexReviewService.reviewTask).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mounted review permissions', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
function appFor(permissions: AuthPermission[]) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as AuthenticatedRequest).auth = { role: 'agent', isLocalhost: false, permissions };
|
||||
next();
|
||||
});
|
||||
app.use(['/api/diff', '/api/v1/diff'], diffAccess, diffRoutes);
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
for (const prefix of ['/api/diff', '/api/v1/diff']) {
|
||||
for (const suffix of ['codex-review', 'CODEX-REVIEW', 'CoDeX-ReViEw/']) {
|
||||
it(`enforces execution authority at ${prefix}/:taskId/${suffix}`, async () => {
|
||||
const taskId = 'task_MixedCase';
|
||||
const path = `${prefix}/${taskId}/${suffix}`;
|
||||
const denied = await request(appFor(['task:write']))
|
||||
.post(path)
|
||||
.send({});
|
||||
expect(denied.status).toBe(403);
|
||||
expect(mockCodexReviewService.reviewTask).not.toHaveBeenCalled();
|
||||
mockCodexReviewService.reviewTask.mockResolvedValue({ taskId, decision: 'approved' });
|
||||
const allowed = await request(appFor(['workflow:execute']))
|
||||
.post(path)
|
||||
.send({});
|
||||
expect(allowed.status).toBe(201);
|
||||
expect(mockCodexReviewService.reviewTask).toHaveBeenCalledExactlyOnceWith({ taskId });
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
90
server/src/__tests__/routes/metadata-preview-bounds.test.ts
Normal file
90
server/src/__tests__/routes/metadata-preview-bounds.test.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { AuthenticatedRequest } from '../../middleware/auth.js';
|
||||
import { authorizeWrite } from '../../middleware/auth.js';
|
||||
import { agentRoutingAccess, costPredictionAccess } from '../../routes/v1/permissions.js';
|
||||
import { errorHandler } from '../../middleware/error-handler.js';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
route: vi.fn(),
|
||||
predict: vi.fn(),
|
||||
record: vi.fn(),
|
||||
getTask: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../services/agent-routing-service.js', () => ({
|
||||
getAgentRoutingService: () => ({ resolveAgentWithTrace: mocks.route }),
|
||||
}));
|
||||
vi.mock('../../services/cost-prediction-service.js', () => ({
|
||||
getCostPredictionService: () => ({ predict: mocks.predict }),
|
||||
}));
|
||||
vi.mock('../../services/governance-trace-service.js', () => ({
|
||||
getGovernanceTraceService: () => ({ record: mocks.record }),
|
||||
}));
|
||||
vi.mock('../../services/task-service.js', () => ({
|
||||
getTaskService: () => ({ getTask: mocks.getTask }),
|
||||
}));
|
||||
import { agentRoutingRoutes } from '../../routes/agent-routing.js';
|
||||
import { costPredictionRoutes } from '../../routes/cost-prediction.js';
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as AuthenticatedRequest).auth = {
|
||||
role: 'agent',
|
||||
isLocalhost: false,
|
||||
permissions: ['agent:read', 'task:write'],
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use(authorizeWrite);
|
||||
app.use(['/api/agents', '/api/v1/agents'], agentRoutingAccess, agentRoutingRoutes);
|
||||
app.use(
|
||||
['/api/cost-prediction', '/api/v1/cost-prediction'],
|
||||
costPredictionAccess,
|
||||
costPredictionRoutes
|
||||
);
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('bounded metadata previews', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.route.mockResolvedValue({ result: { agent: 'fixture' }, trace: {} });
|
||||
mocks.predict.mockResolvedValue({ estimatedCost: 1 });
|
||||
mocks.record.mockResolvedValue({ id: 'trace_fixture' });
|
||||
});
|
||||
|
||||
for (const prefix of ['/api', '/api/v1']) {
|
||||
for (const endpoint of ['/agents/route', '/cost-prediction/predict']) {
|
||||
it(`rejects invalid counts before evaluation at ${prefix}${endpoint}`, async () => {
|
||||
const app = createApp();
|
||||
// 501 proves the bound without risking a large allocation on a regressed build.
|
||||
for (const subtaskCount of [-1, 0.5, 501, '5', null]) {
|
||||
const response = await request(app)
|
||||
.post(prefix + endpoint)
|
||||
.send({ subtaskCount });
|
||||
expect(response.status).toBe(400);
|
||||
expect(mocks.route).not.toHaveBeenCalled();
|
||||
expect(mocks.predict).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it(`passes valid counts without materializing subtasks at ${prefix}${endpoint}`, async () => {
|
||||
const app = createApp();
|
||||
for (const subtaskCount of [0, 1, 500]) {
|
||||
await request(app)
|
||||
.post(prefix + endpoint)
|
||||
.send({ subtaskCount })
|
||||
.expect(200);
|
||||
const call =
|
||||
endpoint === '/agents/route' ? mocks.route.mock.lastCall : mocks.predict.mock.lastCall;
|
||||
expect(call?.[0].subtaskCount).toBe(subtaskCount);
|
||||
expect(call?.[0]).not.toHaveProperty('subtasks');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -48,6 +48,30 @@ describe('Notification Routes', () => {
|
|||
});
|
||||
|
||||
describe('GET /api/notifications', () => {
|
||||
it('passes validated pagination to the filtered repository query', async () => {
|
||||
mockNotificationService.getNotifications.mockResolvedValue([]);
|
||||
const res = await request(app).get(
|
||||
'/api/notifications?agent=alice&undelivered=true&limit=20&offset=40'
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockNotificationService.getNotifications).toHaveBeenCalledWith({
|
||||
agent: 'alice',
|
||||
undelivered: true,
|
||||
taskId: '',
|
||||
limit: 20,
|
||||
offset: 40,
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['limit=-1', 'limit=1.5', 'limit=1001', 'limit=nope', 'offset=-1'])(
|
||||
'rejects invalid pagination: %s',
|
||||
async (query) => {
|
||||
const res = await request(app).get(`/api/notifications?${query}`);
|
||||
expect(res.status).toBe(400);
|
||||
expect(mockNotificationService.getAllNotifications).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('should list all notifications when no agent is provided', async () => {
|
||||
mockNotificationService.getAllNotifications.mockResolvedValue([
|
||||
{ id: 'n1', targetAgent: 'system', delivered: false },
|
||||
|
|
|
|||
|
|
@ -116,6 +116,7 @@ vi.mock('../../middleware/cache-control.js', async () => {
|
|||
// Import after mocking
|
||||
import { taskRoutes } from '../../routes/tasks.js';
|
||||
import { errorHandler } from '../../middleware/error-handler.js';
|
||||
import { taskAccess } from '../../routes/v1/permissions.js';
|
||||
|
||||
describe('Tasks Routes (actual module)', () => {
|
||||
let app: express.Express;
|
||||
|
|
@ -635,7 +636,8 @@ describe('Tasks Routes (actual module)', () => {
|
|||
model: 'llama3.2',
|
||||
threadId: 'thread_docs_refresh',
|
||||
}),
|
||||
})
|
||||
}),
|
||||
{ protectManagedAttempt: true }
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -691,7 +693,7 @@ describe('Tasks Routes (actual module)', () => {
|
|||
expect(mockTaskService.updateTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves authoritative run contracts when patching the same attempt', async () => {
|
||||
it('rejects generic same-ID changes to managed attempt authority and status', async () => {
|
||||
const taskEnvelope = { digest: 'immutable-envelope' };
|
||||
const completionResult = { status: 'success' };
|
||||
mockTaskService.getTask.mockResolvedValue({
|
||||
|
|
@ -708,17 +710,26 @@ describe('Tasks Routes (actual module)', () => {
|
|||
});
|
||||
mockTaskService.updateTask.mockImplementation(async (_id, input) => input);
|
||||
|
||||
const res = await request(app)
|
||||
.patch('/api/tasks/t1')
|
||||
.send({ attempt: { id: 'attempt_1', agent: 'codex', status: 'complete' } });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockTaskService.updateTask).toHaveBeenCalledWith(
|
||||
't1',
|
||||
expect.objectContaining({
|
||||
attempt: expect.objectContaining({ taskEnvelope, completionResult }),
|
||||
})
|
||||
);
|
||||
const scopedApp = express();
|
||||
scopedApp.use(express.json());
|
||||
scopedApp.use((req, _res, next) => {
|
||||
(req as import('../../middleware/auth.js').AuthenticatedRequest).auth = {
|
||||
role: 'agent',
|
||||
isLocalhost: false,
|
||||
permissions: ['task:write'],
|
||||
};
|
||||
next();
|
||||
});
|
||||
scopedApp.use(['/api/tasks', '/api/v1/tasks'], taskAccess, taskRoutes);
|
||||
scopedApp.use(errorHandler);
|
||||
for (const prefix of ['/api/tasks', '/api/v1/tasks']) {
|
||||
const res = await request(scopedApp)
|
||||
.patch(`${prefix}/t1`)
|
||||
.send({ attempt: { id: 'attempt_1', agent: 'codex', status: 'complete' } });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.message).toContain('run lifecycle APIs');
|
||||
}
|
||||
expect(mockTaskService.updateTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects replacing an attempt that owns authoritative run contracts', async () => {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { AuthenticatedRequest } from '../../middleware/auth.js';
|
||||
import type { AuthPermission, AuthenticatedRequest } from '../../middleware/auth.js';
|
||||
import { errorHandler } from '../../middleware/error-handler.js';
|
||||
import { workProductAccess } from '../../routes/v1/permissions.js';
|
||||
import { workProductRoutes } from '../../routes/work-products.js';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
|
|
@ -302,3 +303,55 @@ describe('work product artifact routes', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('mounted artifact permissions', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
function appFor(permissions: AuthPermission[]) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as AuthenticatedRequest).auth = {
|
||||
role: 'agent',
|
||||
isLocalhost: false,
|
||||
workspaceId: 'local',
|
||||
permissions,
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use(['/api/work-products', '/api/v1/work-products'], workProductAccess, workProductRoutes);
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
it('preserves read-scoped preview audit access for mixed-case routes', async () => {
|
||||
mocks.listVersions.mockResolvedValue([
|
||||
{ id: 'wpa_html', version: 1, mediaType: 'text/html', state: 'available' },
|
||||
]);
|
||||
const response = await request(appFor(['work_product:read']))
|
||||
.post('/api/v1/work-products/wp_MixedCase/ARTIFACT/Preview/Audit/')
|
||||
.send({ action: 'close', version: 1 });
|
||||
expect(response.status).toBe(204);
|
||||
expect(mocks.auditLog).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
for (const prefix of ['/api/work-products', '/api/v1/work-products']) {
|
||||
for (const suffix of ['artifact', 'ARTIFACT', 'ArTiFaCt/']) {
|
||||
it(`enforces purge authority at ${prefix}/:id/${suffix}`, async () => {
|
||||
const productId = `wp_${'MixedCase'.repeat(3)}`;
|
||||
const path = `${prefix}/${productId}/${suffix}?confirm=${productId}`;
|
||||
const denied = await request(appFor(['work_product:write'])).delete(path);
|
||||
expect(denied.status).toBe(403);
|
||||
expect(mocks.purge).not.toHaveBeenCalled();
|
||||
mocks.purge.mockResolvedValue({ productId, artifactsDeleted: 1, bytesDeleted: 14 });
|
||||
const allowed = await request(appFor(['admin:manage'])).delete(path);
|
||||
expect(allowed.status).toBe(200);
|
||||
expect(mocks.purge).toHaveBeenCalledExactlyOnceWith({
|
||||
workspaceId: 'local',
|
||||
productId,
|
||||
confirmation: productId,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -203,3 +203,17 @@ describe('shared API permission metadata', () => {
|
|||
).toEqual(['agent:write']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('case-insensitive API permission metadata', () => {
|
||||
it.each([
|
||||
['/API/V1/WORK-PRODUCTS/wp_MixedCase/ARTIFACT', 'DELETE', 'admin:manage'],
|
||||
['/api/work-products/wp_MixedCase/ArTiFaCt/', 'DELETE', 'admin:manage'],
|
||||
['/API/DIFF/task_MixedCase/CODEX-REVIEW', 'POST', 'workflow:execute'],
|
||||
['/api/v1/diff/task_MixedCase/CoDeX-ReViEw/', 'POST', 'workflow:execute'],
|
||||
['/api/work-products/wp_MixedCase/ARTIFACT/PREVIEW/AUDIT', 'POST', 'work_product:read'],
|
||||
])('matches server permissions for %s', (path, method, permission) => {
|
||||
const requirement = getApiPermissionRequirement(path, { method });
|
||||
expect(requirement.permissions).toEqual([permission]);
|
||||
expect(requirement.path).toContain('MixedCase');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -100,3 +100,45 @@ for (const kind of ['notifications', 'subscriptions'] as const) {
|
|||
);
|
||||
});
|
||||
}
|
||||
|
||||
describe('incremental notification file rollback', () => {
|
||||
let root: string;
|
||||
beforeEach(async () => {
|
||||
fault.stage = '';
|
||||
root = await fs.mkdtemp(path.join(os.tmpdir(), 'veritas-notification-rollback-'));
|
||||
});
|
||||
afterEach(async () => {
|
||||
fault.stage = '';
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it.each(['write', 'rename'])(
|
||||
'retains delivery state and supports retry after %s failure',
|
||||
async (stage) => {
|
||||
const repository = new NotificationFileRepository({ dataDir: root });
|
||||
await repository.appendNotifications([
|
||||
{
|
||||
id: 'retained',
|
||||
taskId: 'task',
|
||||
targetAgent: 'alice',
|
||||
fromAgent: 'bob',
|
||||
content: 'fixture',
|
||||
type: 'mention',
|
||||
delivered: false,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
const file = path.join(root, 'notifications.json');
|
||||
const before = await fs.readFile(file, 'utf8');
|
||||
fault.stage = stage;
|
||||
await expect(
|
||||
repository.markDelivered('retained', '2026-01-02T00:00:00.000Z')
|
||||
).rejects.toMatchObject({ code: 'EIO' });
|
||||
expect(await fs.readFile(file, 'utf8')).toBe(before);
|
||||
expect((await repository.listNotifications())[0].delivered).toBe(false);
|
||||
fault.stage = '';
|
||||
expect(await repository.markDelivered('retained', '2026-01-02T00:00:00.000Z')).toBe(true);
|
||||
expect((await repository.listNotifications())[0].delivered).toBe(true);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
|
|
|||
146
server/src/__tests__/storage/notification-incremental.test.ts
Normal file
146
server/src/__tests__/storage/notification-incremental.test.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import type { Notification, ThreadSubscription } from '../../services/notification-service.js';
|
||||
import type { NotificationRepository } from '../../storage/interfaces.js';
|
||||
import { NotificationFileRepository } from '../../storage/notification-file-repository.js';
|
||||
import { SqliteNotificationRepository } from '../../storage/sqlite/notification-repository.js';
|
||||
import {
|
||||
createTestSqliteDatabase,
|
||||
type TestSqliteDatabase,
|
||||
} from '../../storage/sqlite/test-helpers.js';
|
||||
|
||||
const row = (id: string, extra: Partial<Notification> = {}): Notification => ({
|
||||
id,
|
||||
taskId: 'task',
|
||||
targetAgent: 'alice',
|
||||
fromAgent: 'bob',
|
||||
type: 'mention',
|
||||
content: id,
|
||||
delivered: false,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
dedupeKey: 'retained-metadata',
|
||||
...extra,
|
||||
});
|
||||
const deliveredAt = '2026-01-02T00:00:00.000Z';
|
||||
|
||||
describe.each(['file', 'sqlite'] as const)('incremental notifications (%s)', (kind) => {
|
||||
let fixture: TestSqliteDatabase;
|
||||
let repository: NotificationRepository;
|
||||
beforeEach(() => {
|
||||
fixture = createTestSqliteDatabase();
|
||||
fixture.database.open();
|
||||
repository =
|
||||
kind === 'sqlite'
|
||||
? new SqliteNotificationRepository(fixture.database)
|
||||
: new NotificationFileRepository({ dataDir: fixture.rootDir });
|
||||
});
|
||||
afterEach(() => fixture.cleanup());
|
||||
|
||||
it('filters and pages in stable timestamp/id order and preserves metadata', async () => {
|
||||
await repository.appendNotifications([
|
||||
row('b'),
|
||||
row('a'),
|
||||
row('c', { createdAt: '2026-01-01T00:00:00.001Z' }),
|
||||
row('other', { targetAgent: 'case' }),
|
||||
row('delivered', { delivered: true }),
|
||||
]);
|
||||
const page = await repository.listNotifications({
|
||||
agent: 'ALICE',
|
||||
undelivered: true,
|
||||
taskId: 'task',
|
||||
limit: 2,
|
||||
offset: 1,
|
||||
});
|
||||
expect(page.map((value) => value.id)).toEqual(['a', 'b']);
|
||||
expect(page.every((value) => value.dedupeKey === 'retained-metadata')).toBe(true);
|
||||
expect(await repository.listNotifications({ limit: 2, offset: 99 })).toEqual([]);
|
||||
expect(await repository.getStats()).toEqual({
|
||||
totalNotifications: 5,
|
||||
undelivered: 4,
|
||||
byAgent: { alice: { total: 4, undelivered: 3 }, case: { total: 1, undelivered: 1 } },
|
||||
byType: { mention: 5 },
|
||||
});
|
||||
});
|
||||
|
||||
it('updates only intended deliveries and counts only newly delivered batch entries', async () => {
|
||||
const untouched = row('untouched', { targetAgent: 'case', source: { retained: true } });
|
||||
await repository.appendNotifications([row('a'), row('b'), untouched]);
|
||||
expect(await repository.markDelivered('missing', deliveredAt)).toBe(false);
|
||||
expect(await repository.markDelivered('a', deliveredAt)).toBe(true);
|
||||
expect(await repository.markManyDelivered(['a', 'b', 'b', 'missing'], deliveredAt)).toBe(1);
|
||||
expect(await repository.markAllDelivered('ALICE', deliveredAt)).toBe(0);
|
||||
expect((await repository.listNotifications({ agent: 'case' }))[0]).toEqual(untouched);
|
||||
expect(await repository.markAllDelivered('CASE', deliveredAt)).toBe(1);
|
||||
expect((await repository.getStats()).undelivered).toBe(0);
|
||||
expect(await repository.clearNotifications()).toBe(3);
|
||||
expect(await repository.clearNotifications()).toBe(0);
|
||||
});
|
||||
|
||||
it('rolls back an append batch and keeps subscription dedupe stable', async () => {
|
||||
await repository.appendNotifications([row('retained')]);
|
||||
await expect(
|
||||
Promise.resolve().then(() => repository.appendNotifications([row('new'), row('retained')]))
|
||||
).rejects.toThrow();
|
||||
expect(await repository.listNotifications()).toEqual([row('retained')]);
|
||||
const subscription: ThreadSubscription = {
|
||||
taskId: 'task',
|
||||
agent: 'alice',
|
||||
reason: 'manual',
|
||||
subscribedAt: deliveredAt,
|
||||
};
|
||||
await repository.subscribe(subscription);
|
||||
await repository.subscribe({
|
||||
...subscription,
|
||||
reason: 'mentioned',
|
||||
subscribedAt: '2026-01-03T00:00:00.000Z',
|
||||
});
|
||||
expect(await repository.getSubscriptions('task')).toEqual([subscription]);
|
||||
});
|
||||
|
||||
it('sees other repository instances without replacing their updates', async () => {
|
||||
const other =
|
||||
kind === 'sqlite'
|
||||
? new SqliteNotificationRepository(fixture.database)
|
||||
: new NotificationFileRepository({ dataDir: fixture.rootDir });
|
||||
await repository.appendNotifications([row('a')]);
|
||||
await other.appendNotifications([row('b')]);
|
||||
await repository.markDelivered('a', deliveredAt);
|
||||
expect((await other.listNotifications()).map((value) => value.id)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
if (kind === 'sqlite') {
|
||||
it('changes exactly one row and keeps unrelated stored bytes intact', async () => {
|
||||
await repository.appendNotifications([row('a'), row('b')]);
|
||||
const db = fixture.database.getConnection();
|
||||
db.prepare("UPDATE notifications SET notification_json = ? WHERE id = 'b'").run(
|
||||
'{ "untouched" : true }'
|
||||
);
|
||||
const before = Number(db.prepare('SELECT total_changes() AS n').get()?.n);
|
||||
expect(await repository.markDelivered('a', deliveredAt)).toBe(true);
|
||||
expect(Number(db.prepare('SELECT total_changes() AS n').get()?.n) - before).toBe(1);
|
||||
expect(
|
||||
db.prepare("SELECT notification_json FROM notifications WHERE id = 'b'").get()
|
||||
?.notification_json
|
||||
).toBe('{ "untouched" : true }');
|
||||
const updated = await repository.listNotifications({
|
||||
undelivered: false,
|
||||
taskId: 'task',
|
||||
limit: 1,
|
||||
});
|
||||
expect(updated[0]).toEqual(row('a', { delivered: true, deliveredAt }));
|
||||
});
|
||||
|
||||
it('rolls back an entire delivery statement when one row fails', async () => {
|
||||
await repository.appendNotifications([row('a'), row('b')]);
|
||||
const db = fixture.database.getConnection();
|
||||
db.exec(
|
||||
"CREATE TRIGGER reject_delivery BEFORE UPDATE ON notifications WHEN NEW.id = 'b' BEGIN SELECT RAISE(ABORT, 'injected delivery failure'); END"
|
||||
);
|
||||
await expect(
|
||||
Promise.resolve().then(() => repository.markManyDelivered(['a', 'b'], deliveredAt))
|
||||
).rejects.toThrow('injected delivery failure');
|
||||
expect(await repository.listNotifications()).toEqual([row('a'), row('b')]);
|
||||
db.exec('DROP TRIGGER reject_delivery');
|
||||
expect(await repository.markManyDelivered(['a', 'b'], deliveredAt)).toBe(2);
|
||||
});
|
||||
}
|
||||
});
|
||||
113
server/src/__tests__/task-attempt-edit.test.ts
Normal file
113
server/src/__tests__/task-attempt-edit.test.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { DEFAULT_FEATURE_SETTINGS, type TaskAttempt } from '@veritas-kanban/shared';
|
||||
import { TaskService } from '../services/task-service.js';
|
||||
import { TelemetryService } from '../services/telemetry-service.js';
|
||||
import {
|
||||
createTestSqliteDatabase,
|
||||
type TestSqliteDatabase,
|
||||
} from '../storage/sqlite/test-helpers.js';
|
||||
|
||||
for (const storageType of ['file', 'sqlite'] as const) {
|
||||
describe(`generic attempt edits (${storageType})`, () => {
|
||||
let root: string;
|
||||
let database: TestSqliteDatabase | undefined;
|
||||
let service: TaskService;
|
||||
const legacy: TaskAttempt = { id: 'attempt_legacy', agent: 'codex', status: 'running' };
|
||||
|
||||
function openService() {
|
||||
return new TaskService({
|
||||
storageType,
|
||||
sqliteDatabase: database?.database,
|
||||
tasksDir: path.join(root, 'active'),
|
||||
archiveDir: path.join(root, 'archive'),
|
||||
telemetryService: new TelemetryService({
|
||||
telemetryDir: path.join(root, 'telemetry'),
|
||||
config: { enabled: false },
|
||||
}),
|
||||
configService: { getFeatureSettings: async () => DEFAULT_FEATURE_SETTINGS },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await fs.mkdtemp(path.join(os.tmpdir(), 'veritas-attempt-edit-'));
|
||||
database = storageType === 'sqlite' ? createTestSqliteDatabase() : undefined;
|
||||
service = openService();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
service.dispose();
|
||||
database?.cleanup();
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('denies same-ID status changes and replacements without changing durable evidence', async () => {
|
||||
const task = await service.createTask({ title: 'Managed attempt' });
|
||||
const managed: TaskAttempt = {
|
||||
...legacy,
|
||||
runSupervisorId: 'supervisor_test',
|
||||
admissionReservationId: 'reservation_test',
|
||||
};
|
||||
await service.updateTask(task.id, { attempt: managed, attempts: [managed] });
|
||||
const before = await service.getTask(task.id);
|
||||
for (const id of [managed.id, 'attempt_replacement']) {
|
||||
for (const status of ['running', 'complete', 'failed'] as const) {
|
||||
await expect(
|
||||
service.updateTask(
|
||||
task.id,
|
||||
{
|
||||
title: 'Must not be written',
|
||||
attempt: { ...legacy, id, status },
|
||||
},
|
||||
{ protectManagedAttempt: true }
|
||||
)
|
||||
).rejects.toThrow('run lifecycle APIs');
|
||||
}
|
||||
}
|
||||
service.dispose();
|
||||
service = openService();
|
||||
expect(await service.getTask(task.id)).toEqual(before);
|
||||
const renamed = await service.updateTask(task.id, { title: 'Ordinary edit' });
|
||||
expect(renamed?.attempt).toEqual(managed);
|
||||
expect(renamed?.attempts).toEqual([managed]);
|
||||
});
|
||||
|
||||
it('checks the current stored attempt after a launch replaces the route snapshot', async () => {
|
||||
const task = await service.createTask({ title: 'Concurrent launch' });
|
||||
await service.updateTask(task.id, { attempt: legacy });
|
||||
const routeSnapshot = await service.getTask(task.id);
|
||||
await service.updateTask(task.id, {
|
||||
attempt: { ...legacy, runSupervisorId: 'supervisor_new' },
|
||||
});
|
||||
await expect(
|
||||
service.updateTask(
|
||||
task.id,
|
||||
{
|
||||
attempt: { ...routeSnapshot!.attempt!, status: 'complete' },
|
||||
},
|
||||
{ protectManagedAttempt: true }
|
||||
)
|
||||
).rejects.toThrow('run lifecycle APIs');
|
||||
expect((await service.getTask(task.id))?.attempt?.status).toBe('running');
|
||||
});
|
||||
|
||||
it('preserves legacy edits and dedicated lifecycle updates', async () => {
|
||||
const task = await service.createTask({ title: 'Legacy attempt' });
|
||||
await service.updateTask(task.id, { attempt: legacy }, { protectManagedAttempt: true });
|
||||
const completed = await service.updateTask(
|
||||
task.id,
|
||||
{
|
||||
attempt: { ...legacy, status: 'complete' },
|
||||
},
|
||||
{ protectManagedAttempt: true }
|
||||
);
|
||||
expect(completed?.attempt?.status).toBe('complete');
|
||||
const managed = { ...legacy, runSupervisorId: 'supervisor_lifecycle' };
|
||||
await service.updateTask(task.id, { attempt: managed });
|
||||
await service.patchTaskAttempt(task.id, managed.id, { status: 'complete' });
|
||||
expect((await service.getTask(task.id))?.attempt).toEqual({ ...managed, status: 'complete' });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -724,7 +724,15 @@ function normalizePermissions(permissions: PermissionInput): AuthPermission[] {
|
|||
export function authorizePermissionByMethod(config: MethodPermissionConfig) {
|
||||
const readPermissions = normalizePermissions(config.read);
|
||||
const writePermissions = normalizePermissions(config.write ?? config.read);
|
||||
const overrides = config.overrides ?? [];
|
||||
// Express routers match route literals case-insensitively by default. Match
|
||||
// their permission overrides the same way without changing parameter values.
|
||||
// Stateful regex flags must not make authorization depend on earlier requests.
|
||||
const overrides = (config.overrides ?? []).map((override) => ({
|
||||
...override,
|
||||
path: override.path
|
||||
? new RegExp(override.path.source, override.path.flags.replace(/[giy]/g, '') + 'i')
|
||||
: undefined,
|
||||
}));
|
||||
|
||||
return (req: AuthenticatedRequest, res: Response, next: NextFunction): void => {
|
||||
const override = overrides.find((candidate) => {
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ const routeByMetadataSchema = z
|
|||
type: z.string().optional(),
|
||||
priority: z.enum(['low', 'medium', 'high']).optional(),
|
||||
project: z.string().optional(),
|
||||
subtaskCount: z.number().int().nonnegative().optional(),
|
||||
subtaskCount: z.number().int().min(0).max(500).optional(),
|
||||
requiredRuntimeCapabilities: requiredRuntimeCapabilitiesSchema,
|
||||
})
|
||||
.strict();
|
||||
|
|
@ -129,14 +129,7 @@ router.post(
|
|||
type: type || 'feature',
|
||||
priority: priority || 'medium',
|
||||
project,
|
||||
subtasks: subtaskCount
|
||||
? Array.from({ length: subtaskCount }, (_, i) => ({
|
||||
id: `stub_${i}`,
|
||||
title: '',
|
||||
completed: false,
|
||||
created: new Date().toISOString(),
|
||||
}))
|
||||
: undefined,
|
||||
subtaskCount,
|
||||
},
|
||||
{ requiredRuntimeCapabilities }
|
||||
);
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ const predictByMetadataSchema = z.object({
|
|||
priority: z.enum(['low', 'medium', 'high']).optional(),
|
||||
project: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
subtaskCount: z.number().int().nonnegative().optional(),
|
||||
subtaskCount: z.number().int().min(0).max(500).optional(),
|
||||
});
|
||||
|
||||
// ─── Routes ──────────────────────────────────────────────────────
|
||||
|
|
@ -77,7 +77,7 @@ router.post(
|
|||
priority,
|
||||
project,
|
||||
description,
|
||||
subtasks: subtaskCount ? Array.from({ length: subtaskCount }) : undefined,
|
||||
subtaskCount,
|
||||
});
|
||||
return res.json(prediction);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,10 +17,16 @@
|
|||
import { Router, type NextFunction, type Response, type Router as RouterType } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { getNotificationService } from '../services/notification-service.js';
|
||||
import { validate } from '../middleware/validate.js';
|
||||
import { asyncHandler } from '../middleware/async-handler.js';
|
||||
import { NotFoundError } from '../middleware/error-handler.js';
|
||||
import { authorize, type AuthenticatedRequest } from '../middleware/auth.js';
|
||||
|
||||
const notificationPagingSchema = z.object({
|
||||
limit: z.coerce.number().int().min(1).max(1000).optional(),
|
||||
offset: z.coerce.number().int().min(0).optional(),
|
||||
});
|
||||
|
||||
const router: RouterType = Router();
|
||||
const requireAdmin = authorize('admin');
|
||||
|
||||
|
|
@ -70,13 +76,15 @@ router.post(
|
|||
router.get(
|
||||
'/',
|
||||
requireAdminForGlobalNotifications,
|
||||
validate({ query: notificationPagingSchema }),
|
||||
asyncHandler(async (req, res) => {
|
||||
const paging = req.validated?.query as z.infer<typeof notificationPagingSchema>;
|
||||
const agent = String(req.query.agent || '');
|
||||
if (!agent) {
|
||||
const service = getNotificationService();
|
||||
const notifications = await service.getAllNotifications({
|
||||
undelivered: req.query.undelivered === 'true' || req.query.unsent === 'true',
|
||||
limit: req.query.limit ? Number(String(req.query.limit)) : undefined,
|
||||
...paging,
|
||||
});
|
||||
return res.json(notifications);
|
||||
}
|
||||
|
|
@ -86,7 +94,7 @@ router.get(
|
|||
agent,
|
||||
undelivered: req.query.undelivered === 'true',
|
||||
taskId: String(req.query.taskId || ''),
|
||||
limit: req.query.limit ? Number(String(req.query.limit)) : undefined,
|
||||
...paging,
|
||||
});
|
||||
|
||||
res.json(notifications);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { assertLegacyAttemptEditable } from '../utils/task-attempt-edit.js';
|
||||
import { Router, type NextFunction, type Response, type Router as RouterType } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { getTaskService } from '../services/task-service.js';
|
||||
|
|
@ -1014,27 +1015,7 @@ router.patch(
|
|||
if (!oldTask) {
|
||||
throw new NotFoundError('Task not found');
|
||||
}
|
||||
const authoritativeAttempt = oldTask.attempt;
|
||||
if (
|
||||
input.attempt &&
|
||||
authoritativeAttempt &&
|
||||
(authoritativeAttempt.taskEnvelope || authoritativeAttempt.completionResult)
|
||||
) {
|
||||
if (input.attempt.id !== authoritativeAttempt.id) {
|
||||
throw new ValidationError(
|
||||
'Generic task updates cannot replace an attempt with an authoritative run contract'
|
||||
);
|
||||
}
|
||||
input.attempt = {
|
||||
...input.attempt,
|
||||
...(authoritativeAttempt.taskEnvelope
|
||||
? { taskEnvelope: authoritativeAttempt.taskEnvelope }
|
||||
: {}),
|
||||
...(authoritativeAttempt.completionResult
|
||||
? { completionResult: authoritativeAttempt.completionResult }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
if (input.attempt) assertLegacyAttemptEditable(oldTask.attempt);
|
||||
assertFreshRevision(req, 'task', oldTask.id, oldTask);
|
||||
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
|
|
@ -1100,7 +1081,11 @@ router.patch(
|
|||
input.blockedReason = null;
|
||||
}
|
||||
|
||||
const task = await taskService.updateTask(req.params.id as string, input);
|
||||
const task = input.attempt
|
||||
? await taskService.updateTask(req.params.id as string, input, {
|
||||
protectManagedAttempt: true,
|
||||
})
|
||||
: await taskService.updateTask(req.params.id as string, input);
|
||||
if (!task) {
|
||||
throw new NotFoundError('Task not found');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,9 @@ import { selectProviderRuntimeManifest } from './provider-runtime-capability-ser
|
|||
|
||||
const log = createLogger('agent-routing');
|
||||
|
||||
type RoutableTask = Pick<Task, 'type' | 'priority' | 'project' | 'subtasks'>;
|
||||
type RoutableTask = Pick<Task, 'type' | 'priority' | 'project' | 'subtasks'> & {
|
||||
subtaskCount?: number;
|
||||
};
|
||||
|
||||
interface RoutingTraceContext {
|
||||
taskId?: string;
|
||||
|
|
@ -114,7 +116,7 @@ export class AgentRoutingService {
|
|||
type: task.type,
|
||||
priority: task.priority,
|
||||
project: task.project,
|
||||
subtaskCount: task.subtasks?.length,
|
||||
subtaskCount: task.subtasks?.length ?? task.subtaskCount,
|
||||
},
|
||||
config.teamRoster
|
||||
);
|
||||
|
|
@ -433,7 +435,7 @@ export class AgentRoutingService {
|
|||
* Used when an agent fails and `fallbackOnFailure` is enabled.
|
||||
*/
|
||||
async getFallback(
|
||||
task: Pick<Task, 'type' | 'priority' | 'project' | 'subtasks'>,
|
||||
task: RoutableTask,
|
||||
failedAgent: AgentType,
|
||||
context: FallbackRoutingContext = {}
|
||||
): Promise<RoutingResult | null> {
|
||||
|
|
@ -636,10 +638,7 @@ export class AgentRoutingService {
|
|||
* All specified criteria must match (AND logic).
|
||||
* Unspecified criteria are ignored (wildcard).
|
||||
*/
|
||||
private matchesRule(
|
||||
task: Pick<Task, 'type' | 'priority' | 'project' | 'subtasks'>,
|
||||
match: RoutingMatchCriteria
|
||||
): boolean {
|
||||
private matchesRule(task: RoutableTask, match: RoutingMatchCriteria): boolean {
|
||||
// Type check
|
||||
if (match.type !== undefined) {
|
||||
if (!this.matchesValue(task.type, match.type)) return false;
|
||||
|
|
@ -658,7 +657,7 @@ export class AgentRoutingService {
|
|||
|
||||
// Complexity (subtask count)
|
||||
if (match.minSubtasks !== undefined) {
|
||||
const subtaskCount = task.subtasks?.length ?? 0;
|
||||
const subtaskCount = task.subtasks?.length ?? task.subtaskCount ?? 0;
|
||||
if (subtaskCount < match.minSubtasks) return false;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -123,6 +123,7 @@ class CostPredictionService {
|
|||
project?: string;
|
||||
description?: string;
|
||||
subtasks?: Array<unknown>;
|
||||
subtaskCount?: number;
|
||||
}): Promise<CostPrediction> {
|
||||
// 1. Get historical base cost from telemetry
|
||||
const historicalBase = await this.getHistoricalBaseCost(task.type, task.project);
|
||||
|
|
@ -138,7 +139,7 @@ class CostPredictionService {
|
|||
|
||||
// 4. Estimate complexity from description length + subtask count
|
||||
const descLength = (task.description || '').length;
|
||||
const subtaskCount = task.subtasks?.length || 0;
|
||||
const subtaskCount = task.subtasks?.length ?? task.subtaskCount ?? 0;
|
||||
let complexityMultiplier: number;
|
||||
if (descLength < COMPLEXITY_THRESHOLDS.simple && subtaskCount === 0) {
|
||||
complexityMultiplier = COMPLEXITY_MULTIPLIERS.simple;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
* and thread subscriptions for multi-agent communication.
|
||||
*/
|
||||
|
||||
import type { NotificationRepository, NotificationQuery } from '../storage/interfaces.js';
|
||||
import { createLogger } from '../lib/logger.js';
|
||||
import { SqliteDatabase, type SqliteConnectionOptions } from '../storage/sqlite/database.js';
|
||||
import { SqliteNotificationRepository } from '../storage/sqlite/notification-repository.js';
|
||||
|
|
@ -93,17 +94,13 @@ export function parseMentions(text: string): string[] {
|
|||
// ─── Service ─────────────────────────────────────────────────────
|
||||
|
||||
export class NotificationService {
|
||||
private notifications: Notification[] = [];
|
||||
private subscriptions: ThreadSubscription[] = [];
|
||||
private loaded = false;
|
||||
private readonly fileRepository: NotificationFileRepository;
|
||||
private readonly repository: SqliteNotificationRepository | null = null;
|
||||
private readonly repository: NotificationRepository;
|
||||
private readonly sqliteDatabase: SqliteDatabase | null = null;
|
||||
private readonly ownsSqliteDatabase: boolean = false;
|
||||
|
||||
constructor(options: NotificationServiceOptions = {}) {
|
||||
const dataDir = options.dataDir ?? DATA_DIR;
|
||||
this.fileRepository = new NotificationFileRepository({
|
||||
this.repository = new NotificationFileRepository({
|
||||
dataDir,
|
||||
notificationsFile: options.notificationsFile,
|
||||
subscriptionsFile: options.subscriptionsFile,
|
||||
|
|
@ -120,38 +117,6 @@ export class NotificationService {
|
|||
}
|
||||
}
|
||||
|
||||
private async ensureLoaded(): Promise<void> {
|
||||
if (this.loaded) return;
|
||||
if (this.repository) {
|
||||
this.notifications = this.repository.loadNotifications();
|
||||
this.subscriptions = this.repository.loadSubscriptions();
|
||||
this.loaded = true;
|
||||
return;
|
||||
}
|
||||
|
||||
this.notifications = await this.fileRepository.loadNotifications<Notification>();
|
||||
this.subscriptions = await this.fileRepository.loadSubscriptions<ThreadSubscription>();
|
||||
this.loaded = true;
|
||||
}
|
||||
|
||||
private async saveNotifications(): Promise<void> {
|
||||
if (this.repository) {
|
||||
this.repository.saveNotifications(this.notifications);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.fileRepository.saveNotifications(this.notifications);
|
||||
}
|
||||
|
||||
private async saveSubscriptions(): Promise<void> {
|
||||
if (this.repository) {
|
||||
this.repository.saveSubscriptions(this.subscriptions);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.fileRepository.saveSubscriptions(this.subscriptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a comment for @mentions and create notifications.
|
||||
* Also subscribes the commenter to the thread.
|
||||
|
|
@ -162,8 +127,6 @@ export class NotificationService {
|
|||
content: string;
|
||||
allAgents?: string[];
|
||||
}): Promise<Notification[]> {
|
||||
await this.ensureLoaded();
|
||||
|
||||
const mentions = parseMentions(params.content);
|
||||
const created: Notification[] = [];
|
||||
|
||||
|
|
@ -177,9 +140,9 @@ export class NotificationService {
|
|||
targets = targets.filter((t) => t !== params.fromAgent.toLowerCase());
|
||||
|
||||
// Also notify thread subscribers (if not already in mentions)
|
||||
const subscribers = this.subscriptions
|
||||
.filter((s) => s.taskId === params.taskId)
|
||||
.map((s) => s.agent.toLowerCase());
|
||||
const subscribers = (await this.repository.getSubscriptions(params.taskId)).map((s) =>
|
||||
s.agent.toLowerCase()
|
||||
);
|
||||
|
||||
const allTargets = [...new Set([...targets, ...subscribers])].filter(
|
||||
(t) => t !== params.fromAgent.toLowerCase()
|
||||
|
|
@ -196,7 +159,6 @@ export class NotificationService {
|
|||
delivered: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
this.notifications.push(notification);
|
||||
created.push(notification);
|
||||
}
|
||||
|
||||
|
|
@ -208,7 +170,7 @@ export class NotificationService {
|
|||
await this.subscribe(params.taskId, target, 'mentioned');
|
||||
}
|
||||
|
||||
await this.saveNotifications();
|
||||
await this.repository.appendNotifications(created);
|
||||
|
||||
log.info(
|
||||
{
|
||||
|
|
@ -227,8 +189,7 @@ export class NotificationService {
|
|||
* Create a notification for task assignment.
|
||||
*/
|
||||
async notifyAssignment(taskId: string, agents: string[], assignedBy: string): Promise<void> {
|
||||
await this.ensureLoaded();
|
||||
|
||||
const created: Notification[] = [];
|
||||
for (const agent of agents) {
|
||||
if (agent.toLowerCase() === assignedBy.toLowerCase()) continue;
|
||||
|
||||
|
|
@ -242,127 +203,38 @@ export class NotificationService {
|
|||
delivered: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
this.notifications.push(notification);
|
||||
created.push(notification);
|
||||
|
||||
// Auto-subscribe assigned agents
|
||||
await this.subscribe(taskId, agent, 'assigned');
|
||||
}
|
||||
|
||||
await this.saveNotifications();
|
||||
await this.repository.appendNotifications(created);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get notifications for an agent.
|
||||
*/
|
||||
async getNotifications(filters: {
|
||||
agent: string;
|
||||
undelivered?: boolean;
|
||||
taskId?: string;
|
||||
limit?: number;
|
||||
}): Promise<Notification[]> {
|
||||
await this.ensureLoaded();
|
||||
|
||||
let results = this.notifications.filter((n) => n.targetAgent === filters.agent.toLowerCase());
|
||||
|
||||
if (filters.undelivered) {
|
||||
results = results.filter((n) => !n.delivered);
|
||||
}
|
||||
if (filters.taskId) {
|
||||
results = results.filter((n) => n.taskId === filters.taskId);
|
||||
}
|
||||
|
||||
results.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
|
||||
if (filters.limit) {
|
||||
results = results.slice(0, filters.limit);
|
||||
}
|
||||
|
||||
return results;
|
||||
async getNotifications(filters: NotificationQuery & { agent: string }): Promise<Notification[]> {
|
||||
return this.repository.listNotifications(filters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get notifications across all agents.
|
||||
*
|
||||
* Used by the CLI notification commands, which predate the agent-scoped
|
||||
* route shape.
|
||||
*/
|
||||
async getAllNotifications(
|
||||
filters: {
|
||||
undelivered?: boolean;
|
||||
limit?: number;
|
||||
} = {}
|
||||
filters: Omit<NotificationQuery, 'agent' | 'taskId'> = {}
|
||||
): Promise<Notification[]> {
|
||||
await this.ensureLoaded();
|
||||
|
||||
let results = [...this.notifications];
|
||||
|
||||
if (filters.undelivered) {
|
||||
results = results.filter((n) => !n.delivered);
|
||||
}
|
||||
|
||||
results.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
|
||||
if (filters.limit) {
|
||||
results = results.slice(0, filters.limit);
|
||||
}
|
||||
|
||||
return results;
|
||||
return this.repository.listNotifications(filters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a notification as delivered.
|
||||
*/
|
||||
async markDelivered(notificationId: string): Promise<boolean> {
|
||||
await this.ensureLoaded();
|
||||
|
||||
const notification = this.notifications.find((n) => n.id === notificationId);
|
||||
if (!notification) return false;
|
||||
|
||||
notification.delivered = true;
|
||||
notification.deliveredAt = new Date().toISOString();
|
||||
await this.saveNotifications();
|
||||
return true;
|
||||
return this.repository.markDelivered(notificationId, new Date().toISOString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark multiple notifications as delivered.
|
||||
*/
|
||||
async markManyDelivered(notificationIds: string[]): Promise<number> {
|
||||
await this.ensureLoaded();
|
||||
|
||||
const ids = new Set(notificationIds);
|
||||
const now = new Date().toISOString();
|
||||
let count = 0;
|
||||
for (const notification of this.notifications) {
|
||||
if (ids.has(notification.id) && !notification.delivered) {
|
||||
notification.delivered = true;
|
||||
notification.deliveredAt = now;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
if (count > 0) await this.saveNotifications();
|
||||
return count;
|
||||
return this.repository.markManyDelivered(notificationIds, new Date().toISOString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all notifications for an agent as delivered.
|
||||
*/
|
||||
async markAllDelivered(agent: string): Promise<number> {
|
||||
await this.ensureLoaded();
|
||||
|
||||
let count = 0;
|
||||
const now = new Date().toISOString();
|
||||
for (const n of this.notifications) {
|
||||
if (n.targetAgent === agent.toLowerCase() && !n.delivered) {
|
||||
n.delivered = true;
|
||||
n.deliveredAt = now;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
if (count > 0) await this.saveNotifications();
|
||||
return count;
|
||||
return this.repository.markAllDelivered(agent.toLowerCase(), new Date().toISOString());
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -381,8 +253,6 @@ export class NotificationService {
|
|||
dedupeKey?: string;
|
||||
source?: NotificationSourceMetadata;
|
||||
}): Promise<Notification> {
|
||||
await this.ensureLoaded();
|
||||
|
||||
const notification: Notification = {
|
||||
id: `notif_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
|
||||
taskId: params.taskId || 'system',
|
||||
|
|
@ -400,8 +270,7 @@ export class NotificationService {
|
|||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
this.notifications.push(notification);
|
||||
await this.saveNotifications();
|
||||
await this.repository.appendNotifications([notification]);
|
||||
return notification;
|
||||
}
|
||||
|
||||
|
|
@ -409,71 +278,28 @@ export class NotificationService {
|
|||
* Clear all notifications.
|
||||
*/
|
||||
async clearNotifications(): Promise<number> {
|
||||
await this.ensureLoaded();
|
||||
|
||||
const count = this.notifications.length;
|
||||
this.notifications = [];
|
||||
if (count > 0) await this.saveNotifications();
|
||||
return count;
|
||||
return this.repository.clearNotifications();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get notification statistics.
|
||||
*/
|
||||
async getStats(): Promise<NotificationStats> {
|
||||
await this.ensureLoaded();
|
||||
|
||||
const byAgent: Record<string, { total: number; undelivered: number }> = {};
|
||||
const byType: Record<string, number> = {};
|
||||
|
||||
for (const n of this.notifications) {
|
||||
if (!byAgent[n.targetAgent]) {
|
||||
byAgent[n.targetAgent] = { total: 0, undelivered: 0 };
|
||||
}
|
||||
byAgent[n.targetAgent].total++;
|
||||
if (!n.delivered) byAgent[n.targetAgent].undelivered++;
|
||||
|
||||
byType[n.type] = (byType[n.type] || 0) + 1;
|
||||
}
|
||||
|
||||
return {
|
||||
totalNotifications: this.notifications.length,
|
||||
undelivered: this.notifications.filter((n) => !n.delivered).length,
|
||||
byAgent,
|
||||
byType,
|
||||
};
|
||||
return this.repository.getStats();
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe an agent to a task thread.
|
||||
*/
|
||||
async subscribe(
|
||||
taskId: string,
|
||||
agent: string,
|
||||
reason: ThreadSubscription['reason']
|
||||
): Promise<void> {
|
||||
await this.ensureLoaded();
|
||||
|
||||
const exists = this.subscriptions.some(
|
||||
(s) => s.taskId === taskId && s.agent === agent.toLowerCase()
|
||||
);
|
||||
if (exists) return;
|
||||
|
||||
this.subscriptions.push({
|
||||
await this.repository.subscribe({
|
||||
taskId,
|
||||
agent: agent.toLowerCase(),
|
||||
reason,
|
||||
subscribedAt: new Date().toISOString(),
|
||||
});
|
||||
await this.saveSubscriptions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get subscriptions for a task.
|
||||
*/
|
||||
async getSubscriptions(taskId: string): Promise<ThreadSubscription[]> {
|
||||
await this.ensureLoaded();
|
||||
return this.subscriptions.filter((s) => s.taskId === taskId);
|
||||
return this.repository.getSubscriptions(taskId);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { assertLegacyAttemptEditable } from '../utils/task-attempt-edit.js';
|
||||
import { nanoid } from 'nanoid';
|
||||
import type {
|
||||
Task,
|
||||
|
|
@ -93,6 +94,7 @@ interface BoardStatusConfig {
|
|||
}
|
||||
|
||||
type TaskMutationInput = UpdateTaskInput & {
|
||||
protectManagedAttempt?: boolean;
|
||||
attemptPatch?: Pick<TaskAttempt, 'id'> & Partial<Omit<TaskAttempt, 'id'>>;
|
||||
lastBoardMove?: TaskBoardMoveReceipt;
|
||||
boardRank?: string | null;
|
||||
|
|
@ -781,10 +783,17 @@ export class TaskService {
|
|||
return task;
|
||||
}
|
||||
|
||||
async updateTask(id: string, input: UpdateTaskInput): Promise<Task | null> {
|
||||
async updateTask(
|
||||
id: string,
|
||||
input: UpdateTaskInput,
|
||||
options: { protectManagedAttempt?: boolean } = {}
|
||||
): Promise<Task | null> {
|
||||
const affectsBoard = input.position !== undefined || input.status !== undefined;
|
||||
const mutationInput: TaskMutationInput =
|
||||
input.position !== undefined ? { ...input, boardRank: null } : input;
|
||||
const mutationInput: TaskMutationInput = {
|
||||
...input,
|
||||
...(input.position !== undefined ? { boardRank: null } : {}),
|
||||
protectManagedAttempt: options.protectManagedAttempt,
|
||||
};
|
||||
if (affectsBoard) {
|
||||
return this.withBoardMoveMutex((commitStorage) =>
|
||||
this.withTaskMutex(id, () =>
|
||||
|
|
@ -838,6 +847,7 @@ export class TaskService {
|
|||
boardRank: boardRankUpdate,
|
||||
expectedRevision: _expectedRevision,
|
||||
attemptPatch,
|
||||
protectManagedAttempt,
|
||||
...restInput
|
||||
} = input;
|
||||
|
||||
|
|
@ -874,6 +884,12 @@ export class TaskService {
|
|||
? ((await this.sqliteTasks.findById(id)) ?? task)
|
||||
: (fileMutationTask ?? task);
|
||||
|
||||
// Check inside the storage lock: launch may have installed a managed
|
||||
// attempt after the generic route read the previous task revision.
|
||||
if (protectManagedAttempt && input.attempt) {
|
||||
assertLegacyAttemptEditable(freshTask.attempt);
|
||||
}
|
||||
|
||||
if (attemptPatch && freshTask.attempt?.id !== attemptPatch.id) {
|
||||
updatedTask = freshTask;
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -84,6 +84,11 @@ import type {
|
|||
RunOutputArtifactRangeQuery,
|
||||
RunOutputQuarantineReason,
|
||||
} from '@veritas-kanban/shared';
|
||||
import type {
|
||||
Notification,
|
||||
NotificationStats,
|
||||
ThreadSubscription,
|
||||
} from '../services/notification-service.js';
|
||||
import type { Activity, ActivityType } from '../services/activity-service.js';
|
||||
import type {
|
||||
StatusHistoryEntry,
|
||||
|
|
@ -602,3 +607,24 @@ export interface StorageProvider {
|
|||
/** Graceful shutdown (close watchers, release connections, etc.). */
|
||||
shutdown(): Promise<void>;
|
||||
}
|
||||
|
||||
/** Incremental notification operations; bulk import/export remains adapter-specific. */
|
||||
export interface NotificationQuery {
|
||||
agent?: string;
|
||||
undelivered?: boolean;
|
||||
taskId?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface NotificationRepository {
|
||||
appendNotifications(notifications: Notification[]): void | Promise<void>;
|
||||
listNotifications(query?: NotificationQuery): Notification[] | Promise<Notification[]>;
|
||||
markDelivered(id: string, at: string): boolean | Promise<boolean>;
|
||||
markManyDelivered(ids: string[], at: string): number | Promise<number>;
|
||||
markAllDelivered(agent: string, at: string): number | Promise<number>;
|
||||
clearNotifications(): number | Promise<number>;
|
||||
getStats(): NotificationStats | Promise<NotificationStats>;
|
||||
subscribe(subscription: ThreadSubscription): void | Promise<void>;
|
||||
getSubscriptions(taskId: string): ThreadSubscription[] | Promise<ThreadSubscription[]>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,9 @@
|
|||
import type { NotificationRepository, NotificationQuery } from './interfaces.js';
|
||||
import type {
|
||||
Notification,
|
||||
NotificationStats,
|
||||
ThreadSubscription,
|
||||
} from '../services/notification-service.js';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { atomicWriteFile } from './fs-helpers.js';
|
||||
import path from 'node:path';
|
||||
|
|
@ -9,7 +15,7 @@ export interface NotificationFileRepositoryOptions {
|
|||
subscriptionsFile?: string;
|
||||
}
|
||||
|
||||
export class NotificationFileRepository {
|
||||
export class NotificationFileRepository implements NotificationRepository {
|
||||
private readonly notificationsFile: string;
|
||||
private readonly subscriptionsFile: string;
|
||||
|
||||
|
|
@ -36,6 +42,130 @@ export class NotificationFileRepository {
|
|||
return this.saveArray(this.subscriptionsFile, subscriptions);
|
||||
}
|
||||
|
||||
async appendNotifications(notifications: Notification[]): Promise<void> {
|
||||
if (!notifications.length) return;
|
||||
await this.mutateArray<Notification, void>(this.notificationsFile, (values) => {
|
||||
const ids = new Set(values.map((value) => value.id));
|
||||
for (const notification of notifications) {
|
||||
if (ids.has(notification.id))
|
||||
throw new Error(`Duplicate notification id: ${notification.id}`);
|
||||
ids.add(notification.id);
|
||||
values.push(notification);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async listNotifications(query: NotificationQuery = {}): Promise<Notification[]> {
|
||||
const values = await this.loadNotifications<Notification>();
|
||||
const results = values
|
||||
.filter(
|
||||
(value) =>
|
||||
(query.agent === undefined || value.targetAgent === query.agent.toLowerCase()) &&
|
||||
(!query.undelivered || !value.delivered) &&
|
||||
(!query.taskId || value.taskId === query.taskId)
|
||||
)
|
||||
.sort((a, b) =>
|
||||
a.createdAt === b.createdAt
|
||||
? a.id < b.id
|
||||
? -1
|
||||
: a.id > b.id
|
||||
? 1
|
||||
: 0
|
||||
: a.createdAt > b.createdAt
|
||||
? -1
|
||||
: 1
|
||||
);
|
||||
const offset = query.offset ?? 0;
|
||||
return results.slice(offset, query.limit === undefined ? undefined : offset + query.limit);
|
||||
}
|
||||
|
||||
async markDelivered(id: string, at: string): Promise<boolean> {
|
||||
return (await this.deliver((value) => value.id === id, at)) > 0;
|
||||
}
|
||||
|
||||
markManyDelivered(ids: string[], at: string): Promise<number> {
|
||||
const selected = new Set(ids);
|
||||
return this.deliver((value) => !value.delivered && selected.has(value.id), at);
|
||||
}
|
||||
|
||||
markAllDelivered(agent: string, at: string): Promise<number> {
|
||||
return this.deliver(
|
||||
(value) => !value.delivered && value.targetAgent === agent.toLowerCase(),
|
||||
at
|
||||
);
|
||||
}
|
||||
|
||||
private deliver(matches: (value: Notification) => boolean, at: string): Promise<number> {
|
||||
return this.mutateArray<Notification, number>(this.notificationsFile, (values) => {
|
||||
let count = 0;
|
||||
for (const value of values) {
|
||||
if (!matches(value)) continue;
|
||||
value.delivered = true;
|
||||
value.deliveredAt = at;
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
});
|
||||
}
|
||||
|
||||
clearNotifications(): Promise<number> {
|
||||
return this.mutateArray<Notification, number>(this.notificationsFile, (values) => {
|
||||
const count = values.length;
|
||||
values.length = 0;
|
||||
return count;
|
||||
});
|
||||
}
|
||||
|
||||
async getStats(): Promise<NotificationStats> {
|
||||
const values = await this.loadNotifications<Notification>();
|
||||
const byAgent: NotificationStats['byAgent'] = Object.create(null);
|
||||
const byType: NotificationStats['byType'] = Object.create(null);
|
||||
let undelivered = 0;
|
||||
for (const value of values) {
|
||||
const agent = (byAgent[value.targetAgent] ??= { total: 0, undelivered: 0 });
|
||||
agent.total++;
|
||||
if (!value.delivered) {
|
||||
agent.undelivered++;
|
||||
undelivered++;
|
||||
}
|
||||
byType[value.type] = (byType[value.type] ?? 0) + 1;
|
||||
}
|
||||
return { totalNotifications: values.length, undelivered, byAgent, byType };
|
||||
}
|
||||
|
||||
subscribe(subscription: ThreadSubscription): Promise<void> {
|
||||
return this.mutateArray<ThreadSubscription, void>(this.subscriptionsFile, (values) => {
|
||||
if (
|
||||
!values.some(
|
||||
(value) => value.taskId === subscription.taskId && value.agent === subscription.agent
|
||||
)
|
||||
)
|
||||
values.push(subscription);
|
||||
});
|
||||
}
|
||||
|
||||
async getSubscriptions(taskId: string): Promise<ThreadSubscription[]> {
|
||||
return (await this.loadSubscriptions<ThreadSubscription>())
|
||||
.filter((value) => value.taskId === taskId)
|
||||
.sort((a, b) =>
|
||||
a.subscribedAt === b.subscribedAt
|
||||
? a.agent.localeCompare(b.agent)
|
||||
: a.subscribedAt.localeCompare(b.subscribedAt)
|
||||
);
|
||||
}
|
||||
|
||||
private mutateArray<T, R>(filePath: string, mutate: (values: T[]) => R): Promise<R> {
|
||||
return withFileLock(filePath, async () => {
|
||||
const values = await this.loadArray<T>(filePath);
|
||||
const before = JSON.stringify(values);
|
||||
const result = mutate(values);
|
||||
if (JSON.stringify(values) !== before) {
|
||||
await atomicWriteFile(filePath, JSON.stringify(values, null, 2));
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
private async loadArray<T>(filePath: string): Promise<T[]> {
|
||||
try {
|
||||
const values: unknown = JSON.parse(await readFile(filePath, 'utf8'));
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import type { Notification, ThreadSubscription } from '../../services/notification-service.js';
|
||||
import type { NotificationRepository, NotificationQuery } from '../interfaces.js';
|
||||
import type { NotificationStats } from '../../services/notification-service.js';
|
||||
import type { SqliteDatabase } from './database.js';
|
||||
|
||||
interface NotificationRow {
|
||||
|
|
@ -9,7 +11,7 @@ interface ThreadSubscriptionRow {
|
|||
subscription_json: string;
|
||||
}
|
||||
|
||||
export class SqliteNotificationRepository {
|
||||
export class SqliteNotificationRepository implements NotificationRepository {
|
||||
constructor(private readonly database: SqliteDatabase) {}
|
||||
|
||||
loadNotifications(): Notification[] {
|
||||
|
|
@ -35,8 +37,19 @@ export class SqliteNotificationRepository {
|
|||
try {
|
||||
db.prepare("DELETE FROM notifications WHERE workspace_id = 'local'").run();
|
||||
|
||||
const insertNotification = db.prepare(
|
||||
`
|
||||
this.insertNotifications(notifications);
|
||||
|
||||
db.exec('COMMIT;');
|
||||
} catch (error) {
|
||||
db.exec('ROLLBACK;');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private insertNotifications(notifications: Notification[]): void {
|
||||
const db = this.database.getConnection();
|
||||
const insertNotification = db.prepare(
|
||||
`
|
||||
INSERT INTO notifications (
|
||||
id,
|
||||
workspace_id,
|
||||
|
|
@ -58,29 +71,36 @@ export class SqliteNotificationRepository {
|
|||
)
|
||||
VALUES (?, 'local', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
);
|
||||
|
||||
for (const notification of notifications) {
|
||||
insertNotification.run(
|
||||
notification.id,
|
||||
notification.taskId,
|
||||
notification.targetAgent,
|
||||
notification.fromAgent,
|
||||
notification.type,
|
||||
notification.delivered ? 1 : 0,
|
||||
notification.deliveredAt ?? null,
|
||||
notification.content,
|
||||
notification.title ?? null,
|
||||
notification.taskTitle ?? null,
|
||||
notification.project ?? null,
|
||||
notification.targetUrl ?? null,
|
||||
notification.dedupeKey ?? null,
|
||||
notification.source ? JSON.stringify(notification.source) : null,
|
||||
JSON.stringify(notification),
|
||||
notification.createdAt
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const notification of notifications) {
|
||||
insertNotification.run(
|
||||
notification.id,
|
||||
notification.taskId,
|
||||
notification.targetAgent,
|
||||
notification.fromAgent,
|
||||
notification.type,
|
||||
notification.delivered ? 1 : 0,
|
||||
notification.deliveredAt ?? null,
|
||||
notification.content,
|
||||
notification.title ?? null,
|
||||
notification.taskTitle ?? null,
|
||||
notification.project ?? null,
|
||||
notification.targetUrl ?? null,
|
||||
notification.dedupeKey ?? null,
|
||||
notification.source ? JSON.stringify(notification.source) : null,
|
||||
JSON.stringify(notification),
|
||||
notification.createdAt
|
||||
);
|
||||
}
|
||||
|
||||
appendNotifications(notifications: Notification[]): void {
|
||||
if (!notifications.length) return;
|
||||
const db = this.database.getConnection();
|
||||
db.exec('BEGIN IMMEDIATE;');
|
||||
try {
|
||||
this.insertNotifications(notifications);
|
||||
db.exec('COMMIT;');
|
||||
} catch (error) {
|
||||
db.exec('ROLLBACK;');
|
||||
|
|
@ -88,6 +108,115 @@ export class SqliteNotificationRepository {
|
|||
}
|
||||
}
|
||||
|
||||
listNotifications(query: NotificationQuery = {}): Notification[] {
|
||||
const clauses = ["workspace_id = 'local'"];
|
||||
const parameters: (string | number)[] = [];
|
||||
if (query.agent !== undefined) {
|
||||
clauses.push('target_agent = ?');
|
||||
parameters.push(query.agent.toLowerCase());
|
||||
}
|
||||
if (query.undelivered) clauses.push('delivered = 0');
|
||||
if (query.taskId) {
|
||||
clauses.push('task_id = ?');
|
||||
parameters.push(query.taskId);
|
||||
}
|
||||
parameters.push(query.limit ?? -1, query.offset ?? 0);
|
||||
const rows = this.database
|
||||
.getConnection()
|
||||
.prepare(
|
||||
`SELECT notification_json FROM notifications WHERE ${clauses.join(' AND ')} ORDER BY created_at DESC, id ASC LIMIT ? OFFSET ?`
|
||||
)
|
||||
.all(...parameters) as unknown as NotificationRow[];
|
||||
return rows.map((row) => JSON.parse(row.notification_json) as Notification);
|
||||
}
|
||||
|
||||
markDelivered(id: string, at: string): boolean {
|
||||
return this.updateDelivered('id = ?', [id], at) > 0;
|
||||
}
|
||||
|
||||
private updateDelivered(where: string, parameters: string[], at: string): number {
|
||||
return Number(
|
||||
this.database
|
||||
.getConnection()
|
||||
.prepare(
|
||||
`UPDATE notifications SET delivered = 1, delivered_at = ?, notification_json = json_set(notification_json, '$.delivered', json('true'), '$.deliveredAt', ?) WHERE workspace_id = 'local' AND ${where}`
|
||||
)
|
||||
.run(at, at, ...parameters).changes
|
||||
);
|
||||
}
|
||||
|
||||
markManyDelivered(ids: string[], at: string): number {
|
||||
if (!ids.length) return 0;
|
||||
// JSON table avoids SQLite's parameter limit for a large delivery batch.
|
||||
return this.updateDelivered(
|
||||
'delivered = 0 AND id IN (SELECT value FROM json_each(?))',
|
||||
[JSON.stringify([...new Set(ids)])],
|
||||
at
|
||||
);
|
||||
}
|
||||
|
||||
markAllDelivered(agent: string, at: string): number {
|
||||
return this.updateDelivered('delivered = 0 AND target_agent = ?', [agent.toLowerCase()], at);
|
||||
}
|
||||
|
||||
clearNotifications(): number {
|
||||
return Number(
|
||||
this.database
|
||||
.getConnection()
|
||||
.prepare("DELETE FROM notifications WHERE workspace_id = 'local'")
|
||||
.run().changes
|
||||
);
|
||||
}
|
||||
|
||||
getStats(): NotificationStats {
|
||||
const rows = this.database
|
||||
.getConnection()
|
||||
.prepare(
|
||||
"SELECT target_agent AS agent, type, COUNT(*) AS total, SUM(delivered = 0) AS undelivered FROM notifications WHERE workspace_id = 'local' GROUP BY target_agent, type"
|
||||
)
|
||||
.all() as unknown as { agent: string; type: string; total: number; undelivered: number }[];
|
||||
const byAgent: NotificationStats['byAgent'] = Object.create(null);
|
||||
const byType: NotificationStats['byType'] = Object.create(null);
|
||||
let totalNotifications = 0;
|
||||
let undelivered = 0;
|
||||
for (const row of rows) {
|
||||
const total = Number(row.total);
|
||||
const pending = Number(row.undelivered);
|
||||
const agent = (byAgent[row.agent] ??= { total: 0, undelivered: 0 });
|
||||
agent.total += total;
|
||||
agent.undelivered += pending;
|
||||
byType[row.type] = (byType[row.type] ?? 0) + total;
|
||||
totalNotifications += total;
|
||||
undelivered += pending;
|
||||
}
|
||||
return { totalNotifications, undelivered, byAgent, byType };
|
||||
}
|
||||
|
||||
subscribe(subscription: ThreadSubscription): void {
|
||||
this.database
|
||||
.getConnection()
|
||||
.prepare(
|
||||
`INSERT INTO thread_subscriptions (workspace_id, task_id, agent, reason, subscription_json, subscribed_at) VALUES ('local', ?, ?, ?, ?, ?) ON CONFLICT(workspace_id, task_id, agent) DO NOTHING`
|
||||
)
|
||||
.run(
|
||||
subscription.taskId,
|
||||
subscription.agent,
|
||||
subscription.reason,
|
||||
JSON.stringify(subscription),
|
||||
subscription.subscribedAt
|
||||
);
|
||||
}
|
||||
|
||||
getSubscriptions(taskId: string): ThreadSubscription[] {
|
||||
const rows = this.database
|
||||
.getConnection()
|
||||
.prepare(
|
||||
"SELECT subscription_json FROM thread_subscriptions WHERE workspace_id = 'local' AND task_id = ? ORDER BY subscribed_at ASC, agent ASC"
|
||||
)
|
||||
.all(taskId) as unknown as ThreadSubscriptionRow[];
|
||||
return rows.map((row) => JSON.parse(row.subscription_json) as ThreadSubscription);
|
||||
}
|
||||
|
||||
loadSubscriptions(): ThreadSubscription[] {
|
||||
const rows = this.database
|
||||
.getConnection()
|
||||
|
|
|
|||
24
server/src/utils/task-attempt-edit.ts
Normal file
24
server/src/utils/task-attempt-edit.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import type { TaskAttempt } from '@veritas-kanban/shared';
|
||||
import { ValidationError } from '../middleware/error-handler.js';
|
||||
|
||||
// The historical generic task editor supports only these legacy fields. Any
|
||||
// additional field identifies server-owned evidence, including future contracts.
|
||||
const LEGACY_ATTEMPT_FIELDS = new Set([
|
||||
'id',
|
||||
'agent',
|
||||
'status',
|
||||
'started',
|
||||
'ended',
|
||||
'provider',
|
||||
'model',
|
||||
'threadId',
|
||||
'cloudUrl',
|
||||
'cloudTarget',
|
||||
'orchestration',
|
||||
]);
|
||||
|
||||
export function assertLegacyAttemptEditable(attempt: TaskAttempt | undefined): void {
|
||||
if (attempt && Object.keys(attempt).some((key) => !LEGACY_ATTEMPT_FIELDS.has(key))) {
|
||||
throw new ValidationError('Managed attempts can only be changed through run lifecycle APIs');
|
||||
}
|
||||
}
|
||||
|
|
@ -81,9 +81,9 @@ function normalizeApiPath(path: string): string {
|
|||
const url = new URL(path, 'http://veritas.local');
|
||||
let normalized = url.pathname.replace(/\/+$/, '') || '/';
|
||||
|
||||
if (normalized === '/api/v1') {
|
||||
if (normalized.toLowerCase() === '/api/v1') {
|
||||
normalized = '/api';
|
||||
} else if (normalized.startsWith('/api/v1/')) {
|
||||
} else if (normalized.toLowerCase().startsWith('/api/v1/')) {
|
||||
normalized = `/api${normalized.slice('/api/v1'.length)}`;
|
||||
}
|
||||
|
||||
|
|
@ -95,11 +95,12 @@ function routeRequirement(
|
|||
path: string,
|
||||
method: string
|
||||
): ApiPermissionRequirement | null {
|
||||
if (path !== config.prefix && !path.startsWith(`${config.prefix}/`)) {
|
||||
const matchingPath = path.toLowerCase();
|
||||
if (matchingPath !== config.prefix && !matchingPath.startsWith(`${config.prefix}/`)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const relativePath = path.slice(config.prefix.length) || '/';
|
||||
const relativePath = matchingPath.slice(config.prefix.length) || '/';
|
||||
const override = config.overrides?.find((candidate) => {
|
||||
const methodMatches =
|
||||
!candidate.methods ||
|
||||
|
|
@ -500,7 +501,7 @@ export function getApiPermissionRequirement(
|
|||
const method = (options.method || 'GET').toUpperCase();
|
||||
const normalizedPath = normalizeApiPath(path);
|
||||
|
||||
if (isPublicApiPath(normalizedPath)) {
|
||||
if (isPublicApiPath(normalizedPath.toLowerCase())) {
|
||||
return { permissions: [], path: normalizedPath, method, public: true };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { lazy, Suspense, useEffect, useState } from 'react';
|
||||
import { lazy, Suspense, useEffect, useState, useRef } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { resetFeatureSettingsWrites } from './lib/feature-settings-writes';
|
||||
import { Box } from '@mantine/core';
|
||||
import { Header } from './components/layout/Header';
|
||||
import { Toaster } from './components/ui/toaster';
|
||||
|
|
@ -192,6 +194,20 @@ function DesktopAwareAppShell({
|
|||
|
||||
// Main app content (only rendered when authenticated)
|
||||
function AppContent() {
|
||||
const queryClient = useQueryClient();
|
||||
const settingsSessionMounted = useRef(false);
|
||||
useEffect(() => {
|
||||
settingsSessionMounted.current = true;
|
||||
return () => {
|
||||
settingsSessionMounted.current = false;
|
||||
// Strict Mode may remount effects synchronously. End the writer only when
|
||||
// the authenticated application really leaves, never on a Settings tab change.
|
||||
queueMicrotask(() => {
|
||||
if (!settingsSessionMounted.current) resetFeatureSettingsWrites(queryClient);
|
||||
});
|
||||
};
|
||||
}, [queryClient]);
|
||||
|
||||
// Connect to WebSocket for real-time task updates
|
||||
const { isConnected, connectionState, reconnectAttempt, reconnect } = useTaskSync();
|
||||
const { status: authStatus, refreshStatus } = useAuth();
|
||||
|
|
|
|||
|
|
@ -89,14 +89,13 @@ vi.mock('@/hooks/useAgentStatus', () => ({
|
|||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useKeyboard', () => ({
|
||||
useKeyboard: () => ({
|
||||
selectedTaskId: null,
|
||||
setTasks: vi.fn(),
|
||||
setOnOpenTask: vi.fn(),
|
||||
setOnMoveTask: vi.fn(),
|
||||
}),
|
||||
const keyboardRegistration = vi.hoisted(() => ({
|
||||
selectedTaskId: null,
|
||||
setTasks: vi.fn(),
|
||||
setOnOpenTask: vi.fn(),
|
||||
setOnMoveTask: vi.fn(),
|
||||
}));
|
||||
vi.mock('@/hooks/useKeyboard', () => ({ useKeyboard: () => keyboardRegistration }));
|
||||
|
||||
vi.mock('@/hooks/useFeatureSettings', () => ({
|
||||
useFeatureSettings: () => mockFeatureSettingsResult,
|
||||
|
|
@ -310,6 +309,17 @@ afterEach(() => {
|
|||
// ── Tests ────────────────────────────────────────────────────
|
||||
|
||||
describe('KanbanBoard', () => {
|
||||
it('removes task navigation and callbacks when the board unmounts', () => {
|
||||
mockUseTasks = () => ({ data: mockTasks, isLoading: false, error: null });
|
||||
const view = renderBoard();
|
||||
expect(keyboardRegistration.setOnOpenTask).toHaveBeenLastCalledWith(expect.any(Function));
|
||||
expect(keyboardRegistration.setOnMoveTask).toHaveBeenLastCalledWith(expect.any(Function));
|
||||
view.unmount();
|
||||
expect(keyboardRegistration.setTasks).toHaveBeenLastCalledWith([]);
|
||||
expect(keyboardRegistration.setOnOpenTask).toHaveBeenLastCalledWith(null);
|
||||
expect(keyboardRegistration.setOnMoveTask).toHaveBeenLastCalledWith(null);
|
||||
});
|
||||
|
||||
it('shows loading skeleton when data is loading', () => {
|
||||
mockUseTasks = () => ({ data: undefined, isLoading: true, error: null });
|
||||
renderBoard();
|
||||
|
|
|
|||
|
|
@ -156,6 +156,19 @@ describe('TaskCard', () => {
|
|||
cleanup();
|
||||
});
|
||||
|
||||
it('focuses and reveals keyboard selection with an accessible label', () => {
|
||||
ensureMantineBrowserApis();
|
||||
const scroll = vi.spyOn(Element.prototype, 'scrollIntoView').mockImplementation(() => {});
|
||||
try {
|
||||
renderCard(createMockTask({ title: 'Keyboard target' }), { isSelected: true });
|
||||
const card = screen.getByRole('article', { name: /^Selected\. Task: Keyboard target/ });
|
||||
expect(document.activeElement).toBe(card);
|
||||
expect(scroll).toHaveBeenCalledWith({ block: 'nearest', inline: 'nearest' });
|
||||
} finally {
|
||||
scroll.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('renders task title', () => {
|
||||
const task = createMockTask({ title: 'Implement login' });
|
||||
renderCard(task);
|
||||
|
|
|
|||
161
web/src/__tests__/feature-settings-writes.test.tsx
Normal file
161
web/src/__tests__/feature-settings-writes.test.tsx
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import { act, cleanup, renderHook } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ReactNode } from 'react';
|
||||
import { DEFAULT_FEATURE_SETTINGS, type FeatureSettings } from '@veritas-kanban/shared';
|
||||
const mocks = vi.hoisted(() => ({ update: vi.fn(), get: vi.fn(), toast: vi.fn() }));
|
||||
vi.mock('@/lib/api', () => ({
|
||||
settings: {},
|
||||
api: { settings: { updateFeatures: mocks.update, getFeatures: mocks.get } },
|
||||
}));
|
||||
vi.mock('@/hooks/useToast', () => ({ toast: mocks.toast }));
|
||||
import {
|
||||
useDebouncedFeatureUpdate,
|
||||
useFeatureSettings,
|
||||
useUpdateFeatureSettings,
|
||||
} from '@/hooks/useFeatureSettings';
|
||||
import {
|
||||
resetFeatureSettingsWrites,
|
||||
FEATURE_SETTINGS_QUERY_KEY,
|
||||
} from '@/lib/feature-settings-writes';
|
||||
|
||||
describe('feature settings write ownership', () => {
|
||||
let client: QueryClient;
|
||||
let stored: FeatureSettings;
|
||||
function wrapper({ children }: { children: ReactNode }) {
|
||||
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
||||
}
|
||||
async function advance() {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
});
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.clearAllMocks();
|
||||
mocks.toast.mockReturnValue({ dismiss: vi.fn() });
|
||||
client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, gcTime: Infinity }, mutations: { retry: false } },
|
||||
});
|
||||
stored = structuredClone(DEFAULT_FEATURE_SETTINGS);
|
||||
client.setQueryData(FEATURE_SETTINGS_QUERY_KEY, stored);
|
||||
mocks.get.mockImplementation(async () => stored);
|
||||
mocks.update.mockImplementation(async (patch: Record<string, object>) => {
|
||||
for (const [section, values] of Object.entries(patch)) {
|
||||
const data = stored as unknown as Record<string, object>;
|
||||
data[section] = { ...data[section], ...values };
|
||||
}
|
||||
return structuredClone(stored);
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
client.clear();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('persists a pending edit after immediate tab or Settings unmount', async () => {
|
||||
const hook = renderHook(() => useDebouncedFeatureUpdate(), { wrapper });
|
||||
act(() =>
|
||||
hook.result.current.debouncedUpdate({ general: { humanDisplayName: 'Retained name' } })
|
||||
);
|
||||
hook.unmount();
|
||||
await advance();
|
||||
expect(stored.general.humanDisplayName).toBe('Retained name');
|
||||
const reopened = renderHook(() => useFeatureSettings(), { wrapper });
|
||||
expect(reopened.result.current.settings.general.humanDisplayName).toBe('Retained name');
|
||||
});
|
||||
|
||||
it('batches sections without dropping unrelated settings', async () => {
|
||||
const original = structuredClone(stored);
|
||||
const hook = renderHook(
|
||||
() => ({ first: useDebouncedFeatureUpdate(), second: useDebouncedFeatureUpdate() }),
|
||||
{ wrapper }
|
||||
);
|
||||
act(() => {
|
||||
hook.result.current.first.debouncedUpdate({ general: { humanDisplayName: 'Changed' } });
|
||||
hook.result.current.second.debouncedUpdate({ board: { showDashboard: false } });
|
||||
});
|
||||
await advance();
|
||||
expect(mocks.update).toHaveBeenCalledOnce();
|
||||
expect(stored.general.humanDisplayName).toBe('Changed');
|
||||
expect(stored.board.showDashboard).toBe(false);
|
||||
expect(stored.tasks).toEqual(original.tasks);
|
||||
});
|
||||
|
||||
it('serializes writes and overlays newer edits on earlier responses', async () => {
|
||||
let finish!: (settings: FeatureSettings) => void;
|
||||
mocks.update.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<FeatureSettings>((resolve) => {
|
||||
finish = resolve;
|
||||
})
|
||||
);
|
||||
const hook = renderHook(() => useDebouncedFeatureUpdate(), { wrapper });
|
||||
act(() => hook.result.current.debouncedUpdate({ general: { humanDisplayName: 'First' } }));
|
||||
await advance();
|
||||
act(() => hook.result.current.debouncedUpdate({ general: { humanDisplayName: 'Latest' } }));
|
||||
await advance();
|
||||
expect(mocks.update).toHaveBeenCalledOnce();
|
||||
await act(async () => {
|
||||
finish({ ...stored, general: { ...stored.general, humanDisplayName: 'First' } });
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(
|
||||
client.getQueryData<FeatureSettings>(FEATURE_SETTINGS_QUERY_KEY)?.general.humanDisplayName
|
||||
).toBe('Latest');
|
||||
expect(mocks.update).toHaveBeenCalledTimes(2);
|
||||
expect(stored.general.humanDisplayName).toBe('Latest');
|
||||
});
|
||||
|
||||
it('retains rejected edits across unmount and exposes explicit retry', async () => {
|
||||
mocks.update.mockRejectedValueOnce(new Error('Offline'));
|
||||
const hook = renderHook(() => useDebouncedFeatureUpdate(), { wrapper });
|
||||
act(() => hook.result.current.debouncedUpdate({ general: { humanDisplayName: 'Retry me' } }));
|
||||
await advance();
|
||||
expect(hook.result.current.error?.message).toBe('Offline');
|
||||
expect(hook.result.current.isPending).toBe(false);
|
||||
expect(mocks.toast).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'Settings could not be saved' })
|
||||
);
|
||||
hook.unmount();
|
||||
await advance();
|
||||
expect(mocks.update).toHaveBeenCalledOnce();
|
||||
const reopened = renderHook(() => useDebouncedFeatureUpdate(), { wrapper });
|
||||
expect(reopened.result.current.error?.message).toBe('Offline');
|
||||
await act(async () => {
|
||||
reopened.result.current.retry();
|
||||
});
|
||||
expect(stored.general.humanDisplayName).toBe('Retry me');
|
||||
expect(reopened.result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it('does not carry queued edits into another authenticated session', async () => {
|
||||
const hook = renderHook(() => useDebouncedFeatureUpdate(), { wrapper });
|
||||
act(() =>
|
||||
hook.result.current.debouncedUpdate({ general: { humanDisplayName: 'Old session' } })
|
||||
);
|
||||
hook.unmount();
|
||||
resetFeatureSettingsWrites(client);
|
||||
await advance();
|
||||
expect(mocks.update).not.toHaveBeenCalled();
|
||||
const next = renderHook(() => useDebouncedFeatureUpdate(), { wrapper });
|
||||
expect(next.result.current.isPending).toBe(false);
|
||||
expect(client.getQueryData(FEATURE_SETTINGS_QUERY_KEY)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('shares serialization with immediate setting mutations', async () => {
|
||||
const hook = renderHook(
|
||||
() => ({ queued: useDebouncedFeatureUpdate(), immediate: useUpdateFeatureSettings() }),
|
||||
{ wrapper }
|
||||
);
|
||||
act(() =>
|
||||
hook.result.current.queued.debouncedUpdate({ general: { humanDisplayName: 'Queued' } })
|
||||
);
|
||||
act(() => hook.result.current.immediate.mutate({ board: { showDashboard: false } }));
|
||||
await advance();
|
||||
expect(stored.general.humanDisplayName).toBe('Queued');
|
||||
expect(stored.board.showDashboard).toBe(false);
|
||||
expect(mocks.update).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
80
web/src/__tests__/keyboard-shortcut-help.test.tsx
Normal file
80
web/src/__tests__/keyboard-shortcut-help.test.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, fireEvent, screen, within } from '@testing-library/react';
|
||||
import { DEFAULT_FEATURE_SETTINGS } from '@veritas-kanban/shared';
|
||||
import { KeyboardProvider } from '@/hooks/useKeyboard';
|
||||
import { KeyboardShortcutsDialog } from '@/components/layout/KeyboardShortcutsDialog';
|
||||
import { renderWithProviders } from './test-utils';
|
||||
|
||||
const config = vi.hoisted(() => ({
|
||||
settings: { board: { columns: [] as Array<{ id: string; title: string }> } },
|
||||
}));
|
||||
vi.mock('@/hooks/useFeatureSettings', () => ({ useFeatureSettings: () => config }));
|
||||
|
||||
function Surface() {
|
||||
return (
|
||||
<KeyboardProvider>
|
||||
<KeyboardShortcutsDialog />
|
||||
</KeyboardProvider>
|
||||
);
|
||||
}
|
||||
function keyFor(label: string) {
|
||||
const description = screen.getByText(`Move to ${label}`);
|
||||
const row = description.parentElement;
|
||||
if (!row) throw new Error('Shortcut description has no row');
|
||||
return within(row).getByText(/^\d$/).textContent;
|
||||
}
|
||||
describe('configured shortcut help', () => {
|
||||
beforeEach(() => {
|
||||
config.settings.board.columns = [...DEFAULT_FEATURE_SETTINGS.board.columns];
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('matches default destinations and updates rename, order, additions and removals while open', async () => {
|
||||
const view = renderWithProviders(<Surface />);
|
||||
fireEvent.keyDown(window, { key: '?' });
|
||||
await screen.findByRole('dialog');
|
||||
for (const [i, column] of DEFAULT_FEATURE_SETTINGS.board.columns.entries()) {
|
||||
expect(keyFor(column.title)).toBe(String(i + 1));
|
||||
}
|
||||
expect(screen.queryByText('Move to Planning')).toBeNull();
|
||||
config.settings.board.columns = [
|
||||
{ id: 'done', title: 'Complete' },
|
||||
{ id: 'ready', title: 'Ready' },
|
||||
{ id: 'todo', title: 'To Do' },
|
||||
];
|
||||
view.rerender(<Surface />);
|
||||
expect(keyFor('Complete')).toBe('1');
|
||||
expect(keyFor('Ready')).toBe('2');
|
||||
expect(keyFor('To Do')).toBe('3');
|
||||
expect(screen.queryByText('Move to In Progress')).toBeNull();
|
||||
expect(screen.queryByText('Move to Done')).toBeNull();
|
||||
});
|
||||
|
||||
it('lists only nine numeric destinations and explains the limit', async () => {
|
||||
config.settings.board.columns = Array.from({ length: 11 }, (_, i) => ({
|
||||
id: `stage-${i}`,
|
||||
title: `Stage ${i + 1}`,
|
||||
}));
|
||||
renderWithProviders(<Surface />);
|
||||
fireEvent.keyDown(window, { key: '?' });
|
||||
await screen.findByRole('dialog');
|
||||
expect(keyFor('Stage 9')).toBe('9');
|
||||
expect(screen.queryByText('Move to Stage 10')).toBeNull();
|
||||
expect(screen.getByText(/Number shortcuts cover the first nine columns/)).toBeDefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['MacIntel', '⌘⇧C'],
|
||||
['Win32', 'Ctrl+Shift+C'],
|
||||
['Linux x86_64', 'Ctrl+Shift+C'],
|
||||
])('labels chat modifiers for %s', async (platform, label) => {
|
||||
vi.spyOn(navigator, 'platform', 'get').mockReturnValue(platform);
|
||||
renderWithProviders(<Surface />);
|
||||
fireEvent.keyDown(window, { key: '?' });
|
||||
await screen.findByRole('dialog');
|
||||
expect(screen.getByText(label)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,10 +1,13 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, fireEvent, screen, within } from '@testing-library/react';
|
||||
import { cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react';
|
||||
import { SETTINGS_NAVIGATION_GROUPS, SettingsDialog } from '@/components/settings/SettingsDialog';
|
||||
import { renderWithProviders } from './test-utils';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
debouncedUpdate: vi.fn(),
|
||||
importSettings: vi.fn(),
|
||||
retrySave: vi.fn(),
|
||||
saveError: null as Error | null,
|
||||
hasPermission: vi.fn(),
|
||||
toast: vi.fn(),
|
||||
productMode: { selectedMode: 'advanced' as string },
|
||||
|
|
@ -32,7 +35,12 @@ vi.mock('@/hooks/useFeatureSettings', () => ({
|
|||
productMode: mocks.productMode,
|
||||
},
|
||||
}),
|
||||
useDebouncedFeatureUpdate: () => ({ debouncedUpdate: mocks.debouncedUpdate }),
|
||||
useUpdateFeatureSettings: () => ({ mutateAsync: mocks.importSettings }),
|
||||
useDebouncedFeatureUpdate: () => ({
|
||||
debouncedUpdate: mocks.debouncedUpdate,
|
||||
error: mocks.saveError,
|
||||
retry: mocks.retrySave,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useIdentity', () => ({
|
||||
|
|
@ -105,6 +113,7 @@ vi.mock('@/components/settings/tabs/MultiUserTab', () => ({
|
|||
|
||||
describe('SettingsDialog Mantine shell', () => {
|
||||
beforeEach(() => {
|
||||
mocks.saveError = null;
|
||||
mocks.hasPermission.mockReturnValue(true);
|
||||
mocks.productMode.selectedMode = 'advanced';
|
||||
mocks.showSidebar = true;
|
||||
|
|
@ -116,6 +125,46 @@ describe('SettingsDialog Mantine shell', () => {
|
|||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('announces import completion only after persistence and reports failed saves', async () => {
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
let resolveSave: () => void = () => {
|
||||
throw new Error('Save was not started');
|
||||
};
|
||||
mocks.importSettings.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveSave = resolve;
|
||||
})
|
||||
);
|
||||
renderWithProviders(<SettingsDialog open onOpenChange={vi.fn()} />);
|
||||
const file = {
|
||||
text: async () => JSON.stringify({ general: { humanDisplayName: 'Imported' } }),
|
||||
};
|
||||
fireEvent.change(screen.getByLabelText('Import settings file'), { target: { files: [file] } });
|
||||
await waitFor(() => expect(mocks.importSettings).toHaveBeenCalledTimes(1));
|
||||
expect(mocks.toast).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'Import complete' })
|
||||
);
|
||||
resolveSave();
|
||||
await waitFor(() =>
|
||||
expect(mocks.toast).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'Import complete' })
|
||||
)
|
||||
);
|
||||
mocks.toast.mockClear();
|
||||
mocks.importSettings.mockRejectedValueOnce(new Error('Save unavailable'));
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
fireEvent.change(screen.getByLabelText('Import settings file'), { target: { files: [file] } });
|
||||
await waitFor(() =>
|
||||
expect(mocks.toast).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'Import failed', description: 'Save unavailable' })
|
||||
)
|
||||
);
|
||||
expect(mocks.toast).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'Import complete' })
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the settings shell with direct Mantine controls', async () => {
|
||||
const { baseElement } = renderWithProviders(<SettingsDialog open onOpenChange={vi.fn()} />);
|
||||
|
||||
|
|
@ -187,6 +236,15 @@ describe('SettingsDialog Mantine shell', () => {
|
|||
expect(mocks.debouncedUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps failed saves visible with an explicit retry action', async () => {
|
||||
mocks.saveError = new Error('Offline');
|
||||
renderWithProviders(<SettingsDialog open onOpenChange={vi.fn()} />);
|
||||
expect(await screen.findByRole('alert')).toBeDefined();
|
||||
expect(screen.getByText('Changes not saved.')).toBeDefined();
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Retry$/ }));
|
||||
expect(mocks.retrySave).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('keeps compact section navigation in the mobile header flow', async () => {
|
||||
mocks.showSidebar = false;
|
||||
renderWithProviders(<SettingsDialog open onOpenChange={vi.fn()} />);
|
||||
|
|
|
|||
|
|
@ -78,6 +78,17 @@ describe('General settings Mantine migration', () => {
|
|||
cleanup();
|
||||
});
|
||||
|
||||
it('queues display-name edits before blur or unmount', () => {
|
||||
const view = renderWithProviders(<GeneralTab />);
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'Display Name (Squad Chat)' }), {
|
||||
target: { value: 'Retained name' },
|
||||
});
|
||||
view.unmount();
|
||||
expect(mocks.debouncedUpdate).toHaveBeenCalledWith({
|
||||
general: { humanDisplayName: 'Retained name' },
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the base general settings controls through direct Mantine primitives', async () => {
|
||||
const { container } = renderWithProviders(<GeneralTab />);
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
|
||||
import type { Task, TaskStatus } from '@veritas-kanban/shared';
|
||||
import { taskBoardRankAtIndex, type Task, type TaskStatus } from '@veritas-kanban/shared';
|
||||
import { createMockTask } from './test-utils';
|
||||
|
||||
// Mock toast — vi.mock is hoisted before imports.
|
||||
|
|
@ -100,6 +100,54 @@ describe('KeyboardProvider', () => {
|
|||
cleanup();
|
||||
});
|
||||
|
||||
it('follows positions and durable ranks across custom columns, reorder, and filtering', () => {
|
||||
featureSettingsMock.settings.board.columns = [
|
||||
{ id: 'ready', title: 'Ready' },
|
||||
{ id: 'todo', title: 'To Do' },
|
||||
];
|
||||
const legacy = createMockTask({ id: 'legacy', title: 'Zulu', status: 'ready', position: 1 });
|
||||
const later = createMockTask({ id: 'later', title: 'Alpha', status: 'ready', position: 5 });
|
||||
const ranked = createMockTask({
|
||||
id: 'ranked',
|
||||
title: 'Middle',
|
||||
status: 'ready',
|
||||
position: 99,
|
||||
boardRank: taskBoardRankAtIndex([legacy, later], 1),
|
||||
});
|
||||
const todo = createMockTask({ id: 'todo', status: 'todo', position: -100 });
|
||||
const hidden = createMockTask({ id: 'hidden', status: 'retired', position: -200 });
|
||||
const tasks = [todo, later, ranked, legacy, hidden];
|
||||
const view = renderWithProvider({ tasks });
|
||||
for (const id of ['legacy', 'ranked', 'later', 'todo']) {
|
||||
fireEvent.keyDown(window, { key: 'j' });
|
||||
expect(screen.getByTestId('selected').textContent).toBe(id);
|
||||
}
|
||||
fireEvent.keyDown(window, { key: 'ArrowUp' });
|
||||
expect(screen.getByTestId('selected').textContent).toBe('later');
|
||||
const reordered = [
|
||||
todo,
|
||||
{ ...later, boardRank: taskBoardRankAtIndex([legacy, ranked], 0) },
|
||||
ranked,
|
||||
legacy,
|
||||
];
|
||||
view.rerender(
|
||||
<KeyboardProvider>
|
||||
<TestConsumer tasks={reordered} />
|
||||
</KeyboardProvider>
|
||||
);
|
||||
expect(screen.getByTestId('selected').textContent).toBe('later');
|
||||
fireEvent.keyDown(window, { key: 'ArrowDown' });
|
||||
expect(screen.getByTestId('selected').textContent).toBe('legacy');
|
||||
view.rerender(
|
||||
<KeyboardProvider>
|
||||
<TestConsumer tasks={[ranked, todo]} />
|
||||
</KeyboardProvider>
|
||||
);
|
||||
expect(screen.getByTestId('selected').textContent).toBe('none');
|
||||
fireEvent.keyDown(window, { key: 'k' });
|
||||
expect(screen.getByTestId('selected').textContent).toBe('todo');
|
||||
});
|
||||
|
||||
it('throws when useKeyboard is used outside provider', () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
|
|
|
|||
|
|
@ -375,7 +375,6 @@ export function KanbanBoard() {
|
|||
// Register filtered tasks with keyboard context
|
||||
useEffect(() => {
|
||||
setTasks(filteredTasks);
|
||||
return () => setTasks([]);
|
||||
}, [filteredTasks, setTasks]);
|
||||
|
||||
// Handler for opening a task
|
||||
|
|
@ -532,9 +531,17 @@ export function KanbanBoard() {
|
|||
[allTasksByStatus, announce, canWriteTasks, columns, commitBoardMove, filteredTasks, isOnline]
|
||||
);
|
||||
|
||||
// Register callbacks with keyboard context (refs, so no need for useEffect)
|
||||
setOnOpenTask(handleTaskClick);
|
||||
setOnMoveTask(handleMoveTask);
|
||||
// Board-owned callbacks must not outlive this view.
|
||||
useEffect(() => {
|
||||
setOnOpenTask(handleTaskClick);
|
||||
setOnMoveTask(handleMoveTask);
|
||||
return () => {
|
||||
setOnOpenTask(null);
|
||||
setOnMoveTask(null);
|
||||
};
|
||||
}, [handleTaskClick, handleMoveTask, setOnOpenTask, setOnMoveTask]);
|
||||
|
||||
useEffect(() => () => setTasks([]), [setTasks]);
|
||||
|
||||
// Drag and drop logic
|
||||
const {
|
||||
|
|
|
|||
|
|
@ -8,34 +8,6 @@ interface Shortcut {
|
|||
description: string;
|
||||
}
|
||||
|
||||
const shortcuts: { category: string; items: Shortcut[] }[] = [
|
||||
{
|
||||
category: 'Navigation',
|
||||
items: [
|
||||
{ keys: ['j', '↓'], description: 'Select next task' },
|
||||
{ keys: ['k', '↑'], description: 'Select previous task' },
|
||||
{ keys: ['Enter'], description: 'Open selected task' },
|
||||
{ keys: ['Esc'], description: 'Close panel / Clear selection' },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'Actions',
|
||||
items: [
|
||||
{ keys: ['c'], description: 'Create new task' },
|
||||
{ keys: ['⌘⇧C'], description: 'Open agent chat' },
|
||||
{ keys: ['1'], description: 'Move to To Do' },
|
||||
{ keys: ['2'], description: 'Move to Planning' },
|
||||
{ keys: ['3'], description: 'Move to In Progress' },
|
||||
{ keys: ['4'], description: 'Move to Blocked' },
|
||||
{ keys: ['5'], description: 'Move to Done' },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'General',
|
||||
items: [{ keys: ['?'], description: 'Toggle this help' }],
|
||||
},
|
||||
];
|
||||
|
||||
function KeyBadge({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Kbd className="inline-flex min-w-[24px] items-center justify-center px-2 text-xs font-medium">
|
||||
|
|
@ -45,7 +17,34 @@ function KeyBadge({ children }: { children: React.ReactNode }) {
|
|||
}
|
||||
|
||||
export function KeyboardShortcutsDialog() {
|
||||
const { isHelpOpen, closeHelpDialog } = useKeyboard();
|
||||
const { isHelpOpen, closeHelpDialog, columns } = useKeyboard();
|
||||
const isMac = /Mac|iPhone|iPad/.test(navigator.platform);
|
||||
const shortcuts: { category: string; items: Shortcut[] }[] = [
|
||||
{
|
||||
category: 'Navigation',
|
||||
items: [
|
||||
{ keys: ['j', '↓'], description: 'Select next task' },
|
||||
{ keys: ['k', '↑'], description: 'Select previous task' },
|
||||
{ keys: ['Enter'], description: 'Open selected task' },
|
||||
{ keys: ['Esc'], description: 'Close panel / Clear selection' },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'Actions',
|
||||
items: [
|
||||
{ keys: ['c'], description: 'Create new task' },
|
||||
{ keys: [isMac ? '⌘⇧C' : 'Ctrl+Shift+C'], description: 'Open agent chat' },
|
||||
...columns.slice(0, 9).map((column, index) => ({
|
||||
keys: [String(index + 1)],
|
||||
description: `Move to ${column.title}`,
|
||||
})),
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'General',
|
||||
items: [{ keys: ['?'], description: 'Toggle this help' }],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
|
|
@ -87,6 +86,12 @@ export function KeyboardShortcutsDialog() {
|
|||
</dl>
|
||||
</section>
|
||||
))}
|
||||
{columns.length > 9 && (
|
||||
<Text size="sm" c="dimmed">
|
||||
Number shortcuts cover the first nine columns. Use the card status control for other
|
||||
columns.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<OverlayFooter>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
|
|
|
|||
|
|
@ -3,7 +3,11 @@ import { UiPill, UiAction, UiIconAction } from '@/components/ui/UiVocabulary';
|
|||
import { useState, useRef, useCallback, lazy, Suspense, useEffect, useMemo } from 'react';
|
||||
import { Group, Menu, Select, Skeleton, Stack, Text } from '@mantine/core';
|
||||
import { useMediaQuery } from '@mantine/hooks';
|
||||
import { useFeatureSettings, useDebouncedFeatureUpdate } from '@/hooks/useFeatureSettings';
|
||||
import {
|
||||
useFeatureSettings,
|
||||
useDebouncedFeatureUpdate,
|
||||
useUpdateFeatureSettings,
|
||||
} from '@/hooks/useFeatureSettings';
|
||||
import { useIdentity } from '@/hooks/useIdentity';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import {
|
||||
|
|
@ -290,7 +294,8 @@ export function SettingsDialog({ open, onOpenChange, defaultTab }: SettingsDialo
|
|||
setActiveTab(TABS.find(canUseTab)?.id ?? 'general');
|
||||
}
|
||||
}, [activeTab, canUseTab]);
|
||||
const { debouncedUpdate } = useDebouncedFeatureUpdate();
|
||||
const { debouncedUpdate, error: saveError, retry: retrySave } = useDebouncedFeatureUpdate();
|
||||
const importSettings = useUpdateFeatureSettings();
|
||||
const settingsFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { toast } = useToast();
|
||||
const dialogContentRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -379,7 +384,7 @@ export function SettingsDialog({ open, onOpenChange, defaultTab }: SettingsDialo
|
|||
`Import ${Object.keys(validPatch).length} setting sections: ${Object.keys(validPatch).join(', ')}?\n\nThis will overwrite current values.`
|
||||
)
|
||||
) {
|
||||
debouncedUpdate(validPatch);
|
||||
await importSettings.mutateAsync(validPatch);
|
||||
toast({
|
||||
title: 'Import complete',
|
||||
description: 'Settings imported successfully!',
|
||||
|
|
@ -541,6 +546,16 @@ export function SettingsDialog({ open, onOpenChange, defaultTab }: SettingsDialo
|
|||
Settings
|
||||
</Text>
|
||||
{isBoardOnly && <UiPill>Board Only</UiPill>}
|
||||
{saveError && (
|
||||
<Group gap="xs" role="alert">
|
||||
<Text size="xs" c="red">
|
||||
Changes not saved.
|
||||
</Text>
|
||||
<UiAction variant="quiet" onClick={retrySave}>
|
||||
Retry
|
||||
</UiAction>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
}
|
||||
centered
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { Check, Save } from 'lucide-react';
|
||||
|
||||
export function SaveIndicator({ isPending }: { isPending: boolean }) {
|
||||
export function SaveIndicator({ isPending, error }: { isPending: boolean; error?: unknown }) {
|
||||
const [showSaved, setShowSaved] = useState(false);
|
||||
const [wasPending, setWasPending] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPending) {
|
||||
if (error) {
|
||||
setShowSaved(false);
|
||||
setWasPending(false);
|
||||
} else if (isPending) {
|
||||
setWasPending(true);
|
||||
} else if (wasPending) {
|
||||
setShowSaved(true);
|
||||
|
|
@ -14,8 +17,14 @@ export function SaveIndicator({ isPending }: { isPending: boolean }) {
|
|||
const timer = setTimeout(() => setShowSaved(false), 1500);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isPending, wasPending]);
|
||||
}, [isPending, wasPending, error]);
|
||||
|
||||
if (error)
|
||||
return (
|
||||
<div className="text-xs text-destructive" role="status">
|
||||
Not saved
|
||||
</div>
|
||||
);
|
||||
if (isPending) {
|
||||
return (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -133,7 +133,7 @@ export function AgentsTab() {
|
|||
const harnessSupport = harnessCompatibility?.supportStatuses ?? [];
|
||||
const { data: sandboxPresets = [], isLoading: isSandboxPoliciesLoading } = useSandboxPolicies();
|
||||
const { settings } = useFeatureSettings();
|
||||
const { debouncedUpdate, isPending } = useDebouncedFeatureUpdate();
|
||||
const { debouncedUpdate, isPending, error: saveError } = useDebouncedFeatureUpdate();
|
||||
const updateAgents = useUpdateAgents();
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [editingAgent, setEditingAgent] = useState<string | null>(null);
|
||||
|
|
@ -325,7 +325,7 @@ export function AgentsTab() {
|
|||
<SettingsGroup className="space-y-4">
|
||||
<SectionHeader
|
||||
title="Agent Behavior"
|
||||
actions={<SaveIndicator isPending={isPending} />}
|
||||
actions={<SaveIndicator isPending={isPending} error={saveError} />}
|
||||
onReset={resetAgents}
|
||||
contained
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { Plus, Trash2 } from 'lucide-react';
|
|||
|
||||
export function BoardTab() {
|
||||
const { settings } = useFeatureSettings();
|
||||
const { debouncedUpdate, isPending } = useDebouncedFeatureUpdate();
|
||||
const { debouncedUpdate, isPending, error: saveError } = useDebouncedFeatureUpdate();
|
||||
const boardSettings = settings.board ?? DEFAULT_FEATURE_SETTINGS.board;
|
||||
const columns = normalizeBoardColumns(boardSettings.columns);
|
||||
const defaultStatus = normalizeBoardDefaultStatus(boardSettings.defaultStatus, columns);
|
||||
|
|
@ -83,7 +83,7 @@ export function BoardTab() {
|
|||
<SettingsPage
|
||||
title="Board"
|
||||
description="Control board structure, card density, visible metadata, and direct manipulation."
|
||||
actions={<SaveIndicator isPending={isPending} />}
|
||||
actions={<SaveIndicator isPending={isPending} error={saveError} />}
|
||||
>
|
||||
<SettingsSection
|
||||
id="board-display"
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import {
|
|||
|
||||
export function DataTab() {
|
||||
const { settings } = useFeatureSettings();
|
||||
const { debouncedUpdate, isPending } = useDebouncedFeatureUpdate();
|
||||
const { debouncedUpdate, isPending, error: saveError } = useDebouncedFeatureUpdate();
|
||||
|
||||
const updateTelemetry = (key: string, value: unknown) => {
|
||||
debouncedUpdate({ telemetry: { [key]: value } });
|
||||
|
|
@ -56,7 +56,7 @@ export function DataTab() {
|
|||
<SettingsPage
|
||||
title="Data"
|
||||
description="Manage telemetry retention, operating budgets, and archive lifecycle."
|
||||
actions={<SaveIndicator isPending={isPending} />}
|
||||
actions={<SaveIndicator isPending={isPending} error={saveError} />}
|
||||
>
|
||||
<SettingsLocalNav
|
||||
label="Data settings sections"
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
|
||||
export function DocFreshnessTab() {
|
||||
const { settings } = useFeatureSettings();
|
||||
const { debouncedUpdate, isPending } = useDebouncedFeatureUpdate();
|
||||
const { debouncedUpdate, isPending, error: saveError } = useDebouncedFeatureUpdate();
|
||||
|
||||
const docFreshness = settings.docFreshness || DEFAULT_FEATURE_SETTINGS.docFreshness;
|
||||
|
||||
|
|
@ -31,7 +31,7 @@ export function DocFreshnessTab() {
|
|||
<SettingsSection
|
||||
title="Freshness Policy"
|
||||
description="Set the scan cadence and response when documents become stale."
|
||||
actions={<SaveIndicator isPending={isPending} />}
|
||||
actions={<SaveIndicator isPending={isPending} error={saveError} />}
|
||||
onReset={resetDocFreshness}
|
||||
divided
|
||||
>
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ function formatCeremonyTarget(requirement: CeremonyRequirement): string {
|
|||
|
||||
export function EnforcementTab() {
|
||||
const { settings } = useFeatureSettings();
|
||||
const { debouncedUpdate, isPending } = useDebouncedFeatureUpdate();
|
||||
const { debouncedUpdate, isPending, error: saveError } = useDebouncedFeatureUpdate();
|
||||
const { data: config } = useConfig();
|
||||
const { data: pendingCeremonies = [] } = useQuery({
|
||||
queryKey: ['ceremonies', 'pending', 'settings'],
|
||||
|
|
@ -77,7 +77,7 @@ export function EnforcementTab() {
|
|||
<SettingsSection
|
||||
title="Completion Gates"
|
||||
description="Require review and ceremony evidence before eligible work can complete."
|
||||
actions={<SaveIndicator isPending={isPending} />}
|
||||
actions={<SaveIndicator isPending={isPending} error={saveError} />}
|
||||
onReset={resetEnforcement}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
|
|
|
|||
|
|
@ -137,10 +137,11 @@ export function GeneralTab() {
|
|||
</Group>
|
||||
}
|
||||
value={localDisplayName}
|
||||
onChange={(e) => setLocalDisplayName(e.target.value)}
|
||||
onBlur={() =>
|
||||
debouncedUpdate({ general: { humanDisplayName: localDisplayName || 'Human' } })
|
||||
}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setLocalDisplayName(value);
|
||||
debouncedUpdate({ general: { humanDisplayName: value || 'Human' } });
|
||||
}}
|
||||
placeholder="Human"
|
||||
maw={320}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -178,7 +178,7 @@ function formToInput(form: AdapterFormState): CommunicationAdapterInput {
|
|||
|
||||
export function NotificationsTab() {
|
||||
const { settings } = useFeatureSettings();
|
||||
const { debouncedUpdate, isPending } = useDebouncedFeatureUpdate();
|
||||
const { debouncedUpdate, isPending, error: saveError } = useDebouncedFeatureUpdate();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: outboundEndpoints = [] } = useQuery({
|
||||
queryKey: ['integrations', 'outbound', 'endpoints'],
|
||||
|
|
@ -709,7 +709,7 @@ export function NotificationsTab() {
|
|||
id="notifications-preferences"
|
||||
title="Preferences"
|
||||
description="Choose the events and broad destination used for routine notifications."
|
||||
actions={<SaveIndicator isPending={isPending} />}
|
||||
actions={<SaveIndicator isPending={isPending} error={saveError} />}
|
||||
onReset={resetNotifications}
|
||||
divided
|
||||
>
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ const TYPE_OPTIONS: Array<{
|
|||
|
||||
export function SharedResourcesTab() {
|
||||
const { settings } = useFeatureSettings();
|
||||
const { debouncedUpdate, isPending } = useDebouncedFeatureUpdate();
|
||||
const { debouncedUpdate, isPending, error: saveError } = useDebouncedFeatureUpdate();
|
||||
|
||||
const sharedResources = settings?.sharedResources ?? DEFAULT_FEATURE_SETTINGS.sharedResources;
|
||||
|
||||
|
|
@ -50,7 +50,7 @@ export function SharedResourcesTab() {
|
|||
<SettingsSection
|
||||
title="Resource Sharing"
|
||||
description="Choose which resource types can be mounted across projects."
|
||||
actions={<SaveIndicator isPending={isPending} />}
|
||||
actions={<SaveIndicator isPending={isPending} error={saveError} />}
|
||||
onReset={resetSharedResources}
|
||||
divided
|
||||
>
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import {
|
|||
|
||||
export function TasksTab() {
|
||||
const { settings } = useFeatureSettings();
|
||||
const { debouncedUpdate, isPending } = useDebouncedFeatureUpdate();
|
||||
const { debouncedUpdate, isPending, error: saveError } = useDebouncedFeatureUpdate();
|
||||
|
||||
const update = <K extends keyof TaskBehaviorSettings>(key: K, value: TaskBehaviorSettings[K]) => {
|
||||
debouncedUpdate({ tasks: { [key]: value } });
|
||||
|
|
@ -38,7 +38,7 @@ export function TasksTab() {
|
|||
<SettingsPage
|
||||
title="Tasks"
|
||||
description="Set defaults and optional capabilities for task authoring and completion."
|
||||
actions={<SaveIndicator isPending={isPending} />}
|
||||
actions={<SaveIndicator isPending={isPending} error={saveError} />}
|
||||
>
|
||||
<SettingsSection
|
||||
id="task-behavior"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { memo, useMemo, useState } from 'react';
|
||||
import { memo, useMemo, useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { Select, Tooltip } from '@mantine/core';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
|
|
@ -218,6 +218,21 @@ export const TaskCard = memo(function TaskCard({
|
|||
id: task.id,
|
||||
disabled: !dragEnabled,
|
||||
});
|
||||
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||
const attachCard = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
cardRef.current = node;
|
||||
setNodeRef(node);
|
||||
},
|
||||
[setNodeRef]
|
||||
);
|
||||
useEffect(() => {
|
||||
if (isSelected) {
|
||||
cardRef.current?.focus({ preventScroll: true });
|
||||
cardRef.current?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
|
||||
}
|
||||
}, [isSelected]);
|
||||
|
||||
const { isSelecting, toggleSelect, isSelected: isBulkSelected } = useBulkActions();
|
||||
const [tooltipDismissed, setTooltipDismissed] = useState(false);
|
||||
const [statusMenuOpen, setStatusMenuOpen] = useState(false);
|
||||
|
|
@ -362,7 +377,7 @@ export const TaskCard = memo(function TaskCard({
|
|||
}
|
||||
>
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
ref={attachCard}
|
||||
data-task-id={task.id}
|
||||
style={style}
|
||||
{...(dragEnabled ? listeners : {})}
|
||||
|
|
@ -372,7 +387,7 @@ export const TaskCard = memo(function TaskCard({
|
|||
onMouseLeave={() => setTooltipDismissed(false)}
|
||||
role="article"
|
||||
tabIndex={0}
|
||||
aria-label={`Task: ${task.title}, Type: ${typeLabel}, Priority: ${task.priority}${readinessAria}${isBlockedState ? ', Blocked' : ''}${isAgentRunning ? ', Agent running' : ''}${isAttemptFailed ? ', Latest attempt failed' : ''}${isAwaitingReview ? ', Awaiting review' : ''}${isVerified ? ', Verified' : ''}`}
|
||||
aria-label={`${isSelected ? 'Selected. ' : ''}Task: ${task.title}, Type: ${typeLabel}, Priority: ${task.priority}${readinessAria}${isBlockedState ? ', Blocked' : ''}${isAgentRunning ? ', Agent running' : ''}${isAttemptFailed ? ', Latest attempt failed' : ''}${isAwaitingReview ? ', Awaiting review' : ''}${isVerified ? ', Verified' : ''}`}
|
||||
data-type-color-token={typeColorToken}
|
||||
data-selected={isSelected ? 'true' : undefined}
|
||||
data-dragging={isDragging || isCurrentlyDragging ? 'true' : undefined}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,16 @@
|
|||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useCallback, useRef, useEffect } from 'react';
|
||||
import { createElement, useCallback, useSyncExternalStore } from 'react';
|
||||
import { toast } from './useToast';
|
||||
import {
|
||||
FEATURE_SETTINGS_QUERY_KEY,
|
||||
getFeatureSettingsWrites,
|
||||
type FeatureSettingsPatch,
|
||||
} from '@/lib/feature-settings-writes';
|
||||
import { api } from '@/lib/api';
|
||||
import type { FeatureSettings } from '@veritas-kanban/shared';
|
||||
import { DEFAULT_FEATURE_SETTINGS } from '@veritas-kanban/shared';
|
||||
|
||||
const QUERY_KEY = ['settings', 'features'] as const;
|
||||
const QUERY_KEY = FEATURE_SETTINGS_QUERY_KEY;
|
||||
const STALE_TIME = 5 * 60 * 1000; // 5 minutes — settings don't change often
|
||||
|
||||
/**
|
||||
|
|
@ -12,9 +18,10 @@ const STALE_TIME = 5 * 60 * 1000; // 5 minutes — settings don't change often
|
|||
* Returns defaults while loading so consumers never see undefined.
|
||||
*/
|
||||
export function useFeatureSettings() {
|
||||
const writer = getFeatureSettingsWrites(useQueryClient());
|
||||
const query = useQuery({
|
||||
queryKey: QUERY_KEY,
|
||||
queryFn: api.settings.getFeatures,
|
||||
queryFn: async () => writer.overlay(await api.settings.getFeatures()),
|
||||
staleTime: STALE_TIME,
|
||||
placeholderData: DEFAULT_FEATURE_SETTINGS,
|
||||
});
|
||||
|
|
@ -48,90 +55,41 @@ export function useFeatureSetting<
|
|||
* update.mutate({ board: { showDashboard: false } });
|
||||
*/
|
||||
export function useUpdateFeatureSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (patch: Record<string, any>) => api.settings.updateFeatures(patch),
|
||||
onMutate: async (patch) => {
|
||||
// Cancel in-flight fetches
|
||||
await queryClient.cancelQueries({ queryKey: QUERY_KEY });
|
||||
|
||||
// Snapshot current value
|
||||
const previous = queryClient.getQueryData<FeatureSettings>(QUERY_KEY);
|
||||
|
||||
// Optimistically update
|
||||
if (previous) {
|
||||
// SAFETY: FeatureSettings sections are all objects — use Record view for dynamic key access
|
||||
const optimistic = { ...previous } as unknown as Record<string, Record<string, unknown>>;
|
||||
for (const section of Object.keys(patch) as Array<keyof FeatureSettings>) {
|
||||
if (section in optimistic && typeof patch[section] === 'object') {
|
||||
optimistic[section] = {
|
||||
...optimistic[section],
|
||||
...patch[section],
|
||||
};
|
||||
}
|
||||
}
|
||||
queryClient.setQueryData(QUERY_KEY, optimistic as unknown as FeatureSettings);
|
||||
}
|
||||
|
||||
return { previous };
|
||||
},
|
||||
onError: (_err, _patch, context) => {
|
||||
// Rollback on error
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(QUERY_KEY, context.previous);
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
// Refetch to ensure consistency
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||
},
|
||||
});
|
||||
const writer = useFeatureWriter();
|
||||
return useMutation({ mutationFn: (patch: FeatureSettingsPatch) => writer.save(patch) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a debounced updater function that batches rapid changes.
|
||||
* Useful for toggle switches and sliders that fire frequently.
|
||||
*/
|
||||
function useFeatureWriter() {
|
||||
const writer = getFeatureSettingsWrites(useQueryClient());
|
||||
writer.onFailure = () => {
|
||||
return toast({
|
||||
title: 'Settings could not be saved',
|
||||
description: 'Your changes are kept for retry. They have not been saved to the server.',
|
||||
variant: 'destructive',
|
||||
duration: 10000,
|
||||
action: createElement(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
onClick: writer.retry,
|
||||
className: 'text-sm underline rounded focus-visible:outline focus-visible:outline-2',
|
||||
},
|
||||
'Retry settings save'
|
||||
),
|
||||
}).dismiss;
|
||||
};
|
||||
return writer;
|
||||
}
|
||||
|
||||
/** Batches edits across sections and retains failed writes until they are retried. */
|
||||
export function useDebouncedFeatureUpdate(delayMs = 500) {
|
||||
const update = useUpdateFeatureSettings();
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pendingRef = useRef<Record<string, any>>({});
|
||||
|
||||
// Cleanup timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const flush = useCallback(() => {
|
||||
if (Object.keys(pendingRef.current).length > 0) {
|
||||
update.mutate({ ...pendingRef.current });
|
||||
pendingRef.current = {};
|
||||
}
|
||||
}, [update]);
|
||||
|
||||
const writer = useFeatureWriter();
|
||||
const state = useSyncExternalStore(writer.subscribe, writer.getSnapshot, writer.getSnapshot);
|
||||
const debouncedUpdate = useCallback(
|
||||
(patch: Record<string, any>) => {
|
||||
// Merge into pending batch
|
||||
for (const section of Object.keys(patch)) {
|
||||
pendingRef.current[section] = {
|
||||
...(pendingRef.current[section] || {}),
|
||||
...patch[section],
|
||||
};
|
||||
}
|
||||
|
||||
// Reset timer
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
timeoutRef.current = setTimeout(flush, delayMs);
|
||||
(patch: FeatureSettingsPatch) => {
|
||||
writer.enqueue(patch, delayMs);
|
||||
},
|
||||
[flush, delayMs]
|
||||
[writer, delayMs]
|
||||
);
|
||||
|
||||
return { debouncedUpdate, isPending: update.isPending };
|
||||
return { debouncedUpdate, ...state, retry: writer.retry };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
import {
|
||||
DEFAULT_FEATURE_SETTINGS,
|
||||
normalizeBoardColumns,
|
||||
sortTasksByBoardPosition,
|
||||
type Task,
|
||||
type TaskStatus,
|
||||
} from '@veritas-kanban/shared';
|
||||
|
|
@ -33,13 +34,15 @@ interface KeyboardContextValue {
|
|||
selectedTaskId: string | null;
|
||||
setSelectedTaskId: (id: string | null) => void;
|
||||
|
||||
columns: ReturnType<typeof normalizeBoardColumns>;
|
||||
|
||||
// Task list for navigation
|
||||
tasks: Task[];
|
||||
setTasks: (tasks: Task[]) => void;
|
||||
|
||||
// Callbacks (using refs to avoid re-render loops)
|
||||
setOnOpenTask: (fn: (task: Task) => void) => void;
|
||||
setOnMoveTask: (fn: (taskId: string, status: TaskStatus) => void) => void;
|
||||
setOnOpenTask: (fn: ((task: Task) => void) | null) => void;
|
||||
setOnMoveTask: (fn: ((taskId: string, status: TaskStatus) => void) | null) => void;
|
||||
}
|
||||
|
||||
const KeyboardContext = createContext<KeyboardContextValue | null>(null);
|
||||
|
|
@ -52,7 +55,11 @@ function getColumnForShortcut(key: string, columns: Array<{ id: TaskStatus }>):
|
|||
export function KeyboardProvider({ children }: { children: ReactNode }) {
|
||||
const [isHelpOpen, setIsHelpOpen] = useState(false);
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [tasks, updateTasks] = useState<Task[]>([]);
|
||||
const setTasks = useCallback((next: Task[]) => {
|
||||
updateTasks(next);
|
||||
setSelectedTaskId((id) => (id && next.some((task) => task.id === id) ? id : null));
|
||||
}, []);
|
||||
const { settings } = useFeatureSettings();
|
||||
const columns = useMemo(
|
||||
() => normalizeBoardColumns(settings.board?.columns ?? DEFAULT_FEATURE_SETTINGS.board.columns),
|
||||
|
|
@ -89,24 +96,23 @@ export function KeyboardProvider({ children }: { children: ReactNode }) {
|
|||
setIsHelpOpen(false);
|
||||
}, []);
|
||||
|
||||
const setOnOpenTask = useCallback((fn: (task: Task) => void) => {
|
||||
const setOnOpenTask = useCallback((fn: ((task: Task) => void) | null) => {
|
||||
onOpenTaskRef.current = fn;
|
||||
}, []);
|
||||
|
||||
const setOnMoveTask = useCallback((fn: (taskId: string, status: TaskStatus) => void) => {
|
||||
const setOnMoveTask = useCallback((fn: ((taskId: string, status: TaskStatus) => void) | null) => {
|
||||
onMoveTaskRef.current = fn;
|
||||
}, []);
|
||||
|
||||
// Get flat list of tasks sorted by column then position
|
||||
const getTaskList = useCallback(() => {
|
||||
const statusOrder = columns.map((column) => column.id);
|
||||
return [...tasks].sort((a, b) => {
|
||||
const aIndex = statusOrder.indexOf(a.status);
|
||||
const bIndex = statusOrder.indexOf(b.status);
|
||||
if (aIndex !== bIndex) return aIndex - bIndex;
|
||||
return a.title.localeCompare(b.title);
|
||||
});
|
||||
}, [columns, tasks]);
|
||||
// Match the rendered column order and the board's canonical rank/position order.
|
||||
// Compute once per snapshot, rather than sorting during each keystroke.
|
||||
const taskList = useMemo(
|
||||
() =>
|
||||
columns.flatMap((column) =>
|
||||
sortTasksByBoardPosition(tasks.filter((task) => task.status === column.id))
|
||||
),
|
||||
[columns, tasks]
|
||||
);
|
||||
|
||||
// Keyboard event handler
|
||||
useEffect(() => {
|
||||
|
|
@ -141,7 +147,6 @@ export function KeyboardProvider({ children }: { children: ReactNode }) {
|
|||
return;
|
||||
}
|
||||
|
||||
const taskList = getTaskList();
|
||||
const currentIndex = selectedTaskId ? taskList.findIndex((t) => t.id === selectedTaskId) : -1;
|
||||
|
||||
// Cmd+Shift+C (or Ctrl+Shift+C on Windows/Linux) - Toggle chat panel
|
||||
|
|
@ -237,7 +242,7 @@ export function KeyboardProvider({ children }: { children: ReactNode }) {
|
|||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [getTaskList, selectedTaskId, isHelpOpen, openCreateDialog, openChatPanel, columns]);
|
||||
}, [taskList, selectedTaskId, isHelpOpen, openCreateDialog, openChatPanel, columns]);
|
||||
|
||||
const value = useMemo<KeyboardContextValue>(
|
||||
() => ({
|
||||
|
|
@ -250,6 +255,7 @@ export function KeyboardProvider({ children }: { children: ReactNode }) {
|
|||
isHelpOpen,
|
||||
selectedTaskId,
|
||||
setSelectedTaskId,
|
||||
columns,
|
||||
tasks,
|
||||
setTasks,
|
||||
setOnOpenTask,
|
||||
|
|
@ -265,6 +271,7 @@ export function KeyboardProvider({ children }: { children: ReactNode }) {
|
|||
isHelpOpen,
|
||||
selectedTaskId,
|
||||
setSelectedTaskId,
|
||||
columns,
|
||||
tasks,
|
||||
setTasks,
|
||||
setOnOpenTask,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ export function usePendingProductMode(): void {
|
|||
return;
|
||||
}
|
||||
|
||||
if (update.isError) return; // Failed settings remain queued for explicit retry.
|
||||
|
||||
update.mutate(
|
||||
{
|
||||
productMode: {
|
||||
|
|
|
|||
169
web/src/lib/feature-settings-writes.ts
Normal file
169
web/src/lib/feature-settings-writes.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import type { QueryClient } from '@tanstack/react-query';
|
||||
import { DEFAULT_FEATURE_SETTINGS, type FeatureSettings } from '@veritas-kanban/shared';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
export const FEATURE_SETTINGS_QUERY_KEY = ['settings', 'features'] as const;
|
||||
export type FeatureSettingsPatch = Record<string, unknown>;
|
||||
|
||||
function mergePatch(base: FeatureSettingsPatch, patch: FeatureSettingsPatch): FeatureSettingsPatch {
|
||||
const merged = { ...base };
|
||||
for (const [section, values] of Object.entries(patch)) {
|
||||
const previous = merged[section];
|
||||
merged[section] =
|
||||
values !== null && typeof values === 'object' && !Array.isArray(values)
|
||||
? { ...(previous !== null && typeof previous === 'object' ? previous : {}), ...values }
|
||||
: values;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function overlay(settings: FeatureSettings, patch: FeatureSettingsPatch): FeatureSettings {
|
||||
return mergePatch(
|
||||
settings as unknown as FeatureSettingsPatch,
|
||||
patch
|
||||
) as unknown as FeatureSettings;
|
||||
}
|
||||
|
||||
/** One serialized writer per query cache; tab unmounts do not own pending work. */
|
||||
class FeatureSettingsWrites {
|
||||
private pending: FeatureSettingsPatch = {};
|
||||
private disposed = false;
|
||||
private activeWaiters: Array<{
|
||||
resolve: (settings: FeatureSettings) => void;
|
||||
reject: (error: Error) => void;
|
||||
}> = [];
|
||||
private active: FeatureSettingsPatch | null = null;
|
||||
private timer: ReturnType<typeof setTimeout> | undefined;
|
||||
private listeners = new Set<() => void>();
|
||||
private waiters: Array<{
|
||||
resolve: (settings: FeatureSettings) => void;
|
||||
reject: (error: Error) => void;
|
||||
}> = [];
|
||||
private snapshot = { isPending: false, error: null as Error | null };
|
||||
onFailure: ((error: Error) => () => void) | undefined;
|
||||
private dismissFailure: (() => void) | undefined;
|
||||
|
||||
constructor(private client: QueryClient) {}
|
||||
subscribe = (listener: () => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
getSnapshot = () => this.snapshot;
|
||||
overlay = (settings: FeatureSettings) =>
|
||||
overlay(overlay(settings, this.active ?? {}), this.pending);
|
||||
|
||||
private publish(error: Error | null = null) {
|
||||
this.snapshot = {
|
||||
isPending: !error && (this.active !== null || Object.keys(this.pending).length > 0),
|
||||
error,
|
||||
};
|
||||
this.listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
enqueue = (patch: FeatureSettingsPatch, delay: number) => {
|
||||
if (this.disposed) throw new Error('The settings session has ended');
|
||||
for (const [section, values] of Object.entries(patch)) {
|
||||
if (!values || typeof values !== 'object' || Array.isArray(values)) {
|
||||
throw new Error(`Settings section ${section} must be an object`);
|
||||
}
|
||||
}
|
||||
this.pending = mergePatch(this.pending, patch);
|
||||
// Do not let cancellation restore an older query snapshot over this edit.
|
||||
void this.client.cancelQueries({ queryKey: FEATURE_SETTINGS_QUERY_KEY }, { revert: false });
|
||||
this.client.setQueryData<FeatureSettings>(FEATURE_SETTINGS_QUERY_KEY, (current) =>
|
||||
overlay(current ?? DEFAULT_FEATURE_SETTINGS, patch)
|
||||
);
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = setTimeout(() => {
|
||||
void this.flush();
|
||||
}, delay);
|
||||
this.publish();
|
||||
};
|
||||
|
||||
save = (patch: FeatureSettingsPatch): Promise<FeatureSettings> =>
|
||||
new Promise((resolve, reject) => {
|
||||
if (Object.keys(patch).length === 0) {
|
||||
resolve(
|
||||
this.client.getQueryData<FeatureSettings>(FEATURE_SETTINGS_QUERY_KEY) ??
|
||||
DEFAULT_FEATURE_SETTINGS
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.enqueue(patch, 0);
|
||||
this.waiters.push({ resolve, reject });
|
||||
});
|
||||
|
||||
retry = () => {
|
||||
this.publish();
|
||||
void this.flush();
|
||||
};
|
||||
|
||||
dispose() {
|
||||
this.disposed = true;
|
||||
this.dismissFailure?.();
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
const error = new Error('The settings session has ended');
|
||||
[...this.waiters, ...this.activeWaiters].forEach(({ reject }) => reject(error));
|
||||
this.waiters = [];
|
||||
this.activeWaiters = [];
|
||||
this.pending = {};
|
||||
this.active = null;
|
||||
}
|
||||
|
||||
private async flush() {
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = undefined;
|
||||
if (this.active || Object.keys(this.pending).length === 0) return;
|
||||
const batch = this.pending;
|
||||
const waiters = this.waiters;
|
||||
this.activeWaiters = waiters;
|
||||
this.pending = {};
|
||||
this.waiters = [];
|
||||
this.active = batch;
|
||||
this.publish();
|
||||
try {
|
||||
const saved = await api.settings.updateFeatures(batch as Partial<FeatureSettings>);
|
||||
if (this.disposed) return;
|
||||
this.activeWaiters = [];
|
||||
this.active = null;
|
||||
this.dismissFailure?.();
|
||||
this.dismissFailure = undefined;
|
||||
this.client.setQueryData(FEATURE_SETTINGS_QUERY_KEY, overlay(saved, this.pending));
|
||||
waiters.forEach(({ resolve }) => resolve(saved));
|
||||
this.publish();
|
||||
if (Object.keys(this.pending).length > 0 && !this.timer) void this.flush();
|
||||
} catch (cause) {
|
||||
if (this.disposed) return;
|
||||
this.activeWaiters = [];
|
||||
const error = cause instanceof Error ? cause : new Error('Settings could not be saved');
|
||||
// Newer edits win over failed fields; retain both sections for explicit retry.
|
||||
this.pending = mergePatch(batch, this.pending);
|
||||
this.active = null;
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = undefined;
|
||||
[...waiters, ...this.waiters].forEach(({ reject }) => reject(error));
|
||||
this.waiters = [];
|
||||
this.publish(error);
|
||||
this.dismissFailure?.();
|
||||
this.dismissFailure = this.onFailure?.(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const writers = new WeakMap<QueryClient, FeatureSettingsWrites>();
|
||||
export function getFeatureSettingsWrites(client: QueryClient) {
|
||||
let writer = writers.get(client);
|
||||
if (!writer) {
|
||||
writer = new FeatureSettingsWrites(client);
|
||||
writers.set(client, writer);
|
||||
}
|
||||
return writer;
|
||||
}
|
||||
|
||||
export function resetFeatureSettingsWrites(client: QueryClient) {
|
||||
writers.get(client)?.dispose();
|
||||
writers.delete(client);
|
||||
client.removeQueries({ queryKey: FEATURE_SETTINGS_QUERY_KEY });
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue