fix(data): canonicalize operation json fields

This commit is contained in:
ZacharyZhang-NY committed 2026-05-15 02:36:19 -04:00
1 parent 10c1f6d103
commit b92cf62c4c
10 files changed
+246 -21

No files matched your search

+4 -2
View File
@@ -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(),
});
+6 -5
View File
@@ -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(),
});
+2 -1
View File
@@ -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(),
});
+4 -2
View File
@@ -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;
},
+11 -4
View File
@@ -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;
},
+1
View File
@@ -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";
+80
View File
@@ -0,0 +1,80 @@
import { z } from "zod";
export type JsonValue =
| null
| boolean
| number
| string
| JsonValue[]
| { [key: string]: JsonValue };
export const jsonValueSchema: z.ZodType<JsonValue> = 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<T>(value: string | null | undefined, schema: z.ZodType<T>, fallback: T): string {
const raw = value ?? JSON.stringify(fallback);
const parsed = JSON.parse(raw);
return JSON.stringify(schema.parse(parsed));
}
export function canonicalJsonString<T>(schema: z.ZodType<T>, 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<T>(schema: z.ZodType<T>) {
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))));
}
+8 -7
View File
@@ -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"),
});
+56
View File
@@ -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();
});
});
+74
View File
@@ -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);
});
});