fix: enforce work product taxonomy
This commit is contained in:
1 parent
fa53d15822
commit
67ccfc1160
9 files changed
+261
-38
No files matched your search
@@ -1,5 +1,11 @@
|
||||
import log from "electron-log/main.js";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
workProductProviderEnum,
|
||||
workProductReviewStateEnum,
|
||||
workProductStatusEnum,
|
||||
workProductTypeEnum,
|
||||
} from "@shared/contracts";
|
||||
import { requireParam, type AgentApiServer } from "./server-core";
|
||||
import { normalizeExternalHttpUrl } from "../external-url-policy";
|
||||
|
||||
@@ -37,27 +43,27 @@ const updateDocumentSchema = z.object({
|
||||
});
|
||||
|
||||
const createWorkProductSchema = z.object({
|
||||
type: z.string(),
|
||||
provider: z.string(),
|
||||
type: workProductTypeEnum,
|
||||
provider: workProductProviderEnum,
|
||||
title: z.string(),
|
||||
projectId: z.string().optional(),
|
||||
externalId: z.string().optional(),
|
||||
url: workProductUrlSchema.optional(),
|
||||
status: z.string().optional(),
|
||||
reviewState: z.string().optional(),
|
||||
status: workProductStatusEnum.optional(),
|
||||
reviewState: workProductReviewStateEnum.optional(),
|
||||
isPrimary: z.boolean().optional(),
|
||||
summary: z.string().optional(),
|
||||
metadata: z.unknown().optional(),
|
||||
});
|
||||
|
||||
const updateWorkProductSchema = z.object({
|
||||
type: z.string().optional(),
|
||||
provider: z.string().optional(),
|
||||
type: workProductTypeEnum.optional(),
|
||||
provider: workProductProviderEnum.optional(),
|
||||
externalId: z.string().optional(),
|
||||
title: z.string().optional(),
|
||||
url: workProductUrlSchema.optional(),
|
||||
status: z.string().optional(),
|
||||
reviewState: z.string().optional(),
|
||||
status: workProductStatusEnum.optional(),
|
||||
reviewState: workProductReviewStateEnum.optional(),
|
||||
isPrimary: z.boolean().optional(),
|
||||
summary: z.string().optional(),
|
||||
metadataJson: z.string().optional(),
|
||||
|
||||
@@ -2,6 +2,16 @@ import { randomUUID } from "node:crypto";
|
||||
import { statSync } from "node:fs";
|
||||
import { isAbsolute, resolve as resolvePath, relative as relativePath } from "node:path";
|
||||
import type { DatabaseSync, SQLInputValue } from "node:sqlite";
|
||||
import {
|
||||
workProductProviderEnum,
|
||||
workProductReviewStateEnum,
|
||||
workProductStatusEnum,
|
||||
workProductTypeEnum,
|
||||
type WorkProductProvider,
|
||||
type WorkProductReviewState,
|
||||
type WorkProductStatus,
|
||||
type WorkProductType,
|
||||
} from "@shared/contracts";
|
||||
import type { ReadAll, Transaction } from "./helpers";
|
||||
import { nowIso } from "./helpers";
|
||||
|
||||
@@ -73,6 +83,22 @@ function joinWorkspaceRelative(root: string, rel: string): string | null {
|
||||
return joined;
|
||||
}
|
||||
|
||||
function parseWorkProductType(value: string): WorkProductType {
|
||||
return workProductTypeEnum.parse(value);
|
||||
}
|
||||
|
||||
function parseWorkProductProvider(value: string): WorkProductProvider {
|
||||
return workProductProviderEnum.parse(value);
|
||||
}
|
||||
|
||||
function parseWorkProductStatus(value: string): WorkProductStatus {
|
||||
return workProductStatusEnum.parse(value);
|
||||
}
|
||||
|
||||
function parseWorkProductReviewState(value: string): WorkProductReviewState {
|
||||
return workProductReviewStateEnum.parse(value);
|
||||
}
|
||||
|
||||
export function buildWorkProductMethods(
|
||||
getDb: () => DatabaseSync,
|
||||
readAll: ReadAll,
|
||||
@@ -82,19 +108,22 @@ export function buildWorkProductMethods(
|
||||
createWorkProduct(input: {
|
||||
companyId: string; taskId: string | null; projectId?: string | null;
|
||||
workspaceId?: string | null;
|
||||
type: string; provider: string; externalId?: string | null;
|
||||
title: string; url?: string | null; status?: string; reviewState?: string;
|
||||
type: WorkProductType; provider: WorkProductProvider; externalId?: string | null;
|
||||
title: string; url?: string | null; status?: WorkProductStatus; reviewState?: WorkProductReviewState;
|
||||
isPrimary?: boolean; summary?: string | null; metadataJson?: string | null;
|
||||
createdByRunId?: string | null;
|
||||
}): string {
|
||||
return transaction(() => {
|
||||
const id = randomUUID();
|
||||
const ts = nowIso();
|
||||
const status = input.status ?? "active";
|
||||
const type = parseWorkProductType(input.type);
|
||||
const provider = parseWorkProductProvider(input.provider);
|
||||
const status = input.status !== undefined ? parseWorkProductStatus(input.status) : "active";
|
||||
const reviewState = input.reviewState !== undefined ? parseWorkProductReviewState(input.reviewState) : "none";
|
||||
if (input.isPrimary && input.taskId && status !== "archived") {
|
||||
getDb().prepare(
|
||||
"UPDATE work_products SET is_primary = 0, updated_at = ? WHERE company_id = ? AND task_id = ? AND type = ? AND status <> 'archived'",
|
||||
).run(ts, input.companyId, input.taskId, input.type);
|
||||
).run(ts, input.companyId, input.taskId, type);
|
||||
}
|
||||
getDb().prepare(
|
||||
`INSERT INTO work_products (id, company_id, project_id, task_id, workspace_id, type, provider, external_id, title, url, status, review_state, is_primary, summary, metadata_json, created_by_run_id, last_touched_by_run_id, created_at, updated_at)
|
||||
@@ -102,9 +131,9 @@ export function buildWorkProductMethods(
|
||||
).run(
|
||||
id, input.companyId, input.projectId ?? null, input.taskId ?? null,
|
||||
input.workspaceId ?? null,
|
||||
input.type, input.provider, input.externalId ?? null,
|
||||
type, provider, input.externalId ?? null,
|
||||
input.title, input.url ?? null, status,
|
||||
input.reviewState ?? "none", input.isPrimary ? 1 : 0,
|
||||
reviewState, input.isPrimary ? 1 : 0,
|
||||
input.summary ?? null, input.metadataJson ?? null,
|
||||
input.createdByRunId ?? null, input.createdByRunId ?? null, ts, ts,
|
||||
);
|
||||
@@ -114,16 +143,17 @@ export function buildWorkProductMethods(
|
||||
|
||||
findWorkProductByExternalId(
|
||||
companyId: string,
|
||||
provider: string,
|
||||
provider: WorkProductProvider,
|
||||
externalId: string,
|
||||
workspaceId: string | null,
|
||||
): { id: string; taskId: string | null } | null {
|
||||
const parsedProvider = parseWorkProductProvider(provider);
|
||||
if (workspaceId) {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
"SELECT id, task_id as taskId FROM work_products WHERE company_id = ? AND workspace_id = ? AND provider = ? AND external_id = ? LIMIT 1",
|
||||
)
|
||||
.get(companyId, workspaceId, provider, externalId) as
|
||||
.get(companyId, workspaceId, parsedProvider, externalId) as
|
||||
| { id: string; taskId: string | null }
|
||||
| undefined;
|
||||
return row ?? null;
|
||||
@@ -132,7 +162,7 @@ export function buildWorkProductMethods(
|
||||
.prepare(
|
||||
"SELECT id, task_id as taskId FROM work_products WHERE company_id = ? AND workspace_id IS NULL AND provider = ? AND external_id = ? LIMIT 1",
|
||||
)
|
||||
.get(companyId, provider, externalId) as
|
||||
.get(companyId, parsedProvider, externalId) as
|
||||
| { id: string; taskId: string | null }
|
||||
| undefined;
|
||||
return row ?? null;
|
||||
@@ -142,14 +172,14 @@ export function buildWorkProductMethods(
|
||||
id: string,
|
||||
companyId: string,
|
||||
patch: {
|
||||
type?: string;
|
||||
provider?: string;
|
||||
type?: WorkProductType;
|
||||
provider?: WorkProductProvider;
|
||||
externalId?: string | null;
|
||||
workspaceId?: string | null;
|
||||
title?: string;
|
||||
url?: string | null;
|
||||
status?: string;
|
||||
reviewState?: string;
|
||||
status?: WorkProductStatus;
|
||||
reviewState?: WorkProductReviewState;
|
||||
isPrimary?: boolean;
|
||||
summary?: string | null;
|
||||
metadataJson?: string | null;
|
||||
@@ -165,8 +195,10 @@ export function buildWorkProductMethods(
|
||||
if (!current) return 0;
|
||||
|
||||
const ts = nowIso();
|
||||
const nextType = patch.type ?? current.type;
|
||||
const nextStatus = patch.status ?? current.status;
|
||||
const nextType = patch.type !== undefined ? parseWorkProductType(patch.type) : parseWorkProductType(current.type);
|
||||
const nextStatus = patch.status !== undefined ? parseWorkProductStatus(patch.status) : parseWorkProductStatus(current.status);
|
||||
const parsedProvider = patch.provider !== undefined ? parseWorkProductProvider(patch.provider) : undefined;
|
||||
const parsedReviewState = patch.reviewState !== undefined ? parseWorkProductReviewState(patch.reviewState) : undefined;
|
||||
const nextIsPrimary = patch.isPrimary ?? Boolean(current.isPrimary);
|
||||
if (nextIsPrimary && current.taskId && nextStatus !== "archived") {
|
||||
getDb().prepare(
|
||||
@@ -177,21 +209,21 @@ export function buildWorkProductMethods(
|
||||
const sets: string[] = ["updated_at = ?"];
|
||||
const values: SQLInputValue[] = [ts];
|
||||
const map: Array<[keyof typeof patch, string, (v: unknown) => SQLInputValue]> = [
|
||||
["type", "type", (v) => (v as string) ?? null],
|
||||
["provider", "provider", (v) => (v as string) ?? null],
|
||||
["type", "type", () => nextType],
|
||||
["provider", "provider", () => parsedProvider ?? null],
|
||||
["externalId", "external_id", (v) => (v as string | null) ?? null],
|
||||
["workspaceId", "workspace_id", (v) => (v as string | null) ?? null],
|
||||
["title", "title", (v) => (v as string) ?? null],
|
||||
["url", "url", (v) => (v as string | null) ?? null],
|
||||
["status", "status", (v) => (v as string) ?? null],
|
||||
["reviewState", "review_state", (v) => (v as string) ?? null],
|
||||
["status", "status", () => nextStatus],
|
||||
["reviewState", "review_state", () => parsedReviewState ?? null],
|
||||
["isPrimary", "is_primary", (v) => (v ? 1 : 0)],
|
||||
["summary", "summary", (v) => (v as string | null) ?? null],
|
||||
["metadataJson", "metadata_json", (v) => (v as string | null) ?? null],
|
||||
["lastTouchedByRunId", "last_touched_by_run_id", (v) => (v as string | null) ?? null],
|
||||
];
|
||||
for (const [key, col, coerce] of map) {
|
||||
if (key in patch) {
|
||||
if (patch[key] !== undefined) {
|
||||
sets.push(`${col} = ?`);
|
||||
values.push(coerce(patch[key]));
|
||||
}
|
||||
@@ -318,11 +350,12 @@ export function buildWorkProductMethods(
|
||||
archiveWorkProductsByExternalPaths(input: {
|
||||
companyId: string;
|
||||
workspaceId: string | null;
|
||||
provider: string;
|
||||
provider: WorkProductProvider;
|
||||
paths: string[];
|
||||
lastTouchedByRunId: string | null;
|
||||
}): Array<{ id: string; externalId: string }> {
|
||||
if (input.paths.length === 0) return [];
|
||||
const provider = parseWorkProductProvider(input.provider);
|
||||
const ts = nowIso();
|
||||
const CHUNK = 500;
|
||||
const workspaceClause = input.workspaceId
|
||||
@@ -336,7 +369,7 @@ export function buildWorkProductMethods(
|
||||
input.lastTouchedByRunId ?? null,
|
||||
ts,
|
||||
input.companyId,
|
||||
input.provider,
|
||||
provider,
|
||||
];
|
||||
if (input.workspaceId) params.push(input.workspaceId);
|
||||
params.push(...chunk);
|
||||
|
||||
@@ -41,11 +41,11 @@ GET /api/companies/{{companyId}}/documents/{documentId}/revisions
|
||||
### Work Products (Structured Deliverables)
|
||||
POST /api/issues/{issueId}/work-products
|
||||
Body: {
|
||||
"type": "pull_request|branch|commit|artifact|document|preview_url|runtime_service",
|
||||
"provider": "agentcompany|github|custom",
|
||||
"type": "file|link|pull_request|branch|commit|artifact|document|preview_url|runtime_service",
|
||||
"provider": "filesystem|external|agentcompany|github|custom",
|
||||
"title": "Work product title",
|
||||
"url": "https://... (optional)",
|
||||
"status": "active|ready_for_review|approved|merged|closed|failed|draft",
|
||||
"status": "active|ready_for_review|approved|merged|closed|failed|draft|archived",
|
||||
"reviewState": "none|needs_board_review|approved|changes_requested",
|
||||
"isPrimary": true,
|
||||
"summary": "Brief description of the deliverable"
|
||||
|
||||
@@ -7,5 +7,6 @@ export * from "./contracts/schemas-approvals";
|
||||
export * from "./contracts/schemas-messages";
|
||||
export * from "./contracts/schemas-execution";
|
||||
export * from "./contracts/schemas-misc";
|
||||
export * from "./contracts/schemas-work-products";
|
||||
export * from "./contracts/desktop-api";
|
||||
export * from "./contracts/preload-forwarders";
|
||||
@@ -0,0 +1,45 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const workProductTypeEnum = z.enum([
|
||||
"file",
|
||||
"link",
|
||||
"pull_request",
|
||||
"branch",
|
||||
"commit",
|
||||
"artifact",
|
||||
"document",
|
||||
"preview_url",
|
||||
"runtime_service",
|
||||
]);
|
||||
|
||||
export const workProductProviderEnum = z.enum([
|
||||
"filesystem",
|
||||
"external",
|
||||
"agentcompany",
|
||||
"github",
|
||||
"custom",
|
||||
]);
|
||||
|
||||
export const workProductStatusEnum = z.enum([
|
||||
"active",
|
||||
"ready_for_review",
|
||||
"approved",
|
||||
"merged",
|
||||
"closed",
|
||||
"failed",
|
||||
"draft",
|
||||
"archived",
|
||||
]);
|
||||
|
||||
export const workProductReviewStateEnum = z.enum([
|
||||
"none",
|
||||
"needs_board_review",
|
||||
"approved",
|
||||
"changes_requested",
|
||||
]);
|
||||
|
||||
export type WorkProductType = z.infer<typeof workProductTypeEnum>;
|
||||
export type WorkProductProvider = z.infer<typeof workProductProviderEnum>;
|
||||
export type WorkProductStatus = z.infer<typeof workProductStatusEnum>;
|
||||
export type WorkProductReviewState = z.infer<typeof workProductReviewStateEnum>;
|
||||
|
||||
@@ -235,6 +235,13 @@ export type DocumentType =
|
||||
|
||||
export type DocumentStatus = "draft" | "in_review" | "approved" | "archived" | "superseded";
|
||||
|
||||
export type {
|
||||
WorkProductType,
|
||||
WorkProductProvider,
|
||||
WorkProductStatus,
|
||||
WorkProductReviewState,
|
||||
} from "../contracts/schemas-work-products";
|
||||
|
||||
export type KnowledgeCategory =
|
||||
| "lesson_learned"
|
||||
| "best_practice"
|
||||
|
||||
@@ -23,6 +23,10 @@ import type {
|
||||
ThreadWaitingFor,
|
||||
ThreadNotifyOn,
|
||||
DepartmentId,
|
||||
WorkProductType,
|
||||
WorkProductProvider,
|
||||
WorkProductStatus,
|
||||
WorkProductReviewState,
|
||||
} from "./enums";
|
||||
|
||||
export interface SocialAccountRecord {
|
||||
@@ -103,13 +107,13 @@ export interface WorkProductRecord {
|
||||
projectId: string | null;
|
||||
taskId: string | null;
|
||||
workspaceId: string | null;
|
||||
type: string;
|
||||
provider: string;
|
||||
type: WorkProductType;
|
||||
provider: WorkProductProvider;
|
||||
externalId: string | null;
|
||||
title: string;
|
||||
url: string | null;
|
||||
status: string;
|
||||
reviewState: string;
|
||||
status: WorkProductStatus;
|
||||
reviewState: WorkProductReviewState;
|
||||
isPrimary: boolean;
|
||||
summary: string | null;
|
||||
metadataJson: string | null;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { AppDatabase } from "../../src/main/database";
|
||||
import type { WorkProductType } from "../../src/shared/types";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
@@ -49,7 +50,7 @@ async function createHarness() {
|
||||
return { db, companyId, taskId };
|
||||
}
|
||||
|
||||
function createWorkProduct(db: AppDatabase, companyId: string, taskId: string, title: string, isPrimary = false, type = "file") {
|
||||
function createWorkProduct(db: AppDatabase, companyId: string, taskId: string, title: string, isPrimary = false, type: WorkProductType = "file") {
|
||||
return db.core.documents.createWorkProduct({
|
||||
companyId,
|
||||
taskId,
|
||||
|
||||
@@ -93,6 +93,132 @@ async function createHarness() {
|
||||
}
|
||||
|
||||
describe("work product URL API policy", () => {
|
||||
it("rejects unknown work product taxonomy during creation", async () => {
|
||||
const harness = await createHarness();
|
||||
try {
|
||||
const cases = [
|
||||
{ type: "spreadsheet", provider: "external", title: "Bad type" },
|
||||
{ type: "link", provider: "slack", title: "Bad provider" },
|
||||
{ type: "link", provider: "external", title: "Bad status", status: "ship_it" },
|
||||
{ type: "link", provider: "external", title: "Bad review", reviewState: "rubber_stamp" },
|
||||
];
|
||||
|
||||
for (const body of cases) {
|
||||
const response = await fetch(`${harness.baseUrl}/api/issues/${harness.taskId}/work-products`, {
|
||||
method: "POST",
|
||||
headers: harness.headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
}
|
||||
expect(harness.db.queryAll("select id from work_products")).toHaveLength(0);
|
||||
} finally {
|
||||
harness.server.stop();
|
||||
harness.db.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unknown work product taxonomy during updates", async () => {
|
||||
const harness = await createHarness();
|
||||
try {
|
||||
const id = harness.db.core.documents.createWorkProduct({
|
||||
companyId: harness.companyId,
|
||||
taskId: harness.taskId,
|
||||
type: "link",
|
||||
provider: "external",
|
||||
title: "External report",
|
||||
status: "active",
|
||||
reviewState: "none",
|
||||
});
|
||||
const cases = [
|
||||
{ type: "spreadsheet" },
|
||||
{ provider: "slack" },
|
||||
{ status: "ship_it" },
|
||||
{ reviewState: "rubber_stamp" },
|
||||
];
|
||||
|
||||
for (const body of cases) {
|
||||
const response = await fetch(`${harness.baseUrl}/api/work-products/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: harness.headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
}
|
||||
const row = harness.db.queryAll<{
|
||||
type: string;
|
||||
provider: string;
|
||||
status: string;
|
||||
review_state: string;
|
||||
}>("select type, provider, status, review_state from work_products where id = ? limit 1", id)[0];
|
||||
expect(row).toEqual({
|
||||
type: "link",
|
||||
provider: "external",
|
||||
status: "active",
|
||||
review_state: "none",
|
||||
});
|
||||
} finally {
|
||||
harness.server.stop();
|
||||
harness.db.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unknown work product taxonomy at the database boundary", async () => {
|
||||
const harness = await createHarness();
|
||||
try {
|
||||
expect(() => harness.db.core.documents.createWorkProduct({
|
||||
companyId: harness.companyId,
|
||||
taskId: harness.taskId,
|
||||
type: "link",
|
||||
// @ts-expect-error verifies runtime validation for direct DB callers.
|
||||
provider: "slack",
|
||||
title: "Bad provider",
|
||||
})).toThrow();
|
||||
expect(harness.db.queryAll("select id from work_products")).toHaveLength(0);
|
||||
|
||||
expect(() => harness.db.core.documents.createWorkProduct({
|
||||
companyId: harness.companyId,
|
||||
taskId: harness.taskId,
|
||||
type: "link",
|
||||
provider: "external",
|
||||
title: "Empty status",
|
||||
// @ts-expect-error verifies runtime validation for direct DB callers.
|
||||
status: "",
|
||||
})).toThrow();
|
||||
expect(harness.db.queryAll("select id from work_products")).toHaveLength(0);
|
||||
|
||||
expect(() => harness.db.core.documents.createWorkProduct({
|
||||
companyId: harness.companyId,
|
||||
taskId: harness.taskId,
|
||||
type: "link",
|
||||
provider: "external",
|
||||
title: "Empty review state",
|
||||
// @ts-expect-error verifies runtime validation for direct DB callers.
|
||||
reviewState: "",
|
||||
})).toThrow();
|
||||
expect(harness.db.queryAll("select id from work_products")).toHaveLength(0);
|
||||
|
||||
const id = harness.db.core.documents.createWorkProduct({
|
||||
companyId: harness.companyId,
|
||||
taskId: harness.taskId,
|
||||
type: "link",
|
||||
provider: "external",
|
||||
title: "External report",
|
||||
});
|
||||
expect(() => harness.db.core.documents.updateWorkProduct(id, harness.companyId, {
|
||||
// @ts-expect-error verifies runtime validation for direct DB callers.
|
||||
status: "ship_it",
|
||||
})).toThrow();
|
||||
const row = harness.db.queryAll<{ status: string }>("select status from work_products where id = ? limit 1", id)[0];
|
||||
expect(row?.status).toBe("active");
|
||||
} finally {
|
||||
harness.server.stop();
|
||||
harness.db.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects blank URLs during work product creation", async () => {
|
||||
const harness = await createHarness();
|
||||
try {
|
||||
|
||||
Reference in new issue
Block a user