Sessions have been agent-scoped since v34, but the schema kept four
dead task-scoped columns that every session code path had to pretend
were meaningful:
- tasks.active_session_id (mirrored agents.session_id)
- tasks.session_json (superseded by task_sessions.session_params_json)
- tasks.session_display_id (superseded by task_sessions.session_display_id)
- task_sessions.task_id (always empty-string on new rows)
v41 drops all four via SQLite table rebuild — the only safe path when
multiple columns need to go in one step and indexes / FK directions
need to be preserved. `PRAGMA foreign_keys = OFF` spans the swap
window so cascade-delete does not fire mid-rebuild. The migration is
idempotent and also backfills the optional task columns
(task_type / requires_user_review / retry_count / last_failure_at /
started_at / completed_at / cancelled_at / checkout_run_id /
execution_locked_at) before rebuilding so it works whether or not
`ensureOptionalColumns` has run yet on this install.
Along with the schema change, every code path that read or wrote
those columns has been rewired to the agent-scoped equivalents:
- task-sessions-crud.ts: deleted updateTaskSession,
getTaskSession, getActiveTaskSession, updateTasksActiveSessionForAgent;
added updateSessionParams(sessionId, ...) for the worker's
finalizer. ensureAgentSession and createNewSession no longer touch
task_id.
- database-base.ts: facade drops the now-unused pass-throughs.
- snapshot.ts / task-queries.ts / tasks-status.ts / entity-crud.ts:
SELECT projections no longer pull session_json / session_display_id
/ active_session_id from tasks.
- tasks-crud.ts (TaskRow) and shared/types/records-core.ts
(TaskRecord): sessionJson / sessionDisplayId fields removed.
- TaskSessionRecord: taskId field removed.
- turn-context.ts::resolveSession: no more
updateTasksActiveSessionForAgent mirror; sessionParams read directly
from the current session via getSessionById.
- run-finished-helpers.ts::finalizeRun: stamps sessionParams via
updateSessionParams on the agent's current session, resolved through
agents.session_id.
- session-management-service.ts: rotateSession stops calling
updateTasksActiveSessionForAgent.
Test suite updated to use the new session-id API. Existing tests used
`getActiveTaskSession(taskId)` and `createNewSession({ taskId, ...})`
as probes; they now use `getSessionById(sessionId)` and the taskId-less
createNewSession input.
Tests: 603/603 green. Typecheck clean. Migration tested on fresh DB
(runs v1→v41 cleanly) and idempotent re-apply (detects absent dead
columns, skips).
299 lines
15 KiB
TypeScript
299 lines
15 KiB
TypeScript
import type { DatabaseSync } from "node:sqlite";
|
|
import type { AgentRecord, ConnectorId, TaskRecord } from "@shared/types";
|
|
import type { ReadAll, Transaction } from "./helpers";
|
|
import { ensureId, normalizeOptionalId, nowIso } from "./helpers";
|
|
|
|
/**
|
|
* Goal row shape returned by listGoals / getGoalForUpdate. Mirrors the
|
|
* historical inline SQL projection in routes-work-graph.ts so HTTP
|
|
* responses stay byte-stable.
|
|
*/
|
|
export type GoalListRow = {
|
|
id: string;
|
|
companyId: string;
|
|
title: string;
|
|
description: string;
|
|
parentId: string | null;
|
|
ownerAgentId: string | null;
|
|
status: string;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
};
|
|
|
|
/**
|
|
* Project row shape returned by listProjects / getProjectForUpdate.
|
|
* Mirrors the historical inline SQL projection in routes-work-graph.ts.
|
|
*/
|
|
export type ProjectListRow = {
|
|
id: string;
|
|
companyId: string;
|
|
goalId: string | null;
|
|
name: string;
|
|
description: string;
|
|
leadAgentId: string | null;
|
|
status: string;
|
|
targetDate: string | null;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
};
|
|
|
|
export function buildEntityCrudMethods(
|
|
getDb: () => DatabaseSync,
|
|
readAll: ReadAll,
|
|
transaction: Transaction,
|
|
_deps: {
|
|
addActivity: (input: { companyId: string; actor: string; action: string; entityType: string; entityId: string; detail: string }) => void;
|
|
},
|
|
) {
|
|
return {
|
|
getTask(id: string): TaskRecord {
|
|
const row = getDb()
|
|
.prepare(
|
|
"select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, active_run_id as activeRunId, cost_usd as costUsd, task_type as taskType, requires_user_review as requiresUserReview, started_at as startedAt, completed_at as completedAt, cancelled_at as cancelledAt, created_at as createdAt, updated_at as updatedAt from tasks where id = ?",
|
|
)
|
|
.get(id) as TaskRecord | undefined;
|
|
if (!row) throw new Error(`Task not found: ${id}`);
|
|
row.requiresUserReview = Boolean(row.requiresUserReview);
|
|
row.startedAt = row.startedAt ?? null;
|
|
row.completedAt = row.completedAt ?? null;
|
|
row.cancelledAt = row.cancelledAt ?? null;
|
|
return row;
|
|
},
|
|
|
|
getAgent(id: string): AgentRecord {
|
|
const row = getDb()
|
|
.prepare(
|
|
"select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, role_level as roleLevel, model_strategy as modelStrategy, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, session_id as sessionId, created_at as createdAt, updated_at as updatedAt from agents where id = ?",
|
|
)
|
|
.get(id) as AgentRecord | undefined;
|
|
if (!row) throw new Error(`Agent not found: ${id}`);
|
|
row.heartbeatEnabled = Boolean(row.heartbeatEnabled);
|
|
return row;
|
|
},
|
|
|
|
findAgent(id: string): AgentRecord | null {
|
|
const row = getDb()
|
|
.prepare(
|
|
"select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, role_level as roleLevel, model_strategy as modelStrategy, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, session_id as sessionId, created_at as createdAt, updated_at as updatedAt from agents where id = ?",
|
|
)
|
|
.get(id) as AgentRecord | undefined;
|
|
if (!row) return null;
|
|
row.heartbeatEnabled = Boolean(row.heartbeatEnabled);
|
|
return row;
|
|
},
|
|
|
|
/**
|
|
* Returns all goals for a company. Used by GET /api/companies/:id/goals.
|
|
*/
|
|
listGoals(companyId: string): GoalListRow[] {
|
|
return readAll<GoalListRow>(
|
|
"select id, company_id as companyId, title, description, parent_id as parentId, owner_agent_id as ownerAgentId, status, created_at as createdAt, updated_at as updatedAt from goals where company_id = ? order by created_at asc",
|
|
companyId,
|
|
);
|
|
},
|
|
|
|
/**
|
|
* Returns the goal lookup row used by PATCH /api/goals/:goalId.
|
|
* Returns null when the goal is missing or belongs to another company.
|
|
*/
|
|
getGoalForUpdate(goalId: string, companyId: string): GoalListRow | null {
|
|
const row = getDb()
|
|
.prepare(
|
|
"select id, company_id as companyId, title, description, parent_id as parentId, owner_agent_id as ownerAgentId, status, created_at as createdAt, updated_at as updatedAt from goals where id = ? and company_id = ? limit 1",
|
|
)
|
|
.get(goalId, companyId) as GoalListRow | undefined;
|
|
return row ?? null;
|
|
},
|
|
|
|
/**
|
|
* Returns all projects for a company. Used by
|
|
* GET /api/companies/:id/projects.
|
|
*/
|
|
listProjects(companyId: string): ProjectListRow[] {
|
|
return readAll<ProjectListRow>(
|
|
"select id, company_id as companyId, goal_id as goalId, name, description, lead_agent_id as leadAgentId, status, target_date as targetDate, created_at as createdAt, updated_at as updatedAt from projects where company_id = ? order by created_at desc",
|
|
companyId,
|
|
);
|
|
},
|
|
|
|
/**
|
|
* Returns the project lookup row used by PATCH /api/projects/:projectId.
|
|
* Returns null when the project is missing or belongs to another company.
|
|
*/
|
|
getProjectForUpdate(projectId: string, companyId: string): ProjectListRow | null {
|
|
const row = getDb()
|
|
.prepare(
|
|
"select id, company_id as companyId, goal_id as goalId, name, description, lead_agent_id as leadAgentId, status, target_date as targetDate, created_at as createdAt, updated_at as updatedAt from projects where id = ? and company_id = ? limit 1",
|
|
)
|
|
.get(projectId, companyId) as ProjectListRow | undefined;
|
|
return row ?? null;
|
|
},
|
|
|
|
saveGoal(input: { id?: string | null; companyId: string; title: string; description: string; parentId?: string | null; ownerAgentId?: string | null; status: string }) {
|
|
const id = ensureId(input.id ?? null);
|
|
const ts = nowIso();
|
|
const existing = getDb().prepare("select id from goals where id = ? and company_id = ?").get(id, input.companyId) as { id: string } | undefined;
|
|
if (existing) {
|
|
getDb()
|
|
.prepare(
|
|
"update goals set title = ?, description = ?, parent_id = ?, owner_agent_id = ?, status = ?, updated_at = ? where id = ? and company_id = ?",
|
|
)
|
|
.run(input.title, input.description, normalizeOptionalId(input.parentId), normalizeOptionalId(input.ownerAgentId), input.status, ts, id, input.companyId);
|
|
} else {
|
|
getDb()
|
|
.prepare(
|
|
"insert into goals (id, company_id, title, description, parent_id, owner_agent_id, status, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
)
|
|
.run(id, input.companyId, input.title, input.description, normalizeOptionalId(input.parentId), normalizeOptionalId(input.ownerAgentId), input.status, ts, ts);
|
|
}
|
|
return id;
|
|
},
|
|
|
|
saveProject(input: { id?: string | null; companyId: string; goalId?: string | null; name: string; description: string; leadAgentId?: string | null; targetDate?: string | null; status: string }) {
|
|
const id = ensureId(input.id ?? null);
|
|
const ts = nowIso();
|
|
const existing = getDb().prepare("select id from projects where id = ? and company_id = ?").get(id, input.companyId) as { id: string } | undefined;
|
|
if (existing) {
|
|
getDb()
|
|
.prepare(
|
|
"update projects set goal_id = ?, name = ?, description = ?, lead_agent_id = ?, status = ?, target_date = ?, updated_at = ? where id = ? and company_id = ?",
|
|
)
|
|
.run(normalizeOptionalId(input.goalId), input.name, input.description, normalizeOptionalId(input.leadAgentId), input.status, normalizeOptionalId(input.targetDate), ts, id, input.companyId);
|
|
} else {
|
|
getDb()
|
|
.prepare(
|
|
"insert into projects (id, company_id, goal_id, name, description, lead_agent_id, status, target_date, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
)
|
|
.run(id, input.companyId, normalizeOptionalId(input.goalId), input.name, input.description, normalizeOptionalId(input.leadAgentId), input.status, normalizeOptionalId(input.targetDate), ts, ts);
|
|
}
|
|
return id;
|
|
},
|
|
|
|
saveSecret(input: { id?: string | null; companyId: string; name: string; description: string; value: string }, vault: { encrypt: (v: string) => { backend: string; payload: Uint8Array } }) {
|
|
const id = ensureId(input.id ?? null);
|
|
const encrypted = vault.encrypt(input.value);
|
|
const ts = nowIso();
|
|
const existing = getDb().prepare("select id, company_id as companyId from secrets where id = ?").get(id) as
|
|
| { id: string; companyId: string }
|
|
| undefined;
|
|
if (existing) {
|
|
if (existing.companyId !== input.companyId) {
|
|
throw new Error("Secrets cannot be moved across companies.");
|
|
}
|
|
getDb()
|
|
.prepare("update secrets set name = ?, description = ?, backend = ?, ciphertext = ?, updated_at = ? where id = ? and company_id = ?")
|
|
.run(input.name, input.description, encrypted.backend, encrypted.payload, ts, id, input.companyId);
|
|
} else {
|
|
getDb()
|
|
.prepare("insert into secrets (id, company_id, name, description, backend, ciphertext, updated_at, created_at) values (?, ?, ?, ?, ?, ?, ?, ?)")
|
|
.run(id, input.companyId, input.name, input.description, encrypted.backend, encrypted.payload, ts, ts);
|
|
}
|
|
return id;
|
|
},
|
|
|
|
resolveSecretEnv(bindingText: string, companyId: string, vault: { decrypt: (backend: "safe_storage" | "vault", ciphertext: Buffer) => string }, listInvalidEnvBindingKeys: (t: string) => string[], parseEnvBindingText: (t: string) => Record<string, string>): Record<string, string> {
|
|
const invalidKeys = new Set(listInvalidEnvBindingKeys(bindingText));
|
|
const bindings = parseEnvBindingText(bindingText);
|
|
const result: Record<string, string> = {};
|
|
for (const [envKey, rawValue] of Object.entries(bindings)) {
|
|
if (invalidKeys.has(envKey)) continue;
|
|
const row = getDb().prepare("select backend, ciphertext from secrets where company_id = ? and name = ?").get(companyId, rawValue) as
|
|
| { backend: "safe_storage" | "vault"; ciphertext: Uint8Array }
|
|
| undefined;
|
|
if (row) {
|
|
result[envKey] = vault.decrypt(row.backend, Buffer.from(row.ciphertext));
|
|
} else {
|
|
result[envKey] = rawValue;
|
|
}
|
|
}
|
|
return result;
|
|
},
|
|
|
|
deleteGoal(id: string, companyId: string) {
|
|
transaction(() => {
|
|
getDb().prepare("update goals set parent_id = null where parent_id = ? and company_id = ?").run(id, companyId);
|
|
getDb().prepare("update projects set goal_id = null where goal_id = ? and company_id = ?").run(id, companyId);
|
|
getDb().prepare("update tasks set goal_id = null where goal_id = ? and company_id = ?").run(id, companyId);
|
|
getDb().prepare("delete from goals where id = ? and company_id = ?").run(id, companyId);
|
|
});
|
|
},
|
|
|
|
deleteProject(id: string, companyId: string) {
|
|
transaction(() => {
|
|
getDb().prepare("update tasks set project_id = null where project_id = ? and company_id = ?").run(id, companyId);
|
|
getDb().prepare("update workspaces set project_id = null where project_id = ? and company_id = ?").run(id, companyId);
|
|
getDb().prepare("delete from projects where id = ? and company_id = ?").run(id, companyId);
|
|
});
|
|
},
|
|
|
|
deleteSecret(id: string, companyId: string) {
|
|
getDb().prepare("delete from secrets where id = ? and company_id = ?").run(id, companyId);
|
|
},
|
|
|
|
countActiveGoalsForCompany(companyId: string): number {
|
|
const row = getDb().prepare(
|
|
"select count(*) as cnt from goals where company_id = ? and status in ('active', 'planned')",
|
|
).get(companyId) as { cnt: number };
|
|
return row.cnt;
|
|
},
|
|
|
|
createHireRequest(input: {
|
|
companyId: string; requestedByAgentId?: string | null;
|
|
name: string; role: string; title: string; department?: string | null;
|
|
reportsTo?: string | null; connectorId: ConnectorId; workspaceId?: string | null;
|
|
model?: string | null; metadataJson?: string; capabilities: string;
|
|
budgetMonthlyUsd: number; payloadSummary: string; impactSummary: string;
|
|
payloadJson?: string | null;
|
|
}, selfDeps: {
|
|
saveAgent: (input: {
|
|
companyId: string; name: string; role: string; title: string;
|
|
department: string | null; status: AgentRecord["status"];
|
|
reportsTo: string | null; connectorId: ConnectorId;
|
|
workspaceId: string | null; model: string | null;
|
|
metadataJson: string; capabilities: string; budgetMonthlyUsd: number;
|
|
modelStrategy?: "explicit" | "auto";
|
|
}) => string;
|
|
requestApproval: (input: {
|
|
companyId: string; requestedByAgentId: string | null;
|
|
relatedAgentId: string; type: string;
|
|
payloadSummary: string; impactSummary: string;
|
|
payloadJson: string | null;
|
|
}) => string;
|
|
}) {
|
|
return transaction(() => {
|
|
const agentId = selfDeps.saveAgent({
|
|
companyId: input.companyId, name: input.name, role: input.role,
|
|
title: input.title, department: input.department ?? null,
|
|
status: "pending_approval", reportsTo: input.reportsTo ?? null,
|
|
connectorId: input.connectorId, workspaceId: input.workspaceId ?? null,
|
|
model: input.model ?? null, metadataJson: input.metadataJson ?? "{}",
|
|
capabilities: input.capabilities, budgetMonthlyUsd: input.budgetMonthlyUsd,
|
|
// Agents hired by the CEO (or any other agent) default to `auto`:
|
|
// the dispatcher will pick the cheapest enabled model that
|
|
// clears their inferred task profile. User-driven form saves
|
|
// flip this to 'explicit' at the IPC handler.
|
|
modelStrategy: "auto",
|
|
});
|
|
const approvalId = selfDeps.requestApproval({
|
|
companyId: input.companyId,
|
|
requestedByAgentId: input.requestedByAgentId ?? null,
|
|
relatedAgentId: agentId,
|
|
type: "hire_agent",
|
|
payloadSummary: input.payloadSummary,
|
|
impactSummary: input.impactSummary,
|
|
payloadJson: input.payloadJson ?? null,
|
|
});
|
|
return { agentId, approvalId };
|
|
});
|
|
},
|
|
|
|
incrementActiveSprintVelocity(companyId: string, delta: number) {
|
|
const ts = nowIso();
|
|
const today = ts.slice(0, 10);
|
|
getDb().prepare(
|
|
"update sprints set completed_points = max(0, completed_points + ?), updated_at = ? where company_id = ? and status = 'active' and start_date <= ? and end_date >= ?",
|
|
).run(delta, ts, companyId, today, today);
|
|
},
|
|
};
|
|
}
|