diff --git a/AgentCompany-New/src/main/database/migrations/011-board-interventions.ts b/AgentCompany-New/src/main/database/migrations/011-board-interventions.ts new file mode 100644 index 00000000..13bd6f63 --- /dev/null +++ b/AgentCompany-New/src/main/database/migrations/011-board-interventions.ts @@ -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')); +`; diff --git a/AgentCompany-New/src/main/database/schema.ts b/AgentCompany-New/src/main/database/schema.ts index 307f777b..9436a8e5 100644 --- a/AgentCompany-New/src/main/database/schema.ts +++ b/AgentCompany-New/src/main/database/schema.ts @@ -9,6 +9,7 @@ import { skillHardeningSchema } from "./migrations/007-skill-hardening"; import { providerHealthSchema } from "./migrations/008-provider-health"; import { socialGovernanceSchema } from "./migrations/009-social-governance"; import { fileHandoffSecuritySchema } from "./migrations/010-file-handoff-security"; +import { boardInterventionSchema } from "./migrations/011-board-interventions"; interface Migration { version: number; @@ -27,6 +28,7 @@ const MIGRATIONS: Migration[] = [ { version: 8, name: "provider_health", sql: providerHealthSchema }, { version: 9, name: "social_governance", sql: socialGovernanceSchema }, { version: 10, name: "file_handoff_security", sql: fileHandoffSecuritySchema }, + { version: 11, name: "board_interventions", sql: boardInterventionSchema }, ]; const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version; diff --git a/AgentCompany-New/src/main/services/board-intervention-service.ts b/AgentCompany-New/src/main/services/board-intervention-service.ts new file mode 100644 index 00000000..3f701f65 --- /dev/null +++ b/AgentCompany-New/src/main/services/board-intervention-service.ts @@ -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; +} diff --git a/AgentCompany-New/src/main/services/chat-mentions.ts b/AgentCompany-New/src/main/services/chat-mentions.ts new file mode 100644 index 00000000..066a3246 --- /dev/null +++ b/AgentCompany-New/src/main/services/chat-mentions.ts @@ -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(); + 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 { + return new Set([token.toLowerCase(), canonicalMentionHandle(token)].filter((handle) => handle.length > 0)); +} diff --git a/AgentCompany-New/src/main/services/chat-service.ts b/AgentCompany-New/src/main/services/chat-service.ts index 032065aa..b479341c 100644 --- a/AgentCompany-New/src/main/services/chat-service.ts +++ b/AgentCompany-New/src/main/services/chat-service.ts @@ -1,10 +1,15 @@ import type { DatabaseSync } from "node:sqlite"; 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 { ApprovalService } from "./approval-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(["company", "department", "project", "direct"]); const SPACE_VISIBILITIES = new Set(["workspace", "restricted"]); @@ -29,33 +34,27 @@ interface SpaceRow { space_type: string; } +interface ThreadRow { + id: string; + board_state: string; + controlled_by_type: "employee" | "agent" | "board" | null; + controlled_by_id: string | null; +} + interface PrincipalRef { type: "employee" | "agent"; 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 { private readonly audit: AuditService; private readonly approvals: ApprovalService; + private readonly boardInterventions: BoardInterventionService; constructor(private readonly db: DatabaseSync) { this.audit = new AuditService(db); this.approvals = new ApprovalService(db); + this.boardInterventions = new BoardInterventionService(db); } createSpace(input: CreateChatSpaceInput): string { @@ -97,12 +96,13 @@ export class ChatService { const space = this.requireSpace(input.orgId, input.spaceId); const sender = this.requireSender(input.orgId, input.senderType, input.senderId); 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); if (slash && !ALLOWED_SLASH_COMMANDS.has(slash.command)) throw new Error(`Slash command /${slash.command} is unavailable.`); if (slash) validateSlashInvocation(slash); const attachments = this.requireAttachments(input.orgId, input.attachmentFileIds ?? [], sender); - const mentions = this.resolveMentions(input.orgId, input.spaceId, body); + const mentions = resolveChatMentions(this.db, input.orgId, input.spaceId, body); const id = createId("msg"); const threadId = input.threadId ?? createId("thread"); @@ -131,8 +131,8 @@ export class ChatService { } else { this.db.prepare("update chat_threads set updated_at = ? where id = ? and org_id = ?").run(now, input.threadId, input.orgId); } - this.recordMentions(id, mentions); - const slashResultRef = slash ? this.executeSlashCommand(input, space, sender, slash, id, threadId) : null; + recordChatMentions(this.db, id, mentions); + const slashResultRef = slash ? this.executeSlashCommand(input, space, sender, mentions, slash, id, threadId, now) : null; if (slash) this.recordSlashInvocation(id, slash, slashResultRef, now); this.recordAttachments(id, attachments); 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); } - private resolveMentions(orgId: string, spaceId: string, body: string): ResolvedMention[] { - const mentions: ResolvedMention[] = []; - const seen = new Set(); - 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( input: SendMessageInput, space: SpaceRow, sender: PrincipalRef | null, + mentions: ResolvedMention[], slash: SlashInvocation, messageId: string, threadId: string, + now: string, ): 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; const requester = approvalRequester(input.senderType, sender); return this.approvals.create({ @@ -307,6 +285,18 @@ export class ChatService { 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 { if (senderType === "system") return null; if (senderType === "employee") { @@ -324,10 +314,14 @@ export class ChatService { return { type: "employee", id: senderId }; } - private requireThread(orgId: string, spaceId: string, threadId: string): void { - const thread = this.db.prepare("select id from chat_threads where id = ? and org_id = ? and space_id = ?") - .get(threadId, orgId, spaceId) as { id: string } | undefined; + private requireThread(orgId: string, spaceId: string, threadId: string): ThreadRow { + const thread = this.db.prepare(` + 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."); + return thread; } private requireAttachments(orgId: string, fileIds: string[], sender: PrincipalRef | null): AttachmentCandidate[] { @@ -452,6 +446,7 @@ const ALLOWED_SLASH_COMMANDS = new Set([ "runbook", "file", "escalate", + "board", ]); function parseSlashInvocation(body: string): SlashInvocation | null { @@ -464,6 +459,7 @@ function validateSlashInvocation(slash: SlashInvocation): void { if (slash.command === "escalate" && slash.args.trim().length === 0) { throw new Error("Slash command /escalate requires details."); } + if (slash.command === "board") parseBoardCommand(slash.args); } 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(); 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 { - return new Set([token.toLowerCase(), canonicalMentionHandle(token)].filter((handle) => handle.length > 0)); -} diff --git a/AgentCompany-New/src/main/services/migration-service.ts b/AgentCompany-New/src/main/services/migration-service.ts index df16cf91..c8aabcf2 100644 --- a/AgentCompany-New/src/main/services/migration-service.ts +++ b/AgentCompany-New/src/main/services/migration-service.ts @@ -23,6 +23,7 @@ const LEGACY_TABLES = [ "chat_attachments", "chat_mentions", "chat_slash_invocations", + "board_interventions", "chat_presence", "chat_read_state", "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_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: "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: "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" }, diff --git a/AgentCompany-New/src/main/services/snapshot-service.ts b/AgentCompany-New/src/main/services/snapshot-service.ts index a4d5a4e7..e769ac86 100644 --- a/AgentCompany-New/src/main/services/snapshot-service.ts +++ b/AgentCompany-New/src/main/services/snapshot-service.ts @@ -65,6 +65,7 @@ export class SnapshotService { chatAttachments: list(this.db, "chat_attachments", "message_id asc"), chatMentions: list(this.db, "chat_mentions", "message_id asc, offset_start 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"), chatReadStates: list(this.db, "chat_read_state", "last_read_at desc"), files, diff --git a/AgentCompany-New/src/renderer/src/pages/ChatPage.tsx b/AgentCompany-New/src/renderer/src/pages/ChatPage.tsx index d75702bd..063d330e 100644 --- a/AgentCompany-New/src/renderer/src/pages/ChatPage.tsx +++ b/AgentCompany-New/src/renderer/src/pages/ChatPage.tsx @@ -38,6 +38,10 @@ export function ChatPage({ snapshot, actions }: PageProps) { () => snapshot.chatSlashInvocations.filter((invocation) => messageIds.has(invocation.messageId)), [snapshot.chatSlashInvocations, messageIds], ); + const boardInterventionsAll = useMemo( + () => snapshot.boardInterventions.filter((item) => item.orgId === orgId), + [snapshot.boardInterventions, orgId], + ); const approvalsAll = useMemo( () => snapshot.approvals.filter((a) => a.orgId === orgId && a.state === "pending"), [snapshot.approvals, orgId], @@ -93,6 +97,10 @@ export function ChatPage({ snapshot, actions }: PageProps) { () => (selectedThread ? replayEventsForThread(auditEventsAll, selectedThread, messagesInSpace) : []), [auditEventsAll, selectedThread, messagesInSpace], ); + const selectedBoardInterventions = useMemo( + () => (selectedThread ? boardInterventionsAll.filter((item) => item.threadId === selectedThread.id) : []), + [boardInterventionsAll, selectedThread], + ); const participants = useMemo( () => collectParticipants(messagesInSpace, employees, agents), [messagesInSpace, employees, agents], @@ -136,6 +144,27 @@ export function ChatPage({ snapshot, actions }: PageProps) { })); }; + const sendBoardIntervention = (event: FormEvent) => { + 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) => { event.preventDefault(); const form = new FormData(event.currentTarget); @@ -204,16 +233,19 @@ export function ChatPage({ snapshot, actions }: PageProps) { {selectedThread && ( m.id === selectedThread.rootMessageId) ?? null} messages={threadMessages} attachments={snapshot.chatAttachments} mentions={mentionsAll} slashInvocations={slashInvocationsAll} + boardInterventions={selectedBoardInterventions} files={files} auditEvents={threadAuditEvents} context={{ employees, agents }} onClose={() => setOpenThreadId(null)} onSubmit={sendInThread} + onBoardSubmit={sendBoardIntervention} /> )} diff --git a/AgentCompany-New/src/renderer/src/pages/chat/ChatThreadPanel.tsx b/AgentCompany-New/src/renderer/src/pages/chat/ChatThreadPanel.tsx index 7be5a3c7..c6e5409e 100644 --- a/AgentCompany-New/src/renderer/src/pages/chat/ChatThreadPanel.tsx +++ b/AgentCompany-New/src/renderer/src/pages/chat/ChatThreadPanel.tsx @@ -1,11 +1,13 @@ -import type { FormEvent } from "react"; -import { Button, Icon, IconButton } from "../../shared/ui"; +import { useState, type FormEvent } from "react"; +import { Button, Icon, IconButton, Pill } from "../../shared/ui"; import type { AuditEventRecord, + BoardInterventionRecord, ChatAttachmentRecord, ChatMentionRecord, ChatMessageRecord, ChatSlashInvocationRecord, + ChatThreadRecord, FileObjectRecord, } from "../../../../shared/domain"; import { formatTime } from "../types"; @@ -13,27 +15,33 @@ import { ChatMessageRow } from "./ChatMessageRow"; import { senderName, type MessageContext } from "./chat-helpers"; export function ChatThreadPanel({ + thread, rootMessage, messages, attachments, mentions, slashInvocations, + boardInterventions, files, auditEvents, context, onClose, onSubmit, + onBoardSubmit, }: { + thread: ChatThreadRecord; rootMessage: ChatMessageRecord | null; messages: ChatMessageRecord[]; attachments: ChatAttachmentRecord[]; mentions: ChatMentionRecord[]; slashInvocations: ChatSlashInvocationRecord[]; + boardInterventions: BoardInterventionRecord[]; files: FileObjectRecord[]; auditEvents: AuditEventRecord[]; context: MessageContext; onClose: () => void; onSubmit: (event: FormEvent) => void; + onBoardSubmit: (event: FormEvent) => void; }) { return (