Execute approval decisions from chat slash commands
This commit is contained in:
1 parent
5b68c629c4
commit
ee5eff7725
2 files changed
+107
No files matched your search
@@ -1,6 +1,7 @@
|
|||||||
import type { DatabaseSync } from "node:sqlite";
|
import type { DatabaseSync } from "node:sqlite";
|
||||||
import type { CreateChatSpaceInput, DeleteMessageInput, EditMessageInput, MarkChatSpaceReadInput, SendMessageInput } from "../../shared/commands";
|
import type { CreateChatSpaceInput, DeleteMessageInput, EditMessageInput, MarkChatSpaceReadInput, SendMessageInput } from "../../shared/commands";
|
||||||
import {
|
import {
|
||||||
|
APPROVAL_STATE,
|
||||||
CHAT_THREAD_BOARD_STATE,
|
CHAT_THREAD_BOARD_STATE,
|
||||||
FILE_SCAN_STATE,
|
FILE_SCAN_STATE,
|
||||||
RISK_LEVEL,
|
RISK_LEVEL,
|
||||||
@@ -230,6 +231,9 @@ export class ChatService {
|
|||||||
now,
|
now,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (slash.command === "approve" || slash.command === "deny") {
|
||||||
|
return this.decideApprovalFromSlash(input, sender, slash);
|
||||||
|
}
|
||||||
if (slash.command !== "escalate") return null;
|
if (slash.command !== "escalate") return null;
|
||||||
const requester = approvalRequester(input.senderType, sender);
|
const requester = approvalRequester(input.senderType, sender);
|
||||||
return this.approvals.create({
|
return this.approvals.create({
|
||||||
@@ -243,6 +247,18 @@ export class ChatService {
|
|||||||
}, { sourceMessageId: messageId, sourceThreadId: threadId, sourceEntityType: "chat_message", sourceEntityId: messageId });
|
}, { sourceMessageId: messageId, sourceThreadId: threadId, sourceEntityType: "chat_message", sourceEntityId: messageId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private decideApprovalFromSlash(input: SendMessageInput, sender: PrincipalRef | null, slash: SlashInvocation): string {
|
||||||
|
if (!sender || sender.type !== "employee") throw new Error("Approval decision requires employee sender.");
|
||||||
|
const approvalId = parseApprovalDecisionTarget(slash);
|
||||||
|
this.approvals.decide({
|
||||||
|
orgId: input.orgId,
|
||||||
|
approvalId,
|
||||||
|
state: slash.command === "approve" ? APPROVAL_STATE.APPROVED : APPROVAL_STATE.DECLINED,
|
||||||
|
decidedByEmployeeId: sender.id,
|
||||||
|
});
|
||||||
|
return approvalId;
|
||||||
|
}
|
||||||
|
|
||||||
private recordSlashInvocation(messageId: string, slash: SlashInvocation, resultRef: string | null, now: string): void {
|
private recordSlashInvocation(messageId: string, slash: SlashInvocation, resultRef: string | null, now: string): void {
|
||||||
this.db.prepare(`
|
this.db.prepare(`
|
||||||
insert into chat_slash_invocations(id, message_id, command, args_json, result_ref, created_at)
|
insert into chat_slash_invocations(id, message_id, command, args_json, result_ref, created_at)
|
||||||
@@ -408,9 +424,16 @@ function validateSlashInvocation(slash: SlashInvocation): void {
|
|||||||
if (slash.command === "escalate" && slash.args.trim().length === 0) {
|
if (slash.command === "escalate" && slash.args.trim().length === 0) {
|
||||||
throw new Error("Slash command /escalate requires details.");
|
throw new Error("Slash command /escalate requires details.");
|
||||||
}
|
}
|
||||||
|
if (slash.command === "approve" || slash.command === "deny") parseApprovalDecisionTarget(slash);
|
||||||
if (slash.command === "board") parseBoardCommand(slash.args);
|
if (slash.command === "board") parseBoardCommand(slash.args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseApprovalDecisionTarget(slash: SlashInvocation): string {
|
||||||
|
const approvalId = slash.args.trim().split(/\s+/, 1)[0] ?? "";
|
||||||
|
if (!approvalId) throw new Error(`Slash command /${slash.command} requires an approval id.`);
|
||||||
|
return approvalId;
|
||||||
|
}
|
||||||
|
|
||||||
function approvalRequester(senderType: SendMessageInput["senderType"], sender: PrincipalRef | null): ApprovalRequester {
|
function approvalRequester(senderType: SendMessageInput["senderType"], sender: PrincipalRef | null): ApprovalRequester {
|
||||||
if (senderType === "system") return { type: "system", id: null };
|
if (senderType === "system") return { type: "system", id: null };
|
||||||
if (!sender) throw new Error("Approval requester is missing.");
|
if (!sender) throw new Error("Approval requester is missing.");
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import { tmpdir } from "node:os";
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import { DatabaseConnection } from "../../src/main/database/connection";
|
import { DatabaseConnection } from "../../src/main/database/connection";
|
||||||
|
import { ApprovalService } from "../../src/main/services/approval-service";
|
||||||
import { ChatService } from "../../src/main/services/chat-service";
|
import { ChatService } from "../../src/main/services/chat-service";
|
||||||
import { OrganizationService } from "../../src/main/services/organization-service";
|
import { OrganizationService } from "../../src/main/services/organization-service";
|
||||||
import { SnapshotService } from "../../src/main/services/snapshot-service";
|
import { SnapshotService } from "../../src/main/services/snapshot-service";
|
||||||
|
import { RISK_LEVEL } from "../../src/shared/domain";
|
||||||
|
|
||||||
function createConnection(): DatabaseConnection {
|
function createConnection(): DatabaseConnection {
|
||||||
return new DatabaseConnection(join(tmpdir(), `agentcompany-new-chat-${randomUUID()}.db`));
|
return new DatabaseConnection(join(tmpdir(), `agentcompany-new-chat-${randomUUID()}.db`));
|
||||||
@@ -140,6 +142,79 @@ describe("chat governance", () => {
|
|||||||
connection.close();
|
connection.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("decides approvals from chat slash commands", () => {
|
||||||
|
const connection = createConnection();
|
||||||
|
const organization = new OrganizationService(connection.db);
|
||||||
|
const approvals = new ApprovalService(connection.db);
|
||||||
|
const chat = new ChatService(connection.db);
|
||||||
|
const snapshot = new SnapshotService(connection.db);
|
||||||
|
let orgId = "";
|
||||||
|
let employeeId = "";
|
||||||
|
let spaceId = "";
|
||||||
|
let approvalId = "";
|
||||||
|
let declinedApprovalId = "";
|
||||||
|
|
||||||
|
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" });
|
||||||
|
approvalId = approvals.create({
|
||||||
|
orgId,
|
||||||
|
kind: "release_gate",
|
||||||
|
title: "Release gate",
|
||||||
|
details: "Review the release window.",
|
||||||
|
risk: RISK_LEVEL.HIGH,
|
||||||
|
requestedByType: "employee",
|
||||||
|
requestedById: employeeId,
|
||||||
|
});
|
||||||
|
declinedApprovalId = approvals.create({
|
||||||
|
orgId,
|
||||||
|
kind: "release_gate",
|
||||||
|
title: "Rollback gate",
|
||||||
|
details: "Review rollback window.",
|
||||||
|
risk: RISK_LEVEL.HIGH,
|
||||||
|
requestedByType: "employee",
|
||||||
|
requestedById: employeeId,
|
||||||
|
});
|
||||||
|
chat.sendMessage({
|
||||||
|
orgId,
|
||||||
|
spaceId,
|
||||||
|
threadId: null,
|
||||||
|
senderType: "employee",
|
||||||
|
senderId: employeeId,
|
||||||
|
bodyMd: `/approve ${approvalId} release window cleared`,
|
||||||
|
attachmentFileIds: [],
|
||||||
|
});
|
||||||
|
chat.sendMessage({
|
||||||
|
orgId,
|
||||||
|
spaceId,
|
||||||
|
threadId: null,
|
||||||
|
senderType: "employee",
|
||||||
|
senderId: employeeId,
|
||||||
|
bodyMd: `/deny ${declinedApprovalId} rollback window blocked`,
|
||||||
|
attachmentFileIds: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = snapshot.load();
|
||||||
|
expect(state.approvals.find((item) => item.id === approvalId)?.state).toBe("approved");
|
||||||
|
expect(state.approvals.find((item) => item.id === declinedApprovalId)?.state).toBe("declined");
|
||||||
|
expect(state.chatSlashInvocations).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ command: "approve", resultRef: approvalId }),
|
||||||
|
expect.objectContaining({ command: "deny", resultRef: declinedApprovalId }),
|
||||||
|
]));
|
||||||
|
expect(state.auditEvents).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ entityType: "approval", entityId: approvalId, action: "approved" }),
|
||||||
|
expect.objectContaining({ entityType: "approval", entityId: declinedApprovalId, action: "declined" }),
|
||||||
|
]));
|
||||||
|
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);
|
||||||
@@ -357,6 +432,15 @@ describe("chat governance", () => {
|
|||||||
bodyMd: "/escalate",
|
bodyMd: "/escalate",
|
||||||
attachmentFileIds: [],
|
attachmentFileIds: [],
|
||||||
}))).toThrow("Slash command /escalate requires details.");
|
}))).toThrow("Slash command /escalate requires details.");
|
||||||
|
expect(() => connection.transaction(() => chat.sendMessage({
|
||||||
|
orgId,
|
||||||
|
spaceId: workspaceSpaceId,
|
||||||
|
threadId: null,
|
||||||
|
senderType: "employee",
|
||||||
|
senderId: employeeId,
|
||||||
|
bodyMd: "/approve",
|
||||||
|
attachmentFileIds: [],
|
||||||
|
}))).toThrow("Slash command /approve requires an approval id.");
|
||||||
expect(() => connection.transaction(() => chat.sendMessage({
|
expect(() => connection.transaction(() => chat.sendMessage({
|
||||||
orgId,
|
orgId,
|
||||||
spaceId: workspaceSpaceId,
|
spaceId: workspaceSpaceId,
|
||||||
|
|||||||
Reference in new issue
Block a user