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 { providerRateLimitObservationSchema } from "./migrations/022-provider-rate-limit-observations";
|
||||||
import { providerRequestRuntimeSchema } from "./migrations/023-provider-request-runtime";
|
import { providerRequestRuntimeSchema } from "./migrations/023-provider-request-runtime";
|
||||||
import { taskRunProviderRequestSchema } from "./migrations/024-task-run-provider-request";
|
import { taskRunProviderRequestSchema } from "./migrations/024-task-run-provider-request";
|
||||||
|
import { chatMessageDeliverySchema } from "./migrations/025-chat-message-delivery";
|
||||||
|
|
||||||
interface Migration {
|
interface Migration {
|
||||||
version: number;
|
version: number;
|
||||||
@@ -55,6 +56,7 @@ const MIGRATIONS: Migration[] = [
|
|||||||
{ version: 22, name: "provider_rate_limit_observations", sql: providerRateLimitObservationSchema },
|
{ version: 22, name: "provider_rate_limit_observations", sql: providerRateLimitObservationSchema },
|
||||||
{ version: 23, name: "provider_request_runtime", sql: providerRequestRuntimeSchema },
|
{ version: 23, name: "provider_request_runtime", sql: providerRequestRuntimeSchema },
|
||||||
{ version: 24, name: "task_run_provider_request", sql: taskRunProviderRequestSchema },
|
{ 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;
|
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 { ApprovalService } from "./approval-service";
|
||||||
import { AuditService } from "./audit-service";
|
import { AuditService } from "./audit-service";
|
||||||
import { BoardInterventionService, parseBoardCommand } from "./board-intervention-service";
|
import { BoardInterventionService, parseBoardCommand } from "./board-intervention-service";
|
||||||
|
import { buildChatMessageStructure } from "./chat-message-structure";
|
||||||
import { recordChatMentions, resolveChatMentions, type MentionTarget, type ResolvedMention } from "./chat-mentions";
|
import { recordChatMentions, resolveChatMentions, type MentionTarget, type ResolvedMention } from "./chat-mentions";
|
||||||
|
|
||||||
const SPACE_TYPES = new Set<CreateChatSpaceInput["spaceType"]>(["company", "department", "project", "direct"]);
|
const SPACE_TYPES = new Set<CreateChatSpaceInput["spaceType"]>(["company", "department", "project", "direct"]);
|
||||||
const SPACE_VISIBILITIES = new Set<CreateChatSpaceInput["visibility"]>(["workspace", "restricted"]);
|
const SPACE_VISIBILITIES = new Set<CreateChatSpaceInput["visibility"]>(["workspace", "restricted"]);
|
||||||
|
const RETENTION_POLICIES = new Set(["standard_365d", "restricted_365d"]);
|
||||||
|
|
||||||
interface AttachmentCandidate {
|
interface AttachmentCandidate {
|
||||||
fileId: string;
|
fileId: string;
|
||||||
@@ -71,9 +73,9 @@ export class ChatService {
|
|||||||
const id = createId("spc");
|
const id = createId("spc");
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
this.db.prepare(`
|
this.db.prepare(`
|
||||||
insert into chat_spaces(id, org_id, space_type, name, topic, visibility, created_at)
|
insert into chat_spaces(id, org_id, space_type, name, topic, visibility, retention_policy, created_at)
|
||||||
values (?, ?, ?, ?, ?, ?, ?)
|
values (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(id, input.orgId, input.spaceType, name, topic, input.visibility, now);
|
`).run(id, input.orgId, input.spaceType, name, topic, input.visibility, retentionPolicy(input.retentionPolicy, input.visibility), now);
|
||||||
|
|
||||||
const members = broadSpace ? this.resolveWorkspaceMembers(input.orgId) : explicitMembers;
|
const members = broadSpace ? this.resolveWorkspaceMembers(input.orgId) : explicitMembers;
|
||||||
for (const member of members) this.addMembership(id, member, input.visibility === "restricted" ? "approver" : "member");
|
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);
|
const slash = parseSlashInvocation(body);
|
||||||
if (slash && !ALLOWED_SLASH_COMMANDS.has(slash.command)) throw new Error(`Slash command /${slash.command} is unavailable.`);
|
if (slash && !ALLOWED_SLASH_COMMANDS.has(slash.command)) throw new Error(`Slash command /${slash.command} is unavailable.`);
|
||||||
if (slash) validateSlashInvocation(slash);
|
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 mentions = resolveChatMentions(this.db, input.orgId, input.spaceId, body);
|
||||||
|
const structuredBody = buildChatMessageStructure({ slash, mentions, attachmentFileIds });
|
||||||
|
|
||||||
const id = createId("msg");
|
const id = createId("msg");
|
||||||
const threadId = input.threadId ?? createId("thread");
|
const threadId = input.threadId ?? createId("thread");
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
this.db.prepare(`
|
this.db.prepare(`
|
||||||
insert into chat_messages(
|
insert into chat_messages(
|
||||||
id, org_id, space_id, thread_id, sender_type, sender_id, body_md, created_at, edited_at, deleted_at
|
id, org_id, space_id, thread_id, sender_type, sender_id, body_md, body_structured_json,
|
||||||
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
delivery_state, created_at, edited_at, deleted_at
|
||||||
|
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(
|
`).run(
|
||||||
id,
|
id,
|
||||||
input.orgId,
|
input.orgId,
|
||||||
@@ -119,6 +124,8 @@ export class ChatService {
|
|||||||
input.senderType,
|
input.senderType,
|
||||||
input.senderId,
|
input.senderId,
|
||||||
body,
|
body,
|
||||||
|
structuredBody,
|
||||||
|
"sent",
|
||||||
now,
|
now,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
@@ -479,3 +486,9 @@ function createThreadTitle(body: string): string {
|
|||||||
const firstLine = body.split(/\r?\n/, 1)[0].trim();
|
const firstLine = body.split(/\r?\n/, 1)[0].trim();
|
||||||
return firstLine.length > 80 ? `${firstLine.slice(0, 77)}...` : firstLine;
|
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_adapter_coverage", passed: missing.length === 0, value: `${adapterKeys.length}/${catalogKeys.length}` },
|
||||||
{ key: "provider_rate_limit_registry", passed: rateLimitRegistry === 1, value: rateLimitRegistry },
|
{ key: "provider_rate_limit_registry", passed: rateLimitRegistry === 1, value: rateLimitRegistry },
|
||||||
{ key: "provider_runtime_contract", passed: runtimeContractReady, value: runtimeContractReady },
|
{ 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"),
|
taskRuns: list(this.db, "task_runs", "created_at asc"),
|
||||||
approvals: list(this.db, "approvals", "created_at desc"),
|
approvals: list(this.db, "approvals", "created_at desc"),
|
||||||
chatSpaces: list(this.db, "chat_spaces", "created_at asc"),
|
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"),
|
chatMessages: list(this.db, "chat_messages", "created_at asc"),
|
||||||
chatThreads: list(this.db, "chat_threads", "updated_at desc"),
|
chatThreads: list(this.db, "chat_threads", "updated_at desc"),
|
||||||
chatAttachments: list(this.db, "chat_attachments", "message_id asc"),
|
chatAttachments: list(this.db, "chat_attachments", "message_id asc"),
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ export interface CreateChatSpaceInput {
|
|||||||
name: string;
|
name: string;
|
||||||
topic: string;
|
topic: string;
|
||||||
visibility: "workspace" | "restricted";
|
visibility: "workspace" | "restricted";
|
||||||
|
retentionPolicy?: "standard_365d" | "restricted_365d";
|
||||||
memberEmployeeIds?: string[];
|
memberEmployeeIds?: string[];
|
||||||
memberAgentIds?: string[];
|
memberAgentIds?: string[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,11 +182,21 @@ export interface ChatMessageRecord {
|
|||||||
senderType: "employee" | "agent" | "board" | "system";
|
senderType: "employee" | "agent" | "board" | "system";
|
||||||
senderId: string | null;
|
senderId: string | null;
|
||||||
bodyMd: string;
|
bodyMd: string;
|
||||||
|
bodyStructuredJson: string;
|
||||||
|
deliveryState: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
editedAt: string | null;
|
editedAt: string | null;
|
||||||
deletedAt: string | null;
|
deletedAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ChatMembershipRecord {
|
||||||
|
spaceId: string;
|
||||||
|
principalType: "employee" | "agent";
|
||||||
|
principalId: string;
|
||||||
|
role: string;
|
||||||
|
muteState: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ChatThreadRecord {
|
export interface ChatThreadRecord {
|
||||||
id: string;
|
id: string;
|
||||||
orgId: string;
|
orgId: string;
|
||||||
@@ -439,6 +449,7 @@ export interface AppSnapshot {
|
|||||||
taskRuns: TaskRunRecord[];
|
taskRuns: TaskRunRecord[];
|
||||||
approvals: ApprovalRecord[];
|
approvals: ApprovalRecord[];
|
||||||
chatSpaces: ChatSpaceRecord[];
|
chatSpaces: ChatSpaceRecord[];
|
||||||
|
chatMemberships: ChatMembershipRecord[];
|
||||||
chatMessages: ChatMessageRecord[];
|
chatMessages: ChatMessageRecord[];
|
||||||
chatThreads: ChatThreadRecord[];
|
chatThreads: ChatThreadRecord[];
|
||||||
chatAttachments: ChatAttachmentRecord[];
|
chatAttachments: ChatAttachmentRecord[];
|
||||||
|
|||||||
@@ -12,6 +12,72 @@ function createConnection(): DatabaseConnection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("chat governance", () => {
|
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", () => {
|
test("records board interventions and enforces thread control", () => {
|
||||||
const connection = createConnection();
|
const connection = createConnection();
|
||||||
const organization = new OrganizationService(connection.db);
|
const organization = new OrganizationService(connection.db);
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ describe("domain services", () => {
|
|||||||
const connection = createConnection();
|
const connection = createConnection();
|
||||||
|
|
||||||
const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>;
|
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();
|
connection.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user