Create approvals from chat escalations
This commit is contained in:
1 parent
d89bbf708e
commit
d24cf6b289
5 files changed
+143
-12
No files matched your search
@@ -4,6 +4,11 @@ import { APPROVAL_STATE } from "../../shared/domain";
|
||||
import { createId } from "../../shared/ids";
|
||||
import { AuditService } from "./audit-service";
|
||||
|
||||
interface ApprovalAuditContext {
|
||||
sourceMessageId?: string;
|
||||
sourceThreadId?: string;
|
||||
}
|
||||
|
||||
export class ApprovalService {
|
||||
private readonly audit: AuditService;
|
||||
|
||||
@@ -11,8 +16,14 @@ export class ApprovalService {
|
||||
this.audit = new AuditService(db);
|
||||
}
|
||||
|
||||
create(input: CreateApprovalInput): string {
|
||||
create(input: CreateApprovalInput, context: ApprovalAuditContext = {}): string {
|
||||
this.assertRequesterBelongsToOrg(input);
|
||||
const kind = input.kind.trim();
|
||||
const title = input.title.trim();
|
||||
const details = input.details.trim();
|
||||
if (!kind) throw new Error("Approval kind is required.");
|
||||
if (!title) throw new Error("Approval title is required.");
|
||||
if (!details) throw new Error("Approval details are required.");
|
||||
const id = createId("app");
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare(`
|
||||
@@ -23,9 +34,9 @@ export class ApprovalService {
|
||||
`).run(
|
||||
id,
|
||||
input.orgId,
|
||||
input.kind.trim(),
|
||||
input.title.trim(),
|
||||
input.details.trim(),
|
||||
kind,
|
||||
title,
|
||||
details,
|
||||
input.risk,
|
||||
input.requestedByType,
|
||||
input.requestedById,
|
||||
@@ -41,7 +52,7 @@ export class ApprovalService {
|
||||
actorType: input.requestedByType,
|
||||
actorId: input.requestedById,
|
||||
action: "requested",
|
||||
payload: { kind: input.kind, risk: input.risk },
|
||||
payload: { kind, risk: input.risk, ...context },
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { CreateChatSpaceInput, MarkChatSpaceReadInput, SendMessageInput } from "../../shared/commands";
|
||||
import { RISK_LEVEL } from "../../shared/domain";
|
||||
import { createId } from "../../shared/ids";
|
||||
import { ApprovalService } from "./approval-service";
|
||||
import { AuditService } from "./audit-service";
|
||||
import { canonicalMentionHandle } from "./mention-handles";
|
||||
|
||||
@@ -41,9 +43,11 @@ interface ResolvedMention {
|
||||
|
||||
export class ChatService {
|
||||
private readonly audit: AuditService;
|
||||
private readonly approvals: ApprovalService;
|
||||
|
||||
constructor(private readonly db: DatabaseSync) {
|
||||
this.audit = new AuditService(db);
|
||||
this.approvals = new ApprovalService(db);
|
||||
}
|
||||
|
||||
createSpace(input: CreateChatSpaceInput): string {
|
||||
@@ -88,6 +92,7 @@ export class ChatService {
|
||||
if (input.threadId) this.requireThread(input.orgId, input.spaceId, input.threadId);
|
||||
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 ?? []);
|
||||
const mentions = this.resolveMentions(input.orgId, input.spaceId, body);
|
||||
|
||||
@@ -119,15 +124,17 @@ export class ChatService {
|
||||
this.db.prepare("update chat_threads set updated_at = ? where id = ? and org_id = ?").run(now, input.threadId, input.orgId);
|
||||
}
|
||||
this.recordMentions(id, mentions);
|
||||
if (slash) this.recordSlashInvocation(id, slash, now);
|
||||
const slashResultRef = slash ? this.executeSlashCommand(input, space, sender, slash, id, threadId) : null;
|
||||
if (slash) this.recordSlashInvocation(id, slash, slashResultRef, now);
|
||||
this.recordAttachments(id, attachments);
|
||||
if (input.senderId) {
|
||||
const presenceStatus = slash?.command === "escalate" ? "waiting_approval" : "active";
|
||||
this.db.prepare(`
|
||||
insert into chat_presence(principal_type, principal_id, status, status_text, last_seen_at)
|
||||
values (?, ?, ?, ?, ?)
|
||||
on conflict(principal_type, principal_id)
|
||||
do update set status = excluded.status, status_text = excluded.status_text, last_seen_at = excluded.last_seen_at
|
||||
`).run(input.senderType, input.senderId, "active", "", now);
|
||||
`).run(input.senderType, input.senderId, presenceStatus, "", now);
|
||||
}
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
@@ -200,11 +207,32 @@ export class ChatService {
|
||||
return { type: target.type, id: target.id };
|
||||
}
|
||||
|
||||
private recordSlashInvocation(messageId: string, slash: SlashInvocation, now: string): void {
|
||||
private executeSlashCommand(
|
||||
input: SendMessageInput,
|
||||
space: SpaceRow,
|
||||
sender: PrincipalRef | null,
|
||||
slash: SlashInvocation,
|
||||
messageId: string,
|
||||
threadId: string,
|
||||
): string | null {
|
||||
if (slash.command !== "escalate") return null;
|
||||
const requester = approvalRequester(input.senderType, sender);
|
||||
return this.approvals.create({
|
||||
orgId: input.orgId,
|
||||
kind: "chat_escalation",
|
||||
title: `Escalate ${spaceName(this.db, space.id)}`,
|
||||
details: slash.args.trim(),
|
||||
risk: RISK_LEVEL.HIGH,
|
||||
requestedByType: requester.type,
|
||||
requestedById: requester.id,
|
||||
}, { sourceMessageId: messageId, sourceThreadId: threadId });
|
||||
}
|
||||
|
||||
private recordSlashInvocation(messageId: string, slash: SlashInvocation, resultRef: string | null, now: string): void {
|
||||
this.db.prepare(`
|
||||
insert into chat_slash_invocations(id, message_id, command, args_json, result_ref, created_at)
|
||||
values (?, ?, ?, ?, ?, ?)
|
||||
`).run(createId("slash"), messageId, slash.command, JSON.stringify({ raw: slash.args }), null, now);
|
||||
`).run(createId("slash"), messageId, slash.command, JSON.stringify({ raw: slash.args }), resultRef, now);
|
||||
}
|
||||
|
||||
private recordAttachments(messageId: string, attachments: AttachmentCandidate[]): void {
|
||||
@@ -301,6 +329,11 @@ interface SlashInvocation {
|
||||
args: string;
|
||||
}
|
||||
|
||||
interface ApprovalRequester {
|
||||
type: "employee" | "agent" | "system";
|
||||
id: string | null;
|
||||
}
|
||||
|
||||
const ALLOWED_SLASH_COMMANDS = new Set([
|
||||
"agent",
|
||||
"handoff",
|
||||
@@ -318,6 +351,25 @@ function parseSlashInvocation(body: string): SlashInvocation | null {
|
||||
return { command: match[1].toLowerCase(), args: match[2] ?? "" };
|
||||
}
|
||||
|
||||
function validateSlashInvocation(slash: SlashInvocation): void {
|
||||
if (slash.command === "escalate" && slash.args.trim().length === 0) {
|
||||
throw new Error("Slash command /escalate requires details.");
|
||||
}
|
||||
}
|
||||
|
||||
function approvalRequester(senderType: SendMessageInput["senderType"], sender: PrincipalRef | null): ApprovalRequester {
|
||||
if (senderType === "system") return { type: "system", id: null };
|
||||
if (!sender) throw new Error("Approval requester is missing.");
|
||||
if (senderType === "agent") return { type: "agent", id: sender.id };
|
||||
return { type: "employee", id: sender.id };
|
||||
}
|
||||
|
||||
function spaceName(db: DatabaseSync, spaceId: string): string {
|
||||
const row = db.prepare("select name from chat_spaces where id = ?").get(spaceId) as { name: string } | undefined;
|
||||
if (!row) throw new Error("Chat space was not found.");
|
||||
return row.name;
|
||||
}
|
||||
|
||||
function createThreadTitle(body: string): string {
|
||||
const firstLine = body.split(/\r?\n/, 1)[0].trim();
|
||||
return firstLine.length > 80 ? `${firstLine.slice(0, 77)}...` : firstLine;
|
||||
|
||||
@@ -130,6 +130,7 @@ export function ChatPage({ snapshot, actions }: PageProps) {
|
||||
const createSpace = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
const existingSpaceIds = new Set(spaces.map((space) => space.id));
|
||||
void actions.run(() => window.agentCompany.createChatSpace({
|
||||
orgId,
|
||||
spaceType: value(form, "spaceType") as SpaceType,
|
||||
@@ -138,7 +139,14 @@ export function ChatPage({ snapshot, actions }: PageProps) {
|
||||
visibility: value(form, "visibility") as Visibility,
|
||||
memberEmployeeIds: [employee.id, ...form.getAll("memberEmployeeIds").map(String)].filter(Boolean),
|
||||
memberAgentIds: form.getAll("memberAgentIds").map(String).filter(Boolean),
|
||||
})).then((result) => { if (result) setCreateOpen(false); });
|
||||
})).then((result) => {
|
||||
if (!result) throw new Error("Chat space creation returned no snapshot.");
|
||||
const created = result.chatSpaces.find((space) => space.orgId === orgId && !existingSpaceIds.has(space.id));
|
||||
if (!created) throw new Error("Created chat space is missing from snapshot.");
|
||||
setSelectedSpaceId(created.id);
|
||||
setOpenThreadId(null);
|
||||
setCreateOpen(false);
|
||||
});
|
||||
};
|
||||
|
||||
const decideApproval = (approvalId: string, state: "approved" | "declined") =>
|
||||
|
||||
@@ -57,10 +57,12 @@ test("onboards, hires, and creates work in the Electron shell", async () => {
|
||||
await page.getByLabel("Topic").fill("Release coordination");
|
||||
await page.locator(".modal").getByRole("button", { name: "Create" }).click();
|
||||
await expect(page.getByText("release").first()).toBeVisible();
|
||||
await page.getByRole("textbox", { name: "Message" }).fill("/handoff @Board review release plan");
|
||||
await page.getByRole("textbox", { name: "Message" }).fill("/escalate @Board review release plan");
|
||||
await page.getByRole("button", { name: "Send" }).click();
|
||||
await expect(page.getByText("@Board").first()).toBeVisible();
|
||||
await expect(page.getByText("/handoff").first()).toBeVisible();
|
||||
await expect(page.getByText("/escalate").first()).toBeVisible();
|
||||
await page.getByRole("button", { name: "Approvals" }).click();
|
||||
await expect(page.getByText("Escalate release").first()).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Social" }).click();
|
||||
await page.getByRole("button", { name: "Connect account" }).click();
|
||||
|
||||
@@ -246,6 +246,55 @@ describe("domain services", () => {
|
||||
connection.close();
|
||||
});
|
||||
|
||||
test("creates approval and waiting presence from chat escalation", () => {
|
||||
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 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;
|
||||
spaceId = chat.createSpace({
|
||||
orgId,
|
||||
spaceType: "project",
|
||||
name: "release",
|
||||
topic: "Release coordination",
|
||||
visibility: "workspace",
|
||||
});
|
||||
chat.sendMessage({
|
||||
orgId,
|
||||
spaceId,
|
||||
threadId: null,
|
||||
senderType: "employee",
|
||||
senderId: employeeId,
|
||||
bodyMd: "/escalate @Board freeze deployment",
|
||||
attachmentFileIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
const state = snapshot.load();
|
||||
const approval = state.approvals.find((item) => item.kind === "chat_escalation");
|
||||
if (!approval) throw new Error("Chat escalation approval was not created.");
|
||||
expect(approval.title).toBe("Escalate release");
|
||||
expect(approval.details).toBe("@Board freeze deployment");
|
||||
expect(approval.risk).toBe("high");
|
||||
expect(state.chatSlashInvocations[0]?.resultRef).toBe(approval.id);
|
||||
expect(state.chatPresence[0]).toMatchObject({ principalId: employeeId, status: "waiting_approval" });
|
||||
const audit = state.auditEvents.find((event) => event.entityId === approval.id && event.action === "requested");
|
||||
if (!audit) throw new Error("Chat escalation audit event was not created.");
|
||||
expect(audit.payloadJson).toContain(state.chatMessages[0]!.id);
|
||||
connection.close();
|
||||
});
|
||||
|
||||
test("rejects chat writes outside membership and prevents partial invalid messages", () => {
|
||||
const connection = createConnection();
|
||||
const organization = new OrganizationService(connection.db);
|
||||
@@ -327,6 +376,15 @@ describe("domain services", () => {
|
||||
bodyMd: "Missing file",
|
||||
attachmentFileIds: ["file_missing"],
|
||||
}))).toThrow("Attachment file was not found.");
|
||||
expect(() => connection.transaction(() => chat.sendMessage({
|
||||
orgId,
|
||||
spaceId: workspaceSpaceId,
|
||||
threadId: null,
|
||||
senderType: "employee",
|
||||
senderId: employeeId,
|
||||
bodyMd: "/escalate",
|
||||
attachmentFileIds: [],
|
||||
}))).toThrow("Slash command /escalate requires details.");
|
||||
expect(() => connection.transaction(() => chat.sendMessage({
|
||||
orgId,
|
||||
spaceId: workspaceSpaceId,
|
||||
|
||||
Reference in new issue
Block a user