Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
487 lines
22 KiB
TypeScript
487 lines
22 KiB
TypeScript
import type { DatabaseSync } from "node:sqlite";
|
|
import { APPROVAL_STATE, RISK_LEVEL, TASK_STATUS, WRITING_MODE, type RiskLevel } from "../../shared/domain";
|
|
import { createId } from "../../shared/ids";
|
|
import type { CreateAgentInput, CreateGoalInput, CreateOrganizationInput, CreateProjectInput, CreateTaskInput, CreateWorkspaceInput, DeleteTaskInput, UpdateAgentInput, UpdateEmployeeProfileInput, UpdateTaskInput } from "../../shared/commands";
|
|
import { AuditService } from "./audit-service";
|
|
import { ensureAgentHeartbeat, resolveGoalKickoff, type GoalKickoffPlan } from "./goal-kickoff";
|
|
import { PolicyService } from "./policy-service";
|
|
import { OrgGovernanceService } from "./org-governance-service";
|
|
import { assertUsableMentionName } from "../../shared/mention-handles";
|
|
import { seedOrganization } from "./organization-seed";
|
|
import { TaskMutations } from "./task-mutations";
|
|
import { normalizeAvatarColor, normalizeAvatarImage } from "./avatar-values";
|
|
import { approvalDecision, autonomy } from "./org-approvals";
|
|
import { admitAgent } from "./agent-admission";
|
|
import { HOST_ACCESS_NOTE, TASK_APPROVAL_KINDS_SQL, TASK_SNAPSHOT_SQL } from "./task-approval";
|
|
|
|
export class OrganizationService {
|
|
private readonly audit: AuditService;
|
|
private readonly policy: PolicyService;
|
|
private readonly governance: OrgGovernanceService;
|
|
private readonly tasks: TaskMutations;
|
|
|
|
constructor(private readonly db: DatabaseSync) {
|
|
this.audit = new AuditService(db);
|
|
this.policy = new PolicyService(db);
|
|
this.governance = new OrgGovernanceService(db);
|
|
this.tasks = new TaskMutations(db, (input) => this.createTask(input), (orgId, taskId) => this.requestTaskApproval(orgId, taskId));
|
|
}
|
|
|
|
createOrganization(input: CreateOrganizationInput): string {
|
|
return seedOrganization(this.db, input);
|
|
}
|
|
|
|
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 },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* The global handbrake: while paused, the autonomous queue stops enqueuing
|
|
* and claiming runs (the run already in flight finishes). Survives
|
|
* restarts, so a runaway conversation stays stopped until the operator
|
|
* resumes it.
|
|
*/
|
|
setAutonomousPaused(paused: boolean): void {
|
|
this.db.prepare(`
|
|
insert into app_state(id, active_org_id, autorun_paused, updated_at)
|
|
values (1, null, ?, ?)
|
|
on conflict(id) do update set autorun_paused = excluded.autorun_paused, updated_at = excluded.updated_at
|
|
`).run(paused ? 1 : 0, new Date().toISOString());
|
|
}
|
|
|
|
updateUiPreferences(input: { theme?: "system" | "light" | "dark"; language?: "en" | "zh" }): void {
|
|
const current = this.db.prepare("select theme, language from app_state where id = 1")
|
|
.get() as { theme: string; language: string } | undefined;
|
|
const theme = input.theme ?? current?.theme ?? "system";
|
|
const language = input.language ?? current?.language ?? "en";
|
|
this.db.prepare(`
|
|
insert into app_state(id, active_org_id, theme, language, updated_at)
|
|
values (1, null, ?, ?, ?)
|
|
on conflict(id) do update set theme = excluded.theme, language = excluded.language, updated_at = excluded.updated_at
|
|
`).run(theme, language, new Date().toISOString());
|
|
}
|
|
|
|
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);
|
|
this.assertAgentNameAvailable(input.orgId, input.name, null);
|
|
this.governance.assertRank(input.orgId, null, input.roleLevel ?? "staff");
|
|
const hiringPolicy = this.governance.enforceHiringPolicy(input);
|
|
const id = createId("agt");
|
|
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(`
|
|
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(),
|
|
hiringPolicy.departmentName,
|
|
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,
|
|
waitsForReview ? "pending_hire" : "active",
|
|
now,
|
|
);
|
|
this.audit.record({
|
|
orgId: input.orgId,
|
|
entityType: "agent",
|
|
entityId: id,
|
|
actorType: "system",
|
|
actorId: null,
|
|
action: "created",
|
|
payload: { role: input.role, department: hiringPolicy.departmentName },
|
|
});
|
|
if (waitsForReview) console.info(`[hiring] ${input.name.trim()} (${id}) awaits hire approval`);
|
|
else admitAgent(this.db, input.orgId, id, input.name, now);
|
|
if (review) {
|
|
const approvalId = createId("app");
|
|
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: ${hiringPolicy.departmentName}\nRole: ${input.role.trim()}\nBudget: ${input.budgetCents}`,
|
|
RISK_LEVEL.MEDIUM,
|
|
"system",
|
|
null,
|
|
"agent",
|
|
id,
|
|
review.state,
|
|
review.decidedBy,
|
|
now,
|
|
review.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, name, avatar_emoji, avatar_color, avatar_image from agents where id = ? and org_id = ?")
|
|
.get(input.agentId, input.orgId) as { id: string; name: string; avatar_emoji: string | null; avatar_color: string | null; avatar_image: string | null } | undefined;
|
|
if (!agent) throw new Error("Agent was not found.");
|
|
const role = input.role.trim();
|
|
if (!role) throw new Error("Agent role is required.");
|
|
const name = input.name === undefined ? agent.name : input.name.trim();
|
|
if (!name) throw new Error("Agent name is required.");
|
|
if (name !== agent.name) {
|
|
assertUsableMentionName(name);
|
|
this.assertAgentNameAvailable(input.orgId, name, input.agentId);
|
|
}
|
|
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.");
|
|
}
|
|
this.governance.assertRank(input.orgId, input.agentId, input.roleLevel.trim() || "staff");
|
|
const department = this.governance.requireCanonicalDepartment(input.orgId, input.department);
|
|
const budgetCents = Math.max(0, Math.round(input.budgetCents));
|
|
this.governance.enforceStaffingChange({ orgId: input.orgId, agentId: input.agentId, departmentName: department, budgetCents });
|
|
const avatarEmoji = input.avatarEmoji === undefined ? agent.avatar_emoji : (input.avatarEmoji.trim() || null);
|
|
const avatarColor = input.avatarColor === undefined ? agent.avatar_color : normalizeAvatarColor(input.avatarColor);
|
|
const avatarImage = input.avatarImage === undefined ? agent.avatar_image : normalizeAvatarImage(input.avatarImage);
|
|
const result = this.db.prepare(`
|
|
update agents
|
|
set name = ?, role = ?, title = ?, role_level = ?, department = ?, budget_cents = ?, model_provider_id = ?, model_id = ?,
|
|
avatar_emoji = ?, avatar_color = ?, avatar_image = ?
|
|
where id = ? and org_id = ?
|
|
`).run(name, role, input.title.trim() || role, input.roleLevel.trim() || "staff", department, budgetCents, input.modelProviderId, input.modelId, avatarEmoji, avatarColor, avatarImage, 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: { name, role, roleLevel: input.roleLevel.trim() || "staff" },
|
|
});
|
|
}
|
|
|
|
updateEmployeeProfile(input: UpdateEmployeeProfileInput): void {
|
|
const employee = this.db.prepare("select id, avatar_emoji, avatar_color, avatar_image from employees where id = ? and org_id = ?")
|
|
.get(input.employeeId, input.orgId) as { id: string; avatar_emoji: string | null; avatar_color: string | null; avatar_image: string | null } | undefined;
|
|
if (!employee) throw new Error("Employee was not found.");
|
|
const displayName = input.displayName.trim();
|
|
if (!displayName) throw new Error("Display name is required.");
|
|
const avatarEmoji = input.avatarEmoji === undefined ? employee.avatar_emoji : (input.avatarEmoji.trim() || null);
|
|
const avatarColor = input.avatarColor === undefined ? employee.avatar_color : normalizeAvatarColor(input.avatarColor);
|
|
const avatarImage = input.avatarImage === undefined ? employee.avatar_image : normalizeAvatarImage(input.avatarImage);
|
|
this.db.prepare("update employees set display_name = ?, avatar_emoji = ?, avatar_color = ?, avatar_image = ? where id = ? and org_id = ?")
|
|
.run(displayName, avatarEmoji, avatarColor, avatarImage, input.employeeId, input.orgId);
|
|
this.audit.record({
|
|
orgId: input.orgId,
|
|
entityType: "employee",
|
|
entityId: input.employeeId,
|
|
actorType: "employee",
|
|
actorId: input.employeeId,
|
|
action: "profile_updated",
|
|
payload: { displayName },
|
|
});
|
|
}
|
|
|
|
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, writing_mode, origin, 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,
|
|
input.writingMode ?? WRITING_MODE.CONVERSATIONAL,
|
|
input.origin ?? null,
|
|
now,
|
|
now,
|
|
);
|
|
const policy = this.policy.evaluateTaskCreation(input.orgId, input.risk);
|
|
this.requestTaskApproval(input.orgId, id);
|
|
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;
|
|
}
|
|
|
|
/** Asks for approval of a task's current content when policy or file access needs it and no review already covers that content. */
|
|
requestTaskApproval(orgId: string, taskId: string): void {
|
|
const task = this.db.prepare(`select t.title, t.description, t.risk, t.requires_filesystem, ${TASK_SNAPSHOT_SQL} as snapshot from tasks t where t.id = ? and t.org_id = ?`)
|
|
.get(taskId, orgId) as { title: string; description: string; risk: RiskLevel; requires_filesystem: number; snapshot: string };
|
|
const policy = this.policy.evaluateTaskCreation(orgId, task.risk);
|
|
if (!policy.approvalRequired && task.requires_filesystem !== 1) return;
|
|
const latest = this.db.prepare(`
|
|
select subject_snapshot from approvals
|
|
where org_id = ? and source_entity_type = 'task' and source_entity_id = ? and kind in ${TASK_APPROVAL_KINDS_SQL}
|
|
order by rowid desc limit 1
|
|
`).get(orgId, taskId) as { subject_snapshot: string | null } | undefined;
|
|
if (latest?.subject_snapshot === task.snapshot) return;
|
|
const superseded = this.db.prepare(`
|
|
update approvals set state = ? where org_id = ? and source_entity_type = 'task' and source_entity_id = ? and kind in ${TASK_APPROVAL_KINDS_SQL} and state = ?
|
|
`).run(APPROVAL_STATE.SUPERSEDED, orgId, taskId, APPROVAL_STATE.PENDING);
|
|
if (superseded.changes > 0) console.info(`[approvals] task ${taskId} changed; its pending review was superseded`);
|
|
const now = new Date().toISOString();
|
|
const approvalId = createId("app");
|
|
const approvalKind = policy.approvalRequired ? "task_risk" : "task_execution";
|
|
const autoApprove = autonomy(this.db, orgId).executionMode === "ai_ceo";
|
|
const decision = approvalDecision(this.db, 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, subject_snapshot
|
|
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`).run(approvalId, orgId, approvalKind, task.title, task.requires_filesystem === 1 ? `${task.description}\n\n${HOST_ACCESS_NOTE}` : task.description, RISK_LEVEL.HIGH, "system", null, "task", taskId,
|
|
decision.state, decision.decidedBy, now, decision.decidedAt, task.snapshot);
|
|
this.settleApproval(orgId, approvalId, approvalKind, RISK_LEVEL.HIGH, autoApprove);
|
|
}
|
|
|
|
/** 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 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.");
|
|
}
|
|
|
|
/**
|
|
* Agent names are identities: @mentions, DM routing, and task assignment
|
|
* all resolve by name, so two active agents must never share one.
|
|
*/
|
|
private assertAgentNameAvailable(orgId: string, name: string, exceptAgentId: string | null): void {
|
|
const clash = this.db.prepare(`
|
|
select id from agents
|
|
where org_id = ? and lifecycle in ('active', 'pending_hire') and lower(name) = lower(?) and (? is null or id <> ?)
|
|
limit 1
|
|
`).get(orgId, name.trim(), exceptAgentId, exceptAgentId) as { id: string } | undefined;
|
|
if (clash) throw new Error(`An agent named "${name.trim()}" is already on staff or awaiting approval. Pick a different name.`);
|
|
}
|
|
|
|
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 {
|
|
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.tasks.update(input);
|
|
}
|
|
|
|
deleteTask(input: DeleteTaskInput): void {
|
|
this.tasks.delete(input);
|
|
}
|
|
|
|
updateTaskStatus(orgId: string, taskId: string, status: string): void {
|
|
this.tasks.updateStatus(orgId, taskId, status);
|
|
}
|
|
|
|
agentSetTaskStatus(input: { orgId: string; taskId: string; status: string; agentId: string; evidence: string | null }): string {
|
|
return this.tasks.agentSetStatus(input);
|
|
}
|
|
}
|