Q106: a hire awaiting approval is not on staff until approved, and a declined hire leaves
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
170b977bf8
commit
c695fdb0f6
11 files changed
+115
-37
No files matched your search
@@ -0,0 +1,32 @@
|
|||||||
|
import type { DatabaseSync } from "node:sqlite";
|
||||||
|
import { IdentityAccessService } from "./identity-access-service";
|
||||||
|
|
||||||
|
/** Puts an agent on staff: a member of every workspace channel, with the service identity its runs act as. */
|
||||||
|
export function admitAgent(db: DatabaseSync, orgId: string, agentId: string, name: string, now: string): void {
|
||||||
|
const spaces = 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) {
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
new IdentityAccessService(db).createAgentServiceIdentity(orgId, agentId, name, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Carries out a hire decision: an approved pending hire joins the staff; a declined hire, pending or already working, leaves it. */
|
||||||
|
export function settleHire(db: DatabaseSync, orgId: string, agentId: string, approved: boolean): void {
|
||||||
|
const agent = db.prepare("select name, lifecycle from agents where id = ? and org_id = ?")
|
||||||
|
.get(agentId, orgId) as { name: string; lifecycle: string } | undefined;
|
||||||
|
if (!agent) throw new Error("The agent of this hire review was not found.");
|
||||||
|
if (approved) {
|
||||||
|
if (agent.lifecycle !== "pending_hire") return;
|
||||||
|
db.prepare("update agents set lifecycle = 'active' where id = ?").run(agentId);
|
||||||
|
admitAgent(db, orgId, agentId, agent.name, new Date().toISOString());
|
||||||
|
console.info(`[hiring] ${agent.name} (${agentId}) joined after approval`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
db.prepare("update agents set lifecycle = 'declined' where id = ?").run(agentId);
|
||||||
|
console.info(`[hiring] ${agent.name} (${agentId}) was declined (was ${agent.lifecycle})`);
|
||||||
|
}
|
||||||
@@ -337,6 +337,8 @@ function hireAgent(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord)
|
|||||||
const { granted, unknown } = bindSkillSlugs(deps, ctx.orgId, agentId, requestedSkills);
|
const { granted, unknown } = bindSkillSlugs(deps, ctx.orgId, agentId, requestedSkills);
|
||||||
const skillNote = granted.length > 0 ? ` They carry: ${granted.join(", ")} (plus the automatic humanizer house style).` : "";
|
const skillNote = granted.length > 0 ? ` They carry: ${granted.join(", ")} (plus the automatic humanizer house style).` : "";
|
||||||
const unknownNote = unknown.length > 0 ? ` Unknown skills skipped: ${unknown.join(", ")} — run list_skills and grant_skill to fix.` : "";
|
const unknownNote = unknown.length > 0 ? ` Unknown skills skipped: ${unknown.join(", ")} — run list_skills and grant_skill to fix.` : "";
|
||||||
|
const pending = (deps.db.prepare("select lifecycle from agents where id = ?").get(agentId) as { lifecycle: string }).lifecycle === "pending_hire";
|
||||||
|
if (pending) return `Requested ${name} as ${title} (${seniority}) in ${department} (agent_id=${agentId}); the hire awaits the board's approval, and they take no work until it is approved.${skillNote}${unknownNote}`;
|
||||||
return `Hired ${name} as ${title} (${seniority}) in ${department}, reporting to you (agent_id=${agentId}).${skillNote}${unknownNote}`;
|
return `Hired ${name} as ${title} (${seniority}) in ${department}, reporting to you (agent_id=${agentId}).${skillNote}${unknownNote}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { CreateApprovalInput, DecideApprovalInput } from "../../shared/comm
|
|||||||
import { APPROVAL_STATE, ROLE_KEY } from "../../shared/domain";
|
import { APPROVAL_STATE, ROLE_KEY } from "../../shared/domain";
|
||||||
import { createId } from "../../shared/ids";
|
import { createId } from "../../shared/ids";
|
||||||
import { AuditService } from "./audit-service";
|
import { AuditService } from "./audit-service";
|
||||||
|
import { settleHire } from "./agent-admission";
|
||||||
import { IdentityAccessService } from "./identity-access-service";
|
import { IdentityAccessService } from "./identity-access-service";
|
||||||
import { OrgGovernanceService } from "./org-governance-service";
|
import { OrgGovernanceService } from "./org-governance-service";
|
||||||
|
|
||||||
@@ -86,6 +87,11 @@ export class ApprovalService {
|
|||||||
where id = ? and org_id = ? and state = ?
|
where id = ? and org_id = ? and state = ?
|
||||||
`).run(input.state, input.decidedByEmployeeId, now, notes, input.approvalId, input.orgId, APPROVAL_STATE.PENDING);
|
`).run(input.state, input.decidedByEmployeeId, now, notes, input.approvalId, input.orgId, APPROVAL_STATE.PENDING);
|
||||||
if (result.changes === 0) throw new Error("Pending approval was not found.");
|
if (result.changes === 0) throw new Error("Pending approval was not found.");
|
||||||
|
const approval = this.db.prepare("select kind, source_entity_type, source_entity_id from approvals where id = ?")
|
||||||
|
.get(input.approvalId) as { kind: string; source_entity_type: string | null; source_entity_id: string | null };
|
||||||
|
if (approval.kind === "agent_hire" && approval.source_entity_type === "agent" && approval.source_entity_id) {
|
||||||
|
settleHire(this.db, input.orgId, approval.source_entity_id, input.state === APPROVAL_STATE.APPROVED);
|
||||||
|
}
|
||||||
this.audit.record({
|
this.audit.record({
|
||||||
orgId: input.orgId,
|
orgId: input.orgId,
|
||||||
entityType: "approval",
|
entityType: "approval",
|
||||||
|
|||||||
@@ -472,7 +472,8 @@ export class ChatService {
|
|||||||
|
|
||||||
private resolveWorkspaceMembers(orgId: string): PrincipalRef[] {
|
private resolveWorkspaceMembers(orgId: string): PrincipalRef[] {
|
||||||
const employees = this.db.prepare("select id from employees where org_id = ?").all(orgId) as Array<{ id: string }>;
|
const employees = this.db.prepare("select id from employees where org_id = ?").all(orgId) as Array<{ id: string }>;
|
||||||
const agents = this.db.prepare("select id from agents where org_id = ?").all(orgId) as Array<{ id: string }>;
|
// Only agents on staff join: a hire awaiting approval, a declined hire or an erased agent does not.
|
||||||
|
const agents = this.db.prepare("select id from agents where org_id = ? and lifecycle = 'active'").all(orgId) as Array<{ id: string }>;
|
||||||
return [
|
return [
|
||||||
...employees.map((employee) => ({ type: "employee" as const, id: employee.id })),
|
...employees.map((employee) => ({ type: "employee" as const, id: employee.id })),
|
||||||
...agents.map((agent) => ({ type: "agent" as const, id: agent.id })),
|
...agents.map((agent) => ({ type: "agent" as const, id: agent.id })),
|
||||||
@@ -486,7 +487,9 @@ export class ChatService {
|
|||||||
members.push({ type: "employee", id });
|
members.push({ type: "employee", id });
|
||||||
}
|
}
|
||||||
for (const id of new Set(input.memberAgentIds ?? [])) {
|
for (const id of new Set(input.memberAgentIds ?? [])) {
|
||||||
this.requireOrgEntity("agents", id, input.orgId, "Chat member");
|
const agent = this.db.prepare("select name, lifecycle from agents where id = ? and org_id = ?").get(id, input.orgId) as { name: string; lifecycle: string } | undefined;
|
||||||
|
if (!agent) throw new Error("Chat member was not found.");
|
||||||
|
if (agent.lifecycle !== "active") throw new Error(`${agent.name} is not on staff, so cannot join a channel.`);
|
||||||
members.push({ type: "agent", id });
|
members.push({ type: "agent", id });
|
||||||
}
|
}
|
||||||
return members;
|
return members;
|
||||||
|
|||||||
@@ -53,8 +53,8 @@ export class OrgGovernanceService {
|
|||||||
const usage = this.db.prepare(`
|
const usage = this.db.prepare(`
|
||||||
select count(*) as headcount, coalesce(sum(budget_cents), 0) as budget_cents
|
select count(*) as headcount, coalesce(sum(budget_cents), 0) as budget_cents
|
||||||
from agents
|
from agents
|
||||||
where org_id = ? and department = ? and lifecycle = ?
|
where org_id = ? and department = ? and lifecycle in ('active', 'pending_hire')
|
||||||
`).get(input.orgId, departmentName, "active") as { headcount: number; budget_cents: number };
|
`).get(input.orgId, departmentName) as { headcount: number; budget_cents: number };
|
||||||
if (usage.headcount >= policy.max_headcount) throw new Error("Department headcount limit has been reached.");
|
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) {
|
if (usage.budget_cents + input.budgetCents > policy.monthly_budget_limit_cents) {
|
||||||
throw new Error("Department monthly budget limit has been reached.");
|
throw new Error("Department monthly budget limit has been reached.");
|
||||||
@@ -69,7 +69,7 @@ export class OrgGovernanceService {
|
|||||||
if (!policy) throw new Error("Hiring policy is required for department.");
|
if (!policy) throw new Error("Hiring policy is required for department.");
|
||||||
const others = this.db.prepare(`
|
const others = this.db.prepare(`
|
||||||
select count(*) as headcount, coalesce(sum(budget_cents), 0) as budget_cents
|
select count(*) as headcount, coalesce(sum(budget_cents), 0) as budget_cents
|
||||||
from agents where org_id = ? and department = ? and lifecycle = 'active' and id <> ?
|
from agents where org_id = ? and department = ? and lifecycle in ('active', 'pending_hire') and id <> ?
|
||||||
`).get(input.orgId, input.departmentName, input.agentId) as { headcount: number; budget_cents: number };
|
`).get(input.orgId, input.departmentName, input.agentId) as { headcount: number; budget_cents: number };
|
||||||
const current = this.db.prepare("select department, budget_cents from agents where id = ? and org_id = ?").get(input.agentId, input.orgId) as { department: string; budget_cents: number };
|
const current = this.db.prepare("select department, budget_cents from agents where id = ? and org_id = ?").get(input.agentId, input.orgId) as { department: string; budget_cents: number };
|
||||||
const moving = current.department !== input.departmentName;
|
const moving = current.department !== input.departmentName;
|
||||||
|
|||||||
@@ -1,29 +1,27 @@
|
|||||||
import type { DatabaseSync } from "node:sqlite";
|
import type { DatabaseSync } from "node:sqlite";
|
||||||
import { RISK_LEVEL, TASK_STATUS, WRITING_MODE } from "../../shared/domain";
|
import { APPROVAL_STATE, RISK_LEVEL, TASK_STATUS, WRITING_MODE } from "../../shared/domain";
|
||||||
import { createId } from "../../shared/ids";
|
import { createId } from "../../shared/ids";
|
||||||
import type { CreateAgentInput, CreateGoalInput, CreateOrganizationInput, CreateProjectInput, CreateTaskInput, CreateWorkspaceInput, DeleteTaskInput, UpdateAgentInput, UpdateEmployeeProfileInput, UpdateTaskInput } from "../../shared/commands";
|
import type { CreateAgentInput, CreateGoalInput, CreateOrganizationInput, CreateProjectInput, CreateTaskInput, CreateWorkspaceInput, DeleteTaskInput, UpdateAgentInput, UpdateEmployeeProfileInput, UpdateTaskInput } from "../../shared/commands";
|
||||||
import { AuditService } from "./audit-service";
|
import { AuditService } from "./audit-service";
|
||||||
import { ensureAgentHeartbeat, resolveGoalKickoff, type GoalKickoffPlan } from "./goal-kickoff";
|
import { ensureAgentHeartbeat, resolveGoalKickoff, type GoalKickoffPlan } from "./goal-kickoff";
|
||||||
import { PolicyService } from "./policy-service";
|
import { PolicyService } from "./policy-service";
|
||||||
import { IdentityAccessService } from "./identity-access-service";
|
|
||||||
import { OrgGovernanceService } from "./org-governance-service";
|
import { OrgGovernanceService } from "./org-governance-service";
|
||||||
import { assertUsableMentionName } from "../../shared/mention-handles";
|
import { assertUsableMentionName } from "../../shared/mention-handles";
|
||||||
import { seedOrganization } from "./organization-seed";
|
import { seedOrganization } from "./organization-seed";
|
||||||
import { TaskMutations } from "./task-mutations";
|
import { TaskMutations } from "./task-mutations";
|
||||||
import { normalizeAvatarColor, normalizeAvatarImage } from "./avatar-values";
|
import { normalizeAvatarColor, normalizeAvatarImage } from "./avatar-values";
|
||||||
import { approvalDecision, autonomy } from "./org-approvals";
|
import { approvalDecision, autonomy } from "./org-approvals";
|
||||||
|
import { admitAgent } from "./agent-admission";
|
||||||
|
|
||||||
export class OrganizationService {
|
export class OrganizationService {
|
||||||
private readonly audit: AuditService;
|
private readonly audit: AuditService;
|
||||||
private readonly policy: PolicyService;
|
private readonly policy: PolicyService;
|
||||||
private readonly identityAccess: IdentityAccessService;
|
|
||||||
private readonly governance: OrgGovernanceService;
|
private readonly governance: OrgGovernanceService;
|
||||||
private readonly tasks: TaskMutations;
|
private readonly tasks: TaskMutations;
|
||||||
|
|
||||||
constructor(private readonly db: DatabaseSync) {
|
constructor(private readonly db: DatabaseSync) {
|
||||||
this.audit = new AuditService(db);
|
this.audit = new AuditService(db);
|
||||||
this.policy = new PolicyService(db);
|
this.policy = new PolicyService(db);
|
||||||
this.identityAccess = new IdentityAccessService(db);
|
|
||||||
this.governance = new OrgGovernanceService(db);
|
this.governance = new OrgGovernanceService(db);
|
||||||
this.tasks = new TaskMutations(db, (input) => this.createTask(input));
|
this.tasks = new TaskMutations(db, (input) => this.createTask(input));
|
||||||
}
|
}
|
||||||
@@ -99,6 +97,10 @@ export class OrganizationService {
|
|||||||
const hiringPolicy = this.governance.enforceHiringPolicy(input);
|
const hiringPolicy = this.governance.enforceHiringPolicy(input);
|
||||||
const id = createId("agt");
|
const id = createId("agt");
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
const autoApprove = autonomy(this.db, input.orgId).autoApproveHires;
|
||||||
|
const review = hiringPolicy.reviewRequired ? approvalDecision(this.db, input.orgId, autoApprove, now) : null;
|
||||||
|
// A hire waiting for review is not on staff: no runs, no channels, no service identity until approved.
|
||||||
|
const waitsForReview = review?.state === APPROVAL_STATE.PENDING;
|
||||||
this.db.prepare(`
|
this.db.prepare(`
|
||||||
insert into agents(
|
insert into agents(
|
||||||
id, org_id, name, role, department, model_provider_id, model_id,
|
id, org_id, name, role, department, model_provider_id, model_id,
|
||||||
@@ -121,7 +123,7 @@ export class OrganizationService {
|
|||||||
input.workspaceId ?? null,
|
input.workspaceId ?? null,
|
||||||
input.heartbeatEnabled === true ? 1 : 0,
|
input.heartbeatEnabled === true ? 1 : 0,
|
||||||
input.heartbeatIntervalSec ?? 0,
|
input.heartbeatIntervalSec ?? 0,
|
||||||
"active",
|
waitsForReview ? "pending_hire" : "active",
|
||||||
now,
|
now,
|
||||||
);
|
);
|
||||||
this.audit.record({
|
this.audit.record({
|
||||||
@@ -133,12 +135,10 @@ export class OrganizationService {
|
|||||||
action: "created",
|
action: "created",
|
||||||
payload: { role: input.role, department: hiringPolicy.departmentName },
|
payload: { role: input.role, department: hiringPolicy.departmentName },
|
||||||
});
|
});
|
||||||
this.addAgentToWorkspaceSpaces(input.orgId, id);
|
if (waitsForReview) console.info(`[hiring] ${input.name.trim()} (${id}) awaits hire approval`);
|
||||||
this.identityAccess.createAgentServiceIdentity(input.orgId, id, input.name, now);
|
else admitAgent(this.db, input.orgId, id, input.name, now);
|
||||||
if (hiringPolicy.reviewRequired) {
|
if (review) {
|
||||||
const approvalId = createId("app");
|
const approvalId = createId("app");
|
||||||
const autoApprove = autonomy(this.db, input.orgId).autoApproveHires;
|
|
||||||
const decision = approvalDecision(this.db, input.orgId, autoApprove, now);
|
|
||||||
this.db.prepare(`
|
this.db.prepare(`
|
||||||
insert into approvals(
|
insert into approvals(
|
||||||
id, org_id, kind, title, details, risk, requested_by_type,
|
id, org_id, kind, title, details, risk, requested_by_type,
|
||||||
@@ -155,10 +155,10 @@ export class OrganizationService {
|
|||||||
null,
|
null,
|
||||||
"agent",
|
"agent",
|
||||||
id,
|
id,
|
||||||
decision.state,
|
review.state,
|
||||||
decision.decidedBy,
|
review.decidedBy,
|
||||||
now,
|
now,
|
||||||
decision.decidedAt,
|
review.decidedAt,
|
||||||
);
|
);
|
||||||
this.settleApproval(input.orgId, approvalId, "agent_hire", RISK_LEVEL.MEDIUM, autoApprove);
|
this.settleApproval(input.orgId, approvalId, "agent_hire", RISK_LEVEL.MEDIUM, autoApprove);
|
||||||
}
|
}
|
||||||
@@ -341,22 +341,10 @@ export class OrganizationService {
|
|||||||
private assertAgentNameAvailable(orgId: string, name: string, exceptAgentId: string | null): void {
|
private assertAgentNameAvailable(orgId: string, name: string, exceptAgentId: string | null): void {
|
||||||
const clash = this.db.prepare(`
|
const clash = this.db.prepare(`
|
||||||
select id from agents
|
select id from agents
|
||||||
where org_id = ? and lifecycle = 'active' and lower(name) = lower(?) and (? is null or id <> ?)
|
where org_id = ? and lifecycle in ('active', 'pending_hire') and lower(name) = lower(?) and (? is null or id <> ?)
|
||||||
limit 1
|
limit 1
|
||||||
`).get(orgId, name.trim(), exceptAgentId, exceptAgentId) as { id: string } | undefined;
|
`).get(orgId, name.trim(), exceptAgentId, exceptAgentId) as { id: string } | undefined;
|
||||||
if (clash) throw new Error(`An active agent named "${name.trim()}" already exists. Pick a different name.`);
|
if (clash) throw new Error(`An agent named "${name.trim()}" is already on staff or awaiting approval. Pick a different name.`);
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
createGoal(input: CreateGoalInput): string {
|
||||||
|
|||||||
@@ -25,11 +25,14 @@ export function requireTask(db: DatabaseSync, orgId: string, taskId: string): Ta
|
|||||||
export function requireAssignedAgent(db: DatabaseSync, orgId: string, task: TaskExecutionTaskRow): TaskExecutionAgentRow {
|
export function requireAssignedAgent(db: DatabaseSync, orgId: string, task: TaskExecutionTaskRow): TaskExecutionAgentRow {
|
||||||
if (!task.assignee_agent_id) throw new Error("Task requires an assigned agent.");
|
if (!task.assignee_agent_id) throw new Error("Task requires an assigned agent.");
|
||||||
const agent = db.prepare(`
|
const agent = db.prepare(`
|
||||||
select id, name, role, department, title, role_level, model_provider_id, model_id, manager_agent_id
|
select id, name, role, department, title, role_level, model_provider_id, model_id, manager_agent_id, lifecycle
|
||||||
from agents
|
from agents
|
||||||
where id = ? and org_id = ?
|
where id = ? and org_id = ?
|
||||||
`).get(task.assignee_agent_id, orgId) as TaskExecutionAgentRow | undefined;
|
`).get(task.assignee_agent_id, orgId) as (TaskExecutionAgentRow & { lifecycle: string }) | undefined;
|
||||||
if (!agent) throw new Error("Task assignee was not found.");
|
if (!agent) throw new Error("Task assignee was not found.");
|
||||||
|
if (agent.lifecycle === "pending_hire") throw new Error(`${agent.name} is not hired yet: the hire awaits approval.`);
|
||||||
|
if (agent.lifecycle === "declined") throw new Error(`${agent.name}'s hire was declined; reassign the task.`);
|
||||||
|
if (agent.lifecycle !== "active") throw new Error(`${agent.name} is no longer on staff; reassign the task.`);
|
||||||
return agent;
|
return agent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -771,7 +771,7 @@ describe("chat governance", () => {
|
|||||||
organization.createAgent({ orgId, name: "Ada", role: "Reviewer", department: "Engineering", modelProviderId: null, modelId: null, budgetCents: 10000, managerAgentId: null });
|
organization.createAgent({ orgId, name: "Ada", role: "Reviewer", department: "Engineering", modelProviderId: null, modelId: null, budgetCents: 10000, managerAgentId: null });
|
||||||
// The front door refuses duplicate names outright...
|
// The front door refuses duplicate names outright...
|
||||||
expect(() => organization.createAgent({ orgId, name: "ada", role: "Release lead", department: "Engineering", modelProviderId: null, modelId: null, budgetCents: 10000, managerAgentId: null }))
|
expect(() => organization.createAgent({ orgId, name: "ada", role: "Release lead", department: "Engineering", modelProviderId: null, modelId: null, budgetCents: 10000, managerAgentId: null }))
|
||||||
.toThrow("already exists");
|
.toThrow("is already on staff or awaiting approval");
|
||||||
// ...but a legacy database may already carry duplicates; mentions must
|
// ...but a legacy database may already carry duplicates; mentions must
|
||||||
// still refuse to guess between them.
|
// still refuse to guess between them.
|
||||||
const secondId = organization.createAgent({ orgId, name: "Ada Backup", role: "Release lead", department: "Engineering", modelProviderId: null, modelId: null, budgetCents: 10000, managerAgentId: null });
|
const secondId = organization.createAgent({ orgId, name: "Ada Backup", role: "Release lead", department: "Engineering", modelProviderId: null, modelId: null, budgetCents: 10000, managerAgentId: null });
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import { DatabaseConnection } from "../../src/main/database/connection";
|
|||||||
import { OrganizationService } from "../../src/main/services/organization-service";
|
import { OrganizationService } from "../../src/main/services/organization-service";
|
||||||
import { ChatService } from "../../src/main/services/chat-service";
|
import { ChatService } from "../../src/main/services/chat-service";
|
||||||
import { dispatchAgentTool, type AgentToolContext } from "../../src/main/services/agent-tools";
|
import { dispatchAgentTool, type AgentToolContext } from "../../src/main/services/agent-tools";
|
||||||
|
import { ApprovalService } from "../../src/main/services/approval-service";
|
||||||
|
import { requireAssignedAgent } from "../../src/main/services/task-run-preparation";
|
||||||
|
import type { TaskExecutionTaskRow } from "../../src/main/services/task-prompts";
|
||||||
|
|
||||||
function call(name: string, args: Record<string, unknown>) {
|
function call(name: string, args: Record<string, unknown>) {
|
||||||
return { id: `call_${randomUUID()}`, name, arguments: args };
|
return { id: `call_${randomUUID()}`, name, arguments: args };
|
||||||
@@ -113,4 +116,41 @@ describe("execution modes", () => {
|
|||||||
).get(ceoMode.orgId) as { state: string };
|
).get(ceoMode.orgId) as { state: string };
|
||||||
expect(cleared.state).toBe("approved");
|
expect(cleared.state).toBe("approved");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a hire held for review is not on staff until approved, and a declined one never is", () => {
|
||||||
|
const h = harness("ai_cofounder");
|
||||||
|
const db = h.connection.db;
|
||||||
|
const providerId = (db.prepare("select id from providers where org_id = ? and provider_key = 'openai'").get(h.orgId) as { id: string }).id;
|
||||||
|
const hire = (name: string) => h.connection.transaction(() => h.deps.organization.createAgent({
|
||||||
|
orgId: h.orgId, name, role: "Designer", department: "Product",
|
||||||
|
modelProviderId: providerId, modelId: "gpt-5.4", budgetCents: 600000, managerAgentId: h.ceoId,
|
||||||
|
}));
|
||||||
|
const lifecycle = (id: string) => (db.prepare("select lifecycle from agents where id = ?").get(id) as { lifecycle: string }).lifecycle;
|
||||||
|
const identities = (id: string) => (db.prepare("select count(*) as n from service_identities where agent_id = ?").get(id) as { n: number }).n;
|
||||||
|
const memberships = (id: string) => (db.prepare("select count(*) as n from chat_memberships where principal_type = 'agent' and principal_id = ?").get(id) as { n: number }).n;
|
||||||
|
const runAs = (id: string) => requireAssignedAgent(db, h.orgId, { assignee_agent_id: id } as TaskExecutionTaskRow);
|
||||||
|
const approvalOf = (name: string) => (db.prepare("select id from approvals where org_id = ? and kind = 'agent_hire' and title = ?").get(h.orgId, `Review hire: ${name}`) as { id: string }).id;
|
||||||
|
const employeeId = (db.prepare("select id from employees where org_id = ?").get(h.orgId) as { id: string }).id;
|
||||||
|
const approvals = new ApprovalService(db);
|
||||||
|
|
||||||
|
const ava = hire("Ava");
|
||||||
|
expect([lifecycle(ava), identities(ava), memberships(ava)]).toEqual(["pending_hire", 0, 0]);
|
||||||
|
expect(() => runAs(ava)).toThrow("Ava is not hired yet: the hire awaits approval.");
|
||||||
|
expect(() => hire("ava")).toThrow('An agent named "ava" is already on staff or awaiting approval. Pick a different name.');
|
||||||
|
const channel = h.connection.transaction(() => h.deps.chat.createSpace({ orgId: h.orgId, name: "design", topic: "t", spaceType: "project", visibility: "workspace" }));
|
||||||
|
expect(memberships(ava)).toBe(0);
|
||||||
|
expect(() => h.connection.transaction(() => h.deps.chat.createSpace({ orgId: h.orgId, name: "ava-room", topic: "t", spaceType: "project", visibility: "restricted", memberAgentIds: [ava] })))
|
||||||
|
.toThrow("Ava is not on staff, so cannot join a channel.");
|
||||||
|
expect(channel).toBeTruthy();
|
||||||
|
h.connection.transaction(() => approvals.decide({ orgId: h.orgId, approvalId: approvalOf("Ava"), state: "approved", decidedByEmployeeId: employeeId }));
|
||||||
|
expect(lifecycle(ava)).toBe("active");
|
||||||
|
expect(identities(ava)).toBe(1);
|
||||||
|
expect(memberships(ava)).toBeGreaterThan(0);
|
||||||
|
expect(runAs(ava).id).toBe(ava);
|
||||||
|
|
||||||
|
const bo = hire("Bo");
|
||||||
|
h.connection.transaction(() => approvals.decide({ orgId: h.orgId, approvalId: approvalOf("Bo"), state: "declined", decidedByEmployeeId: employeeId }));
|
||||||
|
expect([lifecycle(bo), identities(bo), memberships(bo)]).toEqual(["declined", 0, 0]);
|
||||||
|
expect(() => runAs(bo)).toThrow("Bo's hire was declined; reassign the task.");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -74,12 +74,12 @@ describe("identity editing", () => {
|
|||||||
expect(() => h.organization.createAgent({
|
expect(() => h.organization.createAgent({
|
||||||
orgId: h.orgId, name: "dana lee", role: "Designer", department: "Design",
|
orgId: h.orgId, name: "dana lee", role: "Designer", department: "Design",
|
||||||
modelProviderId: null, modelId: null, budgetCents: 1000, managerAgentId: h.agentId,
|
modelProviderId: null, modelId: null, budgetCents: 1000, managerAgentId: h.agentId,
|
||||||
})).toThrow("already exists");
|
})).toThrow("is already on staff or awaiting approval");
|
||||||
// Renaming the CEO onto that name is equally ambiguous.
|
// Renaming the CEO onto that name is equally ambiguous.
|
||||||
expect(() => h.organization.updateAgent({
|
expect(() => h.organization.updateAgent({
|
||||||
orgId: h.orgId, agentId: h.agentId, name: "Dana Lee", role: "CEO", title: "CEO",
|
orgId: h.orgId, agentId: h.agentId, name: "Dana Lee", role: "CEO", title: "CEO",
|
||||||
roleLevel: "c_suite", department: "Executive", budgetCents: 25000, modelProviderId: null, modelId: null,
|
roleLevel: "c_suite", department: "Executive", budgetCents: 25000, modelProviderId: null, modelId: null,
|
||||||
})).toThrow("already exists");
|
})).toThrow("is already on staff or awaiting approval");
|
||||||
// Saving without changing the name stays legal.
|
// Saving without changing the name stays legal.
|
||||||
h.connection.transaction(() => h.organization.updateAgent({
|
h.connection.transaction(() => h.organization.updateAgent({
|
||||||
orgId: h.orgId, agentId: h.agentId, name: "CEO", role: "CEO", title: "CEO",
|
orgId: h.orgId, agentId: h.agentId, name: "CEO", role: "CEO", title: "CEO",
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ describe("staffing limits on edits", () => {
|
|||||||
connection.db.prepare("update hiring_policies set monthly_budget_limit_cents = 100 where org_id = ? and department_name = 'Sales'").run(orgId);
|
connection.db.prepare("update hiring_policies set monthly_budget_limit_cents = 100 where org_id = ? and department_name = 'Sales'").run(orgId);
|
||||||
edit("Sales", 2000);
|
edit("Sales", 2000);
|
||||||
expect(connection.db.prepare("select department, budget_cents from agents where id = ?").get(sam)).toEqual({ department: "Sales", budget_cents: 2000 });
|
expect(connection.db.prepare("select department, budget_cents from agents where id = ?").get(sam)).toEqual({ department: "Sales", budget_cents: 2000 });
|
||||||
|
// A hire awaiting approval already holds its place in the department.
|
||||||
|
connection.db.prepare("update agents set department = 'Sales' where name = 'Dee'").run();
|
||||||
|
connection.db.prepare("insert into agents(id, org_id, name, role, department, budget_cents, lifecycle, created_at) values ('agt_pending', ?, 'Pat', 'Staff', 'Design', 1000, 'pending_hire', ?)").run(orgId, new Date().toISOString());
|
||||||
|
expect(() => edit("Design", 1000)).toThrow("Design is at its headcount limit (1).");
|
||||||
connection.close();
|
connection.close();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user