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:
Brad Groux 2026-05-31 01:31:00 -05:00 committed by GitHub
parent 277dc6608e
commit b502872b49
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1840 additions and 8 deletions

View file

@ -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

View file

@ -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. |

View 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.

View 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;
}
}
});
});

View file

@ -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(),

View file

@ -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);

View 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 };

View 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(),
});

View file

@ -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);

View 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;
}

View file

@ -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[] {

View 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(' ');
}
}

View file

@ -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';

View 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;
}