Add structured chat delivery records

This commit is contained in:
ZacharyZhang-NY committed 2026-05-23 21:04:59 -04:00
1 parent a27658b56f
commit 18e0c6338e
10 files changed
+133 -8

No files matched your search

@@ -0,0 +1,10 @@
export const chatMessageDeliverySchema = `
alter table chat_messages add column body_structured_json text not null default '{}';
alter table chat_messages add column delivery_state text not null default 'sent';
create index if not exists idx_chat_messages_delivery_state
on chat_messages(org_id, delivery_state, created_at);
insert into schema_migrations(version, name, applied_at)
values (25, 'chat_message_delivery', datetime('now'));
`;
@@ -23,6 +23,7 @@ import { providerAuthBrokerSchema } from "./migrations/021-provider-auth-broker"
import { providerRateLimitObservationSchema } from "./migrations/022-provider-rate-limit-observations";
import { providerRequestRuntimeSchema } from "./migrations/023-provider-request-runtime";
import { taskRunProviderRequestSchema } from "./migrations/024-task-run-provider-request";
import { chatMessageDeliverySchema } from "./migrations/025-chat-message-delivery";
interface Migration {
version: number;
@@ -55,6 +56,7 @@ const MIGRATIONS: Migration[] = [
{ version: 22, name: "provider_rate_limit_observations", sql: providerRateLimitObservationSchema },
{ version: 23, name: "provider_request_runtime", sql: providerRequestRuntimeSchema },
{ version: 24, name: "task_run_provider_request", sql: taskRunProviderRequestSchema },
{ version: 25, name: "chat_message_delivery", sql: chatMessageDeliverySchema },
];
const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
@@ -0,0 +1,21 @@
import type { ResolvedMention } from "./chat-mentions";
export interface ChatMessageStructureInput {
slash: { command: string; args: string } | null;
mentions: ResolvedMention[];
attachmentFileIds: string[];
}
export function buildChatMessageStructure(input: ChatMessageStructureInput): string {
return JSON.stringify({
format: "markdown",
slashInvocation: input.slash ? { command: input.slash.command, args: input.slash.args } : null,
mentions: input.mentions.map((mention) => ({
targetType: mention.target.type,
targetId: mention.target.id,
offsetStart: mention.offsetStart,
offsetEnd: mention.offsetEnd,
})),
attachmentFileIds: input.attachmentFileIds,
});
}
@@ -9,10 +9,12 @@ import { createId } from "../../shared/ids";
import { ApprovalService } from "./approval-service";
import { AuditService } from "./audit-service";
import { BoardInterventionService, parseBoardCommand } from "./board-intervention-service";
import { buildChatMessageStructure } from "./chat-message-structure";
import { recordChatMentions, resolveChatMentions, type MentionTarget, type ResolvedMention } from "./chat-mentions";
const SPACE_TYPES = new Set<CreateChatSpaceInput["spaceType"]>(["company", "department", "project", "direct"]);
const SPACE_VISIBILITIES = new Set<CreateChatSpaceInput["visibility"]>(["workspace", "restricted"]);
const RETENTION_POLICIES = new Set(["standard_365d", "restricted_365d"]);
interface AttachmentCandidate {
fileId: string;
@@ -71,9 +73,9 @@ export class ChatService {
const id = createId("spc");
const now = new Date().toISOString();
this.db.prepare(`
insert into chat_spaces(id, org_id, space_type, name, topic, visibility, created_at)
values (?, ?, ?, ?, ?, ?, ?)
`).run(id, input.orgId, input.spaceType, name, topic, input.visibility, now);
insert into chat_spaces(id, org_id, space_type, name, topic, visibility, retention_policy, created_at)
values (?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, input.orgId, input.spaceType, name, topic, input.visibility, retentionPolicy(input.retentionPolicy, input.visibility), now);
const members = broadSpace ? this.resolveWorkspaceMembers(input.orgId) : explicitMembers;
for (const member of members) this.addMembership(id, member, input.visibility === "restricted" ? "approver" : "member");
@@ -101,16 +103,19 @@ export class ChatService {
const slash = parseSlashInvocation(body);
if (slash && !ALLOWED_SLASH_COMMANDS.has(slash.command)) throw new Error(`Slash command /${slash.command} is unavailable.`);
if (slash) validateSlashInvocation(slash);
const attachments = this.requireAttachments(input.orgId, input.attachmentFileIds ?? [], sender);
const attachmentFileIds = input.attachmentFileIds ?? [];
const attachments = this.requireAttachments(input.orgId, attachmentFileIds, sender);
const mentions = resolveChatMentions(this.db, input.orgId, input.spaceId, body);
const structuredBody = buildChatMessageStructure({ slash, mentions, attachmentFileIds });
const id = createId("msg");
const threadId = input.threadId ?? createId("thread");
const now = new Date().toISOString();
this.db.prepare(`
insert into chat_messages(
id, org_id, space_id, thread_id, sender_type, sender_id, body_md, created_at, edited_at, deleted_at
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
id, org_id, space_id, thread_id, sender_type, sender_id, body_md, body_structured_json,
delivery_state, created_at, edited_at, deleted_at
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id,
input.orgId,
@@ -119,6 +124,8 @@ export class ChatService {
input.senderType,
input.senderId,
body,
structuredBody,
"sent",
now,
null,
null,
@@ -479,3 +486,9 @@ function createThreadTitle(body: string): string {
const firstLine = body.split(/\r?\n/, 1)[0].trim();
return firstLine.length > 80 ? `${firstLine.slice(0, 77)}...` : firstLine;
}
function retentionPolicy(value: string | undefined, visibility: CreateChatSpaceInput["visibility"]): string {
const policy = value ?? (visibility === "restricted" ? "restricted_365d" : "standard_365d");
if (!RETENTION_POLICIES.has(policy)) throw new Error("Chat retention policy is invalid.");
return policy;
}
@@ -262,7 +262,7 @@ export class SdlcService {
{ key: "provider_adapter_coverage", passed: missing.length === 0, value: `${adapterKeys.length}/${catalogKeys.length}` },
{ key: "provider_rate_limit_registry", passed: rateLimitRegistry === 1, value: rateLimitRegistry },
{ key: "provider_runtime_contract", passed: runtimeContractReady, value: runtimeContractReady },
{ key: "schema_version", passed: schemaVersion >= 24, value: schemaVersion },
{ key: "schema_version", passed: schemaVersion >= 25, value: schemaVersion },
];
}
@@ -92,6 +92,7 @@ export class SnapshotService {
taskRuns: list(this.db, "task_runs", "created_at asc"),
approvals: list(this.db, "approvals", "created_at desc"),
chatSpaces: list(this.db, "chat_spaces", "created_at asc"),
chatMemberships: list(this.db, "chat_memberships", "space_id asc, principal_type asc, principal_id asc"),
chatMessages: list(this.db, "chat_messages", "created_at asc"),
chatThreads: list(this.db, "chat_threads", "updated_at desc"),
chatAttachments: list(this.db, "chat_attachments", "message_id asc"),
+1
View File
@@ -129,6 +129,7 @@ export interface CreateChatSpaceInput {
name: string;
topic: string;
visibility: "workspace" | "restricted";
retentionPolicy?: "standard_365d" | "restricted_365d";
memberEmployeeIds?: string[];
memberAgentIds?: string[];
}
+11
View File
@@ -182,11 +182,21 @@ export interface ChatMessageRecord {
senderType: "employee" | "agent" | "board" | "system";
senderId: string | null;
bodyMd: string;
bodyStructuredJson: string;
deliveryState: string;
createdAt: string;
editedAt: string | null;
deletedAt: string | null;
}
export interface ChatMembershipRecord {
spaceId: string;
principalType: "employee" | "agent";
principalId: string;
role: string;
muteState: string;
}
export interface ChatThreadRecord {
id: string;
orgId: string;
@@ -439,6 +449,7 @@ export interface AppSnapshot {
taskRuns: TaskRunRecord[];
approvals: ApprovalRecord[];
chatSpaces: ChatSpaceRecord[];
chatMemberships: ChatMembershipRecord[];
chatMessages: ChatMessageRecord[];
chatThreads: ChatThreadRecord[];
chatAttachments: ChatAttachmentRecord[];
@@ -12,6 +12,72 @@ function createConnection(): DatabaseConnection {
}
describe("chat governance", () => {
test("persists memberships, retention policy, and structured delivery metadata", () => {
const connection = createConnection();
const organization = new OrganizationService(connection.db);
const chat = new ChatService(connection.db);
const snapshot = new SnapshotService(connection.db);
let orgId = "";
let employeeId = "";
let agentId = "";
let spaceId = "";
connection.transaction(() => {
orgId = organization.createOrganization({
organizationName: "Acme Operations",
employeeName: "Dana Lee",
mission: "Coordinate agents with auditable approvals.",
goal: "Ship a verified control plane.",
});
employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id;
agentId = organization.createAgent({
orgId,
name: "Ada Lovelace",
role: "Release reviewer",
department: "Engineering",
modelProviderId: null,
modelId: null,
budgetCents: 10000,
managerAgentId: null,
});
spaceId = chat.createSpace({
orgId,
spaceType: "project",
name: "release-review",
topic: "Release review",
visibility: "restricted",
memberEmployeeIds: [employeeId],
memberAgentIds: [agentId],
});
chat.sendMessage({
orgId,
spaceId,
threadId: null,
senderType: "employee",
senderId: employeeId,
bodyMd: "/escalate review @Ada-Lovelace release gate",
attachmentFileIds: [],
});
});
const state = snapshot.load();
const message = state.chatMessages[0];
const structure = JSON.parse(message!.bodyStructuredJson) as {
slashInvocation: { command: string };
mentions: Array<{ targetType: string; targetId: string }>;
};
expect(state.chatSpaces.find((space) => space.id === spaceId)?.retentionPolicy).toBe("restricted_365d");
expect(state.chatMemberships
.filter((membership) => membership.spaceId === spaceId)
.map((membership) => `${membership.principalType}:${membership.principalId}`)
.sort())
.toEqual([`agent:${agentId}`, `employee:${employeeId}`]);
expect(message).toMatchObject({ deliveryState: "sent" });
expect(structure.slashInvocation.command).toBe("escalate");
expect(structure.mentions[0]).toMatchObject({ targetType: "agent", targetId: agentId });
connection.close();
});
test("records board interventions and enforces thread control", () => {
const connection = createConnection();
const organization = new OrganizationService(connection.db);
@@ -23,7 +23,7 @@ describe("domain services", () => {
const connection = createConnection();
const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>;
expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24]);
expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25]);
connection.close();
});