Add board thread interventions
This commit is contained in:
1 parent
28007b3691
commit
78dbcbe7ce
14 files changed
+990
-370
No files matched your search
@@ -0,0 +1,29 @@
|
|||||||
|
export const boardInterventionSchema = `
|
||||||
|
alter table chat_threads add column board_state text not null default 'open';
|
||||||
|
alter table chat_threads add column controlled_by_type text;
|
||||||
|
alter table chat_threads add column controlled_by_id text;
|
||||||
|
|
||||||
|
create table if not exists board_interventions (
|
||||||
|
id text primary key,
|
||||||
|
org_id text not null references organizations(id) on delete cascade,
|
||||||
|
thread_id text not null references chat_threads(id) on delete cascade,
|
||||||
|
message_id text not null references chat_messages(id) on delete cascade,
|
||||||
|
mode text not null,
|
||||||
|
action text not null,
|
||||||
|
details text not null,
|
||||||
|
target_type text,
|
||||||
|
target_id text,
|
||||||
|
approval_id text references approvals(id) on delete set null,
|
||||||
|
created_by_type text not null,
|
||||||
|
created_by_id text,
|
||||||
|
created_at text not null,
|
||||||
|
unique(message_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists idx_board_interventions_org_thread on board_interventions(org_id, thread_id, created_at);
|
||||||
|
create index if not exists idx_board_interventions_message on board_interventions(message_id);
|
||||||
|
create index if not exists idx_chat_threads_org_board_state on chat_threads(org_id, board_state);
|
||||||
|
|
||||||
|
insert into schema_migrations(version, name, applied_at)
|
||||||
|
values (11, 'board_interventions', datetime('now'));
|
||||||
|
`;
|
||||||
@@ -9,6 +9,7 @@ import { skillHardeningSchema } from "./migrations/007-skill-hardening";
|
|||||||
import { providerHealthSchema } from "./migrations/008-provider-health";
|
import { providerHealthSchema } from "./migrations/008-provider-health";
|
||||||
import { socialGovernanceSchema } from "./migrations/009-social-governance";
|
import { socialGovernanceSchema } from "./migrations/009-social-governance";
|
||||||
import { fileHandoffSecuritySchema } from "./migrations/010-file-handoff-security";
|
import { fileHandoffSecuritySchema } from "./migrations/010-file-handoff-security";
|
||||||
|
import { boardInterventionSchema } from "./migrations/011-board-interventions";
|
||||||
|
|
||||||
interface Migration {
|
interface Migration {
|
||||||
version: number;
|
version: number;
|
||||||
@@ -27,6 +28,7 @@ const MIGRATIONS: Migration[] = [
|
|||||||
{ version: 8, name: "provider_health", sql: providerHealthSchema },
|
{ version: 8, name: "provider_health", sql: providerHealthSchema },
|
||||||
{ version: 9, name: "social_governance", sql: socialGovernanceSchema },
|
{ version: 9, name: "social_governance", sql: socialGovernanceSchema },
|
||||||
{ version: 10, name: "file_handoff_security", sql: fileHandoffSecuritySchema },
|
{ version: 10, name: "file_handoff_security", sql: fileHandoffSecuritySchema },
|
||||||
|
{ version: 11, name: "board_interventions", sql: boardInterventionSchema },
|
||||||
];
|
];
|
||||||
|
|
||||||
const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import type { DatabaseSync } from "node:sqlite";
|
||||||
|
import type { SendMessageInput } from "../../shared/commands";
|
||||||
|
import {
|
||||||
|
BOARD_INTERVENTION_ACTION,
|
||||||
|
BOARD_INTERVENTION_MODE,
|
||||||
|
CHAT_THREAD_BOARD_STATE,
|
||||||
|
RISK_LEVEL,
|
||||||
|
type BoardInterventionAction,
|
||||||
|
type BoardInterventionMode,
|
||||||
|
} from "../../shared/domain";
|
||||||
|
import { createId } from "../../shared/ids";
|
||||||
|
import type { ResolvedMention } from "./chat-mentions";
|
||||||
|
import { ApprovalService } from "./approval-service";
|
||||||
|
import { AuditService } from "./audit-service";
|
||||||
|
|
||||||
|
interface PrincipalRef {
|
||||||
|
type: "employee" | "agent";
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApprovalRequester {
|
||||||
|
type: "employee" | "agent" | "system";
|
||||||
|
id: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BoardCommand {
|
||||||
|
mode: BoardInterventionMode;
|
||||||
|
action: BoardInterventionAction;
|
||||||
|
details: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BoardTarget {
|
||||||
|
type: "employee" | "agent" | "board";
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecordBoardInterventionInput {
|
||||||
|
messageInput: SendMessageInput;
|
||||||
|
sender: PrincipalRef | null;
|
||||||
|
mentions: ResolvedMention[];
|
||||||
|
args: string;
|
||||||
|
messageId: string;
|
||||||
|
threadId: string;
|
||||||
|
spaceId: string;
|
||||||
|
now: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BoardInterventionService {
|
||||||
|
private readonly audit: AuditService;
|
||||||
|
private readonly approvals: ApprovalService;
|
||||||
|
|
||||||
|
constructor(private readonly db: DatabaseSync) {
|
||||||
|
this.audit = new AuditService(db);
|
||||||
|
this.approvals = new ApprovalService(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
record(input: RecordBoardInterventionInput): string {
|
||||||
|
if (input.messageInput.senderType !== "board") throw new Error("Board intervention requires board sender.");
|
||||||
|
const command = parseBoardCommand(input.args);
|
||||||
|
const target = resolveBoardTarget(command, input.messageInput.orgId, input.mentions);
|
||||||
|
const approvalId = command.action === BOARD_INTERVENTION_ACTION.FORCE_APPROVAL
|
||||||
|
? this.createBoardApproval(input, command)
|
||||||
|
: null;
|
||||||
|
const interventionId = createId("bdi");
|
||||||
|
this.db.prepare(`
|
||||||
|
insert into board_interventions(
|
||||||
|
id, org_id, thread_id, message_id, mode, action, details, target_type, target_id,
|
||||||
|
approval_id, created_by_type, created_by_id, created_at
|
||||||
|
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
interventionId,
|
||||||
|
input.messageInput.orgId,
|
||||||
|
input.threadId,
|
||||||
|
input.messageId,
|
||||||
|
command.mode,
|
||||||
|
command.action,
|
||||||
|
command.details,
|
||||||
|
target.type,
|
||||||
|
target.id,
|
||||||
|
approvalId,
|
||||||
|
input.messageInput.senderType,
|
||||||
|
input.messageInput.senderId,
|
||||||
|
input.now,
|
||||||
|
);
|
||||||
|
this.applyBoardThreadState(input, command, target);
|
||||||
|
this.audit.record({
|
||||||
|
orgId: input.messageInput.orgId,
|
||||||
|
entityType: "board_intervention",
|
||||||
|
entityId: interventionId,
|
||||||
|
actorType: input.messageInput.senderType,
|
||||||
|
actorId: input.messageInput.senderId,
|
||||||
|
action: "recorded",
|
||||||
|
payload: {
|
||||||
|
threadId: input.threadId,
|
||||||
|
messageId: input.messageId,
|
||||||
|
mode: command.mode,
|
||||||
|
action: command.action,
|
||||||
|
targetType: target.type,
|
||||||
|
targetId: target.id,
|
||||||
|
approvalId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return interventionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createBoardApproval(input: RecordBoardInterventionInput, command: BoardCommand): string {
|
||||||
|
const requester = approvalRequester(input.messageInput.senderType, input.sender);
|
||||||
|
return this.approvals.create({
|
||||||
|
orgId: input.messageInput.orgId,
|
||||||
|
kind: "board_force_approval",
|
||||||
|
title: `Board approval ${spaceName(this.db, input.spaceId)}`,
|
||||||
|
details: command.details,
|
||||||
|
risk: RISK_LEVEL.HIGH,
|
||||||
|
requestedByType: requester.type,
|
||||||
|
requestedById: requester.id,
|
||||||
|
}, { sourceMessageId: input.messageId, sourceThreadId: input.threadId });
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyBoardThreadState(input: RecordBoardInterventionInput, command: BoardCommand, target: BoardTarget): void {
|
||||||
|
if (command.action === BOARD_INTERVENTION_ACTION.FREEZE) {
|
||||||
|
this.updateBoardThreadState(input, CHAT_THREAD_BOARD_STATE.FROZEN, null, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command.action === BOARD_INTERVENTION_ACTION.TAKEOVER) {
|
||||||
|
this.updateBoardThreadState(input, CHAT_THREAD_BOARD_STATE.TAKEOVER, target.type, target.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command.action === BOARD_INTERVENTION_ACTION.ROLLBACK) {
|
||||||
|
this.updateBoardThreadState(input, CHAT_THREAD_BOARD_STATE.OPEN, null, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateBoardThreadState(
|
||||||
|
input: RecordBoardInterventionInput,
|
||||||
|
boardState: string,
|
||||||
|
controlledByType: BoardTarget["type"] | null,
|
||||||
|
controlledById: string | null,
|
||||||
|
): void {
|
||||||
|
this.db.prepare(`
|
||||||
|
update chat_threads
|
||||||
|
set board_state = ?, controlled_by_type = ?, controlled_by_id = ?, updated_at = ?
|
||||||
|
where id = ? and org_id = ?
|
||||||
|
`).run(boardState, controlledByType, controlledById, input.now, input.threadId, input.messageInput.orgId);
|
||||||
|
this.audit.record({
|
||||||
|
orgId: input.messageInput.orgId,
|
||||||
|
entityType: "chat_thread",
|
||||||
|
entityId: input.threadId,
|
||||||
|
actorType: "board",
|
||||||
|
actorId: input.messageInput.senderId,
|
||||||
|
action: "board_state_changed",
|
||||||
|
payload: { threadId: input.threadId, boardState, controlledByType, controlledById },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseBoardCommand(args: string): BoardCommand {
|
||||||
|
const trimmed = args.trim();
|
||||||
|
if (!trimmed) throw new Error("Slash command /board requires an action.");
|
||||||
|
const [rawAction = "", ...rest] = trimmed.split(/\s+/);
|
||||||
|
const action = normalizeBoardAction(rawAction);
|
||||||
|
const details = rest.join(" ").trim();
|
||||||
|
if (!details) throw new Error(`Slash command /board ${rawAction} requires details.`);
|
||||||
|
return {
|
||||||
|
mode: boardModeFor(action, rawAction),
|
||||||
|
action,
|
||||||
|
details,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBoardTarget(command: BoardCommand, orgId: string, mentions: ResolvedMention[]): BoardTarget {
|
||||||
|
if (command.action !== BOARD_INTERVENTION_ACTION.TAKEOVER) return { type: "board", id: orgId };
|
||||||
|
const principal = mentions.find((mention) => mention.target.type === "employee" || mention.target.type === "agent");
|
||||||
|
if (!principal) return { type: "board", id: orgId };
|
||||||
|
if (principal.target.type === "employee" || principal.target.type === "agent") {
|
||||||
|
return { type: principal.target.type, id: principal.target.id };
|
||||||
|
}
|
||||||
|
return { type: "board", id: orgId };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBoardAction(value: string): BoardInterventionAction {
|
||||||
|
const normalized = value.toLowerCase().replace(/-/g, "_");
|
||||||
|
if (normalized === "observe") return BOARD_INTERVENTION_ACTION.DECISION;
|
||||||
|
if (normalized === BOARD_INTERVENTION_ACTION.DECISION) return BOARD_INTERVENTION_ACTION.DECISION;
|
||||||
|
if (normalized === BOARD_INTERVENTION_ACTION.FREEZE) return BOARD_INTERVENTION_ACTION.FREEZE;
|
||||||
|
if (normalized === BOARD_INTERVENTION_ACTION.TAKEOVER) return BOARD_INTERVENTION_ACTION.TAKEOVER;
|
||||||
|
if (normalized === BOARD_INTERVENTION_ACTION.ROLLBACK) return BOARD_INTERVENTION_ACTION.ROLLBACK;
|
||||||
|
if (normalized === BOARD_INTERVENTION_ACTION.FORCE_APPROVAL) return BOARD_INTERVENTION_ACTION.FORCE_APPROVAL;
|
||||||
|
throw new Error(`Unsupported board action: ${value}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function boardModeFor(action: BoardInterventionAction, rawAction: string): BoardInterventionMode {
|
||||||
|
if (rawAction.toLowerCase() === "observe") return BOARD_INTERVENTION_MODE.OBSERVE;
|
||||||
|
if (action === BOARD_INTERVENTION_ACTION.TAKEOVER) return BOARD_INTERVENTION_MODE.TAKEOVER;
|
||||||
|
return BOARD_INTERVENTION_MODE.INTERVENE;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import type { DatabaseSync } from "node:sqlite";
|
||||||
|
import { canonicalMentionHandle } from "./mention-handles";
|
||||||
|
|
||||||
|
export interface MentionTarget {
|
||||||
|
type: "employee" | "agent" | "board" | "org_group";
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MentionCandidate extends MentionTarget {
|
||||||
|
handle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedMention {
|
||||||
|
target: MentionTarget;
|
||||||
|
offsetStart: number;
|
||||||
|
offsetEnd: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveChatMentions(db: DatabaseSync, orgId: string, spaceId: string, body: string): ResolvedMention[] {
|
||||||
|
const mentions: ResolvedMention[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const match of body.matchAll(/(^|\s)@([A-Za-z][A-Za-z0-9_-]{1,63})/g)) {
|
||||||
|
const token = match[2];
|
||||||
|
const target = resolveMention(db, orgId, spaceId, token);
|
||||||
|
const offsetStart = match.index + match[1].length;
|
||||||
|
const key = `${target.type}:${target.id}:${offsetStart}`;
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
mentions.push({ target, offsetStart, offsetEnd: offsetStart + token.length + 1 });
|
||||||
|
}
|
||||||
|
return mentions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordChatMentions(db: DatabaseSync, messageId: string, mentions: ResolvedMention[]): void {
|
||||||
|
for (const mention of mentions) {
|
||||||
|
db.prepare(`
|
||||||
|
insert into chat_mentions(message_id, target_type, target_id, offset_start, offset_end)
|
||||||
|
values (?, ?, ?, ?, ?)
|
||||||
|
`).run(messageId, mention.target.type, mention.target.id, mention.offsetStart, mention.offsetEnd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveMention(db: DatabaseSync, orgId: string, spaceId: string, token: string): MentionTarget {
|
||||||
|
const handles = mentionLookupHandles(token);
|
||||||
|
if (handles.has("board")) return { type: "board", id: orgId };
|
||||||
|
if (handles.has("oncall")) return { type: "org_group", id: `${orgId}:oncall` };
|
||||||
|
const candidates = mentionCandidates(db, orgId, spaceId).filter((candidate) => handles.has(candidate.handle));
|
||||||
|
const unique = new Map(candidates.map((candidate) => [`${candidate.type}:${candidate.id}`, candidate]));
|
||||||
|
if (unique.size === 0) throw new Error(`Mention @${token} could not be resolved.`);
|
||||||
|
if (unique.size > 1) throw new Error(`Mention @${token} is ambiguous.`);
|
||||||
|
const target = [...unique.values()][0];
|
||||||
|
return { type: target.type, id: target.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mentionCandidates(db: DatabaseSync, orgId: string, spaceId: string): MentionCandidate[] {
|
||||||
|
const employees = db.prepare(`
|
||||||
|
select employees.id, employees.display_name
|
||||||
|
from employees
|
||||||
|
join chat_memberships memberships
|
||||||
|
on memberships.principal_type = 'employee'
|
||||||
|
and memberships.principal_id = employees.id
|
||||||
|
and memberships.space_id = ?
|
||||||
|
where employees.org_id = ?
|
||||||
|
`).all(spaceId, orgId) as Array<{ id: string; display_name: string }>;
|
||||||
|
const agents = db.prepare(`
|
||||||
|
select agents.id, agents.name
|
||||||
|
from agents
|
||||||
|
join chat_memberships memberships
|
||||||
|
on memberships.principal_type = 'agent'
|
||||||
|
and memberships.principal_id = agents.id
|
||||||
|
and memberships.space_id = ?
|
||||||
|
where agents.org_id = ?
|
||||||
|
`).all(spaceId, orgId) as Array<{ id: string; name: string }>;
|
||||||
|
return [
|
||||||
|
...employees.flatMap((employee) => principalMentionHandles("employee", employee.id, employee.display_name)),
|
||||||
|
...agents.flatMap((agent) => principalMentionHandles("agent", agent.id, agent.name)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function principalMentionHandles(type: "employee" | "agent", id: string, name: string): MentionCandidate[] {
|
||||||
|
const handles = new Set([canonicalMentionHandle(name), id.toLowerCase()].filter((handle) => handle.length > 0));
|
||||||
|
return [...handles].map((handle) => ({ type, id, handle }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function mentionLookupHandles(token: string): Set<string> {
|
||||||
|
return new Set([token.toLowerCase(), canonicalMentionHandle(token)].filter((handle) => handle.length > 0));
|
||||||
|
}
|
||||||
@@ -1,10 +1,15 @@
|
|||||||
import type { DatabaseSync } from "node:sqlite";
|
import type { DatabaseSync } from "node:sqlite";
|
||||||
import type { CreateChatSpaceInput, MarkChatSpaceReadInput, SendMessageInput } from "../../shared/commands";
|
import type { CreateChatSpaceInput, MarkChatSpaceReadInput, SendMessageInput } from "../../shared/commands";
|
||||||
import { FILE_SCAN_STATE, RISK_LEVEL } from "../../shared/domain";
|
import {
|
||||||
|
CHAT_THREAD_BOARD_STATE,
|
||||||
|
FILE_SCAN_STATE,
|
||||||
|
RISK_LEVEL,
|
||||||
|
} from "../../shared/domain";
|
||||||
import { createId } from "../../shared/ids";
|
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 { canonicalMentionHandle } from "./mention-handles";
|
import { BoardInterventionService, parseBoardCommand } from "./board-intervention-service";
|
||||||
|
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"]);
|
||||||
@@ -29,33 +34,27 @@ interface SpaceRow {
|
|||||||
space_type: string;
|
space_type: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ThreadRow {
|
||||||
|
id: string;
|
||||||
|
board_state: string;
|
||||||
|
controlled_by_type: "employee" | "agent" | "board" | null;
|
||||||
|
controlled_by_id: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface PrincipalRef {
|
interface PrincipalRef {
|
||||||
type: "employee" | "agent";
|
type: "employee" | "agent";
|
||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MentionTarget {
|
|
||||||
type: "employee" | "agent" | "board" | "org_group";
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MentionCandidate extends MentionTarget {
|
|
||||||
handle: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ResolvedMention {
|
|
||||||
target: MentionTarget;
|
|
||||||
offsetStart: number;
|
|
||||||
offsetEnd: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ChatService {
|
export class ChatService {
|
||||||
private readonly audit: AuditService;
|
private readonly audit: AuditService;
|
||||||
private readonly approvals: ApprovalService;
|
private readonly approvals: ApprovalService;
|
||||||
|
private readonly boardInterventions: BoardInterventionService;
|
||||||
|
|
||||||
constructor(private readonly db: DatabaseSync) {
|
constructor(private readonly db: DatabaseSync) {
|
||||||
this.audit = new AuditService(db);
|
this.audit = new AuditService(db);
|
||||||
this.approvals = new ApprovalService(db);
|
this.approvals = new ApprovalService(db);
|
||||||
|
this.boardInterventions = new BoardInterventionService(db);
|
||||||
}
|
}
|
||||||
|
|
||||||
createSpace(input: CreateChatSpaceInput): string {
|
createSpace(input: CreateChatSpaceInput): string {
|
||||||
@@ -97,12 +96,13 @@ export class ChatService {
|
|||||||
const space = this.requireSpace(input.orgId, input.spaceId);
|
const space = this.requireSpace(input.orgId, input.spaceId);
|
||||||
const sender = this.requireSender(input.orgId, input.senderType, input.senderId);
|
const sender = this.requireSender(input.orgId, input.senderType, input.senderId);
|
||||||
if (sender) this.requireMembership(space.id, sender);
|
if (sender) this.requireMembership(space.id, sender);
|
||||||
if (input.threadId) this.requireThread(input.orgId, input.spaceId, input.threadId);
|
const thread = input.threadId ? this.requireThread(input.orgId, input.spaceId, input.threadId) : null;
|
||||||
|
this.assertThreadControl(input, thread, sender);
|
||||||
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 attachments = this.requireAttachments(input.orgId, input.attachmentFileIds ?? [], sender);
|
||||||
const mentions = this.resolveMentions(input.orgId, input.spaceId, body);
|
const mentions = resolveChatMentions(this.db, input.orgId, input.spaceId, body);
|
||||||
|
|
||||||
const id = createId("msg");
|
const id = createId("msg");
|
||||||
const threadId = input.threadId ?? createId("thread");
|
const threadId = input.threadId ?? createId("thread");
|
||||||
@@ -131,8 +131,8 @@ export class ChatService {
|
|||||||
} else {
|
} else {
|
||||||
this.db.prepare("update chat_threads set updated_at = ? where id = ? and org_id = ?").run(now, input.threadId, input.orgId);
|
this.db.prepare("update chat_threads set updated_at = ? where id = ? and org_id = ?").run(now, input.threadId, input.orgId);
|
||||||
}
|
}
|
||||||
this.recordMentions(id, mentions);
|
recordChatMentions(this.db, id, mentions);
|
||||||
const slashResultRef = slash ? this.executeSlashCommand(input, space, sender, slash, id, threadId) : null;
|
const slashResultRef = slash ? this.executeSlashCommand(input, space, sender, mentions, slash, id, threadId, now) : null;
|
||||||
if (slash) this.recordSlashInvocation(id, slash, slashResultRef, now);
|
if (slash) this.recordSlashInvocation(id, slash, slashResultRef, now);
|
||||||
this.recordAttachments(id, attachments);
|
this.recordAttachments(id, attachments);
|
||||||
this.recordAttachmentGrants(input, id, threadId, attachments, mentions, now);
|
this.recordAttachmentGrants(input, id, threadId, attachments, mentions, now);
|
||||||
@@ -180,50 +180,28 @@ export class ChatService {
|
|||||||
`).run(input.orgId, input.employeeId, input.spaceId, latest?.id ?? null, now);
|
`).run(input.orgId, input.employeeId, input.spaceId, latest?.id ?? null, now);
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveMentions(orgId: string, spaceId: string, body: string): ResolvedMention[] {
|
|
||||||
const mentions: ResolvedMention[] = [];
|
|
||||||
const seen = new Set<string>();
|
|
||||||
for (const match of body.matchAll(/(^|\s)@([A-Za-z][A-Za-z0-9_-]{1,63})/g)) {
|
|
||||||
const token = match[2];
|
|
||||||
const target = this.resolveMention(orgId, spaceId, token);
|
|
||||||
const offsetStart = match.index + match[1].length;
|
|
||||||
const key = `${target.type}:${target.id}:${offsetStart}`;
|
|
||||||
if (seen.has(key)) continue;
|
|
||||||
seen.add(key);
|
|
||||||
mentions.push({ target, offsetStart, offsetEnd: offsetStart + token.length + 1 });
|
|
||||||
}
|
|
||||||
return mentions;
|
|
||||||
}
|
|
||||||
|
|
||||||
private recordMentions(messageId: string, mentions: ResolvedMention[]): void {
|
|
||||||
for (const mention of mentions) {
|
|
||||||
this.db.prepare(`
|
|
||||||
insert into chat_mentions(message_id, target_type, target_id, offset_start, offset_end)
|
|
||||||
values (?, ?, ?, ?, ?)
|
|
||||||
`).run(messageId, mention.target.type, mention.target.id, mention.offsetStart, mention.offsetEnd);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private resolveMention(orgId: string, spaceId: string, token: string): MentionTarget {
|
|
||||||
const handles = mentionLookupHandles(token);
|
|
||||||
if (handles.has("board")) return { type: "board", id: orgId };
|
|
||||||
if (handles.has("oncall")) return { type: "org_group", id: `${orgId}:oncall` };
|
|
||||||
const candidates = mentionCandidates(this.db, orgId, spaceId).filter((candidate) => handles.has(candidate.handle));
|
|
||||||
const unique = new Map(candidates.map((candidate) => [`${candidate.type}:${candidate.id}`, candidate]));
|
|
||||||
if (unique.size === 0) throw new Error(`Mention @${token} could not be resolved.`);
|
|
||||||
if (unique.size > 1) throw new Error(`Mention @${token} is ambiguous.`);
|
|
||||||
const target = [...unique.values()][0];
|
|
||||||
return { type: target.type, id: target.id };
|
|
||||||
}
|
|
||||||
|
|
||||||
private executeSlashCommand(
|
private executeSlashCommand(
|
||||||
input: SendMessageInput,
|
input: SendMessageInput,
|
||||||
space: SpaceRow,
|
space: SpaceRow,
|
||||||
sender: PrincipalRef | null,
|
sender: PrincipalRef | null,
|
||||||
|
mentions: ResolvedMention[],
|
||||||
slash: SlashInvocation,
|
slash: SlashInvocation,
|
||||||
messageId: string,
|
messageId: string,
|
||||||
threadId: string,
|
threadId: string,
|
||||||
|
now: string,
|
||||||
): string | null {
|
): string | null {
|
||||||
|
if (slash.command === "board") {
|
||||||
|
return this.boardInterventions.record({
|
||||||
|
messageInput: input,
|
||||||
|
sender,
|
||||||
|
mentions,
|
||||||
|
args: slash.args,
|
||||||
|
messageId,
|
||||||
|
threadId,
|
||||||
|
spaceId: space.id,
|
||||||
|
now,
|
||||||
|
});
|
||||||
|
}
|
||||||
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({
|
||||||
@@ -307,6 +285,18 @@ export class ChatService {
|
|||||||
return space;
|
return space;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private assertThreadControl(input: SendMessageInput, thread: ThreadRow | null, sender: PrincipalRef | null): void {
|
||||||
|
if (!thread) return;
|
||||||
|
if (input.senderType === "board") return;
|
||||||
|
if (thread.board_state === CHAT_THREAD_BOARD_STATE.FROZEN) {
|
||||||
|
throw new Error("Board-frozen thread requires board sender.");
|
||||||
|
}
|
||||||
|
if (thread.board_state !== CHAT_THREAD_BOARD_STATE.TAKEOVER) return;
|
||||||
|
if (!sender) throw new Error("Board takeover controls this thread.");
|
||||||
|
if (thread.controlled_by_type === sender.type && thread.controlled_by_id === sender.id) return;
|
||||||
|
throw new Error("Board takeover controls this thread.");
|
||||||
|
}
|
||||||
|
|
||||||
private requireSender(orgId: string, senderType: SendMessageInput["senderType"], senderId: string | null): PrincipalRef | null {
|
private requireSender(orgId: string, senderType: SendMessageInput["senderType"], senderId: string | null): PrincipalRef | null {
|
||||||
if (senderType === "system") return null;
|
if (senderType === "system") return null;
|
||||||
if (senderType === "employee") {
|
if (senderType === "employee") {
|
||||||
@@ -324,10 +314,14 @@ export class ChatService {
|
|||||||
return { type: "employee", id: senderId };
|
return { type: "employee", id: senderId };
|
||||||
}
|
}
|
||||||
|
|
||||||
private requireThread(orgId: string, spaceId: string, threadId: string): void {
|
private requireThread(orgId: string, spaceId: string, threadId: string): ThreadRow {
|
||||||
const thread = this.db.prepare("select id from chat_threads where id = ? and org_id = ? and space_id = ?")
|
const thread = this.db.prepare(`
|
||||||
.get(threadId, orgId, spaceId) as { id: string } | undefined;
|
select id, board_state, controlled_by_type, controlled_by_id
|
||||||
|
from chat_threads
|
||||||
|
where id = ? and org_id = ? and space_id = ?
|
||||||
|
`).get(threadId, orgId, spaceId) as ThreadRow | undefined;
|
||||||
if (!thread) throw new Error("Chat thread was not found in this space.");
|
if (!thread) throw new Error("Chat thread was not found in this space.");
|
||||||
|
return thread;
|
||||||
}
|
}
|
||||||
|
|
||||||
private requireAttachments(orgId: string, fileIds: string[], sender: PrincipalRef | null): AttachmentCandidate[] {
|
private requireAttachments(orgId: string, fileIds: string[], sender: PrincipalRef | null): AttachmentCandidate[] {
|
||||||
@@ -452,6 +446,7 @@ const ALLOWED_SLASH_COMMANDS = new Set([
|
|||||||
"runbook",
|
"runbook",
|
||||||
"file",
|
"file",
|
||||||
"escalate",
|
"escalate",
|
||||||
|
"board",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function parseSlashInvocation(body: string): SlashInvocation | null {
|
function parseSlashInvocation(body: string): SlashInvocation | null {
|
||||||
@@ -464,6 +459,7 @@ 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 === "board") parseBoardCommand(slash.args);
|
||||||
}
|
}
|
||||||
|
|
||||||
function approvalRequester(senderType: SendMessageInput["senderType"], sender: PrincipalRef | null): ApprovalRequester {
|
function approvalRequester(senderType: SendMessageInput["senderType"], sender: PrincipalRef | null): ApprovalRequester {
|
||||||
@@ -483,37 +479,3 @@ 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 mentionCandidates(db: DatabaseSync, orgId: string, spaceId: string): MentionCandidate[] {
|
|
||||||
const employees = db.prepare(`
|
|
||||||
select employees.id, employees.display_name
|
|
||||||
from employees
|
|
||||||
join chat_memberships memberships
|
|
||||||
on memberships.principal_type = 'employee'
|
|
||||||
and memberships.principal_id = employees.id
|
|
||||||
and memberships.space_id = ?
|
|
||||||
where employees.org_id = ?
|
|
||||||
`).all(spaceId, orgId) as Array<{ id: string; display_name: string }>;
|
|
||||||
const agents = db.prepare(`
|
|
||||||
select agents.id, agents.name
|
|
||||||
from agents
|
|
||||||
join chat_memberships memberships
|
|
||||||
on memberships.principal_type = 'agent'
|
|
||||||
and memberships.principal_id = agents.id
|
|
||||||
and memberships.space_id = ?
|
|
||||||
where agents.org_id = ?
|
|
||||||
`).all(spaceId, orgId) as Array<{ id: string; name: string }>;
|
|
||||||
return [
|
|
||||||
...employees.flatMap((employee) => principalMentionHandles("employee", employee.id, employee.display_name)),
|
|
||||||
...agents.flatMap((agent) => principalMentionHandles("agent", agent.id, agent.name)),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function principalMentionHandles(type: "employee" | "agent", id: string, name: string): MentionCandidate[] {
|
|
||||||
const handles = new Set([canonicalMentionHandle(name), id.toLowerCase()].filter((handle) => handle.length > 0));
|
|
||||||
return [...handles].map((handle) => ({ type, id, handle }));
|
|
||||||
}
|
|
||||||
|
|
||||||
function mentionLookupHandles(token: string): Set<string> {
|
|
||||||
return new Set([token.toLowerCase(), canonicalMentionHandle(token)].filter((handle) => handle.length > 0));
|
|
||||||
}
|
|
||||||
@@ -23,6 +23,7 @@ const LEGACY_TABLES = [
|
|||||||
"chat_attachments",
|
"chat_attachments",
|
||||||
"chat_mentions",
|
"chat_mentions",
|
||||||
"chat_slash_invocations",
|
"chat_slash_invocations",
|
||||||
|
"board_interventions",
|
||||||
"chat_presence",
|
"chat_presence",
|
||||||
"chat_read_state",
|
"chat_read_state",
|
||||||
"providers",
|
"providers",
|
||||||
@@ -113,6 +114,10 @@ const RELATION_CHECKS: RelationCheck[] = [
|
|||||||
{ table: "chat_attachments", column: "file_id", referencedTable: "file_objects", referencedColumn: "id", reason: "dangling_file_id" },
|
{ table: "chat_attachments", column: "file_id", referencedTable: "file_objects", referencedColumn: "id", reason: "dangling_file_id" },
|
||||||
{ table: "chat_mentions", column: "message_id", referencedTable: "chat_messages", referencedColumn: "id", reason: "dangling_message_id" },
|
{ table: "chat_mentions", column: "message_id", referencedTable: "chat_messages", referencedColumn: "id", reason: "dangling_message_id" },
|
||||||
{ table: "chat_slash_invocations", column: "message_id", referencedTable: "chat_messages", referencedColumn: "id", reason: "dangling_message_id" },
|
{ table: "chat_slash_invocations", column: "message_id", referencedTable: "chat_messages", referencedColumn: "id", reason: "dangling_message_id" },
|
||||||
|
{ table: "board_interventions", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||||
|
{ table: "board_interventions", column: "thread_id", referencedTable: "chat_threads", referencedColumn: "id", reason: "dangling_thread_id" },
|
||||||
|
{ table: "board_interventions", column: "message_id", referencedTable: "chat_messages", referencedColumn: "id", reason: "dangling_message_id" },
|
||||||
|
{ table: "board_interventions", column: "approval_id", referencedTable: "approvals", referencedColumn: "id", reason: "dangling_approval_id" },
|
||||||
{ table: "chat_read_state", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
{ table: "chat_read_state", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||||
{ table: "chat_read_state", column: "employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_employee_id" },
|
{ table: "chat_read_state", column: "employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_employee_id" },
|
||||||
{ table: "chat_read_state", column: "space_id", referencedTable: "chat_spaces", referencedColumn: "id", reason: "dangling_space_id" },
|
{ table: "chat_read_state", column: "space_id", referencedTable: "chat_spaces", referencedColumn: "id", reason: "dangling_space_id" },
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export class SnapshotService {
|
|||||||
chatAttachments: list(this.db, "chat_attachments", "message_id asc"),
|
chatAttachments: list(this.db, "chat_attachments", "message_id asc"),
|
||||||
chatMentions: list(this.db, "chat_mentions", "message_id asc, offset_start asc"),
|
chatMentions: list(this.db, "chat_mentions", "message_id asc, offset_start asc"),
|
||||||
chatSlashInvocations: list(this.db, "chat_slash_invocations", "created_at asc"),
|
chatSlashInvocations: list(this.db, "chat_slash_invocations", "created_at asc"),
|
||||||
|
boardInterventions: list(this.db, "board_interventions", "created_at asc"),
|
||||||
chatPresence: list(this.db, "chat_presence", "last_seen_at desc"),
|
chatPresence: list(this.db, "chat_presence", "last_seen_at desc"),
|
||||||
chatReadStates: list(this.db, "chat_read_state", "last_read_at desc"),
|
chatReadStates: list(this.db, "chat_read_state", "last_read_at desc"),
|
||||||
files,
|
files,
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ export function ChatPage({ snapshot, actions }: PageProps) {
|
|||||||
() => snapshot.chatSlashInvocations.filter((invocation) => messageIds.has(invocation.messageId)),
|
() => snapshot.chatSlashInvocations.filter((invocation) => messageIds.has(invocation.messageId)),
|
||||||
[snapshot.chatSlashInvocations, messageIds],
|
[snapshot.chatSlashInvocations, messageIds],
|
||||||
);
|
);
|
||||||
|
const boardInterventionsAll = useMemo(
|
||||||
|
() => snapshot.boardInterventions.filter((item) => item.orgId === orgId),
|
||||||
|
[snapshot.boardInterventions, orgId],
|
||||||
|
);
|
||||||
const approvalsAll = useMemo(
|
const approvalsAll = useMemo(
|
||||||
() => snapshot.approvals.filter((a) => a.orgId === orgId && a.state === "pending"),
|
() => snapshot.approvals.filter((a) => a.orgId === orgId && a.state === "pending"),
|
||||||
[snapshot.approvals, orgId],
|
[snapshot.approvals, orgId],
|
||||||
@@ -93,6 +97,10 @@ export function ChatPage({ snapshot, actions }: PageProps) {
|
|||||||
() => (selectedThread ? replayEventsForThread(auditEventsAll, selectedThread, messagesInSpace) : []),
|
() => (selectedThread ? replayEventsForThread(auditEventsAll, selectedThread, messagesInSpace) : []),
|
||||||
[auditEventsAll, selectedThread, messagesInSpace],
|
[auditEventsAll, selectedThread, messagesInSpace],
|
||||||
);
|
);
|
||||||
|
const selectedBoardInterventions = useMemo(
|
||||||
|
() => (selectedThread ? boardInterventionsAll.filter((item) => item.threadId === selectedThread.id) : []),
|
||||||
|
[boardInterventionsAll, selectedThread],
|
||||||
|
);
|
||||||
const participants = useMemo(
|
const participants = useMemo(
|
||||||
() => collectParticipants(messagesInSpace, employees, agents),
|
() => collectParticipants(messagesInSpace, employees, agents),
|
||||||
[messagesInSpace, employees, agents],
|
[messagesInSpace, employees, agents],
|
||||||
@@ -136,6 +144,27 @@ export function ChatPage({ snapshot, actions }: PageProps) {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sendBoardIntervention = (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!activeSpace || !selectedThread) return;
|
||||||
|
const form = new FormData(event.currentTarget);
|
||||||
|
const action = value(form, "action");
|
||||||
|
const target = value(form, "target");
|
||||||
|
const details = value(form, "details");
|
||||||
|
if (!action || !details) return;
|
||||||
|
event.currentTarget.reset();
|
||||||
|
const targetMention = action === "takeover" && target && target !== "board" ? ` @${target}` : "";
|
||||||
|
void actions.run(() => window.agentCompany.sendMessage({
|
||||||
|
orgId,
|
||||||
|
spaceId: activeSpace.id,
|
||||||
|
threadId: selectedThread.id,
|
||||||
|
senderType: "board",
|
||||||
|
senderId: employee.id,
|
||||||
|
bodyMd: `/board ${action}${targetMention} ${details}`,
|
||||||
|
attachmentFileIds: [],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
const createSpace = (event: FormEvent<HTMLFormElement>) => {
|
const createSpace = (event: FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const form = new FormData(event.currentTarget);
|
const form = new FormData(event.currentTarget);
|
||||||
@@ -204,16 +233,19 @@ export function ChatPage({ snapshot, actions }: PageProps) {
|
|||||||
|
|
||||||
{selectedThread && (
|
{selectedThread && (
|
||||||
<ChatThreadPanel
|
<ChatThreadPanel
|
||||||
|
thread={selectedThread}
|
||||||
rootMessage={messagesInSpace.find((m) => m.id === selectedThread.rootMessageId) ?? null}
|
rootMessage={messagesInSpace.find((m) => m.id === selectedThread.rootMessageId) ?? null}
|
||||||
messages={threadMessages}
|
messages={threadMessages}
|
||||||
attachments={snapshot.chatAttachments}
|
attachments={snapshot.chatAttachments}
|
||||||
mentions={mentionsAll}
|
mentions={mentionsAll}
|
||||||
slashInvocations={slashInvocationsAll}
|
slashInvocations={slashInvocationsAll}
|
||||||
|
boardInterventions={selectedBoardInterventions}
|
||||||
files={files}
|
files={files}
|
||||||
auditEvents={threadAuditEvents}
|
auditEvents={threadAuditEvents}
|
||||||
context={{ employees, agents }}
|
context={{ employees, agents }}
|
||||||
onClose={() => setOpenThreadId(null)}
|
onClose={() => setOpenThreadId(null)}
|
||||||
onSubmit={sendInThread}
|
onSubmit={sendInThread}
|
||||||
|
onBoardSubmit={sendBoardIntervention}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import type { FormEvent } from "react";
|
import { useState, type FormEvent } from "react";
|
||||||
import { Button, Icon, IconButton } from "../../shared/ui";
|
import { Button, Icon, IconButton, Pill } from "../../shared/ui";
|
||||||
import type {
|
import type {
|
||||||
AuditEventRecord,
|
AuditEventRecord,
|
||||||
|
BoardInterventionRecord,
|
||||||
ChatAttachmentRecord,
|
ChatAttachmentRecord,
|
||||||
ChatMentionRecord,
|
ChatMentionRecord,
|
||||||
ChatMessageRecord,
|
ChatMessageRecord,
|
||||||
ChatSlashInvocationRecord,
|
ChatSlashInvocationRecord,
|
||||||
|
ChatThreadRecord,
|
||||||
FileObjectRecord,
|
FileObjectRecord,
|
||||||
} from "../../../../shared/domain";
|
} from "../../../../shared/domain";
|
||||||
import { formatTime } from "../types";
|
import { formatTime } from "../types";
|
||||||
@@ -13,27 +15,33 @@ import { ChatMessageRow } from "./ChatMessageRow";
|
|||||||
import { senderName, type MessageContext } from "./chat-helpers";
|
import { senderName, type MessageContext } from "./chat-helpers";
|
||||||
|
|
||||||
export function ChatThreadPanel({
|
export function ChatThreadPanel({
|
||||||
|
thread,
|
||||||
rootMessage,
|
rootMessage,
|
||||||
messages,
|
messages,
|
||||||
attachments,
|
attachments,
|
||||||
mentions,
|
mentions,
|
||||||
slashInvocations,
|
slashInvocations,
|
||||||
|
boardInterventions,
|
||||||
files,
|
files,
|
||||||
auditEvents,
|
auditEvents,
|
||||||
context,
|
context,
|
||||||
onClose,
|
onClose,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
onBoardSubmit,
|
||||||
}: {
|
}: {
|
||||||
|
thread: ChatThreadRecord;
|
||||||
rootMessage: ChatMessageRecord | null;
|
rootMessage: ChatMessageRecord | null;
|
||||||
messages: ChatMessageRecord[];
|
messages: ChatMessageRecord[];
|
||||||
attachments: ChatAttachmentRecord[];
|
attachments: ChatAttachmentRecord[];
|
||||||
mentions: ChatMentionRecord[];
|
mentions: ChatMentionRecord[];
|
||||||
slashInvocations: ChatSlashInvocationRecord[];
|
slashInvocations: ChatSlashInvocationRecord[];
|
||||||
|
boardInterventions: BoardInterventionRecord[];
|
||||||
files: FileObjectRecord[];
|
files: FileObjectRecord[];
|
||||||
auditEvents: AuditEventRecord[];
|
auditEvents: AuditEventRecord[];
|
||||||
context: MessageContext;
|
context: MessageContext;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
||||||
|
onBoardSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
@@ -49,11 +57,18 @@ export function ChatThreadPanel({
|
|||||||
>
|
>
|
||||||
<div className="row" style={{ padding: "14px 16px", borderBottom: "1px solid var(--border-soft)", flexShrink: 0 }}>
|
<div className="row" style={{ padding: "14px 16px", borderBottom: "1px solid var(--border-soft)", flexShrink: 0 }}>
|
||||||
<div style={{ fontSize: 12.5, fontWeight: 600 }}>Thread</div>
|
<div style={{ fontSize: 12.5, fontWeight: 600 }}>Thread</div>
|
||||||
|
<Pill tone={boardStateTone(thread.boardState)}>{boardStateLabel(thread.boardState)}</Pill>
|
||||||
<div style={{ marginLeft: "auto" }}>
|
<div style={{ marginLeft: "auto" }}>
|
||||||
<IconButton icon="x" label="Close thread" onClick={onClose} />
|
<IconButton icon="x" label="Close thread" onClick={onClose} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: 16, overflow: "auto", flex: 1 }}>
|
<div style={{ padding: 16, overflow: "auto", flex: 1 }}>
|
||||||
|
<BoardControl
|
||||||
|
thread={thread}
|
||||||
|
interventions={boardInterventions}
|
||||||
|
context={context}
|
||||||
|
onSubmit={onBoardSubmit}
|
||||||
|
/>
|
||||||
{rootMessage && (
|
{rootMessage && (
|
||||||
<>
|
<>
|
||||||
<div style={{ fontSize: 12, color: "var(--text-3)", marginBottom: 10 }}>
|
<div style={{ fontSize: 12, color: "var(--text-3)", marginBottom: 10 }}>
|
||||||
@@ -113,6 +128,90 @@ export function ChatThreadPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function BoardControl({
|
||||||
|
thread,
|
||||||
|
interventions,
|
||||||
|
context,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
thread: ChatThreadRecord;
|
||||||
|
interventions: BoardInterventionRecord[];
|
||||||
|
context: MessageContext;
|
||||||
|
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
||||||
|
}) {
|
||||||
|
const [action, setAction] = useState("decision");
|
||||||
|
return (
|
||||||
|
<section style={{ border: "1px solid var(--border)", borderRadius: 10, padding: 10, background: "var(--panel)", marginBottom: 16 }}>
|
||||||
|
<div className="row" style={{ marginBottom: 8 }}>
|
||||||
|
<div className="section-label">Board</div>
|
||||||
|
<Pill tone={boardStateTone(thread.boardState)}>{boardStateLabel(thread.boardState)}</Pill>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={onSubmit} style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
||||||
|
<label className="field" style={{ margin: 0 }}>
|
||||||
|
<span>Action</span>
|
||||||
|
<select className="input" name="action" value={action} onChange={(event) => setAction(event.target.value)}>
|
||||||
|
<option value="decision">Decision</option>
|
||||||
|
<option value="freeze">Freeze</option>
|
||||||
|
<option value="takeover">Takeover</option>
|
||||||
|
<option value="rollback">Rollback</option>
|
||||||
|
<option value="force-approval">Force approval</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="field" style={{ margin: 0 }}>
|
||||||
|
<span>Target</span>
|
||||||
|
<select className="input" name="target" defaultValue="board" disabled={action !== "takeover"}>
|
||||||
|
<option value="board">Board</option>
|
||||||
|
{context.employees.map((employee) => (
|
||||||
|
<option key={employee.id} value={employee.id}>Employee · {employee.displayName}</option>
|
||||||
|
))}
|
||||||
|
{context.agents.map((agent) => (
|
||||||
|
<option key={agent.id} value={agent.id}>Agent · {agent.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="field" style={{ gridColumn: "1 / -1", margin: 0 }}>
|
||||||
|
<span>Details</span>
|
||||||
|
<input className="input" name="details" required aria-label="Board intervention details" />
|
||||||
|
</label>
|
||||||
|
<div className="row" style={{ gridColumn: "1 / -1", justifyContent: "flex-end" }}>
|
||||||
|
<Button tone="primary" size="small" type="submit"><Icon name="shield" size={12} /> Board</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{interventions.length > 0 && (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 7, marginTop: 10 }}>
|
||||||
|
{interventions.map((intervention) => (
|
||||||
|
<div key={intervention.id} style={{ borderTop: "1px solid var(--border-soft)", paddingTop: 7 }}>
|
||||||
|
<div className="row" style={{ gap: 6, marginBottom: 3 }}>
|
||||||
|
<span className="mono" style={{ fontSize: 11, color: "var(--text-3)" }}>{formatTime(intervention.createdAt)}</span>
|
||||||
|
<Pill tone={intervention.mode === "takeover" ? "violet" : intervention.action === "freeze" ? "red" : undefined}>
|
||||||
|
{formatBoardAction(intervention.action)}
|
||||||
|
</Pill>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: "var(--text-2)", lineHeight: 1.4, wordBreak: "break-word" }}>{intervention.details}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function boardStateTone(state: string): "red" | "violet" | undefined {
|
||||||
|
if (state === "frozen") return "red";
|
||||||
|
if (state === "takeover") return "violet";
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function boardStateLabel(state: string): string {
|
||||||
|
if (state === "frozen") return "Frozen";
|
||||||
|
if (state === "takeover") return "Takeover";
|
||||||
|
return "Open";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBoardAction(action: string): string {
|
||||||
|
return action.replace(/_/g, " ");
|
||||||
|
}
|
||||||
|
|
||||||
function AuditReplay({ events }: { events: AuditEventRecord[] }) {
|
function AuditReplay({ events }: { events: AuditEventRecord[] }) {
|
||||||
return (
|
return (
|
||||||
<section style={{ borderTop: "1px solid var(--border-soft)", marginTop: 18, paddingTop: 14 }}>
|
<section style={{ borderTop: "1px solid var(--border-soft)", marginTop: 18, paddingTop: 14 }}>
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
export const CHAT_THREAD_BOARD_STATE = {
|
||||||
|
OPEN: "open",
|
||||||
|
FROZEN: "frozen",
|
||||||
|
TAKEOVER: "takeover",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const BOARD_INTERVENTION_MODE = {
|
||||||
|
OBSERVE: "observe",
|
||||||
|
INTERVENE: "intervene",
|
||||||
|
TAKEOVER: "takeover",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const BOARD_INTERVENTION_ACTION = {
|
||||||
|
DECISION: "decision",
|
||||||
|
FREEZE: "freeze",
|
||||||
|
TAKEOVER: "takeover",
|
||||||
|
ROLLBACK: "rollback",
|
||||||
|
FORCE_APPROVAL: "force_approval",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type ValueOf<T> = T[keyof T];
|
||||||
|
|
||||||
|
export type ChatThreadBoardState = ValueOf<typeof CHAT_THREAD_BOARD_STATE>;
|
||||||
|
export type BoardInterventionMode = ValueOf<typeof BOARD_INTERVENTION_MODE>;
|
||||||
|
export type BoardInterventionAction = ValueOf<typeof BOARD_INTERVENTION_ACTION>;
|
||||||
|
|
||||||
|
export interface BoardInterventionRecord {
|
||||||
|
id: string;
|
||||||
|
orgId: string;
|
||||||
|
threadId: string;
|
||||||
|
messageId: string;
|
||||||
|
mode: BoardInterventionMode;
|
||||||
|
action: BoardInterventionAction;
|
||||||
|
details: string;
|
||||||
|
targetType: "employee" | "agent" | "board" | null;
|
||||||
|
targetId: string | null;
|
||||||
|
approvalId: string | null;
|
||||||
|
createdByType: "employee" | "agent" | "board" | "system";
|
||||||
|
createdById: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
|
import type { BoardInterventionRecord as BoardInterventionRecordModel, ChatThreadBoardState as ChatThreadBoardStateModel } from "./board-domain";
|
||||||
|
|
||||||
|
export { BOARD_INTERVENTION_ACTION, BOARD_INTERVENTION_MODE, CHAT_THREAD_BOARD_STATE } from "./board-domain";
|
||||||
|
export type { BoardInterventionAction, BoardInterventionMode, BoardInterventionRecord, ChatThreadBoardState } from "./board-domain";
|
||||||
|
|
||||||
export const TASK_STATUS = {
|
export const TASK_STATUS = {
|
||||||
BACKLOG: "backlog",
|
BACKLOG: "backlog",
|
||||||
IN_PROGRESS: "in_progress",
|
IN_PROGRESS: "in_progress",
|
||||||
@@ -210,6 +215,9 @@ export interface ChatThreadRecord {
|
|||||||
rootMessageId: string | null;
|
rootMessageId: string | null;
|
||||||
title: string;
|
title: string;
|
||||||
state: string;
|
state: string;
|
||||||
|
boardState: ChatThreadBoardStateModel;
|
||||||
|
controlledByType: "employee" | "agent" | "board" | null;
|
||||||
|
controlledById: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -468,6 +476,7 @@ export interface AppSnapshot {
|
|||||||
chatAttachments: ChatAttachmentRecord[];
|
chatAttachments: ChatAttachmentRecord[];
|
||||||
chatMentions: ChatMentionRecord[];
|
chatMentions: ChatMentionRecord[];
|
||||||
chatSlashInvocations: ChatSlashInvocationRecord[];
|
chatSlashInvocations: ChatSlashInvocationRecord[];
|
||||||
|
boardInterventions: BoardInterventionRecordModel[];
|
||||||
chatPresence: ChatPresenceRecord[];
|
chatPresence: ChatPresenceRecord[];
|
||||||
chatReadStates: ChatReadStateRecord[];
|
chatReadStates: ChatReadStateRecord[];
|
||||||
files: FileObjectRecord[];
|
files: FileObjectRecord[];
|
||||||
|
|||||||
@@ -0,0 +1,414 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import { DatabaseConnection } from "../../src/main/database/connection";
|
||||||
|
import { ChatService } from "../../src/main/services/chat-service";
|
||||||
|
import { OrganizationService } from "../../src/main/services/organization-service";
|
||||||
|
import { SnapshotService } from "../../src/main/services/snapshot-service";
|
||||||
|
|
||||||
|
function createConnection(): DatabaseConnection {
|
||||||
|
return new DatabaseConnection(join(tmpdir(), `agentcompany-new-chat-${randomUUID()}.db`));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("chat governance", () => {
|
||||||
|
test("records board interventions and enforces thread control", () => {
|
||||||
|
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 = "";
|
||||||
|
let threadId = "";
|
||||||
|
|
||||||
|
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",
|
||||||
|
role: "Release reviewer",
|
||||||
|
department: "Engineering",
|
||||||
|
modelProviderId: null,
|
||||||
|
modelId: null,
|
||||||
|
budgetCents: 10000,
|
||||||
|
managerAgentId: null,
|
||||||
|
});
|
||||||
|
spaceId = chat.createSpace({ orgId, spaceType: "project", name: "release", topic: "Release coordination", visibility: "workspace" });
|
||||||
|
chat.sendMessage({
|
||||||
|
orgId,
|
||||||
|
spaceId,
|
||||||
|
threadId: null,
|
||||||
|
senderType: "employee",
|
||||||
|
senderId: employeeId,
|
||||||
|
bodyMd: "Release thread",
|
||||||
|
attachmentFileIds: [],
|
||||||
|
});
|
||||||
|
threadId = (connection.db.prepare("select id from chat_threads where space_id = ?").get(spaceId) as { id: string }).id;
|
||||||
|
chat.sendMessage({
|
||||||
|
orgId,
|
||||||
|
spaceId,
|
||||||
|
threadId,
|
||||||
|
senderType: "board",
|
||||||
|
senderId: employeeId,
|
||||||
|
bodyMd: "/board freeze hold the release",
|
||||||
|
attachmentFileIds: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => connection.transaction(() => chat.sendMessage({
|
||||||
|
orgId,
|
||||||
|
spaceId,
|
||||||
|
threadId,
|
||||||
|
senderType: "employee",
|
||||||
|
senderId: employeeId,
|
||||||
|
bodyMd: "Proceeding",
|
||||||
|
attachmentFileIds: [],
|
||||||
|
}))).toThrow("Board-frozen thread requires board sender.");
|
||||||
|
|
||||||
|
connection.transaction(() => {
|
||||||
|
chat.sendMessage({
|
||||||
|
orgId,
|
||||||
|
spaceId,
|
||||||
|
threadId,
|
||||||
|
senderType: "board",
|
||||||
|
senderId: employeeId,
|
||||||
|
bodyMd: "/board rollback reopen release coordination",
|
||||||
|
attachmentFileIds: [],
|
||||||
|
});
|
||||||
|
chat.sendMessage({
|
||||||
|
orgId,
|
||||||
|
spaceId,
|
||||||
|
threadId,
|
||||||
|
senderType: "board",
|
||||||
|
senderId: employeeId,
|
||||||
|
bodyMd: `/board takeover @${agentId} Ada owns release checks`,
|
||||||
|
attachmentFileIds: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => connection.transaction(() => chat.sendMessage({
|
||||||
|
orgId,
|
||||||
|
spaceId,
|
||||||
|
threadId,
|
||||||
|
senderType: "employee",
|
||||||
|
senderId: employeeId,
|
||||||
|
bodyMd: "Taking back control",
|
||||||
|
attachmentFileIds: [],
|
||||||
|
}))).toThrow("Board takeover controls this thread.");
|
||||||
|
|
||||||
|
connection.transaction(() => {
|
||||||
|
chat.sendMessage({
|
||||||
|
orgId,
|
||||||
|
spaceId,
|
||||||
|
threadId,
|
||||||
|
senderType: "agent",
|
||||||
|
senderId: agentId,
|
||||||
|
bodyMd: "Release checks are in progress.",
|
||||||
|
attachmentFileIds: [],
|
||||||
|
});
|
||||||
|
chat.sendMessage({
|
||||||
|
orgId,
|
||||||
|
spaceId,
|
||||||
|
threadId,
|
||||||
|
senderType: "board",
|
||||||
|
senderId: employeeId,
|
||||||
|
bodyMd: "/board force-approval approve gated release window",
|
||||||
|
attachmentFileIds: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = snapshot.load();
|
||||||
|
const thread = state.chatThreads.find((item) => item.id === threadId);
|
||||||
|
expect(thread).toMatchObject({ boardState: "takeover", controlledByType: "agent", controlledById: agentId });
|
||||||
|
expect(state.boardInterventions.map((item) => item.action)).toEqual(["freeze", "rollback", "takeover", "force_approval"]);
|
||||||
|
const forceApproval = state.approvals.find((item) => item.kind === "board_force_approval");
|
||||||
|
if (!forceApproval) throw new Error("Board force approval was not created.");
|
||||||
|
const forceIntervention = state.boardInterventions.find((item) => item.action === "force_approval");
|
||||||
|
if (!forceIntervention) throw new Error("Board force approval intervention was not created.");
|
||||||
|
expect(forceIntervention.approvalId).toBe(forceApproval.id);
|
||||||
|
expect(state.chatSlashInvocations.some((item) => item.resultRef === forceIntervention.id)).toBe(true);
|
||||||
|
expect(state.auditEvents.some((event) => event.entityType === "board_intervention" && event.action === "recorded")).toBe(true);
|
||||||
|
connection.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects chat writes outside membership and prevents partial invalid messages", () => {
|
||||||
|
const connection = createConnection();
|
||||||
|
const organization = new OrganizationService(connection.db);
|
||||||
|
const chat = new ChatService(connection.db);
|
||||||
|
let orgId = "";
|
||||||
|
let employeeId = "";
|
||||||
|
let agentId = "";
|
||||||
|
let restrictedSpaceId = "";
|
||||||
|
let workspaceSpaceId = "";
|
||||||
|
|
||||||
|
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",
|
||||||
|
role: "Release reviewer",
|
||||||
|
department: "Engineering",
|
||||||
|
modelProviderId: null,
|
||||||
|
modelId: null,
|
||||||
|
budgetCents: 10000,
|
||||||
|
managerAgentId: null,
|
||||||
|
});
|
||||||
|
restrictedSpaceId = chat.createSpace({
|
||||||
|
orgId,
|
||||||
|
spaceType: "project",
|
||||||
|
name: "board-review",
|
||||||
|
topic: "Restricted release review",
|
||||||
|
visibility: "restricted",
|
||||||
|
memberEmployeeIds: [employeeId],
|
||||||
|
});
|
||||||
|
workspaceSpaceId = chat.createSpace({
|
||||||
|
orgId,
|
||||||
|
spaceType: "project",
|
||||||
|
name: "release",
|
||||||
|
topic: "Release coordination",
|
||||||
|
visibility: "workspace",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => connection.transaction(() => chat.sendMessage({
|
||||||
|
orgId,
|
||||||
|
spaceId: restrictedSpaceId,
|
||||||
|
threadId: null,
|
||||||
|
senderType: "agent",
|
||||||
|
senderId: agentId,
|
||||||
|
bodyMd: "I should not be here.",
|
||||||
|
attachmentFileIds: [],
|
||||||
|
}))).toThrow("Chat membership is required.");
|
||||||
|
expect(() => connection.transaction(() => chat.sendMessage({
|
||||||
|
orgId,
|
||||||
|
spaceId: restrictedSpaceId,
|
||||||
|
threadId: null,
|
||||||
|
senderType: "employee",
|
||||||
|
senderId: employeeId,
|
||||||
|
bodyMd: "Restricted ping @Ada",
|
||||||
|
attachmentFileIds: [],
|
||||||
|
}))).toThrow("Mention @Ada could not be resolved.");
|
||||||
|
expect(() => connection.transaction(() => chat.sendMessage({
|
||||||
|
orgId,
|
||||||
|
spaceId: workspaceSpaceId,
|
||||||
|
threadId: null,
|
||||||
|
senderType: "employee",
|
||||||
|
senderId: employeeId,
|
||||||
|
bodyMd: "/unknown command",
|
||||||
|
attachmentFileIds: [],
|
||||||
|
}))).toThrow("Slash command /unknown is unavailable.");
|
||||||
|
expect(() => connection.transaction(() => chat.sendMessage({
|
||||||
|
orgId,
|
||||||
|
spaceId: workspaceSpaceId,
|
||||||
|
threadId: null,
|
||||||
|
senderType: "employee",
|
||||||
|
senderId: employeeId,
|
||||||
|
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,
|
||||||
|
threadId: null,
|
||||||
|
senderType: "employee",
|
||||||
|
senderId: employeeId,
|
||||||
|
bodyMd: "Unknown @Nobody",
|
||||||
|
attachmentFileIds: [],
|
||||||
|
}))).toThrow("Mention @Nobody could not be resolved.");
|
||||||
|
|
||||||
|
const count = connection.db.prepare("select count(*) as count from chat_messages").get() as { count: number };
|
||||||
|
expect(count.count).toBe(0);
|
||||||
|
connection.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects reserved mention handles for employees and agents", () => {
|
||||||
|
const connection = createConnection();
|
||||||
|
const organization = new OrganizationService(connection.db);
|
||||||
|
let orgId = "";
|
||||||
|
|
||||||
|
expect(() => connection.transaction(() => organization.createOrganization({
|
||||||
|
organizationName: "Acme Operations",
|
||||||
|
employeeName: "Board",
|
||||||
|
mission: "Coordinate agents with auditable approvals.",
|
||||||
|
goal: "Ship a verified control plane.",
|
||||||
|
}))).toThrow("Mention handle is reserved.");
|
||||||
|
|
||||||
|
connection.transaction(() => {
|
||||||
|
orgId = organization.createOrganization({
|
||||||
|
organizationName: "Acme Operations",
|
||||||
|
employeeName: "Dana Lee",
|
||||||
|
mission: "Coordinate agents with auditable approvals.",
|
||||||
|
goal: "Ship a verified control plane.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(() => connection.transaction(() => organization.createAgent({
|
||||||
|
orgId,
|
||||||
|
name: "On Call",
|
||||||
|
role: "Escalation lead",
|
||||||
|
department: "Operations",
|
||||||
|
modelProviderId: null,
|
||||||
|
modelId: null,
|
||||||
|
budgetCents: 10000,
|
||||||
|
managerAgentId: null,
|
||||||
|
}))).toThrow("Mention handle is reserved.");
|
||||||
|
connection.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects ambiguous chat mentions", () => {
|
||||||
|
const connection = createConnection();
|
||||||
|
const organization = new OrganizationService(connection.db);
|
||||||
|
const chat = new ChatService(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;
|
||||||
|
organization.createAgent({ orgId, name: "Ada", role: "Reviewer", department: "Engineering", modelProviderId: null, modelId: null, budgetCents: 10000, managerAgentId: null });
|
||||||
|
organization.createAgent({ orgId, name: "Ada", role: "Release lead", department: "Engineering", modelProviderId: null, modelId: null, budgetCents: 10000, managerAgentId: null });
|
||||||
|
spaceId = chat.createSpace({ orgId, spaceType: "project", name: "release", topic: "Release coordination", visibility: "workspace" });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => connection.transaction(() => chat.sendMessage({
|
||||||
|
orgId,
|
||||||
|
spaceId,
|
||||||
|
threadId: null,
|
||||||
|
senderType: "employee",
|
||||||
|
senderId: employeeId,
|
||||||
|
bodyMd: "Please review @Ada",
|
||||||
|
attachmentFileIds: [],
|
||||||
|
}))).toThrow("Mention @Ada is ambiguous.");
|
||||||
|
const count = connection.db.prepare("select count(*) as count from chat_messages").get() as { count: number };
|
||||||
|
expect(count.count).toBe(0);
|
||||||
|
connection.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolves chat mentions by display handle and principal id", () => {
|
||||||
|
const connection = createConnection();
|
||||||
|
const organization = new OrganizationService(connection.db);
|
||||||
|
const chat = new ChatService(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: "Reviewer",
|
||||||
|
department: "Engineering",
|
||||||
|
modelProviderId: null,
|
||||||
|
modelId: null,
|
||||||
|
budgetCents: 10000,
|
||||||
|
managerAgentId: null,
|
||||||
|
});
|
||||||
|
spaceId = chat.createSpace({ orgId, spaceType: "project", name: "release", topic: "Release coordination", visibility: "workspace" });
|
||||||
|
});
|
||||||
|
|
||||||
|
connection.transaction(() => {
|
||||||
|
chat.sendMessage({
|
||||||
|
orgId,
|
||||||
|
spaceId,
|
||||||
|
threadId: null,
|
||||||
|
senderType: "employee",
|
||||||
|
senderId: employeeId,
|
||||||
|
bodyMd: `Review @Ada-Lovelace and @${agentId}`,
|
||||||
|
attachmentFileIds: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const mentions = connection.db.prepare(`
|
||||||
|
select target_type, target_id
|
||||||
|
from chat_mentions
|
||||||
|
order by offset_start asc
|
||||||
|
`).all() as Array<{ target_type: string; target_id: string }>;
|
||||||
|
expect(mentions).toEqual([
|
||||||
|
{ target_type: "agent", target_id: agentId },
|
||||||
|
{ target_type: "agent", target_id: agentId },
|
||||||
|
]);
|
||||||
|
connection.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects replies to threads from another chat space", () => {
|
||||||
|
const connection = createConnection();
|
||||||
|
const organization = new OrganizationService(connection.db);
|
||||||
|
const chat = new ChatService(connection.db);
|
||||||
|
let orgId = "";
|
||||||
|
let employeeId = "";
|
||||||
|
let firstSpaceId = "";
|
||||||
|
let secondSpaceId = "";
|
||||||
|
let threadId = "";
|
||||||
|
|
||||||
|
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;
|
||||||
|
firstSpaceId = chat.createSpace({ orgId, spaceType: "project", name: "release-a", topic: "A", visibility: "workspace" });
|
||||||
|
secondSpaceId = chat.createSpace({ orgId, spaceType: "project", name: "release-b", topic: "B", visibility: "workspace" });
|
||||||
|
chat.sendMessage({
|
||||||
|
orgId,
|
||||||
|
spaceId: firstSpaceId,
|
||||||
|
threadId: null,
|
||||||
|
senderType: "employee",
|
||||||
|
senderId: employeeId,
|
||||||
|
bodyMd: "Root",
|
||||||
|
attachmentFileIds: [],
|
||||||
|
});
|
||||||
|
threadId = (connection.db.prepare("select id from chat_threads where space_id = ?").get(firstSpaceId) as { id: string }).id;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => connection.transaction(() => chat.sendMessage({
|
||||||
|
orgId,
|
||||||
|
spaceId: secondSpaceId,
|
||||||
|
threadId,
|
||||||
|
senderType: "employee",
|
||||||
|
senderId: employeeId,
|
||||||
|
bodyMd: "Wrong thread",
|
||||||
|
attachmentFileIds: [],
|
||||||
|
}))).toThrow("Chat thread was not found in this space.");
|
||||||
|
const count = connection.db.prepare("select count(*) as count from chat_messages").get() as { count: number };
|
||||||
|
expect(count.count).toBe(1);
|
||||||
|
connection.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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]);
|
expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]);
|
||||||
connection.close();
|
connection.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -405,279 +405,6 @@ describe("domain services", () => {
|
|||||||
connection.close();
|
connection.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rejects chat writes outside membership and prevents partial invalid messages", () => {
|
|
||||||
const connection = createConnection();
|
|
||||||
const organization = new OrganizationService(connection.db);
|
|
||||||
const chat = new ChatService(connection.db);
|
|
||||||
let orgId = "";
|
|
||||||
let employeeId = "";
|
|
||||||
let agentId = "";
|
|
||||||
let restrictedSpaceId = "";
|
|
||||||
let workspaceSpaceId = "";
|
|
||||||
|
|
||||||
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",
|
|
||||||
role: "Release reviewer",
|
|
||||||
department: "Engineering",
|
|
||||||
modelProviderId: null,
|
|
||||||
modelId: null,
|
|
||||||
budgetCents: 10000,
|
|
||||||
managerAgentId: null,
|
|
||||||
});
|
|
||||||
restrictedSpaceId = chat.createSpace({
|
|
||||||
orgId,
|
|
||||||
spaceType: "project",
|
|
||||||
name: "board-review",
|
|
||||||
topic: "Restricted release review",
|
|
||||||
visibility: "restricted",
|
|
||||||
memberEmployeeIds: [employeeId],
|
|
||||||
});
|
|
||||||
workspaceSpaceId = chat.createSpace({
|
|
||||||
orgId,
|
|
||||||
spaceType: "project",
|
|
||||||
name: "release",
|
|
||||||
topic: "Release coordination",
|
|
||||||
visibility: "workspace",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(() => connection.transaction(() => chat.sendMessage({
|
|
||||||
orgId,
|
|
||||||
spaceId: restrictedSpaceId,
|
|
||||||
threadId: null,
|
|
||||||
senderType: "agent",
|
|
||||||
senderId: agentId,
|
|
||||||
bodyMd: "I should not be here.",
|
|
||||||
attachmentFileIds: [],
|
|
||||||
}))).toThrow("Chat membership is required.");
|
|
||||||
expect(() => connection.transaction(() => chat.sendMessage({
|
|
||||||
orgId,
|
|
||||||
spaceId: restrictedSpaceId,
|
|
||||||
threadId: null,
|
|
||||||
senderType: "employee",
|
|
||||||
senderId: employeeId,
|
|
||||||
bodyMd: "Restricted ping @Ada",
|
|
||||||
attachmentFileIds: [],
|
|
||||||
}))).toThrow("Mention @Ada could not be resolved.");
|
|
||||||
expect(() => connection.transaction(() => chat.sendMessage({
|
|
||||||
orgId,
|
|
||||||
spaceId: workspaceSpaceId,
|
|
||||||
threadId: null,
|
|
||||||
senderType: "employee",
|
|
||||||
senderId: employeeId,
|
|
||||||
bodyMd: "/unknown command",
|
|
||||||
attachmentFileIds: [],
|
|
||||||
}))).toThrow("Slash command /unknown is unavailable.");
|
|
||||||
expect(() => connection.transaction(() => chat.sendMessage({
|
|
||||||
orgId,
|
|
||||||
spaceId: workspaceSpaceId,
|
|
||||||
threadId: null,
|
|
||||||
senderType: "employee",
|
|
||||||
senderId: employeeId,
|
|
||||||
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,
|
|
||||||
threadId: null,
|
|
||||||
senderType: "employee",
|
|
||||||
senderId: employeeId,
|
|
||||||
bodyMd: "Unknown @Nobody",
|
|
||||||
attachmentFileIds: [],
|
|
||||||
}))).toThrow("Mention @Nobody could not be resolved.");
|
|
||||||
|
|
||||||
const count = connection.db.prepare("select count(*) as count from chat_messages").get() as { count: number };
|
|
||||||
expect(count.count).toBe(0);
|
|
||||||
connection.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rejects reserved mention handles for employees and agents", () => {
|
|
||||||
const connection = createConnection();
|
|
||||||
const organization = new OrganizationService(connection.db);
|
|
||||||
let orgId = "";
|
|
||||||
|
|
||||||
expect(() => connection.transaction(() => organization.createOrganization({
|
|
||||||
organizationName: "Acme Operations",
|
|
||||||
employeeName: "Board",
|
|
||||||
mission: "Coordinate agents with auditable approvals.",
|
|
||||||
goal: "Ship a verified control plane.",
|
|
||||||
}))).toThrow("Mention handle is reserved.");
|
|
||||||
|
|
||||||
connection.transaction(() => {
|
|
||||||
orgId = organization.createOrganization({
|
|
||||||
organizationName: "Acme Operations",
|
|
||||||
employeeName: "Dana Lee",
|
|
||||||
mission: "Coordinate agents with auditable approvals.",
|
|
||||||
goal: "Ship a verified control plane.",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
expect(() => connection.transaction(() => organization.createAgent({
|
|
||||||
orgId,
|
|
||||||
name: "On Call",
|
|
||||||
role: "Escalation lead",
|
|
||||||
department: "Operations",
|
|
||||||
modelProviderId: null,
|
|
||||||
modelId: null,
|
|
||||||
budgetCents: 10000,
|
|
||||||
managerAgentId: null,
|
|
||||||
}))).toThrow("Mention handle is reserved.");
|
|
||||||
connection.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rejects ambiguous chat mentions", () => {
|
|
||||||
const connection = createConnection();
|
|
||||||
const organization = new OrganizationService(connection.db);
|
|
||||||
const chat = new ChatService(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;
|
|
||||||
organization.createAgent({ orgId, name: "Ada", role: "Reviewer", department: "Engineering", modelProviderId: null, modelId: null, budgetCents: 10000, managerAgentId: null });
|
|
||||||
organization.createAgent({ orgId, name: "Ada", role: "Release lead", department: "Engineering", modelProviderId: null, modelId: null, budgetCents: 10000, managerAgentId: null });
|
|
||||||
spaceId = chat.createSpace({ orgId, spaceType: "project", name: "release", topic: "Release coordination", visibility: "workspace" });
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(() => connection.transaction(() => chat.sendMessage({
|
|
||||||
orgId,
|
|
||||||
spaceId,
|
|
||||||
threadId: null,
|
|
||||||
senderType: "employee",
|
|
||||||
senderId: employeeId,
|
|
||||||
bodyMd: "Please review @Ada",
|
|
||||||
attachmentFileIds: [],
|
|
||||||
}))).toThrow("Mention @Ada is ambiguous.");
|
|
||||||
const count = connection.db.prepare("select count(*) as count from chat_messages").get() as { count: number };
|
|
||||||
expect(count.count).toBe(0);
|
|
||||||
connection.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("resolves chat mentions by display handle and principal id", () => {
|
|
||||||
const connection = createConnection();
|
|
||||||
const organization = new OrganizationService(connection.db);
|
|
||||||
const chat = new ChatService(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: "Reviewer",
|
|
||||||
department: "Engineering",
|
|
||||||
modelProviderId: null,
|
|
||||||
modelId: null,
|
|
||||||
budgetCents: 10000,
|
|
||||||
managerAgentId: null,
|
|
||||||
});
|
|
||||||
spaceId = chat.createSpace({ orgId, spaceType: "project", name: "release", topic: "Release coordination", visibility: "workspace" });
|
|
||||||
});
|
|
||||||
|
|
||||||
connection.transaction(() => {
|
|
||||||
chat.sendMessage({
|
|
||||||
orgId,
|
|
||||||
spaceId,
|
|
||||||
threadId: null,
|
|
||||||
senderType: "employee",
|
|
||||||
senderId: employeeId,
|
|
||||||
bodyMd: `Review @Ada-Lovelace and @${agentId}`,
|
|
||||||
attachmentFileIds: [],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const mentions = connection.db.prepare(`
|
|
||||||
select target_type, target_id
|
|
||||||
from chat_mentions
|
|
||||||
order by offset_start asc
|
|
||||||
`).all() as Array<{ target_type: string; target_id: string }>;
|
|
||||||
expect(mentions).toEqual([
|
|
||||||
{ target_type: "agent", target_id: agentId },
|
|
||||||
{ target_type: "agent", target_id: agentId },
|
|
||||||
]);
|
|
||||||
connection.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rejects replies to threads from another chat space", () => {
|
|
||||||
const connection = createConnection();
|
|
||||||
const organization = new OrganizationService(connection.db);
|
|
||||||
const chat = new ChatService(connection.db);
|
|
||||||
let orgId = "";
|
|
||||||
let employeeId = "";
|
|
||||||
let firstSpaceId = "";
|
|
||||||
let secondSpaceId = "";
|
|
||||||
let threadId = "";
|
|
||||||
|
|
||||||
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;
|
|
||||||
firstSpaceId = chat.createSpace({ orgId, spaceType: "project", name: "release-a", topic: "A", visibility: "workspace" });
|
|
||||||
secondSpaceId = chat.createSpace({ orgId, spaceType: "project", name: "release-b", topic: "B", visibility: "workspace" });
|
|
||||||
chat.sendMessage({
|
|
||||||
orgId,
|
|
||||||
spaceId: firstSpaceId,
|
|
||||||
threadId: null,
|
|
||||||
senderType: "employee",
|
|
||||||
senderId: employeeId,
|
|
||||||
bodyMd: "Root",
|
|
||||||
attachmentFileIds: [],
|
|
||||||
});
|
|
||||||
threadId = (connection.db.prepare("select id from chat_threads where space_id = ?").get(firstSpaceId) as { id: string }).id;
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(() => connection.transaction(() => chat.sendMessage({
|
|
||||||
orgId,
|
|
||||||
spaceId: secondSpaceId,
|
|
||||||
threadId,
|
|
||||||
senderType: "employee",
|
|
||||||
senderId: employeeId,
|
|
||||||
bodyMd: "Wrong thread",
|
|
||||||
attachmentFileIds: [],
|
|
||||||
}))).toThrow("Chat thread was not found in this space.");
|
|
||||||
const count = connection.db.prepare("select count(*) as count from chat_messages").get() as { count: number };
|
|
||||||
expect(count.count).toBe(1);
|
|
||||||
connection.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("creates a read-only migration report from a legacy database", () => {
|
test("creates a read-only migration report from a legacy database", () => {
|
||||||
const connection = createConnection();
|
const connection = createConnection();
|
||||||
const organization = new OrganizationService(connection.db);
|
const organization = new OrganizationService(connection.db);
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ describe("migration service", () => {
|
|||||||
create table chat_attachments(message_id text, file_id text);
|
create table chat_attachments(message_id text, file_id text);
|
||||||
create table chat_mentions(message_id text, target_type text, target_id text);
|
create table chat_mentions(message_id text, target_type text, target_id text);
|
||||||
create table chat_slash_invocations(id text primary key, message_id text);
|
create table chat_slash_invocations(id text primary key, message_id text);
|
||||||
|
create table board_interventions(id text primary key, org_id text, thread_id text, message_id text, approval_id text);
|
||||||
create table chat_presence(principal_type text, principal_id text);
|
create table chat_presence(principal_type text, principal_id text);
|
||||||
create table chat_read_state(org_id text, employee_id text, space_id text, last_read_message_id text);
|
create table chat_read_state(org_id text, employee_id text, space_id text, last_read_message_id text);
|
||||||
create table providers(id text primary key, org_id text, credential_id text);
|
create table providers(id text primary key, org_id text, credential_id text);
|
||||||
@@ -148,6 +149,7 @@ describe("migration service", () => {
|
|||||||
insert into file_access_grants(id, org_id, file_id, source_message_id, source_thread_id) values ('file-grant-1', 'org-1', 'file-1', 'msg-1', 'thread-1');
|
insert into file_access_grants(id, org_id, file_id, source_message_id, source_thread_id) values ('file-grant-1', 'org-1', 'file-1', 'msg-1', 'thread-1');
|
||||||
insert into chat_mentions(message_id, target_type, target_id) values ('msg-1', 'agent', 'agent-1');
|
insert into chat_mentions(message_id, target_type, target_id) values ('msg-1', 'agent', 'agent-1');
|
||||||
insert into chat_slash_invocations(id, message_id) values ('slash-1', 'msg-1');
|
insert into chat_slash_invocations(id, message_id) values ('slash-1', 'msg-1');
|
||||||
|
insert into board_interventions(id, org_id, thread_id, message_id, approval_id) values ('bdi-1', 'org-1', 'thread-1', 'msg-1', 'approval-1');
|
||||||
insert into chat_presence(principal_type, principal_id) values ('agent', 'agent-1');
|
insert into chat_presence(principal_type, principal_id) values ('agent', 'agent-1');
|
||||||
insert into chat_read_state(org_id, employee_id, space_id, last_read_message_id) values ('org-1', 'emp-1', 'space-1', 'msg-1');
|
insert into chat_read_state(org_id, employee_id, space_id, last_read_message_id) values ('org-1', 'emp-1', 'space-1', 'msg-1');
|
||||||
insert into provider_requests(id, org_id, provider_id) values ('req-1', 'org-1', 'prov-1');
|
insert into provider_requests(id, org_id, provider_id) values ('req-1', 'org-1', 'prov-1');
|
||||||
@@ -178,10 +180,12 @@ describe("migration service", () => {
|
|||||||
expect(body.tableCounts.credentials).toBe(1);
|
expect(body.tableCounts.credentials).toBe(1);
|
||||||
expect(body.tableCounts.skill_artifacts).toBe(1);
|
expect(body.tableCounts.skill_artifacts).toBe(1);
|
||||||
expect(body.tableCounts.chat_read_state).toBe(1);
|
expect(body.tableCounts.chat_read_state).toBe(1);
|
||||||
|
expect(body.tableCounts.board_interventions).toBe(1);
|
||||||
expect(body.tableCounts.file_access_grants).toBe(1);
|
expect(body.tableCounts.file_access_grants).toBe(1);
|
||||||
expect(body.tableCounts.provider_requests).toBe(1);
|
expect(body.tableCounts.provider_requests).toBe(1);
|
||||||
expect(body.tableChecksums.audit_events).toHaveLength(64);
|
expect(body.tableChecksums.audit_events).toHaveLength(64);
|
||||||
expect(body.idMaps.skill_runtime_snapshots).toEqual(["snapshot-1"]);
|
expect(body.idMaps.skill_runtime_snapshots).toEqual(["snapshot-1"]);
|
||||||
|
expect(body.idMaps.board_interventions).toEqual(["bdi-1"]);
|
||||||
connection.close();
|
connection.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user