diff --git a/AgentCompany-New/src/main/database/migrations/016-org-governance.ts b/AgentCompany-New/src/main/database/migrations/016-org-governance.ts new file mode 100644 index 00000000..bfb215ef --- /dev/null +++ b/AgentCompany-New/src/main/database/migrations/016-org-governance.ts @@ -0,0 +1,89 @@ +export const orgGovernanceSchema = ` + create table if not exists org_departments ( + id text primary key, + org_id text not null references organizations(id) on delete cascade, + name text not null, + lead_agent_id text references agents(id) on delete set null, + headcount_limit integer not null, + monthly_budget_limit_cents integer not null, + created_at text not null, + updated_at text not null, + unique(org_id, name) + ); + + create table if not exists hiring_policies ( + id text primary key, + org_id text not null references organizations(id) on delete cascade, + department_name text not null, + max_headcount integer not null, + monthly_budget_limit_cents integer not null, + require_approval_above_cents integer not null, + created_at text not null, + updated_at text not null, + unique(org_id, department_name) + ); + + create table if not exists approval_routing_rules ( + id text primary key, + org_id text not null references organizations(id) on delete cascade, + kind text not null, + minimum_risk text not null, + route_to_type text not null, + route_to_id text not null, + created_at text not null, + unique(org_id, kind, minimum_risk) + ); + + create table if not exists approval_assignments ( + id text primary key, + org_id text not null references organizations(id) on delete cascade, + approval_id text not null references approvals(id) on delete cascade, + assignee_type text not null, + assignee_id text not null, + routing_rule_id text references approval_routing_rules(id) on delete set null, + created_at text not null, + unique(approval_id, assignee_type, assignee_id) + ); + + create index if not exists idx_org_departments_org_name on org_departments(org_id, name); + create index if not exists idx_hiring_policies_org_department on hiring_policies(org_id, department_name); + create index if not exists idx_approval_assignments_org_approval on approval_assignments(org_id, approval_id); + + insert into org_departments(id, org_id, name, lead_agent_id, headcount_limit, monthly_budget_limit_cents, created_at, updated_at) + select 'dep_' || lower(hex(randomblob(16))), organizations.id, seed.name, null, 12, 2500000, datetime('now'), datetime('now') + from organizations + cross join ( + select 'Executive' as name union all select 'Engineering' union all select 'Product' union all select 'Design' + union all select 'Marketing' union all select 'Operations' union all select 'Support' + ) seed + where not exists ( + select 1 from org_departments + where org_departments.org_id = organizations.id and org_departments.name = seed.name + ); + + insert into hiring_policies(id, org_id, department_name, max_headcount, monthly_budget_limit_cents, require_approval_above_cents, created_at, updated_at) + select 'hpl_' || lower(hex(randomblob(16))), org_id, name, headcount_limit, monthly_budget_limit_cents, 500000, datetime('now'), datetime('now') + from org_departments + where not exists ( + select 1 from hiring_policies + where hiring_policies.org_id = org_departments.org_id and hiring_policies.department_name = org_departments.name + ); + + insert into approval_routing_rules(id, org_id, kind, minimum_risk, route_to_type, route_to_id, created_at) + select 'arr_' || lower(hex(randomblob(16))), organizations.id, '*', 'low', 'employee', employees.id, datetime('now') + from organizations + join employees on employees.org_id = organizations.id + where employees.id = ( + select first_employee.id from employees first_employee + where first_employee.org_id = organizations.id + order by first_employee.created_at asc + limit 1 + ) + and not exists ( + select 1 from approval_routing_rules + where approval_routing_rules.org_id = organizations.id and approval_routing_rules.kind = '*' and approval_routing_rules.minimum_risk = 'low' + ); + + insert into schema_migrations(version, name, applied_at) + values (16, 'org_governance', datetime('now')); +`; diff --git a/AgentCompany-New/src/main/database/schema.ts b/AgentCompany-New/src/main/database/schema.ts index fa061e50..950bd8e5 100644 --- a/AgentCompany-New/src/main/database/schema.ts +++ b/AgentCompany-New/src/main/database/schema.ts @@ -14,6 +14,7 @@ import { complianceControlsSchema } from "./migrations/012-compliance-controls"; import { searchDocumentsSchema } from "./migrations/013-search-documents"; import { providerPolicyUsageSchema } from "./migrations/014-provider-policy-usage"; import { identityAccessSchema } from "./migrations/015-identity-access"; +import { orgGovernanceSchema } from "./migrations/016-org-governance"; interface Migration { version: number; @@ -37,6 +38,7 @@ const MIGRATIONS: Migration[] = [ { version: 13, name: "search_documents", sql: searchDocumentsSchema }, { version: 14, name: "provider_policy_usage", sql: providerPolicyUsageSchema }, { version: 15, name: "identity_access", sql: identityAccessSchema }, + { version: 16, name: "org_governance", sql: orgGovernanceSchema }, ]; const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version; diff --git a/AgentCompany-New/src/main/services/approval-service.ts b/AgentCompany-New/src/main/services/approval-service.ts index d0d0fbae..86921919 100644 --- a/AgentCompany-New/src/main/services/approval-service.ts +++ b/AgentCompany-New/src/main/services/approval-service.ts @@ -4,6 +4,7 @@ import { APPROVAL_STATE, ROLE_KEY } from "../../shared/domain"; import { createId } from "../../shared/ids"; import { AuditService } from "./audit-service"; import { IdentityAccessService } from "./identity-access-service"; +import { OrgGovernanceService } from "./org-governance-service"; interface ApprovalAuditContext { sourceMessageId?: string; @@ -13,10 +14,12 @@ interface ApprovalAuditContext { export class ApprovalService { private readonly audit: AuditService; private readonly identityAccess: IdentityAccessService; + private readonly governance: OrgGovernanceService; constructor(private readonly db: DatabaseSync) { this.audit = new AuditService(db); this.identityAccess = new IdentityAccessService(db); + this.governance = new OrgGovernanceService(db); } create(input: CreateApprovalInput, context: ApprovalAuditContext = {}): string { @@ -57,6 +60,7 @@ export class ApprovalService { action: "requested", payload: { kind, risk: input.risk, ...context }, }); + this.governance.routeApproval({ orgId: input.orgId, approvalId: id, kind, risk: input.risk }); return id; } diff --git a/AgentCompany-New/src/main/services/migration-service.ts b/AgentCompany-New/src/main/services/migration-service.ts index f5ce57a6..e84d7824 100644 --- a/AgentCompany-New/src/main/services/migration-service.ts +++ b/AgentCompany-New/src/main/services/migration-service.ts @@ -8,6 +8,8 @@ import { AuditService } from "./audit-service"; const LEGACY_TABLES = [ "organizations", + "org_departments", + "hiring_policies", "employees", "employee_identities", "service_identities", @@ -20,6 +22,8 @@ const LEGACY_TABLES = [ "projects", "task_runs", "approvals", + "approval_routing_rules", + "approval_assignments", "chat_spaces", "chat_messages", "chat_threads", @@ -88,6 +92,9 @@ interface MigrationReportBody { } const RELATION_CHECKS: RelationCheck[] = [ + { table: "org_departments", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, + { table: "org_departments", column: "lead_agent_id", referencedTable: "agents", referencedColumn: "id", reason: "dangling_lead_agent_id" }, + { table: "hiring_policies", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, { table: "employees", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, { table: "employee_identities", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, { table: "employee_identities", column: "employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_employee_id" }, @@ -118,6 +125,10 @@ const RELATION_CHECKS: RelationCheck[] = [ { table: "task_runs", column: "provider_id", referencedTable: "providers", referencedColumn: "id", reason: "dangling_provider_id" }, { table: "approvals", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, { table: "approvals", column: "decided_by_employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_decider_id" }, + { table: "approval_routing_rules", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, + { table: "approval_assignments", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, + { table: "approval_assignments", column: "approval_id", referencedTable: "approvals", referencedColumn: "id", reason: "dangling_approval_id" }, + { table: "approval_assignments", column: "routing_rule_id", referencedTable: "approval_routing_rules", referencedColumn: "id", reason: "dangling_routing_rule_id" }, { table: "chat_spaces", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, { table: "chat_messages", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, { table: "chat_messages", column: "space_id", referencedTable: "chat_spaces", referencedColumn: "id", reason: "dangling_space_id" }, diff --git a/AgentCompany-New/src/main/services/org-governance-service.ts b/AgentCompany-New/src/main/services/org-governance-service.ts new file mode 100644 index 00000000..fd4562d7 --- /dev/null +++ b/AgentCompany-New/src/main/services/org-governance-service.ts @@ -0,0 +1,139 @@ +import type { DatabaseSync } from "node:sqlite"; +import type { CreateAgentInput } from "../../shared/commands"; +import { RISK_LEVEL, ROUTE_TO_TYPE, type RiskLevel } from "../../shared/domain"; +import { createId } from "../../shared/ids"; +import { AuditService } from "./audit-service"; + +const DEFAULT_DEPARTMENTS = ["Executive", "Engineering", "Product", "Design", "Marketing", "Operations", "Support"] as const; +const RISK_RANK: Record = { low: 0, medium: 1, high: 2 }; + +export class OrgGovernanceService { + private readonly audit: AuditService; + + constructor(private readonly db: DatabaseSync) { + this.audit = new AuditService(db); + } + + seedOrganizationGovernance(orgId: string, boardEmployeeId: string, now: string): void { + for (const department of DEFAULT_DEPARTMENTS) this.seedDepartmentPolicy(orgId, department, now); + this.db.prepare(` + insert into approval_routing_rules(id, org_id, kind, minimum_risk, route_to_type, route_to_id, created_at) + values (?, ?, ?, ?, ?, ?, ?) + on conflict(org_id, kind, minimum_risk) do nothing + `).run(createId("arr"), orgId, "*", RISK_LEVEL.LOW, ROUTE_TO_TYPE.EMPLOYEE, boardEmployeeId, now); + } + + enforceHiringPolicy(input: CreateAgentInput): HiringPolicyDecision { + const departmentName = input.department.trim(); + if (!departmentName) throw new Error("Department is required."); + const policy = this.db.prepare(` + select max_headcount, monthly_budget_limit_cents, require_approval_above_cents + from hiring_policies + where org_id = ? and department_name = ? + `).get(input.orgId, departmentName) as HiringPolicyRow | undefined; + if (!policy) throw new Error("Hiring policy is required for department."); + const usage = this.db.prepare(` + select count(*) as headcount, coalesce(sum(budget_cents), 0) as budget_cents + from agents + where org_id = ? and department = ? and lifecycle = ? + `).get(input.orgId, departmentName, "active") as { headcount: number; budget_cents: number }; + if (usage.headcount >= policy.max_headcount) throw new Error("Department headcount limit has been reached."); + if (usage.budget_cents + input.budgetCents > policy.monthly_budget_limit_cents) { + throw new Error("Department monthly budget limit has been reached."); + } + return { reviewRequired: input.budgetCents > policy.require_approval_above_cents }; + } + + routeApproval(input: { orgId: string; approvalId: string; kind: string; risk: RiskLevel }): void { + const rule = this.resolveRoutingRule(input.orgId, input.kind, input.risk); + this.assertRouteTarget(input.orgId, rule.route_to_type, rule.route_to_id); + this.db.prepare(` + insert into approval_assignments(id, org_id, approval_id, assignee_type, assignee_id, routing_rule_id, created_at) + values (?, ?, ?, ?, ?, ?, ?) + on conflict(approval_id, assignee_type, assignee_id) do nothing + `).run(createId("aas"), input.orgId, input.approvalId, rule.route_to_type, rule.route_to_id, rule.id, new Date().toISOString()); + this.audit.record({ + orgId: input.orgId, + entityType: "approval", + entityId: input.approvalId, + actorType: "system", + actorId: null, + action: "routed", + payload: { routingRuleId: rule.id, routeToType: rule.route_to_type, routeToId: rule.route_to_id }, + }); + } + + private seedDepartmentPolicy(orgId: string, departmentName: string, now: string): void { + this.db.prepare(` + insert into org_departments(id, org_id, name, lead_agent_id, headcount_limit, monthly_budget_limit_cents, created_at, updated_at) + values (?, ?, ?, null, 12, 2500000, ?, ?) + on conflict(org_id, name) do nothing + `).run(createId("dep"), orgId, departmentName, now, now); + this.db.prepare(` + insert into hiring_policies(id, org_id, department_name, max_headcount, monthly_budget_limit_cents, require_approval_above_cents, created_at, updated_at) + values (?, ?, ?, 12, 2500000, 500000, ?, ?) + on conflict(org_id, department_name) do nothing + `).run(createId("hpl"), orgId, departmentName, now, now); + } + + private resolveRoutingRule(orgId: string, kind: string, risk: RiskLevel): ApprovalRoutingRuleRow { + const rows = this.db.prepare(` + select id, kind, minimum_risk, route_to_type, route_to_id + from approval_routing_rules + where org_id = ? and (kind = ? or kind = ?) + `).all(orgId, kind, "*") as unknown as ApprovalRoutingRuleRow[]; + const matching = rows + .filter((row) => riskApplies(row.minimum_risk, risk)) + .sort((a, b) => ruleRank(kind, b) - ruleRank(kind, a)); + const selected = matching[0]; + if (!selected) throw new Error("Approval routing rule is required."); + return selected; + } + + private assertRouteTarget(orgId: string, routeToType: string, routeToId: string): void { + if (routeToType === ROUTE_TO_TYPE.BOARD) { + if (routeToId !== orgId) throw new Error("Board approval route is invalid."); + return; + } + if (routeToType === ROUTE_TO_TYPE.EMPLOYEE) { + const employee = this.db.prepare("select id from employees where id = ? and org_id = ?").get(routeToId, orgId); + if (!employee) throw new Error("Approval route employee was not found."); + return; + } + if (routeToType === ROUTE_TO_TYPE.AGENT) { + const agent = this.db.prepare("select id from agents where id = ? and org_id = ?").get(routeToId, orgId); + if (!agent) throw new Error("Approval route agent was not found."); + return; + } + throw new Error("Approval route target type is invalid."); + } +} + +interface HiringPolicyRow { + max_headcount: number; + monthly_budget_limit_cents: number; + require_approval_above_cents: number; +} + +interface HiringPolicyDecision { + reviewRequired: boolean; +} + +interface ApprovalRoutingRuleRow { + id: string; + kind: string; + minimum_risk: string; + route_to_type: string; + route_to_id: string; +} + +function riskApplies(minimumRisk: string, risk: RiskLevel): boolean { + const required = RISK_RANK[minimumRisk as RiskLevel]; + if (required === undefined) throw new Error("Approval routing risk is invalid."); + return RISK_RANK[risk] >= required; +} + +function ruleRank(kind: string, rule: ApprovalRoutingRuleRow): number { + const exactKind = rule.kind === kind ? 100 : 0; + return exactKind + RISK_RANK[rule.minimum_risk as RiskLevel]; +} diff --git a/AgentCompany-New/src/main/services/organization-service.ts b/AgentCompany-New/src/main/services/organization-service.ts index 6ec7e508..8b57bce0 100644 --- a/AgentCompany-New/src/main/services/organization-service.ts +++ b/AgentCompany-New/src/main/services/organization-service.ts @@ -6,17 +6,20 @@ import type { CreateAgentInput, CreateOrganizationInput, CreateTaskInput } from import { AuditService } from "./audit-service"; import { PolicyService } from "./policy-service"; import { IdentityAccessService } from "./identity-access-service"; +import { OrgGovernanceService } from "./org-governance-service"; import { assertUsableMentionName } from "./mention-handles"; export class OrganizationService { private readonly audit: AuditService; private readonly policy: PolicyService; private readonly identityAccess: IdentityAccessService; + private readonly governance: OrgGovernanceService; constructor(private readonly db: DatabaseSync) { this.audit = new AuditService(db); this.policy = new PolicyService(db); this.identityAccess = new IdentityAccessService(db); + this.governance = new OrgGovernanceService(db); } createOrganization(input: CreateOrganizationInput): string { @@ -44,6 +47,7 @@ export class OrganizationService { this.db.prepare("insert into employees(id, org_id, display_name, role, created_at) values (?, ?, ?, ?, ?)") .run(employeeId, orgId, input.employeeName.trim(), "Board Chair", now); this.identityAccess.seedEmployeeAccess(orgId, employeeId, now); + this.governance.seedOrganizationGovernance(orgId, employeeId, now); for (const space of [ ["company", "general", "Company coordination", "workspace"], @@ -134,6 +138,7 @@ export class OrganizationService { createAgent(input: CreateAgentInput): string { assertUsableMentionName(input.name); + const hiringPolicy = this.governance.enforceHiringPolicy(input); const id = createId("agt"); const now = new Date().toISOString(); this.db.prepare(` @@ -172,6 +177,29 @@ export class OrganizationService { }); this.addAgentToWorkspaceSpaces(input.orgId, id); this.identityAccess.createAgentServiceIdentity(input.orgId, id, input.name, now); + if (hiringPolicy.reviewRequired) { + const approvalId = createId("app"); + this.db.prepare(` + insert into approvals( + id, org_id, kind, title, details, risk, requested_by_type, + requested_by_id, state, decided_by_employee_id, created_at, decided_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + approvalId, + input.orgId, + "agent_hire", + `Review hire: ${input.name.trim()}`, + `Department: ${input.department.trim()}\nRole: ${input.role.trim()}\nBudget: ${input.budgetCents}`, + RISK_LEVEL.MEDIUM, + "system", + null, + APPROVAL_STATE.PENDING, + null, + now, + null, + ); + this.governance.routeApproval({ orgId: input.orgId, approvalId, kind: "agent_hire", risk: RISK_LEVEL.MEDIUM }); + } return id; } @@ -207,13 +235,14 @@ export class OrganizationService { ); const policy = this.policy.evaluateTaskCreation(input.orgId, input.risk); if (policy.approvalRequired) { + const approvalId = createId("app"); this.db.prepare(` insert into approvals( id, org_id, kind, title, details, risk, requested_by_type, requested_by_id, state, decided_by_employee_id, created_at, decided_at ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( - createId("app"), + approvalId, input.orgId, "task_risk", input.title.trim(), @@ -226,6 +255,7 @@ export class OrganizationService { now, null, ); + this.governance.routeApproval({ orgId: input.orgId, approvalId, kind: "task_risk", risk: RISK_LEVEL.HIGH }); } this.audit.record({ orgId: input.orgId, diff --git a/AgentCompany-New/src/main/services/snapshot-service.ts b/AgentCompany-New/src/main/services/snapshot-service.ts index a4bb6370..e50a07e9 100644 --- a/AgentCompany-New/src/main/services/snapshot-service.ts +++ b/AgentCompany-New/src/main/services/snapshot-service.ts @@ -96,6 +96,10 @@ export class SnapshotService { serviceIdentities: list(this.db, "service_identities", "created_at desc"), roleGrants: list(this.db, "role_grants", "created_at desc"), accessDecisions: list(this.db, "access_decisions", "created_at desc").slice(0, 200), + orgDepartments: list(this.db, "org_departments", "name asc"), + hiringPolicies: list(this.db, "hiring_policies", "department_name asc"), + approvalRoutingRules: list(this.db, "approval_routing_rules", "created_at asc"), + approvalAssignments: list(this.db, "approval_assignments", "created_at desc"), auditEvents: list(this.db, "audit_events", "created_at desc").slice(0, 200), }; } diff --git a/AgentCompany-New/src/renderer/src/pages/SettingsPage.tsx b/AgentCompany-New/src/renderer/src/pages/SettingsPage.tsx index d427b80c..d9bebdbe 100644 --- a/AgentCompany-New/src/renderer/src/pages/SettingsPage.tsx +++ b/AgentCompany-New/src/renderer/src/pages/SettingsPage.tsx @@ -79,6 +79,10 @@ export function SettingsPage({ snapshot, actions }: PageProps) { const employeeIdentities = useMemo(() => snapshot.employeeIdentities.filter((item) => item.orgId === orgId), [snapshot.employeeIdentities, orgId]); const serviceIdentities = useMemo(() => snapshot.serviceIdentities.filter((item) => item.orgId === orgId), [snapshot.serviceIdentities, orgId]); const roleGrants = useMemo(() => snapshot.roleGrants.filter((item) => item.orgId === orgId), [snapshot.roleGrants, orgId]); + const orgDepartments = useMemo(() => snapshot.orgDepartments.filter((item) => item.orgId === orgId), [snapshot.orgDepartments, orgId]); + const hiringPolicies = useMemo(() => snapshot.hiringPolicies.filter((item) => item.orgId === orgId), [snapshot.hiringPolicies, orgId]); + const approvalRoutingRules = useMemo(() => snapshot.approvalRoutingRules.filter((item) => item.orgId === orgId), [snapshot.approvalRoutingRules, orgId]); + const approvalAssignments = useMemo(() => snapshot.approvalAssignments.filter((item) => item.orgId === orgId), [snapshot.approvalAssignments, orgId]); return (
@@ -124,7 +128,17 @@ export function SettingsPage({ snapshot, actions }: PageProps) { actions={actions} /> )} - {tab === "policies" && } + {tab === "policies" && ( + + )} {tab === "compliance" && (

Policies

Policy rules evaluated for actions across the workspace.

+
Rules
))}
+ +
Hiring policies
+
+
+
Department
Headcount
Budget cap
Review
+
+ {hiringPolicies.map((policy, index) => ( + item.name === policy.departmentName) ?? null} + agents={agents} + last={index === hiringPolicies.length - 1} + /> + ))} +
+ +
Review routing
+
+
+
Kind
Min risk
Route
Assigned
+
+ {approvalRoutingRules.map((rule, index) => ( + assignment.routingRuleId === rule.id).length} + last={index === approvalRoutingRules.length - 1} + /> + ))} +
); } + +function HiringPolicyRow({ + policy, + department, + agents, + last, +}: { + policy: HiringPolicyRecord; + department: OrgDepartmentRecord | null; + agents: AgentRecord[]; + last: boolean; +}) { + if (!department) throw new Error(`Department governance is missing for ${policy.departmentName}.`); + const roster = agents.filter((agent) => agent.department === policy.departmentName && agent.lifecycle === "active"); + const budgetCents = roster.reduce((sum, agent) => sum + agent.budgetCents, 0); + return ( +
+
{policy.departmentName}
+
{roster.length} / {department.headcountLimit}
+
{money(budgetCents)} / {money(policy.monthlyBudgetLimitCents)}
+
{money(policy.requireApprovalAboveCents)}
+
+ ); +} + +function RoutingRuleRow({ + rule, + employees, + agents, + assignmentCount, + last, +}: { + rule: ApprovalRoutingRuleRecord; + employees: EmployeeRecord[]; + agents: AgentRecord[]; + assignmentCount: number; + last: boolean; +}) { + return ( +
+
{rule.kind}
+
{rule.minimumRisk}
+
+ {routeLabel(rule, employees, agents)} +
+
{assignmentCount}
+
+ ); +} + +function routeLabel(rule: ApprovalRoutingRuleRecord, employees: EmployeeRecord[], agents: AgentRecord[]): string { + if (rule.routeToType === "board") return "Board"; + if (rule.routeToType === "employee") { + const employee = employees.find((item) => item.id === rule.routeToId); + if (!employee) throw new Error("Approval route employee is missing."); + return employee.displayName; + } + const agent = agents.find((item) => item.id === rule.routeToId); + if (!agent) throw new Error("Approval route agent is missing."); + return agent.name; +} diff --git a/AgentCompany-New/src/shared/domain.ts b/AgentCompany-New/src/shared/domain.ts index 65de59ef..4b8b0020 100644 --- a/AgentCompany-New/src/shared/domain.ts +++ b/AgentCompany-New/src/shared/domain.ts @@ -1,10 +1,12 @@ import type { BoardInterventionRecord as BoardInterventionRecordModel, ChatThreadBoardState as ChatThreadBoardStateModel } from "./board-domain"; import type { ComplianceExportRecord, LegalHoldRecord, RetentionFreezeRecord } from "./compliance-domain"; import type { AccessDecisionRecord, EmployeeIdentityRecord, RoleGrantRecord, ServiceIdentityRecord } from "./identity-domain"; +import type { ApprovalAssignmentRecord, ApprovalRoutingRuleRecord, HiringPolicyRecord, OrgDepartmentRecord } from "./org-governance-domain"; import type { ProviderDataPolicy, ProviderExecutionMode } from "./provider-policy-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 { ACCESS_DECISION, IDENTITY_SOURCE, ROLE_KEY, type AccessDecision, type AccessDecisionRecord, type EmployeeIdentityRecord, type IdentitySource, type PrincipalType, type RoleGrantRecord, type RoleKey, type ServiceIdentityRecord } from "./identity-domain"; +export { ROUTE_TO_TYPE, type ApprovalAssignmentRecord, type ApprovalRoutingRuleRecord, type HiringPolicyRecord, type OrgDepartmentRecord, type RouteToType } from "./org-governance-domain"; export { PROVIDER_DATA_POLICY, PROVIDER_EXECUTION_MODE, type ProviderDataPolicy, type ProviderExecutionMode } from "./provider-policy-domain"; export { SEARCH_ENTITY_TYPES, type SearchEntityType, type SearchResultRecord } from "./search-domain"; export const TASK_STATUS = { BACKLOG: "backlog", IN_PROGRESS: "in_progress", REVIEW: "review", DONE: "done" } as const; @@ -468,5 +470,6 @@ export interface AppSnapshot { migrationReports: MigrationReportRecord[]; complianceExports: ComplianceExportRecord[]; legalHolds: LegalHoldRecord[]; retentionFreezes: RetentionFreezeRecord[]; employeeIdentities: EmployeeIdentityRecord[]; serviceIdentities: ServiceIdentityRecord[]; roleGrants: RoleGrantRecord[]; accessDecisions: AccessDecisionRecord[]; + orgDepartments: OrgDepartmentRecord[]; hiringPolicies: HiringPolicyRecord[]; approvalRoutingRules: ApprovalRoutingRuleRecord[]; approvalAssignments: ApprovalAssignmentRecord[]; auditEvents: AuditEventRecord[]; } diff --git a/AgentCompany-New/src/shared/org-governance-domain.ts b/AgentCompany-New/src/shared/org-governance-domain.ts new file mode 100644 index 00000000..c3a36e34 --- /dev/null +++ b/AgentCompany-New/src/shared/org-governance-domain.ts @@ -0,0 +1,24 @@ +type ValueOf = T[keyof T]; + +export const ROUTE_TO_TYPE = { EMPLOYEE: "employee", AGENT: "agent", BOARD: "board" } as const; +export type RouteToType = ValueOf; + +export interface OrgDepartmentRecord { + id: string; orgId: string; name: string; leadAgentId: string | null; + headcountLimit: number; monthlyBudgetLimitCents: number; createdAt: string; updatedAt: string; +} + +export interface HiringPolicyRecord { + id: string; orgId: string; departmentName: string; maxHeadcount: number; + monthlyBudgetLimitCents: number; requireApprovalAboveCents: number; createdAt: string; updatedAt: string; +} + +export interface ApprovalRoutingRuleRecord { + id: string; orgId: string; kind: string; minimumRisk: string; + routeToType: RouteToType; routeToId: string; createdAt: string; +} + +export interface ApprovalAssignmentRecord { + id: string; orgId: string; approvalId: string; assigneeType: RouteToType; + assigneeId: string; routingRuleId: string | null; createdAt: string; +} diff --git a/AgentCompany-New/tests/e2e/desktop.spec.ts b/AgentCompany-New/tests/e2e/desktop.spec.ts index 4d150d74..2696e647 100644 --- a/AgentCompany-New/tests/e2e/desktop.spec.ts +++ b/AgentCompany-New/tests/e2e/desktop.spec.ts @@ -44,6 +44,9 @@ test("onboards, hires, and creates work in the Electron shell", async () => { await expect(page.getByText("Identity & Access")).toBeVisible(); await expect(page.getByText("org admin").first()).toBeVisible(); await expect(page.getByText("scoped agent").first()).toBeVisible(); + await page.getByRole("button", { name: "Policies" }).click(); + await expect(page.getByText("Hiring policies")).toBeVisible(); + await expect(page.getByText("Review routing")).toBeVisible(); await page.getByRole("button", { name: "Work", exact: true }).click(); await page.getByRole("button", { name: "New task" }).click(); diff --git a/AgentCompany-New/tests/unit/domain-services.test.ts b/AgentCompany-New/tests/unit/domain-services.test.ts index 8cf93169..7e9bbb6b 100644 --- a/AgentCompany-New/tests/unit/domain-services.test.ts +++ b/AgentCompany-New/tests/unit/domain-services.test.ts @@ -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, 13, 14, 15]); + expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]); connection.close(); }); @@ -45,6 +45,9 @@ describe("domain services", () => { expect(state.providers.length).toBeGreaterThanOrEqual(6); expect(state.employeeIdentities).toHaveLength(1); expect(state.roleGrants.map((grant) => grant.roleKey).sort()).toEqual(["approval_decider", "hiring_manager", "org_admin"]); + expect(state.orgDepartments.map((department) => department.name)).toContain("Engineering"); + expect(state.hiringPolicies.some((policy) => policy.departmentName === "Engineering")).toBe(true); + expect(state.approvalRoutingRules[0]).toMatchObject({ kind: "*", minimumRisk: "low", routeToType: "employee" }); expect(state.chatSpaces.map((space) => space.name)).toContain("board"); expect(state.policyRules[0]?.action).toBe("task_create"); expect(state.auditEvents[0]?.action).toBe("created"); @@ -82,6 +85,7 @@ describe("domain services", () => { const state = snapshot.load(); expect(state.tasks).toHaveLength(1); expect(state.approvals).toHaveLength(1); + expect(state.approvalAssignments).toHaveLength(1); expect(state.approvals[0]?.state).toBe("pending"); connection.close(); }); diff --git a/AgentCompany-New/tests/unit/migration-service.test.ts b/AgentCompany-New/tests/unit/migration-service.test.ts index 5bd0d67f..91d4211e 100644 --- a/AgentCompany-New/tests/unit/migration-service.test.ts +++ b/AgentCompany-New/tests/unit/migration-service.test.ts @@ -92,6 +92,8 @@ describe("migration service", () => { const snapshot = new SnapshotService(connection.db); const legacyPath = createLegacyDatabase(` create table organizations(id text primary key); + create table org_departments(id text primary key, org_id text, lead_agent_id text); + create table hiring_policies(id text primary key, org_id text); create table employees(id text primary key, org_id text); create table employee_identities(id text primary key, org_id text, employee_id text); create table service_identities(id text primary key, org_id text, agent_id text); @@ -104,6 +106,8 @@ describe("migration service", () => { create table projects(id text primary key, org_id text, goal_id text, lead_agent_id text); create table task_runs(id text primary key, org_id text, task_id text, agent_id text, provider_id text); create table approvals(id text primary key, org_id text, decided_by_employee_id text); + create table approval_routing_rules(id text primary key, org_id text); + create table approval_assignments(id text primary key, org_id text, approval_id text, routing_rule_id text); create table chat_spaces(id text primary key, org_id text); create table chat_messages(id text primary key, org_id text, space_id text, thread_id text); create table chat_threads(id text primary key, org_id text, space_id text, root_message_id text); @@ -147,6 +151,8 @@ describe("migration service", () => { insert into file_objects(id, org_id, space_id) values ('file-1', 'org-1', 'space-1'); insert into workspaces(id, org_id, project_id) values ('wsp-1', 'org-1', 'prj-1'); insert into agents(id, org_id, model_provider_id, manager_agent_id, workspace_id) values ('agent-1', 'org-1', 'prov-1', null, 'wsp-1'); + insert into org_departments(id, org_id, lead_agent_id) values ('dep-1', 'org-1', 'agent-1'); + insert into hiring_policies(id, org_id) values ('hpl-1', 'org-1'); insert into service_identities(id, org_id, agent_id) values ('sid-1', 'org-1', 'agent-1'); insert into role_grants(id, org_id, granted_by_employee_id) values ('role-1', 'org-1', 'emp-1'); insert into access_decisions(id, org_id) values ('access-1', 'org-1'); @@ -155,6 +161,8 @@ describe("migration service", () => { insert into tasks(id, org_id, assignee_agent_id, project_id, goal_id, workspace_id) values ('task-1', 'org-1', 'agent-1', 'prj-1', 'goal-1', 'wsp-1'); insert into task_runs(id, org_id, task_id, agent_id, provider_id) values ('run-1', 'org-1', 'task-1', 'agent-1', 'prov-1'); insert into approvals(id, org_id, decided_by_employee_id) values ('approval-1', 'org-1', 'emp-1'); + insert into approval_routing_rules(id, org_id) values ('route-1', 'org-1'); + insert into approval_assignments(id, org_id, approval_id, routing_rule_id) values ('assign-1', 'org-1', 'approval-1', 'route-1'); insert into chat_threads(id, org_id, space_id, root_message_id) values ('thread-1', 'org-1', 'space-1', null); insert into chat_messages(id, org_id, space_id, thread_id) values ('msg-1', 'org-1', 'space-1', 'thread-1'); insert into chat_memberships(space_id, principal_type, principal_id) values ('space-1', 'employee', 'emp-1'); @@ -197,6 +205,8 @@ describe("migration service", () => { expect(body.failures).toEqual([]); expect(body.tableCounts.credentials).toBe(1); expect(body.tableCounts.employee_identities).toBe(1); + expect(body.tableCounts.org_departments).toBe(1); + expect(body.tableCounts.hiring_policies).toBe(1); expect(body.tableCounts.role_grants).toBe(1); expect(body.tableCounts.skill_artifacts).toBe(1); expect(body.tableCounts.chat_read_state).toBe(1); @@ -210,6 +220,7 @@ describe("migration service", () => { expect(body.idMaps.skill_runtime_snapshots).toEqual(["snapshot-1"]); expect(body.idMaps.service_identities).toEqual(["sid-1"]); expect(body.idMaps.access_decisions).toEqual(["access-1"]); + expect(body.idMaps.approval_assignments).toEqual(["assign-1"]); expect(body.idMaps.board_interventions).toEqual(["bdi-1"]); expect(body.idMaps.retention_freezes).toEqual(["freeze-1"]); expect(body.idMaps.provider_usage_windows).toEqual(["usage-1"]); diff --git a/AgentCompany-New/tests/unit/org-governance-service.test.ts b/AgentCompany-New/tests/unit/org-governance-service.test.ts new file mode 100644 index 00000000..3bffd550 --- /dev/null +++ b/AgentCompany-New/tests/unit/org-governance-service.test.ts @@ -0,0 +1,108 @@ +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 { ApprovalService } from "../../src/main/services/approval-service"; +import { OrganizationService } from "../../src/main/services/organization-service"; +import { SnapshotService } from "../../src/main/services/snapshot-service"; +import { RISK_LEVEL } from "../../src/shared/domain"; + +function createConnection(): DatabaseConnection { + return new DatabaseConnection(join(tmpdir(), `agentcompany-new-governance-${randomUUID()}.db`)); +} + +function createOrganization(organization: OrganizationService): string { + return organization.createOrganization({ + organizationName: "Acme Operations", + employeeName: "Dana Lee", + mission: "Coordinate agents with auditable approvals.", + goal: "Ship a verified control plane.", + }); +} + +describe("org governance service", () => { + test("enforces department hiring budget policy before creating agents", () => { + const connection = createConnection(); + const organization = new OrganizationService(connection.db); + let orgId = ""; + + connection.transaction(() => { + orgId = createOrganization(organization); + connection.db.prepare(` + update hiring_policies + set monthly_budget_limit_cents = ? + where org_id = ? and department_name = ? + `).run(10000, orgId, "Engineering"); + }); + + expect(() => connection.transaction(() => organization.createAgent({ + orgId, + name: "Ada", + role: "Reviewer", + department: "Engineering", + modelProviderId: null, + modelId: null, + budgetCents: 10001, + managerAgentId: null, + }))).toThrow("Department monthly budget limit has been reached."); + expect(connection.db.prepare("select count(*) as count from agents where org_id = ?").get(orgId)).toEqual({ count: 0 }); + connection.close(); + }); + + test("routes created approvals to the configured review assignee", () => { + const connection = createConnection(); + const organization = new OrganizationService(connection.db); + const approvals = new ApprovalService(connection.db); + const snapshot = new SnapshotService(connection.db); + let orgId = ""; + let approvalId = ""; + + connection.transaction(() => { + orgId = createOrganization(organization); + approvalId = approvals.create({ + orgId, + kind: "release", + title: "Release approval", + details: "Approve release train.", + risk: RISK_LEVEL.MEDIUM, + requestedByType: "system", + requestedById: null, + }); + }); + + const state = snapshot.load(); + expect(state.approvalAssignments).toHaveLength(1); + expect(state.approvalAssignments[0]).toMatchObject({ approvalId, assigneeType: "employee" }); + expect(state.auditEvents.some((event) => event.entityType === "approval" && event.action === "routed")).toBe(true); + connection.close(); + }); + + test("routes high budget hires to the configured review assignee", () => { + const connection = createConnection(); + const organization = new OrganizationService(connection.db); + const snapshot = new SnapshotService(connection.db); + let orgId = ""; + + connection.transaction(() => { + orgId = createOrganization(organization); + organization.createAgent({ + orgId, + name: "Ada", + role: "Reviewer", + department: "Engineering", + modelProviderId: null, + modelId: null, + budgetCents: 600000, + managerAgentId: null, + }); + }); + + const state = snapshot.load(); + const approval = state.approvals.find((item) => item.kind === "agent_hire"); + expect(approval).toMatchObject({ orgId, title: "Review hire: Ada", risk: RISK_LEVEL.MEDIUM }); + expect(state.approvalAssignments).toHaveLength(1); + expect(state.approvalAssignments[0]).toMatchObject({ approvalId: approval?.id, assigneeType: "employee" }); + connection.close(); + }); +});