diff --git a/src/main/api-server/routes-files.ts b/src/main/api-server/routes-files.ts index 1326028b..760e173e 100644 --- a/src/main/api-server/routes-files.ts +++ b/src/main/api-server/routes-files.ts @@ -1,6 +1,8 @@ import log from "electron-log/main.js"; import { z } from "zod"; import { + canonicalOptionalJsonString, + jsonStringArraySchema, workProductProviderEnum, workProductReviewStateEnum, workProductStatusEnum, @@ -29,7 +31,7 @@ const createDocumentSchema = z.object({ reviewerAgentId: z.string().optional(), projectId: z.string().optional(), goalId: z.string().optional(), - tagsJson: z.string().optional(), + tagsJson: canonicalOptionalJsonString(jsonStringArraySchema).optional(), }); const updateDocumentSchema = z.object({ @@ -38,7 +40,7 @@ const updateDocumentSchema = z.object({ content: z.string().optional(), status: z.string().optional(), reviewerAgentId: z.union([z.string(), z.null()]).optional(), - tagsJson: z.string().optional(), + tagsJson: canonicalOptionalJsonString(jsonStringArraySchema).optional(), changeSummary: z.string().optional(), baseRevisionId: z.string().optional(), }); diff --git a/src/main/api-server/routes-meetings.ts b/src/main/api-server/routes-meetings.ts index e0057033..d9b9657f 100644 --- a/src/main/api-server/routes-meetings.ts +++ b/src/main/api-server/routes-meetings.ts @@ -1,4 +1,5 @@ import log from "electron-log/main.js"; +import { canonicalJsonString, canonicalOptionalJsonString, jsonArraySchema, jsonStringArraySchema, meetingActionItemArraySchema } from "@shared/contracts"; import { z } from "zod"; import { validateTaskCreation } from "../task-quality-gates"; import { requireParam, type AgentApiServer } from "./server-core"; @@ -17,10 +18,10 @@ export function registerMeetingRoutes(server: AgentApiServer) { const meetingCreateSchema = z.object({ type: z.string().default("department_sync"), title: z.string().default("Meeting"), - participantAgentIds: z.string().default("[]"), + participantAgentIds: canonicalJsonString(jsonStringArraySchema, []), scheduledAt: z.string().default(new Date().toISOString()), durationMinutes: z.number().default(30), - agendaJson: z.string().default("[]"), + agendaJson: canonicalJsonString(jsonArraySchema, []), }); const parsed = meetingCreateSchema.safeParse(body); @@ -78,9 +79,9 @@ export function registerMeetingRoutes(server: AgentApiServer) { const meetingUpdateSchema = z.object({ type: z.string().optional(), title: z.string().optional(), - notesJson: z.string().optional(), - decisionsJson: z.string().optional(), - actionItemsJson: z.string().optional(), + notesJson: canonicalOptionalJsonString(jsonArraySchema).optional(), + decisionsJson: canonicalOptionalJsonString(jsonArraySchema).optional(), + actionItemsJson: canonicalOptionalJsonString(meetingActionItemArraySchema).optional(), status: z.string().optional(), scheduledAt: z.string().optional(), }); diff --git a/src/main/api-server/routes-misc.ts b/src/main/api-server/routes-misc.ts index 549d5ba6..9f92fbec 100644 --- a/src/main/api-server/routes-misc.ts +++ b/src/main/api-server/routes-misc.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { canonicalJsonString, jsonStringArraySchema } from "@shared/contracts"; import { requireParam, type AgentApiServer } from "./server-core"; import { SocialAccountScopeError } from "../database/social"; @@ -232,7 +233,7 @@ export function registerMiscRoutes(server: AgentApiServer) { topic: z.string().default(""), content: z.string().default(""), importance: z.string().default("medium"), - tagsJson: z.string().default("[]"), + tagsJson: canonicalJsonString(jsonStringArraySchema, []), referencedEntityType: z.string().nullable().optional(), referencedEntityId: z.string().nullable().optional(), }); diff --git a/src/main/database/documents.ts b/src/main/database/documents.ts index 6a692310..338f223d 100644 --- a/src/main/database/documents.ts +++ b/src/main/database/documents.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import type { DatabaseSync } from "node:sqlite"; +import { canonicalizeJsonString, jsonStringArraySchema } from "@shared/contracts"; import type { ReadAll, Transaction } from "./helpers"; import { ensureId, nowIso } from "./helpers"; import { buildConfigRevisionMethods } from "./config-revisions"; @@ -70,14 +71,15 @@ export function buildDocumentMethods( if (existing && existing.companyId !== input.companyId) { throw new Error(`Document ${id} does not belong to company ${input.companyId}`); } + const tagsJson = canonicalizeJsonString(input.tagsJson, jsonStringArraySchema, []); if (existing) { getDb().prepare( "update documents set type = ?, title = ?, content = ?, author_agent_id = ?, reviewer_agent_id = ?, project_id = ?, goal_id = ?, parent_doc_id = ?, version = ?, status = ?, tags_json = ?, source_run_id = coalesce(?, source_run_id), updated_at = ? where id = ? and company_id = ?", - ).run(input.type, input.title, input.content ?? "", input.authorAgentId ?? null, input.reviewerAgentId ?? null, input.projectId ?? null, input.goalId ?? null, input.parentDocId ?? null, input.version ?? 1, input.status ?? "draft", input.tagsJson ?? "[]", input.sourceRunId ?? null, ts, id, input.companyId); + ).run(input.type, input.title, input.content ?? "", input.authorAgentId ?? null, input.reviewerAgentId ?? null, input.projectId ?? null, input.goalId ?? null, input.parentDocId ?? null, input.version ?? 1, input.status ?? "draft", tagsJson, input.sourceRunId ?? null, ts, id, input.companyId); } else { getDb().prepare( "insert into documents (id, company_id, type, title, content, author_agent_id, reviewer_agent_id, project_id, goal_id, parent_doc_id, version, status, tags_json, source_run_id, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - ).run(id, input.companyId, input.type, input.title, input.content ?? "", input.authorAgentId ?? null, input.reviewerAgentId ?? null, input.projectId ?? null, input.goalId ?? null, input.parentDocId ?? null, input.version ?? 1, input.status ?? "draft", input.tagsJson ?? "[]", input.sourceRunId ?? null, ts, ts); + ).run(id, input.companyId, input.type, input.title, input.content ?? "", input.authorAgentId ?? null, input.reviewerAgentId ?? null, input.projectId ?? null, input.goalId ?? null, input.parentDocId ?? null, input.version ?? 1, input.status ?? "draft", tagsJson, input.sourceRunId ?? null, ts, ts); } return id; }, diff --git a/src/main/database/knowledge.ts b/src/main/database/knowledge.ts index 9533b314..addef3d7 100644 --- a/src/main/database/knowledge.ts +++ b/src/main/database/knowledge.ts @@ -1,4 +1,5 @@ import type { DatabaseSync } from "node:sqlite"; +import { canonicalizeJsonString, jsonArraySchema, jsonStringArraySchema, meetingActionItemArraySchema } from "@shared/contracts"; import type { ReadAll } from "./helpers"; import { ensureId, nowIso } from "./helpers"; @@ -101,14 +102,19 @@ export function buildKnowledgeMethods( if (existing && existing.companyId !== input.companyId) { throw new Error(`Meeting ${id} does not belong to company ${input.companyId}`); } + const participantAgentIds = canonicalizeJsonString(input.participantAgentIds, jsonStringArraySchema, []); + const agendaJson = canonicalizeJsonString(input.agendaJson, jsonArraySchema, []); + const notesJson = canonicalizeJsonString(input.notesJson, jsonArraySchema, []); + const decisionsJson = canonicalizeJsonString(input.decisionsJson, jsonArraySchema, []); + const actionItemsJson = canonicalizeJsonString(input.actionItemsJson, meetingActionItemArraySchema, []); if (existing) { getDb().prepare( "update meetings set type = ?, title = ?, organizer_agent_id = ?, participant_agent_ids = ?, scheduled_at = ?, duration_minutes = ?, agenda_json = ?, notes_json = ?, decisions_json = ?, action_items_json = ?, status = ?, updated_at = ? where id = ? and company_id = ?", - ).run(input.type, input.title, input.organizerAgentId ?? null, input.participantAgentIds ?? "[]", input.scheduledAt, input.durationMinutes ?? 30, input.agendaJson ?? "[]", input.notesJson ?? "[]", input.decisionsJson ?? "[]", input.actionItemsJson ?? "[]", input.status ?? "scheduled", ts, id, input.companyId); + ).run(input.type, input.title, input.organizerAgentId ?? null, participantAgentIds, input.scheduledAt, input.durationMinutes ?? 30, agendaJson, notesJson, decisionsJson, actionItemsJson, input.status ?? "scheduled", ts, id, input.companyId); } else { getDb().prepare( "insert into meetings (id, company_id, type, title, organizer_agent_id, participant_agent_ids, scheduled_at, duration_minutes, agenda_json, notes_json, decisions_json, action_items_json, status, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - ).run(id, input.companyId, input.type, input.title, input.organizerAgentId ?? null, input.participantAgentIds ?? "[]", input.scheduledAt, input.durationMinutes ?? 30, input.agendaJson ?? "[]", input.notesJson ?? "[]", input.decisionsJson ?? "[]", input.actionItemsJson ?? "[]", input.status ?? "scheduled", ts, ts); + ).run(id, input.companyId, input.type, input.title, input.organizerAgentId ?? null, participantAgentIds, input.scheduledAt, input.durationMinutes ?? 30, agendaJson, notesJson, decisionsJson, actionItemsJson, input.status ?? "scheduled", ts, ts); } return id; }, @@ -131,14 +137,15 @@ export function buildKnowledgeMethods( if (existing && existing.companyId !== input.companyId) { throw new Error(`Knowledge entry ${id} does not belong to company ${input.companyId}`); } + const tagsJson = canonicalizeJsonString(input.tagsJson, jsonStringArraySchema, []); if (existing) { getDb().prepare( "update knowledge_base set category = ?, topic = ?, content = ?, author_agent_id = ?, importance = ?, tags_json = ?, referenced_entity_type = ?, referenced_entity_id = ?, source_run_id = coalesce(?, source_run_id), updated_at = ? where id = ? and company_id = ?", - ).run(input.category, input.topic, input.content, input.authorAgentId ?? null, input.importance ?? "medium", input.tagsJson ?? "[]", input.referencedEntityType ?? null, input.referencedEntityId ?? null, input.sourceRunId ?? null, ts, id, input.companyId); + ).run(input.category, input.topic, input.content, input.authorAgentId ?? null, input.importance ?? "medium", tagsJson, input.referencedEntityType ?? null, input.referencedEntityId ?? null, input.sourceRunId ?? null, ts, id, input.companyId); } else { getDb().prepare( "insert into knowledge_base (id, company_id, category, topic, content, author_agent_id, importance, tags_json, referenced_entity_type, referenced_entity_id, source_run_id, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - ).run(id, input.companyId, input.category, input.topic, input.content, input.authorAgentId ?? null, input.importance ?? "medium", input.tagsJson ?? "[]", input.referencedEntityType ?? null, input.referencedEntityId ?? null, input.sourceRunId ?? null, ts, ts); + ).run(id, input.companyId, input.category, input.topic, input.content, input.authorAgentId ?? null, input.importance ?? "medium", tagsJson, input.referencedEntityType ?? null, input.referencedEntityId ?? null, input.sourceRunId ?? null, ts, ts); } return id; }, diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index ce168962..52742182 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -1,5 +1,6 @@ export * from "./contracts/channels"; export * from "./contracts/helpers"; +export * from "./contracts/json-fields"; export * from "./contracts/schemas-company"; export * from "./contracts/schemas-agents"; export * from "./contracts/schemas-tasks"; diff --git a/src/shared/contracts/json-fields.ts b/src/shared/contracts/json-fields.ts new file mode 100644 index 00000000..8b0f6976 --- /dev/null +++ b/src/shared/contracts/json-fields.ts @@ -0,0 +1,80 @@ +import { z } from "zod"; + +export type JsonValue = + | null + | boolean + | number + | string + | JsonValue[] + | { [key: string]: JsonValue }; + +export const jsonValueSchema: z.ZodType = z.lazy(() => z.union([ + z.null(), + z.boolean(), + z.number(), + z.string(), + z.array(jsonValueSchema), + z.record(z.string(), jsonValueSchema), +])); + +export const jsonStringArraySchema = z.array(z.string()); +export const jsonArraySchema = z.array(jsonValueSchema); + +export const meetingActionItemArraySchema = z.array(z.object({ + title: z.string().optional(), + assigneeAgentId: z.string().optional(), + description: z.string().optional(), +}).catchall(jsonValueSchema)); + +export function canonicalizeJsonString(value: string | null | undefined, schema: z.ZodType, fallback: T): string { + const raw = value ?? JSON.stringify(fallback); + const parsed = JSON.parse(raw); + return JSON.stringify(schema.parse(parsed)); +} + +export function canonicalJsonString(schema: z.ZodType, fallback: T) { + const fallbackJson = JSON.stringify(fallback); + return z.string().default(fallbackJson).superRefine((value, ctx) => { + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Must be valid JSON.", + }); + return; + } + + const result = schema.safeParse(parsed); + if (!result.success) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "JSON value does not match the expected shape.", + }); + } + }).transform((value) => canonicalizeJsonString(value, schema, fallback)); +} + +export function canonicalOptionalJsonString(schema: z.ZodType) { + return z.string().superRefine((value, ctx) => { + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Must be valid JSON.", + }); + return; + } + + const result = schema.safeParse(parsed); + if (!result.success) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "JSON value does not match the expected shape.", + }); + } + }).transform((value) => JSON.stringify(schema.parse(JSON.parse(value)))); +} diff --git a/src/shared/contracts/schemas-messages.ts b/src/shared/contracts/schemas-messages.ts index b5c54a4b..b0fe43ec 100644 --- a/src/shared/contracts/schemas-messages.ts +++ b/src/shared/contracts/schemas-messages.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { canonicalJsonString, jsonArraySchema, jsonStringArraySchema, meetingActionItemArraySchema } from "./json-fields"; import { MESSAGE_CHANNELS, optionalId } from "./helpers"; export const agentMessageInputSchema = z.object({ @@ -86,7 +87,7 @@ export const documentInputSchema = z.object({ parentDocId: optionalId, version: z.number().int().min(1).default(1), status: z.enum(["draft", "in_review", "approved", "archived", "superseded"]).default("draft"), - tagsJson: z.string().default("[]"), + tagsJson: canonicalJsonString(jsonStringArraySchema, []), }); export const knowledgeEntryInputSchema = z.object({ @@ -97,7 +98,7 @@ export const knowledgeEntryInputSchema = z.object({ content: z.string().min(1), authorAgentId: optionalId, importance: z.enum(["critical", "high", "medium", "low"]).default("medium"), - tagsJson: z.string().default("[]"), + tagsJson: canonicalJsonString(jsonStringArraySchema, []), referencedEntityType: z.enum([ "agents", "goals", @@ -122,13 +123,13 @@ export const meetingInputSchema = z.object({ type: z.enum(["standup", "sprint_planning", "sprint_review", "retrospective", "one_on_one", "all_hands", "department_sync", "incident_review", "hiring_committee", "budget_review", "design_review", "architecture_review"]), title: z.string().min(2).max(160), organizerAgentId: optionalId, - participantAgentIds: z.string().default("[]"), + participantAgentIds: canonicalJsonString(jsonStringArraySchema, []), scheduledAt: z.string(), durationMinutes: z.number().int().min(5).max(480).default(30), - agendaJson: z.string().default("[]"), - notesJson: z.string().default("[]"), - decisionsJson: z.string().default("[]"), - actionItemsJson: z.string().default("[]"), + agendaJson: canonicalJsonString(jsonArraySchema, []), + notesJson: canonicalJsonString(jsonArraySchema, []), + decisionsJson: canonicalJsonString(jsonArraySchema, []), + actionItemsJson: canonicalJsonString(meetingActionItemArraySchema, []), status: z.enum(["scheduled", "in_progress", "completed", "cancelled"]).default("scheduled"), }); diff --git a/tests/unit/contracts.test.ts b/tests/unit/contracts.test.ts index 336d6417..05888ab7 100644 --- a/tests/unit/contracts.test.ts +++ b/tests/unit/contracts.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; import { connectorInputSchema, + documentInputSchema, + knowledgeEntryInputSchema, + meetingInputSchema, runLogSchema, secretInputSchema, startRunSchema, @@ -63,4 +66,57 @@ describe("shared contracts", () => { expect(parsed.projectId).toBeNull(); }); + + it("canonicalizes structured JSON string fields", () => { + const companyId = "00000000-0000-0000-0000-000000000000"; + + const document = documentInputSchema.parse({ + companyId, + type: "technical_spec", + title: "JSON contract", + tagsJson: "[ \"security\", \"audit\" ]", + }); + expect(document.tagsJson).toBe("[\"security\",\"audit\"]"); + + const knowledge = knowledgeEntryInputSchema.parse({ + companyId, + category: "technical", + topic: "JSON fields", + content: "Canonicalize JSON before persistence.", + tagsJson: "[ \"runtime\" ]", + }); + expect(knowledge.tagsJson).toBe("[\"runtime\"]"); + + const meeting = meetingInputSchema.parse({ + companyId, + type: "department_sync", + title: "JSON field sync", + scheduledAt: "2026-05-15T10:00:00.000Z", + participantAgentIds: "[ \"11111111-1111-1111-1111-111111111111\" ]", + agendaJson: "[ \"Plan\" ]", + actionItemsJson: "[ { \"title\": \"Ship\", \"description\": \"Finish\" } ]", + }); + expect(meeting.participantAgentIds).toBe("[\"11111111-1111-1111-1111-111111111111\"]"); + expect(meeting.agendaJson).toBe("[\"Plan\"]"); + expect(meeting.actionItemsJson).toBe("[{\"title\":\"Ship\",\"description\":\"Finish\"}]"); + }); + + it("rejects malformed structured JSON string fields", () => { + const companyId = "00000000-0000-0000-0000-000000000000"; + + expect(() => documentInputSchema.parse({ + companyId, + type: "technical_spec", + title: "Broken tags", + tagsJson: "{", + })).toThrow(); + + expect(() => meetingInputSchema.parse({ + companyId, + type: "department_sync", + title: "Broken participants", + scheduledAt: "2026-05-15T10:00:00.000Z", + participantAgentIds: "[1]", + })).toThrow(); + }); }); diff --git a/tests/unit/operation-json-fields.test.ts b/tests/unit/operation-json-fields.test.ts new file mode 100644 index 00000000..477330b7 --- /dev/null +++ b/tests/unit/operation-json-fields.test.ts @@ -0,0 +1,74 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +beforeAll(() => { + process.env.AGENTCOMPANY_ALLOW_INSECURE_VAULT = "true"; +}); + +vi.mock("electron", () => ({ + safeStorage: { + isEncryptionAvailable: () => false, + getSelectedStorageBackend: () => "basic_text", + encryptString: (value: string) => Buffer.from(value, "utf8"), + decryptString: (value: Buffer) => value.toString("utf8"), + }, +})); + +import { AppDatabase } from "../../src/main/database"; + +describe("operation JSON fields", () => { + let tempDir: string; + let db: AppDatabase; + let companyId: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "agentcompany-json-fields-")); + db = new AppDatabase(tempDir); + db.init(); + companyId = db.core.company.saveCompany({ + name: "JSON Field Co", + description: "JSON field validation", + status: "active", + }); + }); + + afterEach(async () => { + db.close(); + await rm(tempDir, { recursive: true, force: true }); + }); + + it("canonicalizes document and knowledge tag JSON at the DAO boundary", () => { + const documentId = db.core.documents.saveDocument({ + companyId, + type: "technical_spec", + title: "Canonical tags", + tagsJson: "[ \"security\", \"runtime\" ]", + }); + const document = db.core.documents.getDocumentForUpdate(documentId, companyId); + expect(document?.tagsJson).toBe("[\"security\",\"runtime\"]"); + + const knowledgeId = db.core.knowledge.saveKnowledgeEntry({ + companyId, + category: "technical", + topic: "Canonical knowledge tags", + content: "Persisted JSON is canonical.", + tagsJson: "[ \"audit\" ]", + }); + const knowledge = db.core.knowledge.listKnowledgeEntries(companyId).find((entry) => entry.id === knowledgeId); + expect(knowledge?.tagsJson).toBe("[\"audit\"]"); + }); + + it("rejects malformed meeting JSON before writing", () => { + expect(() => db.core.knowledge.saveMeeting({ + companyId, + type: "department_sync", + title: "Broken JSON", + scheduledAt: "2026-05-15T10:00:00.000Z", + participantAgentIds: "[1]", + })).toThrow(); + + expect(db.core.knowledge.listMeetings(companyId)).toHaveLength(0); + }); +});