Add structured chat delivery records
This commit is contained in:
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"),
|
||||
|
||||
@@ -129,6 +129,7 @@ export interface CreateChatSpaceInput {
|
||||
name: string;
|
||||
topic: string;
|
||||
visibility: "workspace" | "restricted";
|
||||
retentionPolicy?: "standard_365d" | "restricted_365d";
|
||||
memberEmployeeIds?: string[];
|
||||
memberAgentIds?: string[];
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user