Add permission aware search

This commit is contained in:
ZacharyZhang-NY committed 2026-05-23 17:34:50 -04:00
1 parent 38c68ca4af
commit 1ee877e630
17 files changed
+608 -4

No files matched your search

@@ -14,6 +14,7 @@ import { FileService } from "../services/file-service";
import { MigrationService } from "../services/migration-service";
import { OnboardingService } from "../services/onboarding-service";
import { ComplianceService } from "../services/compliance-service";
import { SearchService } from "../services/search-service";
export interface IpcDeps {
connection: DatabaseConnection;
@@ -32,6 +33,7 @@ export function registerIpcHandlers(deps: IpcDeps): void {
const files = new FileService(deps.connection.db, deps.artifactRoot);
const migrations = new MigrationService(deps.connection.db);
const compliance = new ComplianceService(deps.connection.db);
const search = new SearchService(deps.connection.db);
const onboarding = new OnboardingService({
db: deps.connection.db,
saveProviderCredential: (input) => providers.saveCredential(input),
@@ -151,6 +153,13 @@ export function registerIpcHandlers(deps: IpcDeps): void {
ipcMain.handle(IPC_CHANNELS.setLegalHoldState, (_event, input) => mutate(() => compliance.setLegalHoldState(input)));
ipcMain.handle(IPC_CHANNELS.createRetentionFreeze, (_event, input) => mutate(() => compliance.createRetentionFreeze(input)));
ipcMain.handle(IPC_CHANNELS.setRetentionFreezeState, (_event, input) => mutate(() => compliance.setRetentionFreezeState(input)));
ipcMain.handle(IPC_CHANNELS.search, (_event, input) => {
try {
return ok(search.search(input));
} catch (error) {
return fail("search_failed", error instanceof Error ? error.message : String(error));
}
});
ipcMain.handle(IPC_CHANNELS.windowMinimize, () => deps.mainWindow.minimize());
ipcMain.handle(IPC_CHANNELS.windowMaximize, () => deps.mainWindow.isMaximized() ? deps.mainWindow.unmaximize() : deps.mainWindow.maximize());
@@ -0,0 +1,13 @@
export const searchDocumentsSchema = `
create virtual table if not exists search_documents using fts5(
org_id unindexed,
entity_type unindexed,
entity_id unindexed,
title,
body,
facet_json unindexed
);
insert into schema_migrations(version, name, applied_at)
values (13, 'search_documents', datetime('now'));
`;
@@ -11,6 +11,7 @@ import { socialGovernanceSchema } from "./migrations/009-social-governance";
import { fileHandoffSecuritySchema } from "./migrations/010-file-handoff-security";
import { boardInterventionSchema } from "./migrations/011-board-interventions";
import { complianceControlsSchema } from "./migrations/012-compliance-controls";
import { searchDocumentsSchema } from "./migrations/013-search-documents";
interface Migration {
version: number;
@@ -31,6 +32,7 @@ const MIGRATIONS: Migration[] = [
{ version: 10, name: "file_handoff_security", sql: fileHandoffSecuritySchema },
{ version: 11, name: "board_interventions", sql: boardInterventionSchema },
{ version: 12, name: "compliance_controls", sql: complianceControlsSchema },
{ version: 13, name: "search_documents", sql: searchDocumentsSchema },
];
const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
@@ -47,6 +47,7 @@ const LEGACY_TABLES = [
"compliance_exports",
"legal_holds",
"retention_freezes",
"search_documents",
"audit_events",
];
@@ -0,0 +1,238 @@
import type { DatabaseSync } from "node:sqlite";
import type { SearchInput } from "../../shared/commands";
import { SEARCH_ENTITY_TYPES, type SearchEntityType, type SearchResultRecord } from "../../shared/domain";
interface SearchRow {
entity_type: SearchEntityType;
entity_id: string;
title: string;
body: string;
facet_json: string;
}
interface SearchDoc {
entityType: SearchEntityType;
entityId: string;
title: string;
body: string;
facets: Record<string, string>;
}
export class SearchService {
constructor(private readonly db: DatabaseSync) {}
search(input: SearchInput): SearchResultRecord[] {
this.assertEmployee(input.orgId, input.employeeId);
const query = normalizeQuery(input.query);
const limit = Math.max(1, Math.min(input.limit ?? 20, 50));
const entityTypes = validEntityTypes(input.entityTypes ?? [...SEARCH_ENTITY_TYPES]);
this.reindexOrg(input.orgId);
const rows = query
? this.db.prepare(`
select entity_type, entity_id, title, body, facet_json
from search_documents
where search_documents match ? and org_id = ?
limit ?
`).all(query, input.orgId, limit * 4) as unknown as SearchRow[]
: this.db.prepare(`
select entity_type, entity_id, title, body, facet_json
from search_documents
where org_id = ?
order by rowid desc
limit ?
`).all(input.orgId, limit * 4) as unknown as SearchRow[];
return rows
.filter((row) => entityTypes.has(row.entity_type))
.filter((row) => this.canRead(input.orgId, input.employeeId, row.entity_type, row.entity_id))
.slice(0, limit)
.map((row) => ({
entityType: row.entity_type,
entityId: row.entity_id,
title: row.title,
preview: preview(row.body),
facets: parseFacets(row.facet_json),
}));
}
private reindexOrg(orgId: string): void {
this.db.exec("savepoint search_reindex");
try {
this.db.prepare("delete from search_documents where org_id = ?").run(orgId);
const insert = this.db.prepare(`
insert into search_documents(org_id, entity_type, entity_id, title, body, facet_json)
values (?, ?, ?, ?, ?, ?)
`);
for (const doc of this.documents(orgId)) {
insert.run(orgId, doc.entityType, doc.entityId, doc.title, doc.body, JSON.stringify(doc.facets));
}
this.db.exec("release search_reindex");
} catch (error) {
this.db.exec("rollback to search_reindex");
this.db.exec("release search_reindex");
throw error;
}
}
private documents(orgId: string): SearchDoc[] {
return [
...this.employeeDocs(orgId),
...this.agentDocs(orgId),
...this.taskDocs(orgId),
...this.projectDocs(orgId),
...this.goalDocs(orgId),
...this.workspaceDocs(orgId),
...this.approvalDocs(orgId),
...this.chatSpaceDocs(orgId),
...this.chatMessageDocs(orgId),
...this.fileDocs(orgId),
...this.providerDocs(orgId),
...this.socialDocs(orgId),
...this.scheduledPostDocs(orgId),
];
}
private employeeDocs(orgId: string): SearchDoc[] {
const rows = this.db.prepare("select id, display_name, role from employees where org_id = ?").all(orgId) as Array<{ id: string; display_name: string; role: string }>;
return rows.map((row) => doc("employee", row.id, row.display_name, row.role, { type: "People" }));
}
private agentDocs(orgId: string): SearchDoc[] {
const rows = this.db.prepare("select id, name, role, department, title from agents where org_id = ?").all(orgId) as Array<{ id: string; name: string; role: string; department: string; title: string }>;
return rows.map((row) => doc("agent", row.id, row.name, [row.role, row.department, row.title].join(" "), { type: "People", department: row.department }));
}
private taskDocs(orgId: string): SearchDoc[] {
const rows = this.db.prepare("select id, title, description, status, risk from tasks where org_id = ?").all(orgId) as Array<{ id: string; title: string; description: string; status: string; risk: string }>;
return rows.map((row) => doc("task", row.id, row.title, row.description, { type: "Work", status: row.status, risk: row.risk }));
}
private projectDocs(orgId: string): SearchDoc[] {
const rows = this.db.prepare("select id, name, description, status from projects where org_id = ?").all(orgId) as Array<{ id: string; name: string; description: string; status: string }>;
return rows.map((row) => doc("project", row.id, row.name, row.description, { type: "Work", status: row.status }));
}
private goalDocs(orgId: string): SearchDoc[] {
const rows = this.db.prepare("select id, title, description, status from goals where org_id = ?").all(orgId) as Array<{ id: string; title: string; description: string; status: string }>;
return rows.map((row) => doc("goal", row.id, row.title, row.description, { type: "Work", status: row.status }));
}
private workspaceDocs(orgId: string): SearchDoc[] {
const rows = this.db.prepare("select id, name, local_path, repo_url from workspaces where org_id = ?").all(orgId) as Array<{ id: string; name: string; local_path: string; repo_url: string }>;
return rows.map((row) => doc("workspace", row.id, row.name, `${row.local_path} ${row.repo_url}`, { type: "Work" }));
}
private approvalDocs(orgId: string): SearchDoc[] {
const rows = this.db.prepare("select id, title, details, state, risk from approvals where org_id = ?").all(orgId) as Array<{ id: string; title: string; details: string; state: string; risk: string }>;
return rows.map((row) => doc("approval", row.id, row.title, row.details, { type: "Governance", state: row.state, risk: row.risk }));
}
private chatSpaceDocs(orgId: string): SearchDoc[] {
const rows = this.db.prepare("select id, name, topic, visibility from chat_spaces where org_id = ?").all(orgId) as Array<{ id: string; name: string; topic: string; visibility: string }>;
return rows.map((row) => doc("chat_space", row.id, row.name, row.topic, { type: "Messages", visibility: row.visibility }));
}
private chatMessageDocs(orgId: string): SearchDoc[] {
const rows = this.db.prepare("select id, thread_id, body_md, sender_type from chat_messages where org_id = ? and deleted_at is null").all(orgId) as Array<{ id: string; thread_id: string; body_md: string; sender_type: string }>;
return rows.map((row) => doc("chat_message", row.id, createTitle(row.body_md), row.body_md, { type: "Messages", sender: row.sender_type, thread: row.thread_id }));
}
private fileDocs(orgId: string): SearchDoc[] {
const rows = this.db.prepare("select id, name, kind, sha256, scan_state from file_objects where org_id = ?").all(orgId) as Array<{ id: string; name: string; kind: string; sha256: string; scan_state: string }>;
return rows.map((row) => doc("file", row.id, row.name, `${row.kind} ${row.sha256}`, { type: "Files", state: row.scan_state }));
}
private providerDocs(orgId: string): SearchDoc[] {
const rows = this.db.prepare("select id, label, provider_key, auth_state, health_state from providers where org_id = ?").all(orgId) as Array<{ id: string; label: string; provider_key: string; auth_state: string; health_state: string }>;
return rows.map((row) => doc("provider", row.id, row.label, row.provider_key, { type: "Settings", auth: row.auth_state, health: row.health_state }));
}
private socialDocs(orgId: string): SearchDoc[] {
const rows = this.db.prepare("select id, platform, handle, display_name from social_accounts where org_id = ?").all(orgId) as Array<{ id: string; platform: string; handle: string; display_name: string }>;
return rows.map((row) => doc("social_account", row.id, row.display_name, `${row.platform} ${row.handle}`, { type: "Social", platform: row.platform }));
}
private scheduledPostDocs(orgId: string): SearchDoc[] {
const rows = this.db.prepare("select id, social_account_id, body_md, state from scheduled_posts where org_id = ?").all(orgId) as Array<{ id: string; social_account_id: string; body_md: string; state: string }>;
return rows.map((row) => doc("scheduled_post", row.id, createTitle(row.body_md), row.body_md, { type: "Social", state: row.state, account: row.social_account_id }));
}
private canRead(orgId: string, employeeId: string, entityType: SearchEntityType, entityId: string): boolean {
if (entityType === "chat_space") return this.canReadSpace(entityId, employeeId);
if (entityType === "chat_message") return this.canReadMessage(orgId, entityId, employeeId);
if (entityType === "file") return this.canReadFile(orgId, entityId, employeeId);
if (entityType === "social_account") return this.canReadSocialAccount(entityId, employeeId);
if (entityType === "scheduled_post") return this.canReadScheduledPost(entityId, employeeId);
return true;
}
private canReadSpace(spaceId: string, employeeId: string): boolean {
const row = this.db.prepare("select space_id from chat_memberships where space_id = ? and principal_type = 'employee' and principal_id = ?")
.get(spaceId, employeeId);
return Boolean(row);
}
private canReadMessage(orgId: string, messageId: string, employeeId: string): boolean {
const row = this.db.prepare("select space_id from chat_messages where id = ? and org_id = ?").get(messageId, orgId) as { space_id: string } | undefined;
return row ? this.canReadSpace(row.space_id, employeeId) : false;
}
private canReadFile(orgId: string, fileId: string, employeeId: string): boolean {
const file = this.db.prepare("select owner_type, owner_id from file_objects where id = ? and org_id = ?").get(fileId, orgId) as { owner_type: string; owner_id: string | null } | undefined;
if (!file) return false;
if (file.owner_type === "employee" && file.owner_id === employeeId) return true;
const workspaceGroupId = `${orgId}:workspace`;
const grant = this.db.prepare(`
select id from file_access_grants
where org_id = ? and file_id = ? and access_level in ('read', 'manage')
and ((granted_to_type = 'employee' and granted_to_id = ?) or (granted_to_type = 'org_group' and granted_to_id = ?))
limit 1
`).get(orgId, fileId, employeeId, workspaceGroupId);
return Boolean(grant);
}
private canReadSocialAccount(accountId: string, employeeId: string): boolean {
const grant = this.db.prepare("select id from social_account_grants where social_account_id = ? and employee_id = ? limit 1").get(accountId, employeeId);
return Boolean(grant);
}
private canReadScheduledPost(postId: string, employeeId: string): boolean {
const row = this.db.prepare("select social_account_id from scheduled_posts where id = ?").get(postId) as { social_account_id: string } | undefined;
return row ? this.canReadSocialAccount(row.social_account_id, employeeId) : false;
}
private assertEmployee(orgId: string, employeeId: string): void {
const row = this.db.prepare("select id from employees where id = ? and org_id = ?").get(employeeId, orgId);
if (!row) throw new Error("Search employee was not found.");
}
}
function doc(entityType: SearchEntityType, entityId: string, title: string, body: string, facets: Record<string, string>): SearchDoc {
return { entityType, entityId, title: title.trim(), body: body.trim(), facets };
}
function normalizeQuery(query: string): string {
const terms = query.toLowerCase().match(/[a-z0-9_@.-]+/g) ?? [];
return terms.map((term) => `${term.replaceAll('"', "")}*`).join(" AND ");
}
function validEntityTypes(entityTypes: SearchEntityType[]): Set<SearchEntityType> {
const allowed = new Set<string>(SEARCH_ENTITY_TYPES);
return new Set(entityTypes.filter((type) => allowed.has(type)));
}
function createTitle(value: string): string {
const firstLine = value.split(/\r?\n/, 1)[0].trim();
if (!firstLine) return "Untitled";
return firstLine.length > 80 ? `${firstLine.slice(0, 77)}...` : firstLine;
}
function preview(value: string): string {
const text = value.replace(/\s+/g, " ").trim();
return text.length > 140 ? `${text.slice(0, 137)}...` : text;
}
function parseFacets(value: string): Record<string, string> {
const parsed = JSON.parse(value) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Search facet payload is invalid.");
return Object.fromEntries(Object.entries(parsed).map(([key, item]) => [key, String(item)]));
}
+1
View File
@@ -40,6 +40,7 @@ const api: DesktopApi = {
setLegalHoldState: (input) => invoke(IPC_CHANNELS.setLegalHoldState, input),
createRetentionFreeze: (input) => invoke(IPC_CHANNELS.createRetentionFreeze, input),
setRetentionFreezeState: (input) => invoke(IPC_CHANNELS.setRetentionFreezeState, input),
search: (input) => invoke(IPC_CHANNELS.search, input),
subscribe: (callback) => {
const listener = (_event: Electron.IpcRendererEvent, payload: RendererEvent) => callback(payload);
ipcRenderer.on(IPC_CHANNELS.rendererEvent, listener);
@@ -0,0 +1,179 @@
import { useEffect, useMemo, useState, type CSSProperties, type FormEvent } from "react";
import type { AppSnapshot, SearchEntityType, SearchResultRecord } from "../../../shared/domain";
import { SEARCH_ENTITY_TYPES } from "../../../shared/domain";
import type { RouteId } from "./Shell";
import { Button, Icon, Pill } from "./ui";
type SearchFilter = "all" | SearchEntityType;
const LABELS: Record<SearchEntityType, string> = {
employee: "Employee",
agent: "Agent",
task: "Task",
goal: "Goal",
project: "Project",
workspace: "Workspace",
approval: "Approval",
chat_space: "Space",
chat_message: "Message",
file: "File",
provider: "Provider",
social_account: "Social",
scheduled_post: "Post",
};
export function SearchDialog({
snapshot,
open,
onClose,
onNavigate,
}: {
snapshot: AppSnapshot;
open: boolean;
onClose: () => void;
onNavigate: (route: RouteId) => void;
}) {
const [query, setQuery] = useState("");
const [filter, setFilter] = useState<SearchFilter>("all");
const [results, setResults] = useState<SearchResultRecord[]>([]);
const orgId = snapshot.activeOrgId;
const employee = useMemo(() => snapshot.employees.find((item) => item.orgId === orgId), [snapshot.employees, orgId]);
const canSearch = Boolean(orgId && employee && query.trim().length >= 2);
const visibleResults = canSearch ? results : [];
useEffect(() => {
if (!open) return;
const trimmed = query.trim();
if (!orgId || !employee || trimmed.length < 2) return;
const timeout = window.setTimeout(() => {
void window.agentCompany.search({
orgId,
employeeId: employee.id,
query: trimmed,
entityTypes: filter === "all" ? undefined : [filter],
limit: 12,
}).then((result) => {
if (!result.ok) throw new Error(result.error.message);
setResults(result.data);
});
}, 120);
return () => window.clearTimeout(timeout);
}, [employee, filter, open, orgId, query]);
useEffect(() => {
if (!open) return;
const handler = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onClose, open]);
if (!open) return null;
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const first = visibleResults[0];
if (!first) return;
onNavigate(routeFor(first.entityType));
onClose();
};
return (
<div role="dialog" aria-modal="true" aria-label="Search" style={overlayStyle}>
<form onSubmit={submit} style={panelStyle}>
<div className="row" style={{ padding: "10px 12px", borderBottom: "1px solid var(--border-soft)" }}>
<Icon name="search" size={14} color="var(--text-3)" />
<input
autoFocus
aria-label="Search query"
value={query}
onChange={(event) => setQuery(event.target.value)}
style={inputStyle}
/>
<select
aria-label="Search filter"
className="input"
value={filter}
onChange={(event) => setFilter(event.target.value as SearchFilter)}
style={{ width: 150, height: 28, padding: "2px 8px", fontSize: 12 }}
>
<option value="all">All</option>
{SEARCH_ENTITY_TYPES.map((type) => <option key={type} value={type}>{LABELS[type]}</option>)}
</select>
<Button size="small" onClick={onClose}>Close</Button>
</div>
<div style={{ maxHeight: 420, overflow: "auto", padding: 8 }}>
{visibleResults.map((result) => (
<button
key={`${result.entityType}:${result.entityId}`}
type="button"
onClick={() => {
onNavigate(routeFor(result.entityType));
onClose();
}}
style={rowStyle}
>
<Pill>{LABELS[result.entityType]}</Pill>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 540, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{result.title}</div>
<div style={{ fontSize: 12, color: "var(--text-3)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{result.preview}</div>
</div>
<span style={{ fontSize: 11, color: "var(--text-3)" }}>{result.facets.type}</span>
</button>
))}
</div>
</form>
</div>
);
}
function routeFor(entityType: SearchEntityType): RouteId {
if (entityType === "employee" || entityType === "agent") return "team";
if (entityType === "approval") return "approvals";
if (entityType === "chat_space" || entityType === "chat_message") return "chat";
if (entityType === "file") return "files";
if (entityType === "provider") return "settings";
if (entityType === "social_account" || entityType === "scheduled_post") return "social";
return "work";
}
const overlayStyle = {
position: "fixed",
inset: 0,
zIndex: 80,
background: "rgba(9, 9, 11, 0.16)",
display: "grid",
placeItems: "start center",
paddingTop: "12vh",
} satisfies CSSProperties;
const panelStyle = {
width: "min(680px, calc(100vw - 32px))",
border: "1px solid var(--border)",
borderRadius: 12,
background: "var(--panel)",
boxShadow: "var(--shadow-md)",
overflow: "hidden",
} satisfies CSSProperties;
const inputStyle = {
flex: 1,
minWidth: 0,
border: 0,
outline: "none",
background: "transparent",
fontSize: 14,
} satisfies CSSProperties;
const rowStyle = {
width: "100%",
border: 0,
borderRadius: 8,
background: "transparent",
display: "flex",
alignItems: "center",
gap: 10,
padding: "9px 10px",
textAlign: "left",
} satisfies CSSProperties;
@@ -1,8 +1,9 @@
import type { ReactNode } from "react";
import { useEffect, useState, type ReactNode } from "react";
import type { AppSnapshot } from "../../../shared/domain";
import { Avatar, Icon, IconButton } from "./ui";
import type { IconName } from "./Icon";
import { OrgSwitcher } from "./OrgSwitcher";
import { SearchDialog } from "./SearchDialog";
export type RouteId =
| "home"
@@ -49,6 +50,18 @@ export function Shell({
onNewOrg: () => void;
children: ReactNode;
}) {
const [searchOpen, setSearchOpen] = useState(false);
useEffect(() => {
const handler = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
setSearchOpen(true);
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, []);
const organization = snapshot.organizations.find((item) => item.id === snapshot.activeOrgId);
const employee = snapshot.employees.find((item) => item.orgId === snapshot.activeOrgId);
if (!organization) throw new Error("Active organization is missing.");
@@ -66,7 +79,7 @@ export function Shell({
onNew={onNewOrg}
onDelete={onDeleteOrg}
/>
<button type="button" className="search-bar" aria-label="Search">
<button type="button" className="search-bar" aria-label="Search" onClick={() => setSearchOpen(true)}>
<Icon name="search" size={13} />
<span>Search</span>
<span className="search-kbd">⌘K</span>
@@ -121,6 +134,12 @@ export function Shell({
</header>
<div className="content">{children}</div>
</main>
<SearchDialog
snapshot={snapshot}
open={searchOpen}
onClose={() => setSearchOpen(false)}
onNavigate={setRoute}
/>
</div>
);
}
+9
View File
@@ -5,6 +5,7 @@ import type {
LegalHoldEntityType,
RetentionFreezeScopeType,
RiskLevel,
SearchEntityType,
SocialPlatform,
TaskPriority,
TaskStatus,
@@ -176,6 +177,14 @@ export interface CreateMigrationReportInput {
sourceDatabasePath: string;
}
export interface SearchInput {
orgId: string;
employeeId: string;
query: string;
entityTypes?: SearchEntityType[];
limit?: number;
}
export interface CreateComplianceExportInput {
orgId: string;
subjectType: ComplianceSubjectType;
+3 -1
View File
@@ -1,5 +1,5 @@
import type { DesktopResult } from "./result";
import type { AppSnapshot } from "./domain";
import type { AppSnapshot, SearchResultRecord } from "./domain";
import type {
BindSkillInput,
BootstrapOnboardingInput,
@@ -26,6 +26,7 @@ import type {
LaunchSocialLoginInput,
ResolveAgentSkillsInput,
SaveProviderCredentialInput,
SearchInput,
SendMessageInput,
SetActiveOrganizationInput,
SetFilePinnedInput,
@@ -73,6 +74,7 @@ export interface DesktopApi {
setLegalHoldState(input: SetLegalHoldStateInput): Promise<DesktopResult<AppSnapshot>>;
createRetentionFreeze(input: CreateRetentionFreezeInput): Promise<DesktopResult<AppSnapshot>>;
setRetentionFreezeState(input: SetRetentionFreezeStateInput): Promise<DesktopResult<AppSnapshot>>;
search(input: SearchInput): Promise<DesktopResult<SearchResultRecord[]>>;
subscribe(callback: (event: RendererEvent) => void): () => void;
platform: "win32" | "darwin" | "linux";
windowMinimize(): Promise<void>;
+1
View File
@@ -2,6 +2,7 @@ import type { BoardInterventionRecord as BoardInterventionRecordModel, ChatThrea
import type { ComplianceExportRecord, LegalHoldRecord, RetentionFreezeRecord } from "./compliance-domain";
export { BOARD_INTERVENTION_ACTION, BOARD_INTERVENTION_MODE, CHAT_THREAD_BOARD_STATE, type BoardInterventionAction, type BoardInterventionMode, type BoardInterventionRecord, type ChatThreadBoardState } from "./board-domain";
export { COMPLIANCE_EXPORT_STATE, COMPLIANCE_EXPORT_TYPE, type ComplianceExportRecord, type ComplianceExportState, type ComplianceExportType, type ComplianceSubjectType, type LegalHoldEntityType, type LegalHoldRecord, type RetentionFreezeRecord, type RetentionFreezeScopeType } from "./compliance-domain";
export { SEARCH_ENTITY_TYPES, type SearchEntityType, type SearchResultRecord } from "./search-domain";
export const TASK_STATUS = {
BACKLOG: "backlog",
IN_PROGRESS: "in_progress",
+1
View File
@@ -32,6 +32,7 @@ export const IPC_CHANNELS = {
setLegalHoldState: "agentcompany:set-legal-hold-state",
createRetentionFreeze: "agentcompany:create-retention-freeze",
setRetentionFreezeState: "agentcompany:set-retention-freeze-state",
search: "agentcompany:search",
rendererEvent: "agentcompany:renderer-event",
windowMinimize: "agentcompany:window-minimize",
windowMaximize: "agentcompany:window-maximize",
@@ -0,0 +1,25 @@
export const SEARCH_ENTITY_TYPES = [
"employee",
"agent",
"task",
"goal",
"project",
"workspace",
"approval",
"chat_space",
"chat_message",
"file",
"provider",
"social_account",
"scheduled_post",
] as const;
export type SearchEntityType = (typeof SEARCH_ENTITY_TYPES)[number];
export interface SearchResultRecord {
entityType: SearchEntityType;
entityId: string;
title: string;
preview: string;
facets: Record<string, string>;
}
@@ -47,6 +47,10 @@ test("onboards, hires, and creates work in the Electron shell", async () => {
await page.getByLabel("Risk").selectOption("high");
await page.locator(".modal").getByRole("button", { name: "Create" }).click();
await expect(page.getByText("Production deploy").first()).toBeVisible();
await page.getByRole("button", { name: "Search" }).click();
await page.getByRole("textbox", { name: "Search query" }).fill("production");
await expect(page.getByText("Production deploy").first()).toBeVisible();
await page.getByRole("dialog", { name: "Search" }).getByRole("button").filter({ hasText: "Production deploy" }).first().click();
await page.getByRole("button", { name: "Approvals" }).click();
await expect(page.getByText("Production deploy").first()).toBeVisible();
@@ -23,7 +23,7 @@ describe("domain services", () => {
const connection = createConnection();
const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>;
expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);
expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]);
connection.close();
});
@@ -131,6 +131,7 @@ describe("migration service", () => {
create table compliance_exports(id text primary key, org_id text, requested_by_employee_id text);
create table legal_holds(id text primary key, org_id text, created_by_employee_id text);
create table retention_freezes(id text primary key, org_id text, created_by_employee_id text);
create table search_documents(entity_id text);
create table audit_events(id text primary key, org_id text);
insert into organizations(id) values ('org-1');
insert into employees(id, org_id) values ('emp-1', 'org-1');
@@ -172,6 +173,7 @@ describe("migration service", () => {
insert into compliance_exports(id, org_id, requested_by_employee_id) values ('cmp-1', 'org-1', 'emp-1');
insert into legal_holds(id, org_id, created_by_employee_id) values ('hold-1', 'org-1', 'emp-1');
insert into retention_freezes(id, org_id, created_by_employee_id) values ('freeze-1', 'org-1', 'emp-1');
insert into search_documents(entity_id) values ('task-1');
insert into audit_events(id, org_id) values ('audit-1', 'org-1');
`);
let orgId = "";
@@ -190,6 +192,7 @@ describe("migration service", () => {
expect(body.tableCounts.file_access_grants).toBe(1);
expect(body.tableCounts.provider_requests).toBe(1);
expect(body.tableCounts.compliance_exports).toBe(1);
expect(body.tableCounts.search_documents).toBe(1);
expect(body.tableChecksums.audit_events).toHaveLength(64);
expect(body.idMaps.skill_runtime_snapshots).toEqual(["snapshot-1"]);
expect(body.idMaps.board_interventions).toEqual(["bdi-1"]);
@@ -0,0 +1,97 @@
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 { SearchService } from "../../src/main/services/search-service";
import { TASK_STATUS } from "../../src/shared/domain";
import { createId } from "../../src/shared/ids";
function createConnection(): DatabaseConnection {
return new DatabaseConnection(join(tmpdir(), `agentcompany-new-search-${randomUUID()}.db`));
}
describe("search service", () => {
test("returns faceted full-text results", () => {
const connection = createConnection();
const organization = new OrganizationService(connection.db);
const search = new SearchService(connection.db);
let orgId = "";
let employeeId = "";
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.createTask({
orgId,
title: "Production deploy",
description: "Release the production deploy checklist.",
status: TASK_STATUS.BACKLOG,
assigneeAgentId: null,
risk: "high",
projectId: null,
goalId: null,
workspaceId: null,
priority: "normal",
requiresFilesystem: false,
});
});
const results = search.search({ orgId, employeeId, query: "production", entityTypes: ["task"] });
expect(results).toHaveLength(1);
expect(results[0]).toMatchObject({ entityType: "task", title: "Production deploy", facets: { type: "Work", risk: "high" } });
connection.close();
});
test("filters restricted chat messages by membership", () => {
const connection = createConnection();
const organization = new OrganizationService(connection.db);
const chat = new ChatService(connection.db);
const search = new SearchService(connection.db);
let orgId = "";
let ownerId = "";
let outsiderId = "";
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.",
});
ownerId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id;
outsiderId = createId("emp");
connection.db.prepare("insert into employees(id, org_id, display_name, role, created_at) values (?, ?, ?, ?, ?)")
.run(outsiderId, orgId, "Riley Chen", "Observer", new Date().toISOString());
spaceId = chat.createSpace({
orgId,
spaceType: "project",
name: "restricted-release",
topic: "Need-to-know release",
visibility: "restricted",
memberEmployeeIds: [ownerId],
});
chat.sendMessage({
orgId,
spaceId,
threadId: null,
senderType: "employee",
senderId: ownerId,
bodyMd: "Secret launch window",
attachmentFileIds: [],
});
});
expect(search.search({ orgId, employeeId: ownerId, query: "secret", entityTypes: ["chat_message"] })).toHaveLength(1);
expect(search.search({ orgId, employeeId: outsiderId, query: "secret", entityTypes: ["chat_message"] })).toHaveLength(0);
connection.close();
});
});