mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-08-28 02:44:59 +00:00
feat: add durable work product foundation
Summary: - adds typed durable work product render contracts - adds SQLite work_products, work_product_versions, and work_product_search storage - adds create, list, refine, archive, restore, preview, and export APIs - wires work products into task-scoped APIs and keyword search - adds redacted preview/export behavior and SQLite regression coverage - documents the work product API and SQLite schema Verification: - CI: Build - CI: Lint & Type Check - CI: Security Audit - CI: Workspace Unit Tests - ./node_modules/.bin/prettier --check README.md docs/SQLITE-SCHEMA.md docs/features/work-products.md shared/src/types/work-product.types.ts shared/src/types/index.ts server/src/schemas/work-product-schemas.ts server/src/storage/sqlite/migrations.ts server/src/storage/sqlite/work-product-repository.ts server/src/services/work-product-service.ts server/src/routes/work-products.ts server/src/routes/v1/index.ts server/src/routes/search.ts server/src/services/search-service.ts server/src/__tests__/storage/sqlite-work-products.test.ts - pnpm --filter @veritas-kanban/server test -- sqlite-work-products - pnpm typecheck - pnpm lint:budget - pnpm --filter @veritas-kanban/server test - pnpm build - pnpm audit --prod --audit-level=high - git diff --check Part of #403. Part of #332.
This commit is contained in:
parent
277dc6608e
commit
b502872b49
14 changed files with 1840 additions and 8 deletions
|
|
@ -85,7 +85,7 @@ When the board is working, use [Setup Paths](docs/SETUP-PATHS.md) to choose the
|
|||
- [Multi-Agent Orchestration](docs/SOP-multi-agent-orchestration.md) — PM + worker handoffs.
|
||||
- [Cross-Model Code Review](docs/SOP-cross-model-code-review.md) — enforce Claude ↔ GPT reviews.
|
||||
- [Agent Governance SOPs](docs/) — [Policy engine](docs/SOP-agent-policy-engine.md), [drift detection](docs/SOP-behavioral-drift-detection.md), [decision audit](docs/SOP-decision-audit-trail.md), [output evaluation](docs/SOP-output-evaluation.md), [user feedback](docs/SOP-user-feedback.md).
|
||||
- [Operational SOPs](docs/) — [Broadcasts](docs/SOP-broadcasts.md), [delegation](docs/SOP-delegation.md), [deliverables](docs/SOP-deliverables.md), [prompt registry](docs/SOP-prompt-registry.md), [squad chat](docs/SOP-squad-chat.md), [system health](docs/SOP-system-health-monitoring.md).
|
||||
- [Operational SOPs](docs/) — [Broadcasts](docs/SOP-broadcasts.md), [delegation](docs/SOP-delegation.md), [deliverables](docs/SOP-deliverables.md), [work products](docs/features/work-products.md), [prompt registry](docs/SOP-prompt-registry.md), [squad chat](docs/SOP-squad-chat.md), [system health](docs/SOP-system-health-monitoring.md).
|
||||
- [Best Practices](docs/BEST-PRACTICES.md) & [Tips + Tricks](docs/TIPS-AND-TRICKS.md) — patterns, shortcuts, integrations.
|
||||
- [Real-World Examples](docs/EXAMPLES-agent-workflows.md) — copy/pasteable agent recipes.
|
||||
- [Troubleshooting](docs/TROUBLESHOOTING.md) — deeper diagnostics when things wobble.
|
||||
|
|
@ -207,6 +207,7 @@ Tasks are markdown files. Settings are JSON. Workflows are YAML. No database, no
|
|||
- **Error learning** — Structured failure analysis with similarity search
|
||||
- **Task lifecycle hooks** — 7 built-in hooks, 8 events, custom hooks API
|
||||
- **Task Deliverables** — First-class deliverable objects with type/status tracking (code, documentation, data, etc.)
|
||||
- **Durable Work Products** — Versioned generated reports, checklists, tables, and handoff artifacts with provenance and redacted previews
|
||||
- **Efficient Polling** — `/api/changes?since=...` endpoint with ETag support for optimized agent polling
|
||||
- **Approval Delegation** — Vacation mode with scoped approval delegation and automatic routing
|
||||
- **OpenClaw Integration** — Optional direct gateway wake for real-time squad chat notifications and agent orchestration
|
||||
|
|
|
|||
|
|
@ -272,6 +272,46 @@ CREATE TABLE task_deliverables (
|
|||
deleted_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE work_products (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
task_id TEXT,
|
||||
source_run_id TEXT,
|
||||
agent TEXT,
|
||||
model TEXT,
|
||||
version_number INTEGER NOT NULL DEFAULT 1,
|
||||
redaction_json TEXT,
|
||||
source_links_json TEXT,
|
||||
metadata_json TEXT,
|
||||
render_json TEXT NOT NULL,
|
||||
product_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
archived_at TEXT,
|
||||
deleted_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE work_product_versions (
|
||||
id TEXT PRIMARY KEY,
|
||||
product_id TEXT NOT NULL REFERENCES work_products(id) ON DELETE CASCADE,
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
version_number INTEGER NOT NULL,
|
||||
change_type TEXT NOT NULL,
|
||||
change_summary TEXT,
|
||||
title TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
agent TEXT,
|
||||
model TEXT,
|
||||
redaction_json TEXT,
|
||||
render_json TEXT NOT NULL,
|
||||
version_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE (product_id, version_number)
|
||||
);
|
||||
|
||||
CREATE TABLE task_time_entries (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
|
|
@ -338,6 +378,20 @@ CREATE INDEX idx_task_deliverables_agent_created
|
|||
CREATE INDEX idx_task_deliverables_source_run
|
||||
ON task_deliverables(source_run_id)
|
||||
WHERE source_run_id IS NOT NULL;
|
||||
CREATE INDEX idx_work_products_workspace_updated
|
||||
ON work_products(workspace_id, updated_at DESC)
|
||||
WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_work_products_task_updated
|
||||
ON work_products(task_id, updated_at DESC)
|
||||
WHERE task_id IS NOT NULL AND deleted_at IS NULL;
|
||||
CREATE INDEX idx_work_products_source_run
|
||||
ON work_products(source_run_id, updated_at DESC)
|
||||
WHERE source_run_id IS NOT NULL AND deleted_at IS NULL;
|
||||
CREATE INDEX idx_work_products_kind_status
|
||||
ON work_products(workspace_id, kind, status, updated_at DESC)
|
||||
WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_work_product_versions_product
|
||||
ON work_product_versions(product_id, version_number DESC);
|
||||
CREATE INDEX idx_task_dependencies_depends ON task_dependencies(depends_on_task_id);
|
||||
```
|
||||
|
||||
|
|
@ -1021,6 +1075,21 @@ completion packet, dashboard, and migration queries.
|
|||
| `task_attachments` | File metadata, MIME validation result, hash/path, owner/session, retention, and cleanup data. |
|
||||
| `task_deliverables` | Typed work-product metadata, source run/model, redaction hints, and full deliverable JSON. |
|
||||
|
||||
## Durable Work Product Repository Implementation
|
||||
|
||||
Work products are a v5 first-class output model for generated reports,
|
||||
handoff notes, evidence summaries, checklists, tables, and lightweight
|
||||
dashboards. They are stored independently from task comments and task
|
||||
deliverables so they can survive archive, be refined without duplication, and
|
||||
be reached from task work views, run timelines, completion packets, command
|
||||
center search, and export flows.
|
||||
|
||||
| Runtime table | Stored data |
|
||||
| ----------------------- | ------------------------------------------------------------------------------------------------------------ |
|
||||
| `work_products` | Current typed render contract, source task/run, agent/model provenance, redaction metadata, links, and JSON. |
|
||||
| `work_product_versions` | Bounded non-destructive version history for refine, regenerate, restore, and manual updates. |
|
||||
| `work_product_search` | FTS index for command-center and search reachability without walking task/comment files. |
|
||||
|
||||
## Scheduled Deliverable Repository Implementation
|
||||
|
||||
Scheduled deliverables and recurring run history move into SQLite when
|
||||
|
|
@ -1084,6 +1153,7 @@ Rollback is safety-first because v5 upgrades existing file-backed projects.
|
|||
| `tasks/archive/*.md` | `tasks.archived_at`, task detail tables | Archive status must remain restorable. |
|
||||
| `tasks/backlog/*.md` | `tasks.status` or backlog marker in `tasks` | If backlog remains distinct, model it as a status or queue field before provider work. |
|
||||
| `tasks/attachments/*` | `task_attachments` plus file blobs on disk | v5 stores attachment metadata in SQLite, not binary blobs. |
|
||||
| `.veritas-kanban/work-products.json` | `work_products`, `work_product_versions`, `work_product_search` | Preserve source task/run provenance, redaction metadata, typed render payload, and bounded versions. |
|
||||
| `.veritas-kanban/config.json` | `app_settings`, `repositories`, `agent_configs`, `agent_routing_rules`, `integrations` | Split security-sensitive integration secrets into `secret_refs_json`. |
|
||||
| `.veritas-kanban/activity.json` | `activity_events` | Existing newest-first array becomes append/query table. |
|
||||
| `.veritas-kanban/agent-status.json` | `app_settings` and `status_history` | Current status can be a setting; transitions remain history records. |
|
||||
|
|
|
|||
84
docs/features/work-products.md
Normal file
84
docs/features/work-products.md
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
# Durable Work Products
|
||||
|
||||
Durable work products are generated outputs that should outlive a chat message
|
||||
or task comment: reports, handoff notes, evidence summaries, checklists, tables,
|
||||
and lightweight dashboards.
|
||||
|
||||
## Model
|
||||
|
||||
Each work product stores:
|
||||
|
||||
- typed render contract: `text`, `markdown`, `summary`, `checklist`, `report`, `table`, or `dashboard`
|
||||
- source provenance: task ID, run ID, agent, model, workspace, and source links
|
||||
- redaction metadata for previews and exports
|
||||
- bounded version history for refinements, regeneration, restore, and manual edits
|
||||
|
||||
The render contract is data-only. It does not execute arbitrary UI code.
|
||||
|
||||
## API
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://localhost:3001/api/work-products \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"kind": "markdown",
|
||||
"title": "Release Readiness Packet",
|
||||
"taskId": "task_20260531_release",
|
||||
"sourceRunId": "run_abc123",
|
||||
"agent": "codex",
|
||||
"model": "gpt-5",
|
||||
"render": {
|
||||
"schemaVersion": 1,
|
||||
"kind": "markdown",
|
||||
"markdown": "## Summary\nReady for release after verification."
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
Useful reads:
|
||||
|
||||
```bash
|
||||
curl -s "http://localhost:3001/api/work-products?taskId=task_20260531_release"
|
||||
curl -s "http://localhost:3001/api/tasks/task_20260531_release/work-products?view=preview"
|
||||
curl -s "http://localhost:3001/api/work-products/{id}/versions"
|
||||
curl -s "http://localhost:3001/api/work-products/{id}/export"
|
||||
```
|
||||
|
||||
Refine an existing product without losing history:
|
||||
|
||||
```bash
|
||||
curl -s -X PATCH http://localhost:3001/api/work-products/{id} \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"changeType": "refine",
|
||||
"changeSummary": "Add rollback notes",
|
||||
"render": {
|
||||
"schemaVersion": 1,
|
||||
"kind": "markdown",
|
||||
"markdown": "## Summary\nReady for release.\n\n## Rollback\nUse the signed rollback artifact."
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
Restore an earlier version:
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://localhost:3001/api/work-products/{id}/versions/1/restore
|
||||
```
|
||||
|
||||
## Search
|
||||
|
||||
Work products participate in keyword search through the `work-products`
|
||||
collection:
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://localhost:3001/api/search \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"query":"release readiness","collections":["work-products"],"backend":"keyword"}'
|
||||
```
|
||||
|
||||
## Redaction
|
||||
|
||||
Previews and exports default to redacted output unless a product explicitly sets
|
||||
`redaction.exportDefault` to `full`. Strict or sensitive products return a
|
||||
redacted placeholder in previews and exports.
|
||||
125
server/src/__tests__/storage/sqlite-work-products.test.ts
Normal file
125
server/src/__tests__/storage/sqlite-work-products.test.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { createTestSqliteDatabase } from '../../storage/sqlite/test-helpers.js';
|
||||
import {
|
||||
WorkProductService,
|
||||
resetWorkProductServiceForTests,
|
||||
} from '../../services/work-product-service.js';
|
||||
import { getSearchService } from '../../services/search-service.js';
|
||||
|
||||
describe('SQLite work products', () => {
|
||||
it('persists durable work products with version history, restore, preview redaction, and search', async () => {
|
||||
const fixture = createTestSqliteDatabase();
|
||||
const originalStorage = process.env.VERITAS_STORAGE;
|
||||
const originalSqlitePath = process.env.VERITAS_SQLITE_PATH;
|
||||
process.env.VERITAS_STORAGE = 'sqlite';
|
||||
process.env.VERITAS_SQLITE_PATH = fixture.databasePath;
|
||||
|
||||
let service: WorkProductService | null = new WorkProductService({
|
||||
storageType: 'sqlite',
|
||||
sqliteDatabase: fixture.database,
|
||||
});
|
||||
|
||||
try {
|
||||
const created = await service.create({
|
||||
kind: 'markdown',
|
||||
title: 'Launch readiness report',
|
||||
taskId: 'task_20260531_launch',
|
||||
sourceRunId: 'run_launch_1',
|
||||
agent: 'codex',
|
||||
model: 'gpt-5',
|
||||
render: {
|
||||
schemaVersion: 1,
|
||||
kind: 'markdown',
|
||||
markdown: 'Initial launch checklist with migration evidence.',
|
||||
},
|
||||
sourceLinks: [{ label: 'Task', href: '/tasks/task_20260531_launch', type: 'task' }],
|
||||
metadata: { risk: 'medium' },
|
||||
});
|
||||
|
||||
await service.update(created.id, {
|
||||
render: {
|
||||
schemaVersion: 1,
|
||||
kind: 'markdown',
|
||||
markdown: 'Refined launch checklist with migration evidence and rollback notes.',
|
||||
},
|
||||
changeType: 'refine',
|
||||
changeSummary: 'Add rollback notes',
|
||||
});
|
||||
|
||||
const versions = await service.listVersions(created.id);
|
||||
expect(versions.map((version) => version.version)).toEqual([2, 1]);
|
||||
|
||||
const restored = await service.restoreVersion(created.id, 1);
|
||||
expect(restored).toMatchObject({
|
||||
id: created.id,
|
||||
version: 3,
|
||||
title: 'Launch readiness report',
|
||||
});
|
||||
expect(restored?.render).toMatchObject({
|
||||
kind: 'markdown',
|
||||
markdown: 'Initial launch checklist with migration evidence.',
|
||||
});
|
||||
|
||||
service.dispose();
|
||||
service = null;
|
||||
fixture.database.close();
|
||||
|
||||
const restarted = new WorkProductService({
|
||||
storageType: 'sqlite',
|
||||
sqliteConnectionOptions: { databasePath: fixture.databasePath },
|
||||
});
|
||||
service = restarted;
|
||||
|
||||
const afterRestart = await restarted.get(created.id);
|
||||
expect(afterRestart?.taskId).toBe('task_20260531_launch');
|
||||
expect(afterRestart?.sourceRunId).toBe('run_launch_1');
|
||||
|
||||
const sensitive = await restarted.create({
|
||||
kind: 'text',
|
||||
title: 'Sensitive handoff',
|
||||
render: {
|
||||
schemaVersion: 1,
|
||||
kind: 'text',
|
||||
text: 'Use token=secret-value and /Users/bradgroux/private/path for local validation.',
|
||||
},
|
||||
redaction: { level: 'strict', containsSensitiveContent: true },
|
||||
});
|
||||
const preview = restarted.toPreview(sensitive);
|
||||
expect(preview.redacted).toBe(true);
|
||||
expect(preview.snippet).toBe('[redacted work product preview]');
|
||||
expect(restarted.exportProduct(sensitive)).not.toContain('secret-value');
|
||||
|
||||
resetWorkProductServiceForTests();
|
||||
const search = await getSearchService().search({
|
||||
query: 'launch',
|
||||
backend: 'keyword',
|
||||
collections: ['work-products'],
|
||||
});
|
||||
expect(search.results[0]).toMatchObject({
|
||||
id: created.id,
|
||||
collection: 'work-products',
|
||||
metadata: {
|
||||
taskId: 'task_20260531_launch',
|
||||
sourceRunId: 'run_launch_1',
|
||||
agent: 'codex',
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
service?.dispose();
|
||||
resetWorkProductServiceForTests();
|
||||
fixture.cleanup();
|
||||
|
||||
if (originalStorage === undefined) {
|
||||
delete process.env.VERITAS_STORAGE;
|
||||
} else {
|
||||
process.env.VERITAS_STORAGE = originalStorage;
|
||||
}
|
||||
|
||||
if (originalSqlitePath === undefined) {
|
||||
delete process.env.VERITAS_SQLITE_PATH;
|
||||
} else {
|
||||
process.env.VERITAS_SQLITE_PATH = originalSqlitePath;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -11,9 +11,9 @@ const SearchBodySchema = z.object({
|
|||
query: z.string().trim().min(1).max(500),
|
||||
limit: z.number().int().min(1).max(50).optional(),
|
||||
collections: z
|
||||
.array(z.enum(['tasks-active', 'tasks-archive', 'docs']))
|
||||
.array(z.enum(['tasks-active', 'tasks-archive', 'docs', 'work-products']))
|
||||
.min(1)
|
||||
.max(3)
|
||||
.max(4)
|
||||
.optional(),
|
||||
backend: z.enum(['auto', 'qmd', 'keyword']).optional(),
|
||||
minScore: z.number().min(0).max(1).optional(),
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import { taskObservationRoutes, observationSearchRouter } from '../task-observat
|
|||
import { taskSubtaskRoutes } from '../task-subtasks.js';
|
||||
import { taskVerificationRoutes } from '../task-verification.js';
|
||||
import { taskDeliverableRoutes } from '../task-deliverables.js';
|
||||
import { taskWorkProductRoutes, workProductRoutes } from '../work-products.js';
|
||||
import attachmentRoutes from '../attachments.js';
|
||||
import { backlogRoutes } from '../backlog.js';
|
||||
|
||||
|
|
@ -105,6 +106,7 @@ v1Router.use('/tasks', taskObservationRoutes);
|
|||
v1Router.use('/tasks', taskSubtaskRoutes);
|
||||
v1Router.use('/tasks', taskVerificationRoutes);
|
||||
v1Router.use('/tasks', taskDeliverableRoutes);
|
||||
v1Router.use('/tasks', taskWorkProductRoutes);
|
||||
|
||||
// Attachment routes get the stricter upload rate limit (20 req/min)
|
||||
// applied BEFORE the route handler for upload (POST) requests.
|
||||
|
|
@ -162,6 +164,7 @@ v1Router.use('/doc-freshness', docFreshnessRoutes);
|
|||
v1Router.use('/docs', docsRoutes);
|
||||
v1Router.use('/errors', errorLearningRoutes);
|
||||
v1Router.use('/search', searchRoutes);
|
||||
v1Router.use('/work-products', workProductRoutes);
|
||||
v1Router.use('/hooks', lifecycleHooksRoutes);
|
||||
v1Router.use('/shared-resources', sharedResourcesRoutes);
|
||||
v1Router.use('/status-history', statusHistoryRoutes);
|
||||
|
|
|
|||
179
server/src/routes/work-products.ts
Normal file
179
server/src/routes/work-products.ts
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
import { Router, type Router as RouterType } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { asyncHandler } from '../middleware/async-handler.js';
|
||||
import { NotFoundError, ValidationError } from '../middleware/error-handler.js';
|
||||
import { getWorkProductService } from '../services/work-product-service.js';
|
||||
import {
|
||||
CreateWorkProductBodySchema,
|
||||
UpdateWorkProductBodySchema,
|
||||
WorkProductExportQuerySchema,
|
||||
WorkProductListQuerySchema,
|
||||
} from '../schemas/work-product-schemas.js';
|
||||
|
||||
const router: RouterType = Router();
|
||||
const taskRouter: RouterType = Router();
|
||||
|
||||
function validationError(error: z.ZodError): ValidationError {
|
||||
return new ValidationError(
|
||||
'Validation failed',
|
||||
error.issues.map((issue) => ({
|
||||
path: issue.path.join('.'),
|
||||
message: issue.message,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
router.get(
|
||||
'/',
|
||||
asyncHandler(async (req, res) => {
|
||||
const parsed = WorkProductListQuerySchema.safeParse(req.query);
|
||||
if (!parsed.success) throw validationError(parsed.error);
|
||||
|
||||
const service = getWorkProductService();
|
||||
const query = parsed.data;
|
||||
const products = await service.list({
|
||||
taskId: query.taskId,
|
||||
sourceRunId: query.sourceRunId,
|
||||
agent: query.agent,
|
||||
kind: query.kind,
|
||||
status: query.status,
|
||||
query: query.q,
|
||||
includeArchived: query.includeArchived === 'true',
|
||||
limit: query.limit,
|
||||
});
|
||||
|
||||
res.json(
|
||||
query.view === 'preview' ? products.map((product) => service.toPreview(product)) : products
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/',
|
||||
asyncHandler(async (req, res) => {
|
||||
const parsed = CreateWorkProductBodySchema.safeParse(req.body);
|
||||
if (!parsed.success) throw validationError(parsed.error);
|
||||
|
||||
const product = await getWorkProductService().create(parsed.data);
|
||||
res.status(201).json(product);
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:id',
|
||||
asyncHandler(async (req, res) => {
|
||||
const product = await getWorkProductService().get(req.params.id as string);
|
||||
if (!product) throw new NotFoundError('Work product not found');
|
||||
|
||||
if (req.query.view === 'preview') {
|
||||
res.json(getWorkProductService().toPreview(product));
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(product);
|
||||
})
|
||||
);
|
||||
|
||||
router.patch(
|
||||
'/:id',
|
||||
asyncHandler(async (req, res) => {
|
||||
const parsed = UpdateWorkProductBodySchema.safeParse(req.body);
|
||||
if (!parsed.success) throw validationError(parsed.error);
|
||||
|
||||
const product = await getWorkProductService().update(req.params.id as string, parsed.data);
|
||||
if (!product) throw new NotFoundError('Work product not found');
|
||||
|
||||
res.json(product);
|
||||
})
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/:id',
|
||||
asyncHandler(async (req, res) => {
|
||||
const product = await getWorkProductService().archive(req.params.id as string);
|
||||
if (!product) throw new NotFoundError('Work product not found');
|
||||
|
||||
res.json(product);
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:id/versions',
|
||||
asyncHandler(async (req, res) => {
|
||||
const product = await getWorkProductService().get(req.params.id as string);
|
||||
if (!product) throw new NotFoundError('Work product not found');
|
||||
|
||||
res.json(await getWorkProductService().listVersions(product.id));
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:id/versions/:version/restore',
|
||||
asyncHandler(async (req, res) => {
|
||||
const version = Number.parseInt(req.params.version as string, 10);
|
||||
if (!Number.isInteger(version) || version < 1) {
|
||||
throw new ValidationError('Invalid version');
|
||||
}
|
||||
|
||||
const product = await getWorkProductService().restoreVersion(req.params.id as string, version);
|
||||
if (!product) throw new NotFoundError('Work product version not found');
|
||||
|
||||
res.json(product);
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:id/export',
|
||||
asyncHandler(async (req, res) => {
|
||||
const parsed = WorkProductExportQuerySchema.safeParse(req.query);
|
||||
if (!parsed.success) throw validationError(parsed.error);
|
||||
|
||||
const product = await getWorkProductService().get(req.params.id as string);
|
||||
if (!product) throw new NotFoundError('Work product not found');
|
||||
|
||||
const format = parsed.data.format ?? 'markdown';
|
||||
const redacted =
|
||||
parsed.data.redacted === undefined ? undefined : parsed.data.redacted === 'true';
|
||||
const exported = getWorkProductService().exportProduct(product, { format, redacted });
|
||||
|
||||
if (format === 'json') {
|
||||
res.type('application/json').send(exported);
|
||||
return;
|
||||
}
|
||||
|
||||
res.type('text/markdown').send(exported);
|
||||
})
|
||||
);
|
||||
|
||||
taskRouter.get(
|
||||
'/:id/work-products',
|
||||
asyncHandler(async (req, res) => {
|
||||
const service = getWorkProductService();
|
||||
const products = await service.list({
|
||||
taskId: req.params.id as string,
|
||||
includeArchived: req.query.includeArchived === 'true',
|
||||
limit: req.query.limit ? Number.parseInt(req.query.limit as string, 10) : undefined,
|
||||
});
|
||||
res.json(
|
||||
req.query.view === 'preview'
|
||||
? products.map((product) => service.toPreview(product))
|
||||
: products
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
taskRouter.post(
|
||||
'/:id/work-products',
|
||||
asyncHandler(async (req, res) => {
|
||||
const parsed = CreateWorkProductBodySchema.safeParse({
|
||||
...req.body,
|
||||
taskId: req.params.id,
|
||||
});
|
||||
if (!parsed.success) throw validationError(parsed.error);
|
||||
|
||||
const product = await getWorkProductService().create(parsed.data);
|
||||
res.status(201).json(product);
|
||||
})
|
||||
);
|
||||
|
||||
export { router as workProductRoutes, taskRouter as taskWorkProductRoutes };
|
||||
177
server/src/schemas/work-product-schemas.ts
Normal file
177
server/src/schemas/work-product-schemas.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
const PrimitiveSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]);
|
||||
|
||||
export const WorkProductKindSchema = z.enum([
|
||||
'text',
|
||||
'markdown',
|
||||
'summary',
|
||||
'checklist',
|
||||
'report',
|
||||
'table',
|
||||
'dashboard',
|
||||
]);
|
||||
|
||||
export const WorkProductStatusSchema = z.enum(['active', 'archived']);
|
||||
|
||||
export const WorkProductChangeTypeSchema = z.enum(['refine', 'regenerate', 'restore', 'manual']);
|
||||
|
||||
const RedactionSchema = z.object({
|
||||
level: z.enum(['none', 'standard', 'strict']).optional(),
|
||||
containsSensitiveContent: z.boolean().optional(),
|
||||
sensitiveFields: z.array(z.string().min(1).max(100)).max(50).optional(),
|
||||
notes: z.array(z.string().max(500)).max(20).optional(),
|
||||
exportDefault: z.enum(['redacted', 'full']).optional(),
|
||||
});
|
||||
|
||||
const SourceLinkSchema = z.object({
|
||||
label: z.string().min(1).max(120),
|
||||
href: z.string().min(1).max(1000),
|
||||
type: z.enum(['task', 'run', 'file', 'url', 'pr', 'other']).optional(),
|
||||
});
|
||||
|
||||
const RenderBaseSchema = {
|
||||
schemaVersion: z.literal(1),
|
||||
};
|
||||
|
||||
export const WorkProductRenderSchema = z.discriminatedUnion('kind', [
|
||||
z.object({
|
||||
...RenderBaseSchema,
|
||||
kind: z.literal('text'),
|
||||
text: z.string().max(500_000),
|
||||
}),
|
||||
z.object({
|
||||
...RenderBaseSchema,
|
||||
kind: z.literal('markdown'),
|
||||
markdown: z.string().max(500_000),
|
||||
}),
|
||||
z.object({
|
||||
...RenderBaseSchema,
|
||||
kind: z.literal('summary'),
|
||||
summary: z.string().max(100_000),
|
||||
keyPoints: z.array(z.string().max(2000)).max(200).optional(),
|
||||
sections: z
|
||||
.array(
|
||||
z.object({
|
||||
heading: z.string().min(1).max(200),
|
||||
body: z.string().max(50_000),
|
||||
})
|
||||
)
|
||||
.max(200)
|
||||
.optional(),
|
||||
}),
|
||||
z.object({
|
||||
...RenderBaseSchema,
|
||||
kind: z.literal('checklist'),
|
||||
items: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().min(1).max(120),
|
||||
label: z.string().min(1).max(1000),
|
||||
checked: z.boolean(),
|
||||
notes: z.string().max(5000).optional(),
|
||||
})
|
||||
)
|
||||
.max(1000),
|
||||
}),
|
||||
z.object({
|
||||
...RenderBaseSchema,
|
||||
kind: z.literal('report'),
|
||||
summary: z.string().max(100_000),
|
||||
sections: z
|
||||
.array(
|
||||
z.object({
|
||||
heading: z.string().min(1).max(200),
|
||||
body: z.string().max(100_000),
|
||||
})
|
||||
)
|
||||
.max(200),
|
||||
}),
|
||||
z.object({
|
||||
...RenderBaseSchema,
|
||||
kind: z.literal('table'),
|
||||
columns: z
|
||||
.array(
|
||||
z.object({
|
||||
key: z.string().min(1).max(120),
|
||||
label: z.string().min(1).max(200),
|
||||
type: z.enum(['text', 'number', 'boolean', 'date']).optional(),
|
||||
})
|
||||
)
|
||||
.min(1)
|
||||
.max(100),
|
||||
rows: z.array(z.record(z.string(), PrimitiveSchema)).max(5000),
|
||||
}),
|
||||
z.object({
|
||||
...RenderBaseSchema,
|
||||
kind: z.literal('dashboard'),
|
||||
widgets: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().min(1).max(120),
|
||||
title: z.string().min(1).max(200),
|
||||
value: PrimitiveSchema.optional(),
|
||||
description: z.string().max(5000).optional(),
|
||||
tone: z.enum(['neutral', 'good', 'warning', 'critical']).optional(),
|
||||
})
|
||||
)
|
||||
.max(200),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const CreateWorkProductBodySchema = z
|
||||
.object({
|
||||
kind: WorkProductKindSchema,
|
||||
title: z.string().min(1).max(240),
|
||||
render: WorkProductRenderSchema,
|
||||
taskId: z.string().min(1).max(200).optional(),
|
||||
sourceRunId: z.string().min(1).max(200).optional(),
|
||||
agent: z.string().min(1).max(100).optional(),
|
||||
model: z.string().min(1).max(100).optional(),
|
||||
workspaceId: z.string().min(1).max(100).optional(),
|
||||
redaction: RedactionSchema.optional(),
|
||||
sourceLinks: z.array(SourceLinkSchema).max(50).optional(),
|
||||
metadata: z.record(z.string(), PrimitiveSchema).optional(),
|
||||
changeSummary: z.string().max(1000).optional(),
|
||||
})
|
||||
.refine((body) => body.kind === body.render.kind, {
|
||||
message: 'kind must match render.kind',
|
||||
path: ['render', 'kind'],
|
||||
});
|
||||
|
||||
export const UpdateWorkProductBodySchema = z
|
||||
.object({
|
||||
title: z.string().min(1).max(240).optional(),
|
||||
render: WorkProductRenderSchema.optional(),
|
||||
status: WorkProductStatusSchema.optional(),
|
||||
taskId: z.string().min(1).max(200).optional(),
|
||||
sourceRunId: z.string().min(1).max(200).optional(),
|
||||
agent: z.string().min(1).max(100).optional(),
|
||||
model: z.string().min(1).max(100).optional(),
|
||||
redaction: RedactionSchema.optional(),
|
||||
sourceLinks: z.array(SourceLinkSchema).max(50).optional(),
|
||||
metadata: z.record(z.string(), PrimitiveSchema).optional(),
|
||||
changeType: WorkProductChangeTypeSchema.optional(),
|
||||
changeSummary: z.string().max(1000).optional(),
|
||||
})
|
||||
.refine((body) => !body.render || !body.render.kind || body.render.kind.length > 0, {
|
||||
message: 'render.kind is required when render is provided',
|
||||
path: ['render', 'kind'],
|
||||
});
|
||||
|
||||
export const WorkProductListQuerySchema = z.object({
|
||||
taskId: z.string().min(1).max(200).optional(),
|
||||
sourceRunId: z.string().min(1).max(200).optional(),
|
||||
agent: z.string().min(1).max(100).optional(),
|
||||
kind: WorkProductKindSchema.optional(),
|
||||
status: WorkProductStatusSchema.optional(),
|
||||
q: z.string().trim().min(1).max(500).optional(),
|
||||
includeArchived: z.enum(['true', 'false']).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(200).optional(),
|
||||
view: z.enum(['full', 'preview']).optional(),
|
||||
});
|
||||
|
||||
export const WorkProductExportQuerySchema = z.object({
|
||||
format: z.enum(['markdown', 'json']).optional(),
|
||||
redacted: z.enum(['true', 'false']).optional(),
|
||||
});
|
||||
|
|
@ -2,11 +2,12 @@ import { execFile } from 'node:child_process';
|
|||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { createLogger } from '../lib/logger.js';
|
||||
import { getWorkProductService } from './work-product-service.js';
|
||||
|
||||
const log = createLogger('search-service');
|
||||
|
||||
export type SearchBackend = 'auto' | 'qmd' | 'keyword';
|
||||
export type SearchCollection = 'tasks-active' | 'tasks-archive' | 'docs';
|
||||
export type SearchCollection = 'tasks-active' | 'tasks-archive' | 'docs' | 'work-products';
|
||||
|
||||
export interface SearchRequest {
|
||||
query: string;
|
||||
|
|
@ -49,7 +50,12 @@ interface SearchSource {
|
|||
}
|
||||
|
||||
const PROJECT_ROOT = path.resolve(process.cwd(), '..');
|
||||
const DEFAULT_COLLECTIONS: SearchCollection[] = ['tasks-active', 'tasks-archive', 'docs'];
|
||||
const DEFAULT_COLLECTIONS: SearchCollection[] = [
|
||||
'tasks-active',
|
||||
'tasks-archive',
|
||||
'docs',
|
||||
'work-products',
|
||||
];
|
||||
const MAX_LIMIT = 50;
|
||||
|
||||
class SearchService {
|
||||
|
|
@ -154,14 +160,28 @@ class SearchService {
|
|||
query: string,
|
||||
options: { limit: number; collections: SearchCollection[]; minScore?: number }
|
||||
): Promise<SearchResult[]> {
|
||||
const includeWorkProducts = options.collections.includes('work-products');
|
||||
const qmdCollections = options.collections.filter(
|
||||
(collection) => collection !== 'work-products'
|
||||
);
|
||||
const results: SearchResult[] = [];
|
||||
|
||||
if (includeWorkProducts) {
|
||||
results.push(...(await this.searchWorkProducts(query, options.limit)));
|
||||
}
|
||||
|
||||
if (qmdCollections.length === 0) {
|
||||
return results.slice(0, options.limit);
|
||||
}
|
||||
|
||||
const args = ['query', query, '--json', '-n', String(options.limit)];
|
||||
|
||||
if (options.minScore !== undefined) {
|
||||
args.push('--min-score', String(options.minScore));
|
||||
}
|
||||
|
||||
if (options.collections.length > 0) {
|
||||
args.push('--collections', options.collections.join(','));
|
||||
if (qmdCollections.length > 0) {
|
||||
args.push('--collections', qmdCollections.join(','));
|
||||
}
|
||||
|
||||
const stdout = await this.runQmdCommand(
|
||||
|
|
@ -169,7 +189,10 @@ class SearchService {
|
|||
Number(process.env.VERITAS_QMD_TIMEOUT_MS || 10_000)
|
||||
);
|
||||
|
||||
return this.normalizeQmdResults(stdout, options.limit);
|
||||
results.push(...this.normalizeQmdResults(stdout, options.limit));
|
||||
return results
|
||||
.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path))
|
||||
.slice(0, options.limit);
|
||||
}
|
||||
|
||||
private async runQmdCommand(args: string[], timeout: number): Promise<string> {
|
||||
|
|
@ -247,6 +270,11 @@ class SearchService {
|
|||
if (terms.length === 0) return [];
|
||||
|
||||
const results: SearchResult[] = [];
|
||||
const selected = new Set(this.normalizeCollections(options.collections));
|
||||
if (selected.has('work-products')) {
|
||||
results.push(...(await this.searchWorkProducts(query, options.limit)));
|
||||
}
|
||||
|
||||
const sources = this.sources(options.collections);
|
||||
|
||||
for (const source of sources) {
|
||||
|
|
@ -318,6 +346,32 @@ class SearchService {
|
|||
return candidates.filter((source) => selected.has(source.collection));
|
||||
}
|
||||
|
||||
private async searchWorkProducts(query: string, limit: number): Promise<SearchResult[]> {
|
||||
const service = getWorkProductService();
|
||||
const products = await service.search(query, limit);
|
||||
return products.map((product, index) => {
|
||||
const preview = service.toPreview(product);
|
||||
return {
|
||||
id: product.id,
|
||||
title: product.title,
|
||||
path: `/work-products/${product.id}`,
|
||||
collection: 'work-products',
|
||||
snippet: preview.snippet,
|
||||
score: limit - index,
|
||||
metadata: {
|
||||
kind: product.kind,
|
||||
taskId: product.taskId,
|
||||
sourceRunId: product.sourceRunId,
|
||||
agent: product.agent,
|
||||
model: product.model,
|
||||
version: product.version,
|
||||
updatedAt: product.updatedAt,
|
||||
redacted: preview.redacted,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private normalizeCollections(collections?: SearchCollection[]): SearchCollection[] {
|
||||
if (!collections || collections.length === 0) return DEFAULT_COLLECTIONS;
|
||||
const allowed = new Set<SearchCollection>(DEFAULT_COLLECTIONS);
|
||||
|
|
|
|||
471
server/src/services/work-product-service.ts
Normal file
471
server/src/services/work-product-service.ts
Normal file
|
|
@ -0,0 +1,471 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type {
|
||||
CreateWorkProductInput,
|
||||
UpdateWorkProductInput,
|
||||
WorkProduct,
|
||||
WorkProductListOptions,
|
||||
WorkProductPreview,
|
||||
WorkProductRedaction,
|
||||
WorkProductRender,
|
||||
WorkProductVersion,
|
||||
} from '@veritas-kanban/shared';
|
||||
import { SqliteDatabase, type SqliteConnectionOptions } from '../storage/sqlite/database.js';
|
||||
import { SqliteWorkProductRepository } from '../storage/sqlite/work-product-repository.js';
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), '..', '.veritas-kanban');
|
||||
const DEFAULT_VERSION_LIMIT = 25;
|
||||
|
||||
interface WorkProductFileState {
|
||||
products: WorkProduct[];
|
||||
versions: WorkProductVersion[];
|
||||
}
|
||||
|
||||
export interface WorkProductServiceOptions {
|
||||
dataDir?: string;
|
||||
filePath?: string;
|
||||
storageType?: 'file' | 'sqlite';
|
||||
sqliteDatabase?: SqliteDatabase;
|
||||
sqliteConnectionOptions?: SqliteConnectionOptions;
|
||||
versionLimit?: number;
|
||||
}
|
||||
|
||||
export class WorkProductService {
|
||||
private readonly filePath: string;
|
||||
private readonly versionLimit: number;
|
||||
private readonly repository: SqliteWorkProductRepository | null = null;
|
||||
private readonly sqliteDatabase: SqliteDatabase | null = null;
|
||||
private readonly ownsSqliteDatabase: boolean = false;
|
||||
private loaded = false;
|
||||
private fileState: WorkProductFileState = { products: [], versions: [] };
|
||||
|
||||
constructor(options: WorkProductServiceOptions = {}) {
|
||||
const dataDir = options.dataDir ?? DATA_DIR;
|
||||
this.filePath = options.filePath ?? path.join(dataDir, 'work-products.json');
|
||||
this.versionLimit = options.versionLimit ?? DEFAULT_VERSION_LIMIT;
|
||||
const storageType =
|
||||
options.storageType ?? (process.env.VERITAS_STORAGE === 'sqlite' ? 'sqlite' : 'file');
|
||||
|
||||
if (storageType === 'sqlite') {
|
||||
this.sqliteDatabase =
|
||||
options.sqliteDatabase ?? new SqliteDatabase(options.sqliteConnectionOptions);
|
||||
this.ownsSqliteDatabase = !options.sqliteDatabase;
|
||||
this.sqliteDatabase.open();
|
||||
this.repository = new SqliteWorkProductRepository(this.sqliteDatabase, {
|
||||
versionLimit: this.versionLimit,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async create(input: CreateWorkProductInput): Promise<WorkProduct> {
|
||||
this.assertRenderKind(input.kind, input.render);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const product: WorkProduct = {
|
||||
id: `wp_${randomUUID()}`,
|
||||
workspaceId: input.workspaceId ?? 'local',
|
||||
kind: input.kind,
|
||||
title: input.title,
|
||||
status: 'active',
|
||||
render: input.render,
|
||||
version: 1,
|
||||
taskId: input.taskId,
|
||||
sourceRunId: input.sourceRunId,
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
redaction: input.redaction,
|
||||
sourceLinks: input.sourceLinks,
|
||||
metadata: input.metadata,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
if (this.repository) {
|
||||
return this.repository.save(product, this.extractSearchText(product), input.changeSummary);
|
||||
}
|
||||
|
||||
await this.ensureLoaded();
|
||||
this.fileState.products.push(product);
|
||||
this.fileState.versions.push(this.createVersion(product, 'create', input.changeSummary));
|
||||
this.pruneFileVersions(product.id);
|
||||
await this.saveFileState();
|
||||
return product;
|
||||
}
|
||||
|
||||
async list(options: WorkProductListOptions = {}): Promise<WorkProduct[]> {
|
||||
if (this.repository) {
|
||||
return this.repository.list(options);
|
||||
}
|
||||
|
||||
await this.ensureLoaded();
|
||||
const limit = Math.min(Math.max(options.limit ?? 100, 1), 200);
|
||||
const query = options.query?.toLowerCase();
|
||||
return this.fileState.products
|
||||
.filter((product) => {
|
||||
if (!options.includeArchived && product.status !== 'active') return false;
|
||||
if (options.status && product.status !== options.status) return false;
|
||||
if (options.taskId && product.taskId !== options.taskId) return false;
|
||||
if (options.sourceRunId && product.sourceRunId !== options.sourceRunId) return false;
|
||||
if (options.agent && product.agent !== options.agent) return false;
|
||||
if (options.kind && product.kind !== options.kind) return false;
|
||||
if (query) {
|
||||
const haystack = `${product.title}\n${this.extractSearchText(product)}`.toLowerCase();
|
||||
if (!haystack.includes(query)) return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) || a.id.localeCompare(b.id))
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
async get(id: string): Promise<WorkProduct | null> {
|
||||
if (this.repository) {
|
||||
return this.repository.get(id);
|
||||
}
|
||||
|
||||
await this.ensureLoaded();
|
||||
return this.fileState.products.find((product) => product.id === id) ?? null;
|
||||
}
|
||||
|
||||
async update(id: string, input: UpdateWorkProductInput): Promise<WorkProduct | null> {
|
||||
const current = await this.get(id);
|
||||
if (!current) return null;
|
||||
|
||||
if (input.render) {
|
||||
this.assertRenderKind(input.render.kind, input.render);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const { changeType: requestedChangeType, changeSummary, ...productPatch } = input;
|
||||
const next: WorkProduct = {
|
||||
...current,
|
||||
...productPatch,
|
||||
kind: input.render?.kind ?? current.kind,
|
||||
render: input.render ?? current.render,
|
||||
version: current.version + 1,
|
||||
updatedAt: now,
|
||||
archivedAt: input.status === 'archived' ? (current.archivedAt ?? now) : undefined,
|
||||
};
|
||||
const changeType = requestedChangeType ?? (input.render ? 'refine' : 'manual');
|
||||
|
||||
if (this.repository) {
|
||||
return this.repository.update(next, this.extractSearchText(next), changeType, changeSummary);
|
||||
}
|
||||
|
||||
await this.ensureLoaded();
|
||||
const index = this.fileState.products.findIndex((product) => product.id === id);
|
||||
if (index === -1) return null;
|
||||
|
||||
this.fileState.products[index] = next;
|
||||
this.fileState.versions.push(this.createVersion(next, changeType, changeSummary));
|
||||
this.pruneFileVersions(id);
|
||||
await this.saveFileState();
|
||||
return next;
|
||||
}
|
||||
|
||||
async archive(id: string): Promise<WorkProduct | null> {
|
||||
if (this.repository) {
|
||||
return this.repository.archive(id, new Date().toISOString());
|
||||
}
|
||||
return this.update(id, {
|
||||
status: 'archived',
|
||||
changeType: 'manual',
|
||||
changeSummary: 'Archived work product',
|
||||
});
|
||||
}
|
||||
|
||||
async listVersions(productId: string): Promise<WorkProductVersion[]> {
|
||||
if (this.repository) {
|
||||
return this.repository.listVersions(productId);
|
||||
}
|
||||
|
||||
await this.ensureLoaded();
|
||||
return this.fileState.versions
|
||||
.filter((version) => version.productId === productId)
|
||||
.sort((a, b) => b.version - a.version);
|
||||
}
|
||||
|
||||
async restoreVersion(productId: string, versionNumber: number): Promise<WorkProduct | null> {
|
||||
const product = await this.get(productId);
|
||||
if (!product) return null;
|
||||
|
||||
const version = this.repository
|
||||
? this.repository.getVersion(productId, versionNumber)
|
||||
: ((await this.listVersions(productId)).find(
|
||||
(candidate) => candidate.version === versionNumber
|
||||
) ?? null);
|
||||
if (!version) return null;
|
||||
|
||||
return this.update(productId, {
|
||||
title: version.title,
|
||||
render: version.render,
|
||||
agent: version.agent,
|
||||
model: version.model,
|
||||
redaction: version.redaction,
|
||||
changeType: 'restore',
|
||||
changeSummary: `Restored version ${versionNumber}`,
|
||||
});
|
||||
}
|
||||
|
||||
async search(query: string, limit = 20): Promise<WorkProduct[]> {
|
||||
if (this.repository) {
|
||||
return this.repository.search(query, limit);
|
||||
}
|
||||
return this.list({ query, limit });
|
||||
}
|
||||
|
||||
toPreview(product: WorkProduct): WorkProductPreview {
|
||||
const rawText = this.extractSearchText(product);
|
||||
const redactedText = this.redactText(rawText, product.redaction);
|
||||
const fullyRedacted = this.shouldFullyRedact(product.redaction);
|
||||
return {
|
||||
id: product.id,
|
||||
workspaceId: product.workspaceId,
|
||||
kind: product.kind,
|
||||
title: product.title,
|
||||
status: product.status,
|
||||
version: product.version,
|
||||
taskId: product.taskId,
|
||||
sourceRunId: product.sourceRunId,
|
||||
agent: product.agent,
|
||||
model: product.model,
|
||||
sourceLinks: product.sourceLinks,
|
||||
redacted: fullyRedacted || redactedText !== rawText,
|
||||
snippet: (fullyRedacted ? '[redacted work product preview]' : redactedText).slice(0, 500),
|
||||
createdAt: product.createdAt,
|
||||
updatedAt: product.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
exportProduct(
|
||||
product: WorkProduct,
|
||||
options: { format?: 'markdown' | 'json'; redacted?: boolean } = {}
|
||||
): string {
|
||||
const redacted = options.redacted ?? product.redaction?.exportDefault !== 'full';
|
||||
if (options.format === 'json') {
|
||||
const exported = redacted ? this.redactProduct(product) : product;
|
||||
return JSON.stringify(exported, null, 2);
|
||||
}
|
||||
|
||||
const body = redacted
|
||||
? this.redactText(this.extractSearchText(product), product.redaction)
|
||||
: this.extractSearchText(product);
|
||||
const lines = [
|
||||
`# ${product.title}`,
|
||||
'',
|
||||
`Kind: ${product.kind}`,
|
||||
`Version: ${product.version}`,
|
||||
product.taskId ? `Task: ${product.taskId}` : null,
|
||||
product.sourceRunId ? `Run: ${product.sourceRunId}` : null,
|
||||
product.agent ? `Agent: ${product.agent}` : null,
|
||||
product.model ? `Model: ${product.model}` : null,
|
||||
`Updated: ${product.updatedAt}`,
|
||||
'',
|
||||
body,
|
||||
].filter((line): line is string => line !== null);
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.ownsSqliteDatabase) {
|
||||
this.sqliteDatabase?.close();
|
||||
}
|
||||
this.loaded = false;
|
||||
this.fileState = { products: [], versions: [] };
|
||||
}
|
||||
|
||||
extractSearchText(product: WorkProduct): string {
|
||||
return renderToText(product.render);
|
||||
}
|
||||
|
||||
private async ensureLoaded(): Promise<void> {
|
||||
if (this.loaded) return;
|
||||
|
||||
try {
|
||||
const raw = await fs.readFile(this.filePath, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as WorkProductFileState | WorkProduct[];
|
||||
this.fileState = Array.isArray(parsed) ? { products: parsed, versions: [] } : parsed;
|
||||
} catch {
|
||||
this.fileState = { products: [], versions: [] };
|
||||
}
|
||||
|
||||
this.loaded = true;
|
||||
}
|
||||
|
||||
private async saveFileState(): Promise<void> {
|
||||
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
|
||||
await fs.writeFile(this.filePath, JSON.stringify(this.fileState, null, 2));
|
||||
}
|
||||
|
||||
private createVersion(
|
||||
product: WorkProduct,
|
||||
changeType: WorkProductVersion['changeType'],
|
||||
changeSummary?: string
|
||||
): WorkProductVersion {
|
||||
return {
|
||||
id: `wpv_${randomUUID()}`,
|
||||
productId: product.id,
|
||||
workspaceId: product.workspaceId,
|
||||
version: product.version,
|
||||
changeType,
|
||||
changeSummary,
|
||||
render: product.render,
|
||||
title: product.title,
|
||||
kind: product.kind,
|
||||
agent: product.agent,
|
||||
model: product.model,
|
||||
redaction: product.redaction,
|
||||
createdAt: product.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private pruneFileVersions(productId: string): void {
|
||||
const versions = this.fileState.versions
|
||||
.filter((version) => version.productId === productId)
|
||||
.sort((a, b) => b.version - a.version);
|
||||
const keep = new Set(versions.slice(0, this.versionLimit).map((version) => version.id));
|
||||
this.fileState.versions = this.fileState.versions.filter(
|
||||
(version) => version.productId !== productId || keep.has(version.id)
|
||||
);
|
||||
}
|
||||
|
||||
private assertRenderKind(kind: string, render: WorkProductRender): void {
|
||||
if (kind !== render.kind) {
|
||||
throw new Error('Work product kind must match render.kind');
|
||||
}
|
||||
}
|
||||
|
||||
private shouldFullyRedact(redaction?: WorkProductRedaction): boolean {
|
||||
return redaction?.level === 'strict' || redaction?.containsSensitiveContent === true;
|
||||
}
|
||||
|
||||
private redactProduct(product: WorkProduct): WorkProduct {
|
||||
if (this.shouldFullyRedact(product.redaction)) {
|
||||
return {
|
||||
...product,
|
||||
render: redactRender(product.render, '[redacted work product content]'),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...product,
|
||||
render: redactRender(
|
||||
product.render,
|
||||
this.redactText(this.extractSearchText(product), product.redaction)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private redactText(text: string, redaction?: WorkProductRedaction): string {
|
||||
if (this.shouldFullyRedact(redaction)) {
|
||||
return '[redacted work product content]';
|
||||
}
|
||||
|
||||
return text
|
||||
.replace(
|
||||
/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
|
||||
'[redacted-private-key]'
|
||||
)
|
||||
.replace(
|
||||
/\b(?:sk|rk|ghp|gho|github_pat|xoxb|xoxp)_[A-Za-z0-9_:-]{12,}\b/g,
|
||||
'[redacted-token]'
|
||||
)
|
||||
.replace(/\bBearer\s+[A-Za-z0-9._~+/-]+=*/gi, 'Bearer [redacted-token]')
|
||||
.replace(/(api[_-]?key|token|secret|password)\s*[:=]\s*['"]?[^'"\s]+/gi, '$1=[redacted]')
|
||||
.replace(/\/Users\/[^/\s]+\/[^\s)]+/g, '[redacted-local-path]')
|
||||
.replace(/[A-Z]:\\Users\\[^\\\s]+\\[^\s)]+/g, '[redacted-local-path]');
|
||||
}
|
||||
}
|
||||
|
||||
function renderToText(render: WorkProductRender): string {
|
||||
switch (render.kind) {
|
||||
case 'text':
|
||||
return render.text;
|
||||
case 'markdown':
|
||||
return render.markdown;
|
||||
case 'summary':
|
||||
return [
|
||||
render.summary,
|
||||
...(render.keyPoints ?? []),
|
||||
...(render.sections ?? []).flatMap((section) => [section.heading, section.body]),
|
||||
].join('\n');
|
||||
case 'checklist':
|
||||
return render.items
|
||||
.map(
|
||||
(item) =>
|
||||
`${item.checked ? '[x]' : '[ ]'} ${item.label}${item.notes ? ` - ${item.notes}` : ''}`
|
||||
)
|
||||
.join('\n');
|
||||
case 'report':
|
||||
return [
|
||||
render.summary,
|
||||
...render.sections.flatMap((section) => [section.heading, section.body]),
|
||||
].join('\n');
|
||||
case 'table':
|
||||
return [
|
||||
render.columns.map((column) => column.label).join('\t'),
|
||||
...render.rows.map((row) =>
|
||||
render.columns.map((column) => String(row[column.key] ?? '')).join('\t')
|
||||
),
|
||||
].join('\n');
|
||||
case 'dashboard':
|
||||
return render.widgets
|
||||
.map((widget) =>
|
||||
[
|
||||
widget.title,
|
||||
widget.value === undefined ? null : String(widget.value),
|
||||
widget.description,
|
||||
]
|
||||
.filter((part): part is string => Boolean(part))
|
||||
.join(': ')
|
||||
)
|
||||
.join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
function redactRender(render: WorkProductRender, text: string): WorkProductRender {
|
||||
switch (render.kind) {
|
||||
case 'text':
|
||||
return { schemaVersion: 1, kind: 'text', text };
|
||||
case 'markdown':
|
||||
return { schemaVersion: 1, kind: 'markdown', markdown: text };
|
||||
case 'summary':
|
||||
return { schemaVersion: 1, kind: 'summary', summary: text };
|
||||
case 'checklist':
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
kind: 'checklist',
|
||||
items: [{ id: 'redacted', label: text, checked: false }],
|
||||
};
|
||||
case 'report':
|
||||
return { schemaVersion: 1, kind: 'report', summary: text, sections: [] };
|
||||
case 'table':
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
kind: 'table',
|
||||
columns: [{ key: 'redacted', label: 'Redacted' }],
|
||||
rows: [{ redacted: text }],
|
||||
};
|
||||
case 'dashboard':
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
kind: 'dashboard',
|
||||
widgets: [{ id: 'redacted', title: 'Redacted', description: text }],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let workProductServiceInstance: WorkProductService | null = null;
|
||||
|
||||
export function getWorkProductService(): WorkProductService {
|
||||
if (!workProductServiceInstance) {
|
||||
workProductServiceInstance = new WorkProductService();
|
||||
}
|
||||
return workProductServiceInstance;
|
||||
}
|
||||
|
||||
export function resetWorkProductServiceForTests(): void {
|
||||
workProductServiceInstance?.dispose();
|
||||
workProductServiceInstance = null;
|
||||
}
|
||||
|
|
@ -784,6 +784,87 @@ export const SQLITE_BASE_MIGRATIONS: readonly SqliteMigration[] = [
|
|||
WHERE source_run_id IS NOT NULL;
|
||||
`,
|
||||
},
|
||||
{
|
||||
version: 13,
|
||||
name: '0013_work_product_repositories',
|
||||
up: `
|
||||
CREATE TABLE IF NOT EXISTS work_products (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL DEFAULT 'local'
|
||||
REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK (status IN ('active', 'archived')),
|
||||
task_id TEXT,
|
||||
source_run_id TEXT,
|
||||
agent TEXT,
|
||||
model TEXT,
|
||||
version_number INTEGER NOT NULL DEFAULT 1,
|
||||
redaction_json TEXT,
|
||||
source_links_json TEXT,
|
||||
metadata_json TEXT,
|
||||
render_json TEXT NOT NULL,
|
||||
product_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
archived_at TEXT,
|
||||
deleted_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_work_products_workspace_updated
|
||||
ON work_products(workspace_id, updated_at DESC)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_work_products_task_updated
|
||||
ON work_products(task_id, updated_at DESC)
|
||||
WHERE task_id IS NOT NULL AND deleted_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_work_products_source_run
|
||||
ON work_products(source_run_id, updated_at DESC)
|
||||
WHERE source_run_id IS NOT NULL AND deleted_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_work_products_kind_status
|
||||
ON work_products(workspace_id, kind, status, updated_at DESC)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_work_products_agent_updated
|
||||
ON work_products(agent, updated_at DESC)
|
||||
WHERE agent IS NOT NULL AND deleted_at IS NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS work_product_versions (
|
||||
id TEXT PRIMARY KEY,
|
||||
product_id TEXT NOT NULL REFERENCES work_products(id) ON DELETE CASCADE,
|
||||
workspace_id TEXT NOT NULL DEFAULT 'local'
|
||||
REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
version_number INTEGER NOT NULL,
|
||||
change_type TEXT NOT NULL,
|
||||
change_summary TEXT,
|
||||
title TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
agent TEXT,
|
||||
model TEXT,
|
||||
redaction_json TEXT,
|
||||
render_json TEXT NOT NULL,
|
||||
version_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE (product_id, version_number)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_work_product_versions_product
|
||||
ON work_product_versions(product_id, version_number DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_work_product_versions_workspace_created
|
||||
ON work_product_versions(workspace_id, created_at DESC);
|
||||
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS work_product_search USING fts5(
|
||||
product_id UNINDEXED,
|
||||
title,
|
||||
body,
|
||||
tokenize='porter unicode61'
|
||||
);
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
export function sortedMigrations(migrations: readonly SqliteMigration[]): SqliteMigration[] {
|
||||
|
|
|
|||
389
server/src/storage/sqlite/work-product-repository.ts
Normal file
389
server/src/storage/sqlite/work-product-repository.ts
Normal file
|
|
@ -0,0 +1,389 @@
|
|||
import type { SQLInputValue } from 'node:sqlite';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type {
|
||||
WorkProduct,
|
||||
WorkProductListOptions,
|
||||
WorkProductVersion,
|
||||
} from '@veritas-kanban/shared';
|
||||
import type { SqliteDatabase } from './database.js';
|
||||
|
||||
interface WorkProductRow {
|
||||
product_json: string;
|
||||
}
|
||||
|
||||
interface WorkProductVersionRow {
|
||||
version_json: string;
|
||||
}
|
||||
|
||||
export class SqliteWorkProductRepository {
|
||||
constructor(
|
||||
private readonly database: SqliteDatabase,
|
||||
private readonly options: { versionLimit?: number } = {}
|
||||
) {}
|
||||
|
||||
list(options: WorkProductListOptions = {}): WorkProduct[] {
|
||||
const { sql, params } = this.buildListQuery(options);
|
||||
const rows = this.database
|
||||
.getConnection()
|
||||
.prepare(sql)
|
||||
.all(...params) as unknown as WorkProductRow[];
|
||||
return rows.map((row) => JSON.parse(row.product_json) as WorkProduct);
|
||||
}
|
||||
|
||||
get(id: string): WorkProduct | null {
|
||||
const row = this.database
|
||||
.getConnection()
|
||||
.prepare(
|
||||
`
|
||||
SELECT product_json
|
||||
FROM work_products
|
||||
WHERE workspace_id = 'local'
|
||||
AND id = ?
|
||||
AND deleted_at IS NULL
|
||||
`
|
||||
)
|
||||
.get(id) as unknown as WorkProductRow | undefined;
|
||||
|
||||
return row ? (JSON.parse(row.product_json) as WorkProduct) : null;
|
||||
}
|
||||
|
||||
save(product: WorkProduct, searchText: string, changeSummary?: string): WorkProduct {
|
||||
const db = this.database.getConnection();
|
||||
db.exec('BEGIN IMMEDIATE;');
|
||||
try {
|
||||
this.upsertProduct(product);
|
||||
this.recordVersion(product, 'create', searchText, changeSummary);
|
||||
this.syncSearchRow(product, searchText);
|
||||
db.exec('COMMIT;');
|
||||
return product;
|
||||
} catch (error) {
|
||||
db.exec('ROLLBACK;');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
update(
|
||||
product: WorkProduct,
|
||||
searchText: string,
|
||||
changeType: WorkProductVersion['changeType'],
|
||||
changeSummary?: string
|
||||
): WorkProduct {
|
||||
const db = this.database.getConnection();
|
||||
db.exec('BEGIN IMMEDIATE;');
|
||||
try {
|
||||
this.upsertProduct(product);
|
||||
this.recordVersion(product, changeType, searchText, changeSummary);
|
||||
this.syncSearchRow(product, searchText);
|
||||
this.pruneVersions(product.id);
|
||||
db.exec('COMMIT;');
|
||||
return product;
|
||||
} catch (error) {
|
||||
db.exec('ROLLBACK;');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
listVersions(productId: string): WorkProductVersion[] {
|
||||
const rows = this.database
|
||||
.getConnection()
|
||||
.prepare(
|
||||
`
|
||||
SELECT version_json
|
||||
FROM work_product_versions
|
||||
WHERE workspace_id = 'local'
|
||||
AND product_id = ?
|
||||
ORDER BY version_number DESC
|
||||
`
|
||||
)
|
||||
.all(productId) as unknown as WorkProductVersionRow[];
|
||||
|
||||
return rows.map((row) => JSON.parse(row.version_json) as WorkProductVersion);
|
||||
}
|
||||
|
||||
getVersion(productId: string, version: number): WorkProductVersion | null {
|
||||
const row = this.database
|
||||
.getConnection()
|
||||
.prepare(
|
||||
`
|
||||
SELECT version_json
|
||||
FROM work_product_versions
|
||||
WHERE workspace_id = 'local'
|
||||
AND product_id = ?
|
||||
AND version_number = ?
|
||||
`
|
||||
)
|
||||
.get(productId, version) as unknown as WorkProductVersionRow | undefined;
|
||||
|
||||
return row ? (JSON.parse(row.version_json) as WorkProductVersion) : null;
|
||||
}
|
||||
|
||||
archive(id: string, now: string): WorkProduct | null {
|
||||
const product = this.get(id);
|
||||
if (!product) return null;
|
||||
|
||||
const archived: WorkProduct = {
|
||||
...product,
|
||||
status: 'archived',
|
||||
archivedAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
return this.update(archived, '', 'manual', 'Archived work product');
|
||||
}
|
||||
|
||||
search(query: string, limit = 20): WorkProduct[] {
|
||||
const ftsQuery = this.toFtsQuery(query);
|
||||
if (!ftsQuery) return [];
|
||||
|
||||
const rows = this.database
|
||||
.getConnection()
|
||||
.prepare(
|
||||
`
|
||||
SELECT wp.product_json
|
||||
FROM work_product_search wps
|
||||
JOIN work_products wp ON wp.id = wps.product_id
|
||||
WHERE work_product_search MATCH ?
|
||||
AND wp.workspace_id = 'local'
|
||||
AND wp.status = 'active'
|
||||
AND wp.deleted_at IS NULL
|
||||
ORDER BY rank
|
||||
LIMIT ?
|
||||
`
|
||||
)
|
||||
.all(ftsQuery, Math.min(Math.max(limit, 1), 200)) as unknown as WorkProductRow[];
|
||||
|
||||
return rows.map((row) => JSON.parse(row.product_json) as WorkProduct);
|
||||
}
|
||||
|
||||
private buildListQuery(options: WorkProductListOptions): {
|
||||
sql: string;
|
||||
params: SQLInputValue[];
|
||||
} {
|
||||
const clauses = ["workspace_id = 'local'", 'deleted_at IS NULL'];
|
||||
const params: SQLInputValue[] = [];
|
||||
|
||||
if (!options.includeArchived) {
|
||||
clauses.push("status = 'active'");
|
||||
}
|
||||
if (options.status) {
|
||||
clauses.push('status = ?');
|
||||
params.push(options.status);
|
||||
}
|
||||
if (options.taskId) {
|
||||
clauses.push('task_id = ?');
|
||||
params.push(options.taskId);
|
||||
}
|
||||
if (options.sourceRunId) {
|
||||
clauses.push('source_run_id = ?');
|
||||
params.push(options.sourceRunId);
|
||||
}
|
||||
if (options.agent) {
|
||||
clauses.push('agent = ?');
|
||||
params.push(options.agent);
|
||||
}
|
||||
if (options.kind) {
|
||||
clauses.push('kind = ?');
|
||||
params.push(options.kind);
|
||||
}
|
||||
if (options.query) {
|
||||
const like = `%${options.query}%`;
|
||||
clauses.push(
|
||||
'(title LIKE ? OR product_json LIKE ? OR render_json LIKE ? OR metadata_json LIKE ?)'
|
||||
);
|
||||
params.push(like, like, like, like);
|
||||
}
|
||||
|
||||
params.push(Math.min(Math.max(options.limit ?? 100, 1), 200));
|
||||
return {
|
||||
sql: `
|
||||
SELECT product_json
|
||||
FROM work_products
|
||||
WHERE ${clauses.join(' AND ')}
|
||||
ORDER BY datetime(updated_at) DESC, id ASC
|
||||
LIMIT ?
|
||||
`,
|
||||
params,
|
||||
};
|
||||
}
|
||||
|
||||
private upsertProduct(product: WorkProduct): void {
|
||||
this.database
|
||||
.getConnection()
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO work_products (
|
||||
id,
|
||||
workspace_id,
|
||||
kind,
|
||||
title,
|
||||
status,
|
||||
task_id,
|
||||
source_run_id,
|
||||
agent,
|
||||
model,
|
||||
version_number,
|
||||
redaction_json,
|
||||
source_links_json,
|
||||
metadata_json,
|
||||
render_json,
|
||||
product_json,
|
||||
created_at,
|
||||
updated_at,
|
||||
archived_at,
|
||||
deleted_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
workspace_id = excluded.workspace_id,
|
||||
kind = excluded.kind,
|
||||
title = excluded.title,
|
||||
status = excluded.status,
|
||||
task_id = excluded.task_id,
|
||||
source_run_id = excluded.source_run_id,
|
||||
agent = excluded.agent,
|
||||
model = excluded.model,
|
||||
version_number = excluded.version_number,
|
||||
redaction_json = excluded.redaction_json,
|
||||
source_links_json = excluded.source_links_json,
|
||||
metadata_json = excluded.metadata_json,
|
||||
render_json = excluded.render_json,
|
||||
product_json = excluded.product_json,
|
||||
updated_at = excluded.updated_at,
|
||||
archived_at = excluded.archived_at,
|
||||
deleted_at = NULL
|
||||
`
|
||||
)
|
||||
.run(
|
||||
product.id,
|
||||
product.workspaceId,
|
||||
product.kind,
|
||||
product.title,
|
||||
product.status,
|
||||
product.taskId ?? null,
|
||||
product.sourceRunId ?? null,
|
||||
product.agent ?? null,
|
||||
product.model ?? null,
|
||||
product.version,
|
||||
this.optionalJson(product.redaction),
|
||||
this.optionalJson(product.sourceLinks),
|
||||
this.optionalJson(product.metadata),
|
||||
JSON.stringify(product.render),
|
||||
JSON.stringify(product),
|
||||
product.createdAt,
|
||||
product.updatedAt,
|
||||
product.archivedAt ?? null
|
||||
);
|
||||
}
|
||||
|
||||
private recordVersion(
|
||||
product: WorkProduct,
|
||||
changeType: WorkProductVersion['changeType'],
|
||||
_searchText: string,
|
||||
changeSummary?: string
|
||||
): void {
|
||||
const version: WorkProductVersion = {
|
||||
id: `wpv_${randomUUID()}`,
|
||||
productId: product.id,
|
||||
workspaceId: product.workspaceId,
|
||||
version: product.version,
|
||||
changeType,
|
||||
changeSummary,
|
||||
render: product.render,
|
||||
title: product.title,
|
||||
kind: product.kind,
|
||||
agent: product.agent,
|
||||
model: product.model,
|
||||
redaction: product.redaction,
|
||||
createdAt: product.updatedAt,
|
||||
};
|
||||
|
||||
this.database
|
||||
.getConnection()
|
||||
.prepare(
|
||||
`
|
||||
INSERT OR REPLACE INTO work_product_versions (
|
||||
id,
|
||||
product_id,
|
||||
workspace_id,
|
||||
version_number,
|
||||
change_type,
|
||||
change_summary,
|
||||
title,
|
||||
kind,
|
||||
agent,
|
||||
model,
|
||||
redaction_json,
|
||||
render_json,
|
||||
version_json,
|
||||
created_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
)
|
||||
.run(
|
||||
version.id,
|
||||
product.id,
|
||||
product.workspaceId,
|
||||
version.version,
|
||||
version.changeType,
|
||||
version.changeSummary ?? null,
|
||||
version.title,
|
||||
version.kind,
|
||||
version.agent ?? null,
|
||||
version.model ?? null,
|
||||
this.optionalJson(version.redaction),
|
||||
JSON.stringify(version.render),
|
||||
JSON.stringify(version),
|
||||
version.createdAt
|
||||
);
|
||||
}
|
||||
|
||||
private syncSearchRow(product: WorkProduct, searchText: string): void {
|
||||
const db = this.database.getConnection();
|
||||
db.prepare('DELETE FROM work_product_search WHERE product_id = ?').run(product.id);
|
||||
|
||||
if (product.status !== 'active') {
|
||||
return;
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO work_product_search (product_id, title, body)
|
||||
VALUES (?, ?, ?)
|
||||
`
|
||||
).run(product.id, product.title, searchText);
|
||||
}
|
||||
|
||||
private pruneVersions(productId: string): void {
|
||||
const limit = this.options.versionLimit ?? 25;
|
||||
if (limit < 1) return;
|
||||
|
||||
this.database
|
||||
.getConnection()
|
||||
.prepare(
|
||||
`
|
||||
DELETE FROM work_product_versions
|
||||
WHERE product_id = ?
|
||||
AND version_number NOT IN (
|
||||
SELECT version_number
|
||||
FROM work_product_versions
|
||||
WHERE product_id = ?
|
||||
ORDER BY version_number DESC
|
||||
LIMIT ?
|
||||
)
|
||||
`
|
||||
)
|
||||
.run(productId, productId, limit);
|
||||
}
|
||||
|
||||
private optionalJson(value: unknown): string | null {
|
||||
return value === undefined ? null : JSON.stringify(value);
|
||||
}
|
||||
|
||||
private toFtsQuery(query: string): string {
|
||||
return query
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map((term) => `"${term.replace(/"/g, '""')}"`)
|
||||
.join(' ');
|
||||
}
|
||||
}
|
||||
|
|
@ -22,3 +22,4 @@ export * from './prompt-registry.types.js';
|
|||
export * from './system-health.types.js';
|
||||
export * from './feedback.types.js';
|
||||
export * from './workflow.js';
|
||||
export * from './work-product.types.js';
|
||||
|
|
|
|||
197
shared/src/types/work-product.types.ts
Normal file
197
shared/src/types/work-product.types.ts
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
export type WorkProductKind =
|
||||
| 'text'
|
||||
| 'markdown'
|
||||
| 'summary'
|
||||
| 'checklist'
|
||||
| 'report'
|
||||
| 'table'
|
||||
| 'dashboard';
|
||||
|
||||
export type WorkProductStatus = 'active' | 'archived';
|
||||
export type WorkProductChangeType = 'create' | 'refine' | 'regenerate' | 'restore' | 'manual';
|
||||
export type WorkProductRedactionLevel = 'none' | 'standard' | 'strict';
|
||||
|
||||
export type WorkProductPrimitive = string | number | boolean | null;
|
||||
|
||||
export interface WorkProductRedaction {
|
||||
level?: WorkProductRedactionLevel;
|
||||
containsSensitiveContent?: boolean;
|
||||
sensitiveFields?: string[];
|
||||
notes?: string[];
|
||||
exportDefault?: 'redacted' | 'full';
|
||||
}
|
||||
|
||||
export interface WorkProductSourceLink {
|
||||
label: string;
|
||||
href: string;
|
||||
type?: 'task' | 'run' | 'file' | 'url' | 'pr' | 'other';
|
||||
}
|
||||
|
||||
export interface WorkProductRenderBase {
|
||||
schemaVersion: 1;
|
||||
kind: WorkProductKind;
|
||||
}
|
||||
|
||||
export interface TextWorkProductRender extends WorkProductRenderBase {
|
||||
kind: 'text';
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface MarkdownWorkProductRender extends WorkProductRenderBase {
|
||||
kind: 'markdown';
|
||||
markdown: string;
|
||||
}
|
||||
|
||||
export interface SummaryWorkProductRender extends WorkProductRenderBase {
|
||||
kind: 'summary';
|
||||
summary: string;
|
||||
keyPoints?: string[];
|
||||
sections?: Array<{
|
||||
heading: string;
|
||||
body: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ChecklistWorkProductRender extends WorkProductRenderBase {
|
||||
kind: 'checklist';
|
||||
items: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
checked: boolean;
|
||||
notes?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ReportWorkProductRender extends WorkProductRenderBase {
|
||||
kind: 'report';
|
||||
summary: string;
|
||||
sections: Array<{
|
||||
heading: string;
|
||||
body: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface TableWorkProductRender extends WorkProductRenderBase {
|
||||
kind: 'table';
|
||||
columns: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
type?: 'text' | 'number' | 'boolean' | 'date';
|
||||
}>;
|
||||
rows: Array<Record<string, WorkProductPrimitive>>;
|
||||
}
|
||||
|
||||
export interface DashboardWorkProductRender extends WorkProductRenderBase {
|
||||
kind: 'dashboard';
|
||||
widgets: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
value?: WorkProductPrimitive;
|
||||
description?: string;
|
||||
tone?: 'neutral' | 'good' | 'warning' | 'critical';
|
||||
}>;
|
||||
}
|
||||
|
||||
export type WorkProductRender =
|
||||
| TextWorkProductRender
|
||||
| MarkdownWorkProductRender
|
||||
| SummaryWorkProductRender
|
||||
| ChecklistWorkProductRender
|
||||
| ReportWorkProductRender
|
||||
| TableWorkProductRender
|
||||
| DashboardWorkProductRender;
|
||||
|
||||
export interface WorkProduct {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
kind: WorkProductKind;
|
||||
title: string;
|
||||
status: WorkProductStatus;
|
||||
render: WorkProductRender;
|
||||
version: number;
|
||||
taskId?: string;
|
||||
sourceRunId?: string;
|
||||
agent?: string;
|
||||
model?: string;
|
||||
redaction?: WorkProductRedaction;
|
||||
sourceLinks?: WorkProductSourceLink[];
|
||||
metadata?: Record<string, WorkProductPrimitive>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
archivedAt?: string;
|
||||
}
|
||||
|
||||
export interface WorkProductVersion {
|
||||
id: string;
|
||||
productId: string;
|
||||
workspaceId: string;
|
||||
version: number;
|
||||
changeType: WorkProductChangeType;
|
||||
changeSummary?: string;
|
||||
render: WorkProductRender;
|
||||
title: string;
|
||||
kind: WorkProductKind;
|
||||
agent?: string;
|
||||
model?: string;
|
||||
redaction?: WorkProductRedaction;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface WorkProductPreview {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
kind: WorkProductKind;
|
||||
title: string;
|
||||
status: WorkProductStatus;
|
||||
version: number;
|
||||
taskId?: string;
|
||||
sourceRunId?: string;
|
||||
agent?: string;
|
||||
model?: string;
|
||||
sourceLinks?: WorkProductSourceLink[];
|
||||
redacted: boolean;
|
||||
snippet: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateWorkProductInput {
|
||||
kind: WorkProductKind;
|
||||
title: string;
|
||||
render: WorkProductRender;
|
||||
taskId?: string;
|
||||
sourceRunId?: string;
|
||||
agent?: string;
|
||||
model?: string;
|
||||
workspaceId?: string;
|
||||
redaction?: WorkProductRedaction;
|
||||
sourceLinks?: WorkProductSourceLink[];
|
||||
metadata?: Record<string, WorkProductPrimitive>;
|
||||
changeSummary?: string;
|
||||
}
|
||||
|
||||
export interface UpdateWorkProductInput {
|
||||
title?: string;
|
||||
render?: WorkProductRender;
|
||||
status?: WorkProductStatus;
|
||||
taskId?: string;
|
||||
sourceRunId?: string;
|
||||
agent?: string;
|
||||
model?: string;
|
||||
redaction?: WorkProductRedaction;
|
||||
sourceLinks?: WorkProductSourceLink[];
|
||||
metadata?: Record<string, WorkProductPrimitive>;
|
||||
changeType?: Exclude<WorkProductChangeType, 'create'>;
|
||||
changeSummary?: string;
|
||||
}
|
||||
|
||||
export interface WorkProductListOptions {
|
||||
taskId?: string;
|
||||
sourceRunId?: string;
|
||||
agent?: string;
|
||||
kind?: WorkProductKind;
|
||||
status?: WorkProductStatus;
|
||||
query?: string;
|
||||
includeArchived?: boolean;
|
||||
limit?: number;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue