Files
AgentCompany/AgentCompany-New/src/main/services/organization-service.ts
T
ZacharyZhang-NY 4e47da4d6b Route goal kickoffs through task policy and revive waiting goals on hire
Adversarial review of the kickoff feature surfaced two gaps. First, the
kickoff task was inserted with raw SQL, so a workspace-bound kickoff
carried requires_filesystem without the execution approval every other
task receives - manual runs failed and review-mode orgs had no approval
trail. Kickoffs (in-app and onboarding) now go through createTask and
its policy gates. Second, a goal created before any agent existed
stayed inert forever; creating an agent now operationalizes every
active goal that has no task yet. Goal owners must be active agents:
an archived owner falls back to the top-level agent and the owner
dropdown only offers active agents.
2026-06-09 20:38:35 -04:00

553 lines
22 KiB
TypeScript

import type { DatabaseSync } from "node:sqlite";
import { APPROVAL_STATE, RISK_LEVEL, TASK_STATUS, type ApprovalState } from "../../shared/domain";
import { PROVIDER_CATALOG } from "../../shared/provider-catalog";
import { createId } from "../../shared/ids";
import type { CreateAgentInput, CreateGoalInput, CreateOrganizationInput, CreateProjectInput, CreateTaskInput, CreateWorkspaceInput, DeleteTaskInput, UpdateAgentInput, UpdateTaskInput } from "../../shared/commands";
import { AuditService } from "./audit-service";
import { ensureAgentHeartbeat, resolveGoalKickoff, type GoalKickoffPlan } from "./goal-kickoff";
import { PolicyService } from "./policy-service";
import { IdentityAccessService } from "./identity-access-service";
import { OrgGovernanceService } from "./org-governance-service";
import { OperationsService } from "./operations-service";
import { SdlcService } from "./sdlc-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;
private readonly operations: OperationsService;
private readonly sdlc: SdlcService;
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);
this.operations = new OperationsService(db);
this.sdlc = new SdlcService(db);
}
createOrganization(input: CreateOrganizationInput): string {
assertUsableMentionName(input.employeeName);
const orgId = createId("org");
const employeeId = createId("emp");
const now = new Date().toISOString();
this.db.prepare(`
insert into organizations(
id, name, mission, goal, description, execution_mode, auto_approve_hires, review_deliverables, created_at
)
values (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
orgId,
input.organizationName.trim(),
input.mission.trim(),
input.goal.trim(),
input.description?.trim() ?? "",
input.executionMode ?? "ai_ceo",
input.autoApproveHires === false ? 0 : 1,
input.reviewDeliverables === true ? 1 : 0,
now,
);
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);
this.operations.seedOrganizationOperations(orgId, now);
this.sdlc.seedOrganizationSdlc(orgId, now);
for (const space of [
["company", "general", "Company coordination", "workspace"],
["department", "product", "Product decisions", "workspace"],
["department", "engineering", "Engineering delivery", "workspace"],
["company", "board", "Board decisions", "restricted"],
]) {
const spaceId = createId("spc");
this.db.prepare(`
insert into chat_spaces(id, org_id, space_type, name, topic, visibility, created_at)
values (?, ?, ?, ?, ?, ?, ?)
`).run(spaceId, orgId, space[0], space[1], space[2], space[3], now);
this.db.prepare(`
insert into chat_memberships(space_id, principal_type, principal_id, role, mute_state)
values (?, ?, ?, ?, ?)
`).run(spaceId, "employee", employeeId, space[1] === "board" ? "approver" : "member", "unmuted");
}
for (const provider of PROVIDER_CATALOG) {
this.db.prepare(`
insert into providers(id, org_id, provider_key, label, auth_type, model, credential_id, enabled, created_at)
values (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
createId("prv"),
orgId,
provider.key,
provider.label,
provider.authType,
provider.defaultModel,
null,
0,
now,
);
}
this.policy.seedOrganizationPolicies(orgId, now);
this.db.prepare(`
insert into app_state(id, active_org_id, updated_at)
values (1, ?, ?)
on conflict(id) do update set active_org_id = excluded.active_org_id, updated_at = excluded.updated_at
`).run(orgId, now);
this.audit.record({
orgId,
entityType: "organization",
entityId: orgId,
actorType: "employee",
actorId: employeeId,
action: "created",
payload: { name: input.organizationName.trim() },
});
return orgId;
}
setActiveOrganization(orgId: string): void {
const row = this.db.prepare("select id from organizations where id = ?").get(orgId) as { id: string } | undefined;
if (!row) throw new Error("Organization was not found.");
const now = new Date().toISOString();
this.db.prepare(`
insert into app_state(id, active_org_id, updated_at)
values (1, ?, ?)
on conflict(id) do update set active_org_id = excluded.active_org_id, updated_at = excluded.updated_at
`).run(orgId, now);
this.audit.record({
orgId,
entityType: "app_state",
entityId: "active_org",
actorType: "employee",
actorId: null,
action: "switched",
payload: { orgId },
});
}
deleteOrganization(orgId: string): void {
const row = this.db.prepare("select id from organizations where id = ?")
.get(orgId) as { id: string } | undefined;
if (!row) throw new Error("Organization was not found.");
this.db.prepare("delete from organizations where id = ?").run(orgId);
const nextActive = this.db.prepare("select id from organizations order by created_at asc limit 1")
.get() as { id: string } | undefined;
const now = new Date().toISOString();
this.db.prepare(`
insert into app_state(id, active_org_id, updated_at)
values (1, ?, ?)
on conflict(id) do update set active_org_id = excluded.active_org_id, updated_at = excluded.updated_at
`).run(nextActive?.id ?? null, now);
}
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(`
insert into agents(
id, org_id, name, role, department, model_provider_id, model_id,
budget_cents, manager_agent_id, title, role_level, model_strategy,
workspace_id, heartbeat_enabled, heartbeat_interval_sec, lifecycle, created_at
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id,
input.orgId,
input.name.trim(),
input.role.trim(),
input.department.trim(),
input.modelProviderId,
input.modelId,
input.budgetCents,
input.managerAgentId,
input.title?.trim() || input.role.trim(),
input.roleLevel ?? "staff",
input.modelStrategy ?? "pooled",
input.workspaceId ?? null,
input.heartbeatEnabled === true ? 1 : 0,
input.heartbeatIntervalSec ?? 0,
"active",
now,
);
this.audit.record({
orgId: input.orgId,
entityType: "agent",
entityId: id,
actorType: "system",
actorId: null,
action: "created",
payload: { role: input.role, department: input.department },
});
this.addAgentToWorkspaceSpaces(input.orgId, id);
this.identityAccess.createAgentServiceIdentity(input.orgId, id, input.name, now);
if (hiringPolicy.reviewRequired) {
const approvalId = createId("app");
const autoApprove = this.autonomy(input.orgId).autoApproveHires;
const decision = this.approvalDecision(input.orgId, autoApprove, now);
this.db.prepare(`
insert into approvals(
id, org_id, kind, title, details, risk, requested_by_type,
requested_by_id, source_entity_type, source_entity_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,
"agent",
id,
decision.state,
decision.decidedBy,
now,
decision.decidedAt,
);
this.settleApproval(input.orgId, approvalId, "agent_hire", RISK_LEVEL.MEDIUM, autoApprove);
}
this.kickoffUntouchedGoals(input.orgId);
return id;
}
updateAgent(input: UpdateAgentInput): void {
const agent = this.db.prepare("select id from agents where id = ? and org_id = ?")
.get(input.agentId, input.orgId) as { id: string } | undefined;
if (!agent) throw new Error("Agent was not found.");
const role = input.role.trim();
if (!role) throw new Error("Agent role is required.");
if (input.modelProviderId) {
const provider = this.db.prepare("select id from providers where id = ? and org_id = ?")
.get(input.modelProviderId, input.orgId) as { id: string } | undefined;
if (!provider) throw new Error("Agent provider was not found.");
}
const department = input.department.trim();
const budgetCents = Math.max(0, Math.round(input.budgetCents));
const result = this.db.prepare(`
update agents
set role = ?, title = ?, role_level = ?, department = ?, budget_cents = ?, model_provider_id = ?, model_id = ?
where id = ? and org_id = ?
`).run(role, input.title.trim() || role, input.roleLevel.trim() || "staff", department || "General", budgetCents, input.modelProviderId, input.modelId, input.agentId, input.orgId);
if (result.changes === 0) throw new Error("Agent update failed.");
this.audit.record({
orgId: input.orgId,
entityType: "agent",
entityId: input.agentId,
actorType: "employee",
actorId: null,
action: "updated",
payload: { role, roleLevel: input.roleLevel.trim() || "staff" },
});
}
createTask(input: CreateTaskInput): string {
const id = createId("tsk");
const now = new Date().toISOString();
this.verifyOptionalOrgEntity("agents", input.assigneeAgentId, input.orgId, "Assignee agent");
this.verifyOptionalOrgEntity("goals", input.goalId, input.orgId, "Goal");
this.verifyOptionalOrgEntity("workspaces", input.workspaceId, input.orgId, "Workspace");
this.verifyProject(input.projectId, input.goalId, input.orgId);
this.db.prepare(`
insert into tasks(
id, org_id, title, description, status, assignee_agent_id, risk,
project_id, goal_id, workspace_id, priority, requires_filesystem, created_at, updated_at
)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id,
input.orgId,
input.title.trim(),
input.description.trim(),
input.status,
input.assigneeAgentId,
input.risk,
input.projectId,
input.goalId,
input.workspaceId,
input.priority,
input.requiresFilesystem ? 1 : 0,
now,
now,
);
const policy = this.policy.evaluateTaskCreation(input.orgId, input.risk);
const approvalRequired = policy.approvalRequired || input.requiresFilesystem;
if (approvalRequired) {
const approvalId = createId("app");
const approvalKind = policy.approvalRequired ? "task_risk" : "task_execution";
const autoApprove = this.autonomy(input.orgId).executionMode === "ai_ceo";
const decision = this.approvalDecision(input.orgId, autoApprove, now);
this.db.prepare(`
insert into approvals(
id, org_id, kind, title, details, risk, requested_by_type,
requested_by_id, source_entity_type, source_entity_id, state, decided_by_employee_id, created_at, decided_at
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
approvalId,
input.orgId,
approvalKind,
input.title.trim(),
input.description.trim(),
RISK_LEVEL.HIGH,
"system",
null,
"task",
id,
decision.state,
decision.decidedBy,
now,
decision.decidedAt,
);
this.settleApproval(input.orgId, approvalId, approvalKind, RISK_LEVEL.HIGH, autoApprove);
}
this.audit.record({
orgId: input.orgId,
entityType: "task",
entityId: id,
actorType: "system",
actorId: null,
action: "created",
payload: { status: TASK_STATUS.BACKLOG, policyRuleId: policy.matchedRuleId },
});
return id;
}
/**
* Reads the org's autonomy settings. In `ai_ceo` mode the CEO runs without
* the operator approving each task; `autoApproveHires` clears hire reviews.
*/
private autonomy(orgId: string): { executionMode: string; autoApproveHires: boolean } {
const row = this.db.prepare("select execution_mode, auto_approve_hires from organizations where id = ?")
.get(orgId) as { execution_mode: string; auto_approve_hires: number } | undefined;
if (!row) throw new Error("Organization was not found.");
return { executionMode: row.execution_mode, autoApproveHires: row.auto_approve_hires === 1 };
}
/** The decision recorded on an approval: pending, or auto-approved by the board chair. */
private approvalDecision(orgId: string, autoApprove: boolean, now: string): { state: ApprovalState; decidedBy: string | null; decidedAt: string | null } {
if (!autoApprove) return { state: APPROVAL_STATE.PENDING, decidedBy: null, decidedAt: null };
return { state: APPROVAL_STATE.APPROVED, decidedBy: this.boardChairEmployeeId(orgId), decidedAt: now };
}
/** Routes a pending approval to its decider, or records the auto-approval in the audit trail. */
private settleApproval(orgId: string, approvalId: string, kind: string, risk: typeof RISK_LEVEL[keyof typeof RISK_LEVEL], autoApproved: boolean): void {
if (!autoApproved) {
this.governance.routeApproval({ orgId, approvalId, kind, risk });
return;
}
this.audit.record({
orgId,
entityType: "approval",
entityId: approvalId,
actorType: "system",
actorId: null,
action: "approved",
payload: { kind, auto: true },
});
}
private boardChairEmployeeId(orgId: string): string {
const row = this.db.prepare("select id from employees where org_id = ? order by created_at asc limit 1")
.get(orgId) as { id: string } | undefined;
if (!row) throw new Error("Organization has no employee to record the approval decision.");
return row.id;
}
private verifyOptionalOrgEntity(table: "agents" | "goals" | "workspaces", id: string | null, orgId: string, label: string): void {
if (!id) return;
const row = this.db.prepare(`select id from ${table} where id = ? and org_id = ?`).get(id, orgId);
if (!row) throw new Error(`${label} was not found.`);
}
private verifyProject(projectId: string | null, goalId: string | null, orgId: string): void {
if (!projectId) return;
const project = this.db.prepare("select goal_id from projects where id = ? and org_id = ?")
.get(projectId, orgId) as { goal_id: string | null } | undefined;
if (!project) throw new Error("Project was not found.");
if (goalId && project.goal_id !== goalId) throw new Error("Project is linked to a different goal.");
}
private addAgentToWorkspaceSpaces(orgId: string, agentId: string): void {
const spaces = this.db.prepare("select id from chat_spaces where org_id = ? and visibility = ? and space_type <> ?")
.all(orgId, "workspace", "direct") as Array<{ id: string }>;
for (const space of spaces) {
this.db.prepare(`
insert into chat_memberships(space_id, principal_type, principal_id, role, mute_state)
values (?, ?, ?, ?, ?)
on conflict(space_id, principal_type, principal_id) do nothing
`).run(space.id, "agent", agentId, "member", "unmuted");
}
}
createGoal(input: CreateGoalInput): string {
const id = createId("gol");
const now = new Date().toISOString();
if (input.ownerAgentId) this.verifyOptionalOrgEntity("agents", input.ownerAgentId, input.orgId, "Owner agent");
this.db.prepare(`
insert into goals(id, org_id, title, description, status, owner_agent_id, created_at, updated_at)
values (?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, input.orgId, input.title.trim(), input.description.trim(), "active", input.ownerAgentId, now, now);
this.audit.record({
orgId: input.orgId,
entityType: "goal",
entityId: id,
actorType: "employee",
actorId: null,
action: "created",
payload: { title: input.title.trim() },
});
const plan = resolveGoalKickoff(this.db, {
orgId: input.orgId,
goalTitle: input.title,
goalDescription: input.description,
ownerAgentId: input.ownerAgentId,
});
if (plan) this.createKickoffTask(input.orgId, id, plan);
return id;
}
/**
* Materializes a goal's kickoff plan as a first-class task: it passes the
* same policy/approval gates as any other task and wakes the assignee.
*/
private createKickoffTask(orgId: string, goalId: string, plan: GoalKickoffPlan): string {
ensureAgentHeartbeat(this.db, orgId, plan.assigneeAgentId);
return this.createTask({
orgId,
title: plan.title,
description: plan.description,
status: TASK_STATUS.BACKLOG,
assigneeAgentId: plan.assigneeAgentId,
risk: RISK_LEVEL.MEDIUM,
projectId: null,
goalId,
workspaceId: plan.workspaceId,
priority: "high",
requiresFilesystem: plan.workspaceId !== null,
});
}
/**
* Goals created before any agent existed (or whose owner was archived)
* have no kickoff task yet; the moment an agent joins, each untouched
* active goal is operationalized so no goal stays inert.
*/
private kickoffUntouchedGoals(orgId: string): void {
const goals = this.db.prepare(`
select id, title, description, owner_agent_id
from goals g
where g.org_id = ? and g.status = 'active'
and not exists (select 1 from tasks t where t.goal_id = g.id)
`).all(orgId) as Array<{ id: string; title: string; description: string; owner_agent_id: string | null }>;
for (const goal of goals) {
const plan = resolveGoalKickoff(this.db, {
orgId,
goalTitle: goal.title,
goalDescription: goal.description,
ownerAgentId: goal.owner_agent_id,
});
if (plan) this.createKickoffTask(orgId, goal.id, plan);
}
}
createProject(input: CreateProjectInput): string {
const id = createId("prj");
const now = new Date().toISOString();
if (input.goalId) this.verifyOptionalOrgEntity("goals", input.goalId, input.orgId, "Goal");
if (input.leadAgentId) this.verifyOptionalOrgEntity("agents", input.leadAgentId, input.orgId, "Lead agent");
this.db.prepare(`
insert into projects(id, org_id, goal_id, name, description, lead_agent_id, status, created_at, updated_at)
values (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, input.orgId, input.goalId, input.name.trim(), input.description.trim(), input.leadAgentId, "in_progress", now, now);
this.audit.record({
orgId: input.orgId,
entityType: "project",
entityId: id,
actorType: "employee",
actorId: null,
action: "created",
payload: { name: input.name.trim() },
});
return id;
}
createWorkspace(input: CreateWorkspaceInput): string {
const id = createId("wsp");
const now = new Date().toISOString();
if (input.projectId) this.verifyProject(input.projectId, null, input.orgId);
this.db.prepare(`
insert into workspaces(id, org_id, name, local_path, repo_url, repo_ref, project_id, is_primary, created_at, updated_at)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, input.orgId, input.name.trim(), input.localPath.trim(), "", "", input.projectId, 0, now, now);
this.audit.record({
orgId: input.orgId,
entityType: "workspace",
entityId: id,
actorType: "employee",
actorId: null,
action: "created",
payload: { name: input.name.trim() },
});
return id;
}
updateTask(input: UpdateTaskInput): void {
const now = new Date().toISOString();
if (input.assigneeAgentId) this.verifyOptionalOrgEntity("agents", input.assigneeAgentId, input.orgId, "Assignee agent");
if (input.parentTaskId) {
const parent = this.db.prepare("select id from tasks where id = ? and org_id = ?").get(input.parentTaskId, input.orgId);
if (!parent) throw new Error("Parent task was not found.");
}
const result = this.db.prepare(`
update tasks set title = ?, description = ?, assignee_agent_id = ?, risk = ?, priority = ?, parent_task_id = ?, updated_at = ?
where id = ? and org_id = ?
`).run(input.title.trim(), input.description.trim(), input.assigneeAgentId, input.risk, input.priority, input.parentTaskId, now, input.taskId, input.orgId);
if (result.changes === 0) throw new Error("Task was not found.");
this.audit.record({
orgId: input.orgId,
entityType: "task",
entityId: input.taskId,
actorType: "employee",
actorId: null,
action: "updated",
payload: { title: input.title.trim() },
});
}
deleteTask(input: DeleteTaskInput): void {
const result = this.db.prepare("delete from tasks where id = ? and org_id = ?").run(input.taskId, input.orgId);
if (result.changes === 0) throw new Error("Task was not found.");
this.audit.record({
orgId: input.orgId,
entityType: "task",
entityId: input.taskId,
actorType: "employee",
actorId: null,
action: "deleted",
payload: {},
});
}
updateTaskStatus(orgId: string, taskId: string, status: string): void {
const result = this.db.prepare("update tasks set status = ?, updated_at = ? where id = ? and org_id = ?")
.run(status, new Date().toISOString(), taskId, orgId);
if (result.changes === 0) throw new Error("Task was not found.");
this.audit.record({
orgId,
entityType: "task",
entityId: taskId,
actorType: "system",
actorId: null,
action: "status_changed",
payload: { status },
});
}
}