refactor(database): split 5 god modules into domain-scoped files
Five files under `src/main/database/` exceeded 400 LOC by combining multiple responsibilities. Split along natural fault lines. Zero behavior change — each original `build*Methods` factory still returns the same method surface, now via spread of smaller builders. | Original | Split into | LOC each | |-----------------------------------|-------------------------------------|----------------| | tasks-crud.ts (640) | tasks-crud / task-sessions-crud / | 250 / 252 / | | | task-queries | 177 | | agents-hierarchy.ts (487) | agents-hierarchy / agent-permissions| 350 / 151 | | company-settings.ts (476) | company-settings / company-metrics | 122 / 370 | | documents.ts (426) | documents / config-revisions | 318 / 126 | | runs.ts (413) | runs / run-logs / run-events / | 245 / 96 / | | | cost-entries | 82 / 52 | AppDatabase call sites unchanged: - db.tasks.saveTask, db.tasks.ensureAgentSession (task-sessions-crud), db.tasks.listAgentTasks (task-queries) - db.agents.getChainOfCommand, db.agents.hasAgentPermission - db.company.getCompanySettings, db.company.getStandupReport (company-metrics) - db.documentsMod.saveDocument, db.documentsMod.recordConfigRevision - db.runs.createRun, db.runs.appendRunLog (run-logs), db.runs.appendRunEvent (run-events), db.runs.recordCostEntry (cost-entries) `database-base.ts` (449) kept as-is — thin facade where every pass-through is one line; splitting adds mechanical files without behavior benefit. Will revisit if it grows further. Aggregator files (`tasks.ts`, `agents.ts`, `company.ts`) each import and spread the new sub-builders. No renamed exports.
This commit is contained in:
1 parent
f012f8e880
commit
dbdec86cdd
16 files changed
+1329
-1172
No files matched your search
@@ -0,0 +1,151 @@
|
|||||||
|
import type { DatabaseSync } from "node:sqlite";
|
||||||
|
import { type ReadAll, type Transaction, nowIso, parseJson, toBoolean } from "./helpers";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent permission, runtime-state, and heartbeat-policy helpers extracted
|
||||||
|
* from agents-hierarchy.ts. Every method is spread into the final
|
||||||
|
* `buildAgentMethods` result so AppDatabase call-sites remain unchanged.
|
||||||
|
*/
|
||||||
|
export function buildAgentPermissionMethods(
|
||||||
|
getDb: () => DatabaseSync,
|
||||||
|
_readAll: ReadAll,
|
||||||
|
_transaction: Transaction,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
getAgentPermissions(agentId: string): Record<string, boolean> {
|
||||||
|
const row = getDb()
|
||||||
|
.prepare("SELECT permissions_json FROM agents WHERE id = ?")
|
||||||
|
.get(agentId) as { permissions_json: string } | undefined;
|
||||||
|
return parseJson<Record<string, boolean>>(row?.permissions_json, {});
|
||||||
|
},
|
||||||
|
|
||||||
|
setAgentPermission(
|
||||||
|
agentId: string,
|
||||||
|
companyId: string,
|
||||||
|
permissionKey: string,
|
||||||
|
enabled: boolean,
|
||||||
|
): void {
|
||||||
|
const row = getDb()
|
||||||
|
.prepare("SELECT permissions_json FROM agents WHERE id = ?")
|
||||||
|
.get(agentId) as { permissions_json: string } | undefined;
|
||||||
|
const current = parseJson<Record<string, boolean>>(row?.permissions_json, {});
|
||||||
|
if (enabled) {
|
||||||
|
current[permissionKey] = true;
|
||||||
|
} else {
|
||||||
|
delete current[permissionKey];
|
||||||
|
}
|
||||||
|
const ts = nowIso();
|
||||||
|
getDb()
|
||||||
|
.prepare(
|
||||||
|
"UPDATE agents SET permissions_json = ?, updated_at = ? WHERE id = ? AND company_id = ?",
|
||||||
|
)
|
||||||
|
.run(JSON.stringify(current), ts, agentId, companyId);
|
||||||
|
},
|
||||||
|
|
||||||
|
hasAgentPermission(agentId: string, permissionKey: string): boolean {
|
||||||
|
const row = getDb()
|
||||||
|
.prepare("SELECT permissions_json FROM agents WHERE id = ?")
|
||||||
|
.get(agentId) as { permissions_json: string } | undefined;
|
||||||
|
const perms = parseJson<Record<string, boolean>>(row?.permissions_json, {});
|
||||||
|
return perms[permissionKey] === true;
|
||||||
|
},
|
||||||
|
|
||||||
|
getRuntimeState(agentId: string): Record<string, unknown> {
|
||||||
|
const row = getDb()
|
||||||
|
.prepare("SELECT runtime_state_json FROM agents WHERE id = ?")
|
||||||
|
.get(agentId) as { runtime_state_json: string | null } | undefined;
|
||||||
|
return parseJson<Record<string, unknown>>(row?.runtime_state_json, {});
|
||||||
|
},
|
||||||
|
|
||||||
|
setRuntimeState(
|
||||||
|
agentId: string,
|
||||||
|
companyId: string,
|
||||||
|
state: Record<string, unknown>,
|
||||||
|
): void {
|
||||||
|
const ts = nowIso();
|
||||||
|
getDb()
|
||||||
|
.prepare(
|
||||||
|
"UPDATE agents SET runtime_state_json = ?, updated_at = ? WHERE id = ? AND company_id = ?",
|
||||||
|
)
|
||||||
|
.run(JSON.stringify(state), ts, agentId, companyId);
|
||||||
|
},
|
||||||
|
|
||||||
|
getHeartbeatPolicy(agentId: string): {
|
||||||
|
enabled: boolean;
|
||||||
|
intervalSec: number;
|
||||||
|
cooldownSec: number;
|
||||||
|
maxConcurrentRuns: number;
|
||||||
|
wakeOnDemand: boolean;
|
||||||
|
} {
|
||||||
|
const row = getDb()
|
||||||
|
.prepare(
|
||||||
|
"SELECT heartbeat_enabled, heartbeat_interval_sec, heartbeat_policy_json FROM agents WHERE id = ?",
|
||||||
|
)
|
||||||
|
.get(agentId) as
|
||||||
|
| {
|
||||||
|
heartbeat_enabled: number;
|
||||||
|
heartbeat_interval_sec: number;
|
||||||
|
heartbeat_policy_json: string | null;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
if (!row) throw new Error(`Agent ${agentId} not found.`);
|
||||||
|
const policy = parseJson<Record<string, unknown>>(row.heartbeat_policy_json, {});
|
||||||
|
return {
|
||||||
|
enabled: toBoolean(row.heartbeat_enabled),
|
||||||
|
intervalSec: row.heartbeat_interval_sec ?? 120,
|
||||||
|
cooldownSec: typeof policy.cooldownSec === "number" ? policy.cooldownSec : 10,
|
||||||
|
maxConcurrentRuns:
|
||||||
|
typeof policy.maxConcurrentRuns === "number" ? policy.maxConcurrentRuns : 1,
|
||||||
|
wakeOnDemand: policy.wakeOnDemand !== false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
setHeartbeatPolicy(
|
||||||
|
agentId: string,
|
||||||
|
companyId: string,
|
||||||
|
policy: {
|
||||||
|
enabled?: boolean;
|
||||||
|
intervalSec?: number;
|
||||||
|
cooldownSec?: number;
|
||||||
|
maxConcurrentRuns?: number;
|
||||||
|
wakeOnDemand?: boolean;
|
||||||
|
},
|
||||||
|
): void {
|
||||||
|
const row = getDb()
|
||||||
|
.prepare(
|
||||||
|
"SELECT heartbeat_enabled, heartbeat_interval_sec, heartbeat_policy_json FROM agents WHERE id = ?",
|
||||||
|
)
|
||||||
|
.get(agentId) as
|
||||||
|
| {
|
||||||
|
heartbeat_enabled: number;
|
||||||
|
heartbeat_interval_sec: number;
|
||||||
|
heartbeat_policy_json: string | null;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
if (!row) throw new Error(`Agent ${agentId} not found.`);
|
||||||
|
const current = parseJson<Record<string, unknown>>(row.heartbeat_policy_json, {});
|
||||||
|
const merged = {
|
||||||
|
enabled: policy.enabled ?? toBoolean(row.heartbeat_enabled),
|
||||||
|
intervalSec: policy.intervalSec ?? row.heartbeat_interval_sec ?? 120,
|
||||||
|
cooldownSec:
|
||||||
|
policy.cooldownSec ??
|
||||||
|
(typeof current.cooldownSec === "number" ? current.cooldownSec : 10),
|
||||||
|
maxConcurrentRuns:
|
||||||
|
policy.maxConcurrentRuns ??
|
||||||
|
(typeof current.maxConcurrentRuns === "number" ? current.maxConcurrentRuns : 1),
|
||||||
|
wakeOnDemand: policy.wakeOnDemand ?? (current.wakeOnDemand !== false),
|
||||||
|
};
|
||||||
|
const policyJson = JSON.stringify({
|
||||||
|
cooldownSec: merged.cooldownSec,
|
||||||
|
maxConcurrentRuns: merged.maxConcurrentRuns,
|
||||||
|
wakeOnDemand: merged.wakeOnDemand,
|
||||||
|
});
|
||||||
|
const ts = nowIso();
|
||||||
|
getDb()
|
||||||
|
.prepare(
|
||||||
|
"UPDATE agents SET heartbeat_enabled = ?, heartbeat_interval_sec = ?, heartbeat_policy_json = ?, updated_at = ? WHERE id = ? AND company_id = ?",
|
||||||
|
)
|
||||||
|
.run(merged.enabled ? 1 : 0, merged.intervalSec, policyJson, ts, agentId, companyId);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
type ReadAll,
|
type ReadAll,
|
||||||
type Transaction,
|
type Transaction,
|
||||||
nowIso,
|
nowIso,
|
||||||
parseJson,
|
|
||||||
toBoolean,
|
toBoolean,
|
||||||
} from "./helpers";
|
} from "./helpers";
|
||||||
|
|
||||||
@@ -273,142 +272,6 @@ export function buildAgentHierarchyMethods(
|
|||||||
.run(nowIso(), companyId);
|
.run(nowIso(), companyId);
|
||||||
},
|
},
|
||||||
|
|
||||||
getAgentPermissions(agentId: string): Record<string, boolean> {
|
|
||||||
const row = getDb()
|
|
||||||
.prepare("SELECT permissions_json FROM agents WHERE id = ?")
|
|
||||||
.get(agentId) as { permissions_json: string } | undefined;
|
|
||||||
return parseJson<Record<string, boolean>>(row?.permissions_json, {});
|
|
||||||
},
|
|
||||||
|
|
||||||
setAgentPermission(
|
|
||||||
agentId: string,
|
|
||||||
companyId: string,
|
|
||||||
permissionKey: string,
|
|
||||||
enabled: boolean,
|
|
||||||
): void {
|
|
||||||
const row = getDb()
|
|
||||||
.prepare("SELECT permissions_json FROM agents WHERE id = ?")
|
|
||||||
.get(agentId) as { permissions_json: string } | undefined;
|
|
||||||
const current = parseJson<Record<string, boolean>>(row?.permissions_json, {});
|
|
||||||
if (enabled) {
|
|
||||||
current[permissionKey] = true;
|
|
||||||
} else {
|
|
||||||
delete current[permissionKey];
|
|
||||||
}
|
|
||||||
const ts = nowIso();
|
|
||||||
getDb()
|
|
||||||
.prepare(
|
|
||||||
"UPDATE agents SET permissions_json = ?, updated_at = ? WHERE id = ? AND company_id = ?",
|
|
||||||
)
|
|
||||||
.run(JSON.stringify(current), ts, agentId, companyId);
|
|
||||||
},
|
|
||||||
|
|
||||||
hasAgentPermission(agentId: string, permissionKey: string): boolean {
|
|
||||||
const row = getDb()
|
|
||||||
.prepare("SELECT permissions_json FROM agents WHERE id = ?")
|
|
||||||
.get(agentId) as { permissions_json: string } | undefined;
|
|
||||||
const perms = parseJson<Record<string, boolean>>(row?.permissions_json, {});
|
|
||||||
return perms[permissionKey] === true;
|
|
||||||
},
|
|
||||||
|
|
||||||
getRuntimeState(agentId: string): Record<string, unknown> {
|
|
||||||
const row = getDb()
|
|
||||||
.prepare("SELECT runtime_state_json FROM agents WHERE id = ?")
|
|
||||||
.get(agentId) as { runtime_state_json: string | null } | undefined;
|
|
||||||
return parseJson<Record<string, unknown>>(row?.runtime_state_json, {});
|
|
||||||
},
|
|
||||||
|
|
||||||
setRuntimeState(
|
|
||||||
agentId: string,
|
|
||||||
companyId: string,
|
|
||||||
state: Record<string, unknown>,
|
|
||||||
): void {
|
|
||||||
const ts = nowIso();
|
|
||||||
getDb()
|
|
||||||
.prepare(
|
|
||||||
"UPDATE agents SET runtime_state_json = ?, updated_at = ? WHERE id = ? AND company_id = ?",
|
|
||||||
)
|
|
||||||
.run(JSON.stringify(state), ts, agentId, companyId);
|
|
||||||
},
|
|
||||||
|
|
||||||
getHeartbeatPolicy(agentId: string): {
|
|
||||||
enabled: boolean;
|
|
||||||
intervalSec: number;
|
|
||||||
cooldownSec: number;
|
|
||||||
maxConcurrentRuns: number;
|
|
||||||
wakeOnDemand: boolean;
|
|
||||||
} {
|
|
||||||
const row = getDb()
|
|
||||||
.prepare(
|
|
||||||
"SELECT heartbeat_enabled, heartbeat_interval_sec, heartbeat_policy_json FROM agents WHERE id = ?",
|
|
||||||
)
|
|
||||||
.get(agentId) as
|
|
||||||
| {
|
|
||||||
heartbeat_enabled: number;
|
|
||||||
heartbeat_interval_sec: number;
|
|
||||||
heartbeat_policy_json: string | null;
|
|
||||||
}
|
|
||||||
| undefined;
|
|
||||||
if (!row) throw new Error(`Agent ${agentId} not found.`);
|
|
||||||
const policy = parseJson<Record<string, unknown>>(row.heartbeat_policy_json, {});
|
|
||||||
return {
|
|
||||||
enabled: toBoolean(row.heartbeat_enabled),
|
|
||||||
intervalSec: row.heartbeat_interval_sec ?? 120,
|
|
||||||
cooldownSec: typeof policy.cooldownSec === "number" ? policy.cooldownSec : 10,
|
|
||||||
maxConcurrentRuns:
|
|
||||||
typeof policy.maxConcurrentRuns === "number" ? policy.maxConcurrentRuns : 1,
|
|
||||||
wakeOnDemand: policy.wakeOnDemand !== false,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
setHeartbeatPolicy(
|
|
||||||
agentId: string,
|
|
||||||
companyId: string,
|
|
||||||
policy: {
|
|
||||||
enabled?: boolean;
|
|
||||||
intervalSec?: number;
|
|
||||||
cooldownSec?: number;
|
|
||||||
maxConcurrentRuns?: number;
|
|
||||||
wakeOnDemand?: boolean;
|
|
||||||
},
|
|
||||||
): void {
|
|
||||||
const row = getDb()
|
|
||||||
.prepare(
|
|
||||||
"SELECT heartbeat_enabled, heartbeat_interval_sec, heartbeat_policy_json FROM agents WHERE id = ?",
|
|
||||||
)
|
|
||||||
.get(agentId) as
|
|
||||||
| {
|
|
||||||
heartbeat_enabled: number;
|
|
||||||
heartbeat_interval_sec: number;
|
|
||||||
heartbeat_policy_json: string | null;
|
|
||||||
}
|
|
||||||
| undefined;
|
|
||||||
if (!row) throw new Error(`Agent ${agentId} not found.`);
|
|
||||||
const current = parseJson<Record<string, unknown>>(row.heartbeat_policy_json, {});
|
|
||||||
const merged = {
|
|
||||||
enabled: policy.enabled ?? toBoolean(row.heartbeat_enabled),
|
|
||||||
intervalSec: policy.intervalSec ?? row.heartbeat_interval_sec ?? 120,
|
|
||||||
cooldownSec:
|
|
||||||
policy.cooldownSec ??
|
|
||||||
(typeof current.cooldownSec === "number" ? current.cooldownSec : 10),
|
|
||||||
maxConcurrentRuns:
|
|
||||||
policy.maxConcurrentRuns ??
|
|
||||||
(typeof current.maxConcurrentRuns === "number" ? current.maxConcurrentRuns : 1),
|
|
||||||
wakeOnDemand: policy.wakeOnDemand ?? (current.wakeOnDemand !== false),
|
|
||||||
};
|
|
||||||
const policyJson = JSON.stringify({
|
|
||||||
cooldownSec: merged.cooldownSec,
|
|
||||||
maxConcurrentRuns: merged.maxConcurrentRuns,
|
|
||||||
wakeOnDemand: merged.wakeOnDemand,
|
|
||||||
});
|
|
||||||
const ts = nowIso();
|
|
||||||
getDb()
|
|
||||||
.prepare(
|
|
||||||
"UPDATE agents SET heartbeat_enabled = ?, heartbeat_interval_sec = ?, heartbeat_policy_json = ?, updated_at = ? WHERE id = ? AND company_id = ?",
|
|
||||||
)
|
|
||||||
.run(merged.enabled ? 1 : 0, merged.intervalSec, policyJson, ts, agentId, companyId);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns ids of non-terminated agents in a department. Used by
|
* Returns ids of non-terminated agents in a department. Used by
|
||||||
* `resolveMessageWakeTargets` to fan out "department" channel messages.
|
* `resolveMessageWakeTargets` to fan out "department" channel messages.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { AgentRecord } from "@shared/types";
|
|||||||
import type { ReadAll, Transaction } from "./helpers";
|
import type { ReadAll, Transaction } from "./helpers";
|
||||||
import { buildAgentCrudMethods } from "./agents-crud";
|
import { buildAgentCrudMethods } from "./agents-crud";
|
||||||
import { buildAgentHierarchyMethods } from "./agents-hierarchy";
|
import { buildAgentHierarchyMethods } from "./agents-hierarchy";
|
||||||
|
import { buildAgentPermissionMethods } from "./agent-permissions";
|
||||||
|
|
||||||
export function buildAgentMethods(
|
export function buildAgentMethods(
|
||||||
getDb: () => DatabaseSync,
|
getDb: () => DatabaseSync,
|
||||||
@@ -29,5 +30,6 @@ export function buildAgentMethods(
|
|||||||
return {
|
return {
|
||||||
...buildAgentCrudMethods(getDb, readAll, transaction, deps),
|
...buildAgentCrudMethods(getDb, readAll, transaction, deps),
|
||||||
...buildAgentHierarchyMethods(getDb, readAll, transaction, { getAgent: deps.getAgent }),
|
...buildAgentHierarchyMethods(getDb, readAll, transaction, { getAgent: deps.getAgent }),
|
||||||
|
...buildAgentPermissionMethods(getDb, readAll, transaction),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,370 @@
|
|||||||
|
import type { DatabaseSync } from "node:sqlite";
|
||||||
|
import type {
|
||||||
|
CompanyMetrics,
|
||||||
|
AgentMetrics,
|
||||||
|
RunStatus,
|
||||||
|
StandupReport,
|
||||||
|
} from "@shared/types";
|
||||||
|
import { type ReadAll, type Transaction } from "./helpers";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dashboard-aggregate read queries extracted from company-settings.ts.
|
||||||
|
* Covers getCompanyMetrics and getStandupReport. Every method is spread
|
||||||
|
* into the final `buildCompanyMethods` result so AppDatabase call-sites
|
||||||
|
* remain unchanged.
|
||||||
|
*/
|
||||||
|
export function buildCompanyMetricsMethods(
|
||||||
|
getDb: () => DatabaseSync,
|
||||||
|
readAll: ReadAll,
|
||||||
|
_transaction: Transaction,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
getCompanyMetrics(companyId: string, days: number = 30): CompanyMetrics {
|
||||||
|
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
const agentRows = readAll<{
|
||||||
|
id: string; name: string; status: string;
|
||||||
|
}>(
|
||||||
|
`select id, name, status from agents where company_id = ? order by created_at asc`,
|
||||||
|
companyId,
|
||||||
|
);
|
||||||
|
const activeAgentRows = agentRows.filter(
|
||||||
|
(a) => a.status !== "pending_approval" && a.status !== "terminated",
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalAgents = activeAgentRows.length;
|
||||||
|
const activeAgents = agentRows.filter(
|
||||||
|
(a) => a.status === "active" || a.status === "running",
|
||||||
|
).length;
|
||||||
|
|
||||||
|
const totalTasks = (db.prepare(
|
||||||
|
"select count(*) as c from tasks where company_id = ?",
|
||||||
|
).get(companyId) as { c: number }).c;
|
||||||
|
const completedTasks = (db.prepare(
|
||||||
|
"select count(*) as c from tasks where company_id = ? and status = 'done'",
|
||||||
|
).get(companyId) as { c: number }).c;
|
||||||
|
const blockedTasks = (db.prepare(
|
||||||
|
"select count(*) as c from tasks where company_id = ? and status = 'blocked'",
|
||||||
|
).get(companyId) as { c: number }).c;
|
||||||
|
|
||||||
|
const recentRunStats = db.prepare(
|
||||||
|
`select
|
||||||
|
count(*) as totalRuns,
|
||||||
|
sum(case when status = 'succeeded' then 1 else 0 end) as successful,
|
||||||
|
sum(case when status = 'failed' or status = 'timed_out' then 1 else 0 end) as failed
|
||||||
|
from runs where company_id = ? and created_at > ?`,
|
||||||
|
).get(companyId, cutoff) as { totalRuns: number; successful: number | null; failed: number | null };
|
||||||
|
const totalRuns = recentRunStats.totalRuns ?? 0;
|
||||||
|
const successfulRuns = recentRunStats.successful ?? 0;
|
||||||
|
const failedRuns = recentRunStats.failed ?? 0;
|
||||||
|
|
||||||
|
const totalCost = (db.prepare(
|
||||||
|
"select coalesce(sum(amount_usd), 0) as s from cost_entries where company_id = ?",
|
||||||
|
).get(companyId) as { s: number }).s;
|
||||||
|
|
||||||
|
const recentTasksDone = (db.prepare(
|
||||||
|
"select count(*) as c from tasks where company_id = ? and status = 'done' and updated_at > ?",
|
||||||
|
).get(companyId, cutoff) as { c: number }).c;
|
||||||
|
|
||||||
|
const taskAggRows = readAll<{
|
||||||
|
agentId: string;
|
||||||
|
done: number;
|
||||||
|
assigned: number;
|
||||||
|
cancelled: number;
|
||||||
|
}>(
|
||||||
|
`select assignee_agent_id as agentId,
|
||||||
|
sum(case when status = 'done' then 1 else 0 end) as done,
|
||||||
|
sum(case when status != 'done' and status != 'cancelled' then 1 else 0 end) as assigned,
|
||||||
|
sum(case when status = 'cancelled' then 1 else 0 end) as cancelled
|
||||||
|
from tasks
|
||||||
|
where company_id = ? and assignee_agent_id is not null
|
||||||
|
group by assignee_agent_id`,
|
||||||
|
companyId,
|
||||||
|
);
|
||||||
|
const taskAggMap = new Map(taskAggRows.map((row) => [row.agentId, row]));
|
||||||
|
|
||||||
|
const runRecentAggRows = readAll<{
|
||||||
|
agentId: string;
|
||||||
|
total: number;
|
||||||
|
succeeded: number;
|
||||||
|
failed: number;
|
||||||
|
}>(
|
||||||
|
`select agent_id as agentId,
|
||||||
|
count(*) as total,
|
||||||
|
sum(case when status = 'succeeded' then 1 else 0 end) as succeeded,
|
||||||
|
sum(case when status = 'failed' or status = 'timed_out' then 1 else 0 end) as failed
|
||||||
|
from runs
|
||||||
|
where company_id = ? and created_at > ?
|
||||||
|
group by agent_id`,
|
||||||
|
companyId,
|
||||||
|
cutoff,
|
||||||
|
);
|
||||||
|
const runRecentAggMap = new Map(runRecentAggRows.map((row) => [row.agentId, row]));
|
||||||
|
|
||||||
|
const runDurationRows = readAll<{
|
||||||
|
agentId: string;
|
||||||
|
avgDurationSec: number | null;
|
||||||
|
lastActiveAt: string | null;
|
||||||
|
}>(
|
||||||
|
`select agent_id as agentId,
|
||||||
|
avg((julianday(finished_at) - julianday(started_at)) * 86400.0) as avgDurationSec,
|
||||||
|
max(created_at) as lastActiveAt
|
||||||
|
from runs
|
||||||
|
where company_id = ? and started_at is not null and finished_at is not null
|
||||||
|
group by agent_id`,
|
||||||
|
companyId,
|
||||||
|
);
|
||||||
|
const runDurationMap = new Map(runDurationRows.map((row) => [row.agentId, row]));
|
||||||
|
|
||||||
|
const lastActiveRows = readAll<{ agentId: string; lastActiveAt: string }>(
|
||||||
|
`select agent_id as agentId, max(created_at) as lastActiveAt
|
||||||
|
from runs where company_id = ? group by agent_id`,
|
||||||
|
companyId,
|
||||||
|
);
|
||||||
|
const lastActiveMap = new Map(lastActiveRows.map((row) => [row.agentId, row.lastActiveAt]));
|
||||||
|
|
||||||
|
const costAggRows = readAll<{ agentId: string; totalCost: number }>(
|
||||||
|
`select agent_id as agentId, coalesce(sum(amount_usd), 0) as totalCost
|
||||||
|
from cost_entries where company_id = ? and agent_id is not null
|
||||||
|
group by agent_id`,
|
||||||
|
companyId,
|
||||||
|
);
|
||||||
|
const costAggMap = new Map(costAggRows.map((row) => [row.agentId, row.totalCost]));
|
||||||
|
|
||||||
|
const reviewCycleRows = readAll<{ actor: string; cycles: number }>(
|
||||||
|
`select actor, count(*) as cycles
|
||||||
|
from activity
|
||||||
|
where company_id = ?
|
||||||
|
and action in ('task.status_changed', 'task.updated', 'task.auto_assigned')
|
||||||
|
group by actor`,
|
||||||
|
companyId,
|
||||||
|
);
|
||||||
|
const reviewCycleMap = new Map(reviewCycleRows.map((row) => [row.actor, row.cycles]));
|
||||||
|
|
||||||
|
const agentMetrics: AgentMetrics[] = activeAgentRows.map((agent) => {
|
||||||
|
const taskAgg = taskAggMap.get(agent.id);
|
||||||
|
const runRecent = runRecentAggMap.get(agent.id);
|
||||||
|
const runDur = runDurationMap.get(agent.id);
|
||||||
|
const succeeded = runRecent?.succeeded ?? 0;
|
||||||
|
const total = runRecent?.total ?? 0;
|
||||||
|
return {
|
||||||
|
agentId: agent.id,
|
||||||
|
agentName: agent.name,
|
||||||
|
tasksCompleted: taskAgg?.done ?? 0,
|
||||||
|
tasksAssigned: taskAgg?.assigned ?? 0,
|
||||||
|
tasksFailed: taskAgg?.cancelled ?? 0,
|
||||||
|
successRate: total > 0 ? Math.round((succeeded / total) * 100) : 0,
|
||||||
|
avgRunDurationSec: Math.round(runDur?.avgDurationSec ?? 0),
|
||||||
|
totalCostUsd: costAggMap.get(agent.id) ?? 0,
|
||||||
|
runsTotal: total,
|
||||||
|
runsSucceeded: succeeded,
|
||||||
|
runsFailed: runRecent?.failed ?? 0,
|
||||||
|
reviewCycles: reviewCycleMap.get(agent.id) ?? 0,
|
||||||
|
lastActiveAt: lastActiveMap.get(agent.id) ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalAgents,
|
||||||
|
activeAgents,
|
||||||
|
totalTasks,
|
||||||
|
completedTasks,
|
||||||
|
blockedTasks,
|
||||||
|
totalRuns,
|
||||||
|
successfulRuns,
|
||||||
|
failedRuns,
|
||||||
|
overallSuccessRate: totalRuns > 0 ? Math.round((successfulRuns / totalRuns) * 100) : 0,
|
||||||
|
totalCostUsd: totalCost,
|
||||||
|
avgCostPerTask: completedTasks > 0 ? totalCost / completedTasks : 0,
|
||||||
|
throughputTasksPerDay: days > 0 ? recentTasksDone / days : 0,
|
||||||
|
agentMetrics,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
getStandupReport(companyId: string): StandupReport {
|
||||||
|
const db = getDb();
|
||||||
|
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
|
||||||
|
type AgentRow = {
|
||||||
|
id: string; name: string; role: string; status: StandupReport["agentReports"][number]["status"];
|
||||||
|
budgetMonthlyUsd: number; spentMonthlyUsd: number;
|
||||||
|
};
|
||||||
|
const agents = readAll<AgentRow>(
|
||||||
|
`select id, name, role, status,
|
||||||
|
budget_monthly_usd as budgetMonthlyUsd,
|
||||||
|
spent_monthly_usd as spentMonthlyUsd
|
||||||
|
from agents
|
||||||
|
where company_id = ? and status != 'terminated' and status != 'pending_approval'
|
||||||
|
order by created_at asc`,
|
||||||
|
companyId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const recentCompletedTasksCount = (db.prepare(
|
||||||
|
"select count(*) as c from tasks where company_id = ? and status = 'done' and updated_at > ?",
|
||||||
|
).get(companyId, oneDayAgo) as { c: number }).c;
|
||||||
|
|
||||||
|
const recentRunStats = db.prepare(
|
||||||
|
`select
|
||||||
|
count(*) as total,
|
||||||
|
sum(case when status = 'succeeded' then 1 else 0 end) as succeeded,
|
||||||
|
sum(case when status = 'failed' or status = 'timed_out' then 1 else 0 end) as failed
|
||||||
|
from runs where company_id = ? and created_at > ?`,
|
||||||
|
).get(companyId, oneDayAgo) as { total: number; succeeded: number | null; failed: number | null };
|
||||||
|
const recentRunsTotal = recentRunStats.total ?? 0;
|
||||||
|
const successfulRuns = recentRunStats.succeeded ?? 0;
|
||||||
|
const failedRuns = recentRunStats.failed ?? 0;
|
||||||
|
|
||||||
|
const blockedTaskRows = readAll<{ id: string; title: string; assigneeAgentId: string | null }>(
|
||||||
|
`select id, title, assignee_agent_id as assigneeAgentId
|
||||||
|
from tasks where company_id = ? and status = 'blocked'`,
|
||||||
|
companyId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const pendingApprovals = (db.prepare(
|
||||||
|
"select count(*) as c from approvals where company_id = ? and state = 'pending'",
|
||||||
|
).get(companyId) as { c: number }).c;
|
||||||
|
|
||||||
|
const activeSprint = db.prepare(
|
||||||
|
`select name, velocity_points as velocityPoints, completed_points as completedPoints
|
||||||
|
from sprints where company_id = ? and status = 'active' order by start_date desc limit 1`,
|
||||||
|
).get(companyId) as { name: string; velocityPoints: number; completedPoints: number } | undefined;
|
||||||
|
|
||||||
|
const urgentUnread = (db.prepare(
|
||||||
|
`select count(*) as c
|
||||||
|
from agent_messages messages
|
||||||
|
left join agent_message_reads reads
|
||||||
|
on reads.message_id = messages.id and reads.agent_id = '__board__'
|
||||||
|
where messages.company_id = ?
|
||||||
|
and reads.read_at is null
|
||||||
|
and (messages.priority = 'urgent' or messages.channel = 'incident')`,
|
||||||
|
).get(companyId) as { c: number }).c;
|
||||||
|
|
||||||
|
const tasksByAgentRows = readAll<{
|
||||||
|
agentId: string; inProgress: number; blocked: number; completed24h: number;
|
||||||
|
}>(
|
||||||
|
`select assignee_agent_id as agentId,
|
||||||
|
sum(case when status = 'in_progress' then 1 else 0 end) as inProgress,
|
||||||
|
sum(case when status = 'blocked' then 1 else 0 end) as blocked,
|
||||||
|
sum(case when status = 'done' and updated_at > ? then 1 else 0 end) as completed24h
|
||||||
|
from tasks
|
||||||
|
where company_id = ? and assignee_agent_id is not null
|
||||||
|
group by assignee_agent_id`,
|
||||||
|
oneDayAgo,
|
||||||
|
companyId,
|
||||||
|
);
|
||||||
|
const tasksByAgent = new Map(tasksByAgentRows.map((row) => [row.agentId, row]));
|
||||||
|
|
||||||
|
const recentRunsByAgent = readAll<{
|
||||||
|
agentId: string; runCount: number; lastStatus: string | null; lastAt: string | null;
|
||||||
|
}>(
|
||||||
|
`select agent_id as agentId,
|
||||||
|
count(*) as runCount,
|
||||||
|
(select status from runs r2 where r2.agent_id = runs.agent_id and r2.company_id = runs.company_id and r2.created_at > ? order by created_at desc limit 1) as lastStatus,
|
||||||
|
max(created_at) as lastAt
|
||||||
|
from runs
|
||||||
|
where company_id = ? and created_at > ?
|
||||||
|
group by agent_id`,
|
||||||
|
oneDayAgo,
|
||||||
|
companyId,
|
||||||
|
oneDayAgo,
|
||||||
|
);
|
||||||
|
const runsByAgent = new Map(recentRunsByAgent.map((row) => [row.agentId, row]));
|
||||||
|
|
||||||
|
const costByAgentRows = readAll<{ agentId: string; cost24h: number }>(
|
||||||
|
`select agent_id as agentId, coalesce(sum(amount_usd), 0) as cost24h
|
||||||
|
from cost_entries
|
||||||
|
where company_id = ? and agent_id is not null and created_at > ?
|
||||||
|
group by agent_id`,
|
||||||
|
companyId,
|
||||||
|
oneDayAgo,
|
||||||
|
);
|
||||||
|
const costByAgent = new Map(costByAgentRows.map((row) => [row.agentId, row.cost24h]));
|
||||||
|
|
||||||
|
const reviewByAgentRows = readAll<{ actor: string; cycles: number }>(
|
||||||
|
`select actor, count(*) as cycles
|
||||||
|
from activity
|
||||||
|
where company_id = ? and created_at > ?
|
||||||
|
and action in ('task.status_changed', 'task.updated', 'task.auto_assigned')
|
||||||
|
group by actor`,
|
||||||
|
companyId,
|
||||||
|
oneDayAgo,
|
||||||
|
);
|
||||||
|
const reviewByAgent = new Map(reviewByAgentRows.map((row) => [row.actor, row.cycles]));
|
||||||
|
|
||||||
|
const agentNameMap = new Map(agents.map((a) => [a.id, a.name]));
|
||||||
|
|
||||||
|
const companyHighlights: string[] = [];
|
||||||
|
const blockers: string[] = [];
|
||||||
|
if (recentCompletedTasksCount > 0) {
|
||||||
|
companyHighlights.push(`${recentCompletedTasksCount} task(s) completed in last 24h`);
|
||||||
|
}
|
||||||
|
if (recentRunsTotal > 0) {
|
||||||
|
companyHighlights.push(
|
||||||
|
`${recentRunsTotal} runs (${successfulRuns} succeeded, ${failedRuns} failed)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const task of blockedTaskRows) {
|
||||||
|
const assigneeName = task.assigneeAgentId ? agentNameMap.get(task.assigneeAgentId) ?? null : null;
|
||||||
|
blockers.push(`${task.title} — blocked (${assigneeName ?? "unassigned"})`);
|
||||||
|
}
|
||||||
|
if (activeSprint) {
|
||||||
|
const pct = activeSprint.velocityPoints > 0
|
||||||
|
? Math.round((activeSprint.completedPoints / activeSprint.velocityPoints) * 100)
|
||||||
|
: 0;
|
||||||
|
companyHighlights.push(
|
||||||
|
`Sprint "${activeSprint.name}": ${activeSprint.completedPoints}/${activeSprint.velocityPoints} pts (${pct}%)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (urgentUnread > 0) {
|
||||||
|
blockers.push(`${urgentUnread} urgent unread message(s) need attention`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalBudget = agents.reduce((sum, a) => sum + a.budgetMonthlyUsd, 0);
|
||||||
|
const totalSpent = agents.reduce((sum, a) => sum + a.spentMonthlyUsd, 0);
|
||||||
|
const budgetUtilization = totalBudget > 0 ? Math.round((totalSpent / totalBudget) * 100) : 0;
|
||||||
|
|
||||||
|
const agentReports = agents.map((agent) => {
|
||||||
|
const taskAgg = tasksByAgent.get(agent.id);
|
||||||
|
const runAgg = runsByAgent.get(agent.id);
|
||||||
|
const completed24h = taskAgg?.completed24h ?? 0;
|
||||||
|
const runs24h = runAgg?.runCount ?? 0;
|
||||||
|
const reviewedTasks = reviewByAgent.get(agent.id) ?? 0;
|
||||||
|
const highlights: string[] = [];
|
||||||
|
if (completed24h > 0) highlights.push(`Completed ${completed24h} task(s)`);
|
||||||
|
if (runs24h > 0) highlights.push(`${runs24h} run(s) in last 24h`);
|
||||||
|
if (reviewedTasks > 0) highlights.push(`${reviewedTasks} status update(s)`);
|
||||||
|
return {
|
||||||
|
agentId: agent.id,
|
||||||
|
agentName: agent.name,
|
||||||
|
role: agent.role,
|
||||||
|
status: agent.status,
|
||||||
|
tasksInProgress: taskAgg?.inProgress ?? 0,
|
||||||
|
tasksCompleted24h: completed24h,
|
||||||
|
tasksBlocked: taskAgg?.blocked ?? 0,
|
||||||
|
lastRunStatus: (runAgg?.lastStatus ?? null) as RunStatus | null,
|
||||||
|
lastRunAt: runAgg?.lastAt ?? null,
|
||||||
|
costLast24h: costByAgent.get(agent.id) ?? 0,
|
||||||
|
highlights,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const summaryParts: string[] = [];
|
||||||
|
if (agents.length > 0) summaryParts.push(`${agents.length} active agents`);
|
||||||
|
summaryParts.push(`${recentCompletedTasksCount} tasks done`);
|
||||||
|
if (blockedTaskRows.length > 0) summaryParts.push(`${blockedTaskRows.length} blocked`);
|
||||||
|
if (pendingApprovals > 0) summaryParts.push(`${pendingApprovals} pending approvals`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
companyId,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
summary: summaryParts.join(" · "),
|
||||||
|
agentReports,
|
||||||
|
companyHighlights,
|
||||||
|
blockers,
|
||||||
|
pendingApprovals,
|
||||||
|
budgetUtilization,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,11 +1,5 @@
|
|||||||
import type { DatabaseSync } from "node:sqlite";
|
import type { DatabaseSync } from "node:sqlite";
|
||||||
import type {
|
import type { ProfileSnapshot } from "@shared/types";
|
||||||
CompanyMetrics,
|
|
||||||
AgentMetrics,
|
|
||||||
ProfileSnapshot,
|
|
||||||
RunStatus,
|
|
||||||
StandupReport,
|
|
||||||
} from "@shared/types";
|
|
||||||
import {
|
import {
|
||||||
BOARD_READER_ID,
|
BOARD_READER_ID,
|
||||||
type ReadAll,
|
type ReadAll,
|
||||||
@@ -59,354 +53,6 @@ export function buildCompanySettingsMethods(
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
getCompanyMetrics(companyId: string, days: number = 30): CompanyMetrics {
|
|
||||||
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
const agentRows = readAll<{
|
|
||||||
id: string; name: string; status: string;
|
|
||||||
}>(
|
|
||||||
`select id, name, status from agents where company_id = ? order by created_at asc`,
|
|
||||||
companyId,
|
|
||||||
);
|
|
||||||
const activeAgentRows = agentRows.filter(
|
|
||||||
(a) => a.status !== "pending_approval" && a.status !== "terminated",
|
|
||||||
);
|
|
||||||
|
|
||||||
const totalAgents = activeAgentRows.length;
|
|
||||||
const activeAgents = agentRows.filter(
|
|
||||||
(a) => a.status === "active" || a.status === "running",
|
|
||||||
).length;
|
|
||||||
|
|
||||||
const totalTasks = (db.prepare(
|
|
||||||
"select count(*) as c from tasks where company_id = ?",
|
|
||||||
).get(companyId) as { c: number }).c;
|
|
||||||
const completedTasks = (db.prepare(
|
|
||||||
"select count(*) as c from tasks where company_id = ? and status = 'done'",
|
|
||||||
).get(companyId) as { c: number }).c;
|
|
||||||
const blockedTasks = (db.prepare(
|
|
||||||
"select count(*) as c from tasks where company_id = ? and status = 'blocked'",
|
|
||||||
).get(companyId) as { c: number }).c;
|
|
||||||
|
|
||||||
const recentRunStats = db.prepare(
|
|
||||||
`select
|
|
||||||
count(*) as totalRuns,
|
|
||||||
sum(case when status = 'succeeded' then 1 else 0 end) as successful,
|
|
||||||
sum(case when status = 'failed' or status = 'timed_out' then 1 else 0 end) as failed
|
|
||||||
from runs where company_id = ? and created_at > ?`,
|
|
||||||
).get(companyId, cutoff) as { totalRuns: number; successful: number | null; failed: number | null };
|
|
||||||
const totalRuns = recentRunStats.totalRuns ?? 0;
|
|
||||||
const successfulRuns = recentRunStats.successful ?? 0;
|
|
||||||
const failedRuns = recentRunStats.failed ?? 0;
|
|
||||||
|
|
||||||
const totalCost = (db.prepare(
|
|
||||||
"select coalesce(sum(amount_usd), 0) as s from cost_entries where company_id = ?",
|
|
||||||
).get(companyId) as { s: number }).s;
|
|
||||||
|
|
||||||
const recentTasksDone = (db.prepare(
|
|
||||||
"select count(*) as c from tasks where company_id = ? and status = 'done' and updated_at > ?",
|
|
||||||
).get(companyId, cutoff) as { c: number }).c;
|
|
||||||
|
|
||||||
const taskAggRows = readAll<{
|
|
||||||
agentId: string;
|
|
||||||
done: number;
|
|
||||||
assigned: number;
|
|
||||||
cancelled: number;
|
|
||||||
}>(
|
|
||||||
`select assignee_agent_id as agentId,
|
|
||||||
sum(case when status = 'done' then 1 else 0 end) as done,
|
|
||||||
sum(case when status != 'done' and status != 'cancelled' then 1 else 0 end) as assigned,
|
|
||||||
sum(case when status = 'cancelled' then 1 else 0 end) as cancelled
|
|
||||||
from tasks
|
|
||||||
where company_id = ? and assignee_agent_id is not null
|
|
||||||
group by assignee_agent_id`,
|
|
||||||
companyId,
|
|
||||||
);
|
|
||||||
const taskAggMap = new Map(taskAggRows.map((row) => [row.agentId, row]));
|
|
||||||
|
|
||||||
const runRecentAggRows = readAll<{
|
|
||||||
agentId: string;
|
|
||||||
total: number;
|
|
||||||
succeeded: number;
|
|
||||||
failed: number;
|
|
||||||
}>(
|
|
||||||
`select agent_id as agentId,
|
|
||||||
count(*) as total,
|
|
||||||
sum(case when status = 'succeeded' then 1 else 0 end) as succeeded,
|
|
||||||
sum(case when status = 'failed' or status = 'timed_out' then 1 else 0 end) as failed
|
|
||||||
from runs
|
|
||||||
where company_id = ? and created_at > ?
|
|
||||||
group by agent_id`,
|
|
||||||
companyId,
|
|
||||||
cutoff,
|
|
||||||
);
|
|
||||||
const runRecentAggMap = new Map(runRecentAggRows.map((row) => [row.agentId, row]));
|
|
||||||
|
|
||||||
const runDurationRows = readAll<{
|
|
||||||
agentId: string;
|
|
||||||
avgDurationSec: number | null;
|
|
||||||
lastActiveAt: string | null;
|
|
||||||
}>(
|
|
||||||
`select agent_id as agentId,
|
|
||||||
avg((julianday(finished_at) - julianday(started_at)) * 86400.0) as avgDurationSec,
|
|
||||||
max(created_at) as lastActiveAt
|
|
||||||
from runs
|
|
||||||
where company_id = ? and started_at is not null and finished_at is not null
|
|
||||||
group by agent_id`,
|
|
||||||
companyId,
|
|
||||||
);
|
|
||||||
const runDurationMap = new Map(runDurationRows.map((row) => [row.agentId, row]));
|
|
||||||
|
|
||||||
const lastActiveRows = readAll<{ agentId: string; lastActiveAt: string }>(
|
|
||||||
`select agent_id as agentId, max(created_at) as lastActiveAt
|
|
||||||
from runs where company_id = ? group by agent_id`,
|
|
||||||
companyId,
|
|
||||||
);
|
|
||||||
const lastActiveMap = new Map(lastActiveRows.map((row) => [row.agentId, row.lastActiveAt]));
|
|
||||||
|
|
||||||
const costAggRows = readAll<{ agentId: string; totalCost: number }>(
|
|
||||||
`select agent_id as agentId, coalesce(sum(amount_usd), 0) as totalCost
|
|
||||||
from cost_entries where company_id = ? and agent_id is not null
|
|
||||||
group by agent_id`,
|
|
||||||
companyId,
|
|
||||||
);
|
|
||||||
const costAggMap = new Map(costAggRows.map((row) => [row.agentId, row.totalCost]));
|
|
||||||
|
|
||||||
const reviewCycleRows = readAll<{ actor: string; cycles: number }>(
|
|
||||||
`select actor, count(*) as cycles
|
|
||||||
from activity
|
|
||||||
where company_id = ?
|
|
||||||
and action in ('task.status_changed', 'task.updated', 'task.auto_assigned')
|
|
||||||
group by actor`,
|
|
||||||
companyId,
|
|
||||||
);
|
|
||||||
const reviewCycleMap = new Map(reviewCycleRows.map((row) => [row.actor, row.cycles]));
|
|
||||||
|
|
||||||
const agentMetrics: AgentMetrics[] = activeAgentRows.map((agent) => {
|
|
||||||
const taskAgg = taskAggMap.get(agent.id);
|
|
||||||
const runRecent = runRecentAggMap.get(agent.id);
|
|
||||||
const runDur = runDurationMap.get(agent.id);
|
|
||||||
const succeeded = runRecent?.succeeded ?? 0;
|
|
||||||
const total = runRecent?.total ?? 0;
|
|
||||||
return {
|
|
||||||
agentId: agent.id,
|
|
||||||
agentName: agent.name,
|
|
||||||
tasksCompleted: taskAgg?.done ?? 0,
|
|
||||||
tasksAssigned: taskAgg?.assigned ?? 0,
|
|
||||||
tasksFailed: taskAgg?.cancelled ?? 0,
|
|
||||||
successRate: total > 0 ? Math.round((succeeded / total) * 100) : 0,
|
|
||||||
avgRunDurationSec: Math.round(runDur?.avgDurationSec ?? 0),
|
|
||||||
totalCostUsd: costAggMap.get(agent.id) ?? 0,
|
|
||||||
runsTotal: total,
|
|
||||||
runsSucceeded: succeeded,
|
|
||||||
runsFailed: runRecent?.failed ?? 0,
|
|
||||||
reviewCycles: reviewCycleMap.get(agent.id) ?? 0,
|
|
||||||
lastActiveAt: lastActiveMap.get(agent.id) ?? null,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
totalAgents,
|
|
||||||
activeAgents,
|
|
||||||
totalTasks,
|
|
||||||
completedTasks,
|
|
||||||
blockedTasks,
|
|
||||||
totalRuns,
|
|
||||||
successfulRuns,
|
|
||||||
failedRuns,
|
|
||||||
overallSuccessRate: totalRuns > 0 ? Math.round((successfulRuns / totalRuns) * 100) : 0,
|
|
||||||
totalCostUsd: totalCost,
|
|
||||||
avgCostPerTask: completedTasks > 0 ? totalCost / completedTasks : 0,
|
|
||||||
throughputTasksPerDay: days > 0 ? recentTasksDone / days : 0,
|
|
||||||
agentMetrics,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
getStandupReport(companyId: string): StandupReport {
|
|
||||||
const db = getDb();
|
|
||||||
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
|
||||||
|
|
||||||
type AgentRow = {
|
|
||||||
id: string; name: string; role: string; status: StandupReport["agentReports"][number]["status"];
|
|
||||||
budgetMonthlyUsd: number; spentMonthlyUsd: number;
|
|
||||||
};
|
|
||||||
const agents = readAll<AgentRow>(
|
|
||||||
`select id, name, role, status,
|
|
||||||
budget_monthly_usd as budgetMonthlyUsd,
|
|
||||||
spent_monthly_usd as spentMonthlyUsd
|
|
||||||
from agents
|
|
||||||
where company_id = ? and status != 'terminated' and status != 'pending_approval'
|
|
||||||
order by created_at asc`,
|
|
||||||
companyId,
|
|
||||||
);
|
|
||||||
|
|
||||||
const recentCompletedTasksCount = (db.prepare(
|
|
||||||
"select count(*) as c from tasks where company_id = ? and status = 'done' and updated_at > ?",
|
|
||||||
).get(companyId, oneDayAgo) as { c: number }).c;
|
|
||||||
|
|
||||||
const recentRunStats = db.prepare(
|
|
||||||
`select
|
|
||||||
count(*) as total,
|
|
||||||
sum(case when status = 'succeeded' then 1 else 0 end) as succeeded,
|
|
||||||
sum(case when status = 'failed' or status = 'timed_out' then 1 else 0 end) as failed
|
|
||||||
from runs where company_id = ? and created_at > ?`,
|
|
||||||
).get(companyId, oneDayAgo) as { total: number; succeeded: number | null; failed: number | null };
|
|
||||||
const recentRunsTotal = recentRunStats.total ?? 0;
|
|
||||||
const successfulRuns = recentRunStats.succeeded ?? 0;
|
|
||||||
const failedRuns = recentRunStats.failed ?? 0;
|
|
||||||
|
|
||||||
const blockedTaskRows = readAll<{ id: string; title: string; assigneeAgentId: string | null }>(
|
|
||||||
`select id, title, assignee_agent_id as assigneeAgentId
|
|
||||||
from tasks where company_id = ? and status = 'blocked'`,
|
|
||||||
companyId,
|
|
||||||
);
|
|
||||||
|
|
||||||
const pendingApprovals = (db.prepare(
|
|
||||||
"select count(*) as c from approvals where company_id = ? and state = 'pending'",
|
|
||||||
).get(companyId) as { c: number }).c;
|
|
||||||
|
|
||||||
const activeSprint = db.prepare(
|
|
||||||
`select name, velocity_points as velocityPoints, completed_points as completedPoints
|
|
||||||
from sprints where company_id = ? and status = 'active' order by start_date desc limit 1`,
|
|
||||||
).get(companyId) as { name: string; velocityPoints: number; completedPoints: number } | undefined;
|
|
||||||
|
|
||||||
const urgentUnread = (db.prepare(
|
|
||||||
`select count(*) as c
|
|
||||||
from agent_messages messages
|
|
||||||
left join agent_message_reads reads
|
|
||||||
on reads.message_id = messages.id and reads.agent_id = '__board__'
|
|
||||||
where messages.company_id = ?
|
|
||||||
and reads.read_at is null
|
|
||||||
and (messages.priority = 'urgent' or messages.channel = 'incident')`,
|
|
||||||
).get(companyId) as { c: number }).c;
|
|
||||||
|
|
||||||
const tasksByAgentRows = readAll<{
|
|
||||||
agentId: string; inProgress: number; blocked: number; completed24h: number;
|
|
||||||
}>(
|
|
||||||
`select assignee_agent_id as agentId,
|
|
||||||
sum(case when status = 'in_progress' then 1 else 0 end) as inProgress,
|
|
||||||
sum(case when status = 'blocked' then 1 else 0 end) as blocked,
|
|
||||||
sum(case when status = 'done' and updated_at > ? then 1 else 0 end) as completed24h
|
|
||||||
from tasks
|
|
||||||
where company_id = ? and assignee_agent_id is not null
|
|
||||||
group by assignee_agent_id`,
|
|
||||||
oneDayAgo,
|
|
||||||
companyId,
|
|
||||||
);
|
|
||||||
const tasksByAgent = new Map(tasksByAgentRows.map((row) => [row.agentId, row]));
|
|
||||||
|
|
||||||
const recentRunsByAgent = readAll<{
|
|
||||||
agentId: string; runCount: number; lastStatus: string | null; lastAt: string | null;
|
|
||||||
}>(
|
|
||||||
`select agent_id as agentId,
|
|
||||||
count(*) as runCount,
|
|
||||||
(select status from runs r2 where r2.agent_id = runs.agent_id and r2.company_id = runs.company_id and r2.created_at > ? order by created_at desc limit 1) as lastStatus,
|
|
||||||
max(created_at) as lastAt
|
|
||||||
from runs
|
|
||||||
where company_id = ? and created_at > ?
|
|
||||||
group by agent_id`,
|
|
||||||
oneDayAgo,
|
|
||||||
companyId,
|
|
||||||
oneDayAgo,
|
|
||||||
);
|
|
||||||
const runsByAgent = new Map(recentRunsByAgent.map((row) => [row.agentId, row]));
|
|
||||||
|
|
||||||
const costByAgentRows = readAll<{ agentId: string; cost24h: number }>(
|
|
||||||
`select agent_id as agentId, coalesce(sum(amount_usd), 0) as cost24h
|
|
||||||
from cost_entries
|
|
||||||
where company_id = ? and agent_id is not null and created_at > ?
|
|
||||||
group by agent_id`,
|
|
||||||
companyId,
|
|
||||||
oneDayAgo,
|
|
||||||
);
|
|
||||||
const costByAgent = new Map(costByAgentRows.map((row) => [row.agentId, row.cost24h]));
|
|
||||||
|
|
||||||
const reviewByAgentRows = readAll<{ actor: string; cycles: number }>(
|
|
||||||
`select actor, count(*) as cycles
|
|
||||||
from activity
|
|
||||||
where company_id = ? and created_at > ?
|
|
||||||
and action in ('task.status_changed', 'task.updated', 'task.auto_assigned')
|
|
||||||
group by actor`,
|
|
||||||
companyId,
|
|
||||||
oneDayAgo,
|
|
||||||
);
|
|
||||||
const reviewByAgent = new Map(reviewByAgentRows.map((row) => [row.actor, row.cycles]));
|
|
||||||
|
|
||||||
const agentNameMap = new Map(agents.map((a) => [a.id, a.name]));
|
|
||||||
|
|
||||||
const companyHighlights: string[] = [];
|
|
||||||
const blockers: string[] = [];
|
|
||||||
if (recentCompletedTasksCount > 0) {
|
|
||||||
companyHighlights.push(`${recentCompletedTasksCount} task(s) completed in last 24h`);
|
|
||||||
}
|
|
||||||
if (recentRunsTotal > 0) {
|
|
||||||
companyHighlights.push(
|
|
||||||
`${recentRunsTotal} runs (${successfulRuns} succeeded, ${failedRuns} failed)`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for (const task of blockedTaskRows) {
|
|
||||||
const assigneeName = task.assigneeAgentId ? agentNameMap.get(task.assigneeAgentId) ?? null : null;
|
|
||||||
blockers.push(`${task.title} — blocked (${assigneeName ?? "unassigned"})`);
|
|
||||||
}
|
|
||||||
if (activeSprint) {
|
|
||||||
const pct = activeSprint.velocityPoints > 0
|
|
||||||
? Math.round((activeSprint.completedPoints / activeSprint.velocityPoints) * 100)
|
|
||||||
: 0;
|
|
||||||
companyHighlights.push(
|
|
||||||
`Sprint "${activeSprint.name}": ${activeSprint.completedPoints}/${activeSprint.velocityPoints} pts (${pct}%)`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (urgentUnread > 0) {
|
|
||||||
blockers.push(`${urgentUnread} urgent unread message(s) need attention`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalBudget = agents.reduce((sum, a) => sum + a.budgetMonthlyUsd, 0);
|
|
||||||
const totalSpent = agents.reduce((sum, a) => sum + a.spentMonthlyUsd, 0);
|
|
||||||
const budgetUtilization = totalBudget > 0 ? Math.round((totalSpent / totalBudget) * 100) : 0;
|
|
||||||
|
|
||||||
const agentReports = agents.map((agent) => {
|
|
||||||
const taskAgg = tasksByAgent.get(agent.id);
|
|
||||||
const runAgg = runsByAgent.get(agent.id);
|
|
||||||
const completed24h = taskAgg?.completed24h ?? 0;
|
|
||||||
const runs24h = runAgg?.runCount ?? 0;
|
|
||||||
const reviewedTasks = reviewByAgent.get(agent.id) ?? 0;
|
|
||||||
const highlights: string[] = [];
|
|
||||||
if (completed24h > 0) highlights.push(`Completed ${completed24h} task(s)`);
|
|
||||||
if (runs24h > 0) highlights.push(`${runs24h} run(s) in last 24h`);
|
|
||||||
if (reviewedTasks > 0) highlights.push(`${reviewedTasks} status update(s)`);
|
|
||||||
return {
|
|
||||||
agentId: agent.id,
|
|
||||||
agentName: agent.name,
|
|
||||||
role: agent.role,
|
|
||||||
status: agent.status,
|
|
||||||
tasksInProgress: taskAgg?.inProgress ?? 0,
|
|
||||||
tasksCompleted24h: completed24h,
|
|
||||||
tasksBlocked: taskAgg?.blocked ?? 0,
|
|
||||||
lastRunStatus: (runAgg?.lastStatus ?? null) as RunStatus | null,
|
|
||||||
lastRunAt: runAgg?.lastAt ?? null,
|
|
||||||
costLast24h: costByAgent.get(agent.id) ?? 0,
|
|
||||||
highlights,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const summaryParts: string[] = [];
|
|
||||||
if (agents.length > 0) summaryParts.push(`${agents.length} active agents`);
|
|
||||||
summaryParts.push(`${recentCompletedTasksCount} tasks done`);
|
|
||||||
if (blockedTaskRows.length > 0) summaryParts.push(`${blockedTaskRows.length} blocked`);
|
|
||||||
if (pendingApprovals > 0) summaryParts.push(`${pendingApprovals} pending approvals`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
companyId,
|
|
||||||
generatedAt: new Date().toISOString(),
|
|
||||||
summary: summaryParts.join(" · "),
|
|
||||||
agentReports,
|
|
||||||
companyHighlights,
|
|
||||||
blockers,
|
|
||||||
pendingApprovals,
|
|
||||||
budgetUtilization,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
getSidebarBadges(companyId: string): {
|
getSidebarBadges(companyId: string): {
|
||||||
pendingApprovals: number;
|
pendingApprovals: number;
|
||||||
activeRuns: number;
|
activeRuns: number;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { DatabaseSync } from "node:sqlite";
|
|||||||
import type { ProfileSnapshot } from "@shared/types";
|
import type { ProfileSnapshot } from "@shared/types";
|
||||||
import type { ReadAll, Transaction } from "./helpers";
|
import type { ReadAll, Transaction } from "./helpers";
|
||||||
import { buildCompanyCrudMethods } from "./company-crud";
|
import { buildCompanyCrudMethods } from "./company-crud";
|
||||||
|
import { buildCompanyMetricsMethods } from "./company-metrics";
|
||||||
import { buildCompanySettingsMethods } from "./company-settings";
|
import { buildCompanySettingsMethods } from "./company-settings";
|
||||||
|
|
||||||
export function buildCompanyMethods(
|
export function buildCompanyMethods(
|
||||||
@@ -18,5 +19,6 @@ export function buildCompanyMethods(
|
|||||||
return {
|
return {
|
||||||
...buildCompanyCrudMethods(getDb, readAll, transaction, deps),
|
...buildCompanyCrudMethods(getDb, readAll, transaction, deps),
|
||||||
...buildCompanySettingsMethods(getDb, readAll, transaction, { listSnapshot: deps.listSnapshot }),
|
...buildCompanySettingsMethods(getDb, readAll, transaction, { listSnapshot: deps.listSnapshot }),
|
||||||
|
...buildCompanyMetricsMethods(getDb, readAll, transaction),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import type { DatabaseSync } from "node:sqlite";
|
||||||
|
import type { AgentRecord } from "@shared/types";
|
||||||
|
import type { ReadAll, SqliteRow, Transaction } from "./helpers";
|
||||||
|
import { nowIso, parseJson } from "./helpers";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent config-revision helpers extracted from documents.ts. Spread into
|
||||||
|
* the final `buildDocumentMethods` result so AppDatabase call-sites remain
|
||||||
|
* unchanged.
|
||||||
|
*/
|
||||||
|
export function buildConfigRevisionMethods(
|
||||||
|
getDb: () => DatabaseSync,
|
||||||
|
readAll: ReadAll,
|
||||||
|
transaction: Transaction,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
recordConfigRevision(input: {
|
||||||
|
companyId: string; agentId: string; changedKeys: string[];
|
||||||
|
configBefore: Record<string, unknown>; configAfter: Record<string, unknown>;
|
||||||
|
source?: string; rolledBackFromRevisionId?: string | null;
|
||||||
|
createdByAgentId?: string | null; createdByUserId?: string | null;
|
||||||
|
}): string {
|
||||||
|
const id = randomUUID();
|
||||||
|
const ts = nowIso();
|
||||||
|
const lastRev = getDb().prepare(
|
||||||
|
"SELECT MAX(revision_number) as maxRev FROM agent_config_revisions WHERE agent_id = ?"
|
||||||
|
).get(input.agentId) as { maxRev: number | null } | undefined;
|
||||||
|
const revisionNumber = (lastRev?.maxRev ?? 0) + 1;
|
||||||
|
getDb().prepare(
|
||||||
|
"INSERT INTO agent_config_revisions (id, company_id, agent_id, revision_number, changed_keys, config_before, config_after, source, rolled_back_from_revision_id, created_by_agent_id, created_by_user_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||||
|
).run(
|
||||||
|
id, input.companyId, input.agentId, revisionNumber,
|
||||||
|
JSON.stringify(input.changedKeys), JSON.stringify(input.configBefore),
|
||||||
|
JSON.stringify(input.configAfter), input.source ?? "patch",
|
||||||
|
input.rolledBackFromRevisionId ?? null, input.createdByAgentId ?? null,
|
||||||
|
input.createdByUserId ?? null, ts
|
||||||
|
);
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
|
||||||
|
listConfigRevisions(agentId: string, companyId: string): Array<{
|
||||||
|
id: string; agentId: string; revisionNumber: number; changedKeys: string[];
|
||||||
|
source: string; rolledBackFromRevisionId: string | null;
|
||||||
|
createdByAgentId: string | null; createdByUserId: string | null; createdAt: string;
|
||||||
|
}> {
|
||||||
|
return readAll<SqliteRow>(
|
||||||
|
"SELECT id, agent_id, revision_number, changed_keys, source, rolled_back_from_revision_id, created_by_agent_id, created_by_user_id, created_at FROM agent_config_revisions WHERE agent_id = ? AND company_id = ? ORDER BY revision_number DESC",
|
||||||
|
agentId, companyId
|
||||||
|
).map((row) => ({
|
||||||
|
id: row.id, agentId: row.agent_id, revisionNumber: row.revision_number,
|
||||||
|
changedKeys: parseJson<string[]>(row.changed_keys, []),
|
||||||
|
source: row.source, rolledBackFromRevisionId: row.rolled_back_from_revision_id ?? null,
|
||||||
|
createdByAgentId: row.created_by_agent_id ?? null,
|
||||||
|
createdByUserId: row.created_by_user_id ?? null, createdAt: row.created_at,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
getConfigRevision(revisionId: string, companyId: string): {
|
||||||
|
id: string; agentId: string; revisionNumber: number; changedKeys: string[];
|
||||||
|
configBefore: Record<string, unknown>; configAfter: Record<string, unknown>;
|
||||||
|
source: string; rolledBackFromRevisionId: string | null;
|
||||||
|
createdByAgentId: string | null; createdByUserId: string | null; createdAt: string;
|
||||||
|
} {
|
||||||
|
const row = getDb().prepare(
|
||||||
|
"SELECT * FROM agent_config_revisions WHERE id = ? AND company_id = ?"
|
||||||
|
).get(revisionId, companyId) as {
|
||||||
|
id: string; agent_id: string; revision_number: number; changed_keys: string;
|
||||||
|
config_before: string; config_after: string; source: string;
|
||||||
|
rolled_back_from_revision_id: string | null;
|
||||||
|
created_by_agent_id: string | null; created_by_user_id: string | null; created_at: string;
|
||||||
|
} | undefined;
|
||||||
|
if (!row) throw new Error(`Config revision ${revisionId} not found.`);
|
||||||
|
return {
|
||||||
|
id: row.id, agentId: row.agent_id, revisionNumber: row.revision_number,
|
||||||
|
changedKeys: parseJson<string[]>(row.changed_keys, []),
|
||||||
|
configBefore: parseJson<Record<string, unknown>>(row.config_before, {}),
|
||||||
|
configAfter: parseJson<Record<string, unknown>>(row.config_after, {}),
|
||||||
|
source: row.source, rolledBackFromRevisionId: row.rolled_back_from_revision_id ?? null,
|
||||||
|
createdByAgentId: row.created_by_agent_id ?? null,
|
||||||
|
createdByUserId: row.created_by_user_id ?? null, createdAt: row.created_at,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
rollbackConfigRevision(agentId: string, revisionId: string, companyId: string, selfDeps: {
|
||||||
|
getConfigRevision: (revisionId: string, companyId: string) => {
|
||||||
|
id: string; agentId: string; revisionNumber: number; changedKeys: string[];
|
||||||
|
configBefore: Record<string, unknown>; configAfter: Record<string, unknown>;
|
||||||
|
source: string; rolledBackFromRevisionId: string | null;
|
||||||
|
createdByAgentId: string | null; createdByUserId: string | null; createdAt: string;
|
||||||
|
};
|
||||||
|
getAgent: (id: string) => AgentRecord;
|
||||||
|
recordConfigRevision: (input: {
|
||||||
|
companyId: string; agentId: string; changedKeys: string[];
|
||||||
|
configBefore: Record<string, unknown>; configAfter: Record<string, unknown>;
|
||||||
|
source?: string; rolledBackFromRevisionId?: string | null;
|
||||||
|
createdByAgentId?: string | null; createdByUserId?: string | null;
|
||||||
|
}) => string;
|
||||||
|
}): string {
|
||||||
|
return transaction(() => {
|
||||||
|
const revision = selfDeps.getConfigRevision(revisionId, companyId);
|
||||||
|
if (revision.agentId !== agentId) throw new Error("Revision does not belong to this agent.");
|
||||||
|
const before = revision.configBefore as Record<string, string | number | null>;
|
||||||
|
const ts = nowIso();
|
||||||
|
getDb().prepare(
|
||||||
|
"UPDATE agents SET name = ?, role = ?, title = ?, department = ?, capabilities = ?, connector_id = ?, model = ?, budget_monthly_usd = ?, metadata_json = ?, updated_at = ? WHERE id = ? AND company_id = ?"
|
||||||
|
).run(
|
||||||
|
before.name ?? "", before.role ?? "", before.title ?? "", before.department ?? null,
|
||||||
|
before.capabilities ?? "", before.connectorId ?? before.connector_id ?? "",
|
||||||
|
before.model ?? null, before.budgetMonthlyUsd ?? before.budget_monthly_usd ?? 0,
|
||||||
|
before.metadataJson ?? before.metadata_json ?? "{}", ts, agentId, companyId
|
||||||
|
);
|
||||||
|
const agent = selfDeps.getAgent(agentId);
|
||||||
|
const currentConfig = { name: agent.name, role: agent.role, title: agent.title, department: agent.department, capabilities: agent.capabilities, connectorId: agent.connectorId, model: agent.model, budgetMonthlyUsd: agent.budgetMonthlyUsd, metadataJson: agent.metadataJson };
|
||||||
|
return selfDeps.recordConfigRevision({
|
||||||
|
companyId, agentId,
|
||||||
|
changedKeys: revision.changedKeys,
|
||||||
|
configBefore: currentConfig,
|
||||||
|
configAfter: before,
|
||||||
|
source: "rollback",
|
||||||
|
rolledBackFromRevisionId: revisionId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import type { DatabaseSync } from "node:sqlite";
|
||||||
|
import { type ReadAll, type Transaction, nowIso } from "./helpers";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* cost_entries writer extracted from runs.ts. Spread into the final
|
||||||
|
* `buildRunMethods` result so AppDatabase call-sites remain unchanged.
|
||||||
|
*
|
||||||
|
* Note: finishRun still reads SUM(cost_entries.amount_usd) inline. The
|
||||||
|
* SUM query stays in runs.ts to keep the run-aggregate read path local to
|
||||||
|
* the rest of finishRun.
|
||||||
|
*/
|
||||||
|
export function buildCostEntryMethods(
|
||||||
|
getDb: () => DatabaseSync,
|
||||||
|
_readAll: ReadAll,
|
||||||
|
_transaction: Transaction,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Single writer for `cost_entries`. CostWriter calls this per
|
||||||
|
* `message_end`; tests also call it to simulate per-turn cost.
|
||||||
|
* finishRun reads SUM(cost_entries) for the run-level aggregate,
|
||||||
|
* so this is the authoritative path for cost attribution.
|
||||||
|
*/
|
||||||
|
recordCostEntry(input: {
|
||||||
|
runId: string;
|
||||||
|
companyId: string;
|
||||||
|
agentId: string;
|
||||||
|
taskId: string | null;
|
||||||
|
connectorId: string;
|
||||||
|
amountUsd: number | null;
|
||||||
|
unattributed: boolean;
|
||||||
|
}): string {
|
||||||
|
const id = randomUUID();
|
||||||
|
const ts = nowIso();
|
||||||
|
const projectRow = input.taskId
|
||||||
|
? getDb().prepare("SELECT project_id FROM tasks WHERE id = ?").get(input.taskId) as { project_id: string | null } | undefined
|
||||||
|
: undefined;
|
||||||
|
const projectId = projectRow?.project_id ?? null;
|
||||||
|
getDb()
|
||||||
|
.prepare(
|
||||||
|
"insert into cost_entries (id, company_id, run_id, task_id, agent_id, connector_id, amount_usd, unattributed, project_id, created_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
id, input.companyId, input.runId, input.taskId, input.agentId,
|
||||||
|
input.connectorId, input.amountUsd,
|
||||||
|
input.unattributed ? 1 : 0, projectId, ts,
|
||||||
|
);
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import type { DatabaseSync, SQLInputValue } from "node:sqlite";
|
import type { DatabaseSync, SQLInputValue } from "node:sqlite";
|
||||||
import type { AgentRecord } from "@shared/types";
|
import type { ReadAll, Transaction } from "./helpers";
|
||||||
import type { ReadAll, SqliteRow, Transaction } from "./helpers";
|
import { ensureId, nowIso } from "./helpers";
|
||||||
import { ensureId, nowIso, parseJson } from "./helpers";
|
import { buildConfigRevisionMethods } from "./config-revisions";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Document row shape returned by listDocuments — used by
|
* Document row shape returned by listDocuments — used by
|
||||||
@@ -53,6 +53,7 @@ export function buildDocumentMethods(
|
|||||||
transaction: Transaction,
|
transaction: Transaction,
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
|
...buildConfigRevisionMethods(getDb, readAll, transaction),
|
||||||
saveDocument(input: {
|
saveDocument(input: {
|
||||||
id?: string | null; companyId: string; type: string; title: string; content?: string;
|
id?: string | null; companyId: string; type: string; title: string; content?: string;
|
||||||
authorAgentId?: string | null; reviewerAgentId?: string | null; projectId?: string | null;
|
authorAgentId?: string | null; reviewerAgentId?: string | null; projectId?: string | null;
|
||||||
@@ -313,114 +314,5 @@ export function buildDocumentMethods(
|
|||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Config Revisions ──
|
|
||||||
|
|
||||||
recordConfigRevision(input: {
|
|
||||||
companyId: string; agentId: string; changedKeys: string[];
|
|
||||||
configBefore: Record<string, unknown>; configAfter: Record<string, unknown>;
|
|
||||||
source?: string; rolledBackFromRevisionId?: string | null;
|
|
||||||
createdByAgentId?: string | null; createdByUserId?: string | null;
|
|
||||||
}): string {
|
|
||||||
const id = randomUUID();
|
|
||||||
const ts = nowIso();
|
|
||||||
const lastRev = getDb().prepare(
|
|
||||||
"SELECT MAX(revision_number) as maxRev FROM agent_config_revisions WHERE agent_id = ?"
|
|
||||||
).get(input.agentId) as { maxRev: number | null } | undefined;
|
|
||||||
const revisionNumber = (lastRev?.maxRev ?? 0) + 1;
|
|
||||||
getDb().prepare(
|
|
||||||
"INSERT INTO agent_config_revisions (id, company_id, agent_id, revision_number, changed_keys, config_before, config_after, source, rolled_back_from_revision_id, created_by_agent_id, created_by_user_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
|
||||||
).run(
|
|
||||||
id, input.companyId, input.agentId, revisionNumber,
|
|
||||||
JSON.stringify(input.changedKeys), JSON.stringify(input.configBefore),
|
|
||||||
JSON.stringify(input.configAfter), input.source ?? "patch",
|
|
||||||
input.rolledBackFromRevisionId ?? null, input.createdByAgentId ?? null,
|
|
||||||
input.createdByUserId ?? null, ts
|
|
||||||
);
|
|
||||||
return id;
|
|
||||||
},
|
|
||||||
|
|
||||||
listConfigRevisions(agentId: string, companyId: string): Array<{
|
|
||||||
id: string; agentId: string; revisionNumber: number; changedKeys: string[];
|
|
||||||
source: string; rolledBackFromRevisionId: string | null;
|
|
||||||
createdByAgentId: string | null; createdByUserId: string | null; createdAt: string;
|
|
||||||
}> {
|
|
||||||
return readAll<SqliteRow>(
|
|
||||||
"SELECT id, agent_id, revision_number, changed_keys, source, rolled_back_from_revision_id, created_by_agent_id, created_by_user_id, created_at FROM agent_config_revisions WHERE agent_id = ? AND company_id = ? ORDER BY revision_number DESC",
|
|
||||||
agentId, companyId
|
|
||||||
).map((row) => ({
|
|
||||||
id: row.id, agentId: row.agent_id, revisionNumber: row.revision_number,
|
|
||||||
changedKeys: parseJson<string[]>(row.changed_keys, []),
|
|
||||||
source: row.source, rolledBackFromRevisionId: row.rolled_back_from_revision_id ?? null,
|
|
||||||
createdByAgentId: row.created_by_agent_id ?? null,
|
|
||||||
createdByUserId: row.created_by_user_id ?? null, createdAt: row.created_at,
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
|
|
||||||
getConfigRevision(revisionId: string, companyId: string): {
|
|
||||||
id: string; agentId: string; revisionNumber: number; changedKeys: string[];
|
|
||||||
configBefore: Record<string, unknown>; configAfter: Record<string, unknown>;
|
|
||||||
source: string; rolledBackFromRevisionId: string | null;
|
|
||||||
createdByAgentId: string | null; createdByUserId: string | null; createdAt: string;
|
|
||||||
} {
|
|
||||||
const row = getDb().prepare(
|
|
||||||
"SELECT * FROM agent_config_revisions WHERE id = ? AND company_id = ?"
|
|
||||||
).get(revisionId, companyId) as {
|
|
||||||
id: string; agent_id: string; revision_number: number; changed_keys: string;
|
|
||||||
config_before: string; config_after: string; source: string;
|
|
||||||
rolled_back_from_revision_id: string | null;
|
|
||||||
created_by_agent_id: string | null; created_by_user_id: string | null; created_at: string;
|
|
||||||
} | undefined;
|
|
||||||
if (!row) throw new Error(`Config revision ${revisionId} not found.`);
|
|
||||||
return {
|
|
||||||
id: row.id, agentId: row.agent_id, revisionNumber: row.revision_number,
|
|
||||||
changedKeys: parseJson<string[]>(row.changed_keys, []),
|
|
||||||
configBefore: parseJson<Record<string, unknown>>(row.config_before, {}),
|
|
||||||
configAfter: parseJson<Record<string, unknown>>(row.config_after, {}),
|
|
||||||
source: row.source, rolledBackFromRevisionId: row.rolled_back_from_revision_id ?? null,
|
|
||||||
createdByAgentId: row.created_by_agent_id ?? null,
|
|
||||||
createdByUserId: row.created_by_user_id ?? null, createdAt: row.created_at,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
rollbackConfigRevision(agentId: string, revisionId: string, companyId: string, selfDeps: {
|
|
||||||
getConfigRevision: (revisionId: string, companyId: string) => {
|
|
||||||
id: string; agentId: string; revisionNumber: number; changedKeys: string[];
|
|
||||||
configBefore: Record<string, unknown>; configAfter: Record<string, unknown>;
|
|
||||||
source: string; rolledBackFromRevisionId: string | null;
|
|
||||||
createdByAgentId: string | null; createdByUserId: string | null; createdAt: string;
|
|
||||||
};
|
|
||||||
getAgent: (id: string) => AgentRecord;
|
|
||||||
recordConfigRevision: (input: {
|
|
||||||
companyId: string; agentId: string; changedKeys: string[];
|
|
||||||
configBefore: Record<string, unknown>; configAfter: Record<string, unknown>;
|
|
||||||
source?: string; rolledBackFromRevisionId?: string | null;
|
|
||||||
createdByAgentId?: string | null; createdByUserId?: string | null;
|
|
||||||
}) => string;
|
|
||||||
}): string {
|
|
||||||
return transaction(() => {
|
|
||||||
const revision = selfDeps.getConfigRevision(revisionId, companyId);
|
|
||||||
if (revision.agentId !== agentId) throw new Error("Revision does not belong to this agent.");
|
|
||||||
const before = revision.configBefore as Record<string, string | number | null>;
|
|
||||||
const ts = nowIso();
|
|
||||||
getDb().prepare(
|
|
||||||
"UPDATE agents SET name = ?, role = ?, title = ?, department = ?, capabilities = ?, connector_id = ?, model = ?, budget_monthly_usd = ?, metadata_json = ?, updated_at = ? WHERE id = ? AND company_id = ?"
|
|
||||||
).run(
|
|
||||||
before.name ?? "", before.role ?? "", before.title ?? "", before.department ?? null,
|
|
||||||
before.capabilities ?? "", before.connectorId ?? before.connector_id ?? "",
|
|
||||||
before.model ?? null, before.budgetMonthlyUsd ?? before.budget_monthly_usd ?? 0,
|
|
||||||
before.metadataJson ?? before.metadata_json ?? "{}", ts, agentId, companyId
|
|
||||||
);
|
|
||||||
const agent = selfDeps.getAgent(agentId);
|
|
||||||
const currentConfig = { name: agent.name, role: agent.role, title: agent.title, department: agent.department, capabilities: agent.capabilities, connectorId: agent.connectorId, model: agent.model, budgetMonthlyUsd: agent.budgetMonthlyUsd, metadataJson: agent.metadataJson };
|
|
||||||
return selfDeps.recordConfigRevision({
|
|
||||||
companyId, agentId,
|
|
||||||
changedKeys: revision.changedKeys,
|
|
||||||
configBefore: currentConfig,
|
|
||||||
configAfter: before,
|
|
||||||
source: "rollback",
|
|
||||||
rolledBackFromRevisionId: revisionId,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import type { DatabaseSync } from "node:sqlite";
|
||||||
|
import { type ReadAll, type SqliteRow, type Transaction, nowIso } from "./helpers";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* run_events helpers extracted from runs.ts. Every method is spread into
|
||||||
|
* the final `buildRunMethods` result so AppDatabase call-sites remain
|
||||||
|
* unchanged.
|
||||||
|
*/
|
||||||
|
export function buildRunEventMethods(
|
||||||
|
getDb: () => DatabaseSync,
|
||||||
|
readAll: ReadAll,
|
||||||
|
transaction: Transaction,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
appendRunEvent(input: {
|
||||||
|
companyId: string; runId: string; agentId: string;
|
||||||
|
eventType: string; stream?: string | null; level?: string | null;
|
||||||
|
message?: string | null; payload?: Record<string, unknown> | null;
|
||||||
|
}): void {
|
||||||
|
const ts = nowIso();
|
||||||
|
transaction(() => {
|
||||||
|
const lastSeq = getDb().prepare(
|
||||||
|
"SELECT MAX(seq) as maxSeq FROM run_events WHERE run_id = ?"
|
||||||
|
).get(input.runId) as { maxSeq: number | null } | undefined;
|
||||||
|
const seq = (lastSeq?.maxSeq ?? 0) + 1;
|
||||||
|
getDb().prepare(
|
||||||
|
"INSERT INTO run_events (company_id, run_id, agent_id, seq, event_type, stream, level, message, payload, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||||
|
).run(
|
||||||
|
input.companyId, input.runId, input.agentId, seq,
|
||||||
|
input.eventType, input.stream ?? null, input.level ?? null,
|
||||||
|
input.message ?? null, input.payload ? JSON.stringify(input.payload) : null, ts
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
listRunEvents(runId: string, companyId: string, opts?: { offset?: number; limit?: number }): Array<{
|
||||||
|
id: number; runId: string; agentId: string; seq: number; eventType: string;
|
||||||
|
stream: string | null; level: string | null; message: string | null;
|
||||||
|
payload: Record<string, unknown> | null; createdAt: string;
|
||||||
|
}> {
|
||||||
|
const offset = opts?.offset ?? 0;
|
||||||
|
const limit = opts?.limit ?? 500;
|
||||||
|
return readAll<SqliteRow>(
|
||||||
|
"SELECT id, run_id, agent_id, seq, event_type, stream, level, message, payload, created_at FROM run_events WHERE run_id = ? AND company_id = ? ORDER BY seq ASC LIMIT ? OFFSET ?",
|
||||||
|
runId, companyId, limit, offset
|
||||||
|
).map((row: SqliteRow) => ({
|
||||||
|
id: row.id,
|
||||||
|
runId: row.run_id,
|
||||||
|
agentId: row.agent_id,
|
||||||
|
seq: row.seq,
|
||||||
|
eventType: row.event_type,
|
||||||
|
stream: row.stream,
|
||||||
|
level: row.level,
|
||||||
|
message: row.message,
|
||||||
|
payload: row.payload ? JSON.parse(row.payload) : null,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
listRunEventsAfter(runId: string, companyId: string, afterSeq: number, limit: number): Array<{
|
||||||
|
id: number; runId: string; agentId: string; seq: number; eventType: string;
|
||||||
|
stream: string | null; level: string | null; message: string | null;
|
||||||
|
payload: Record<string, unknown> | null; createdAt: string;
|
||||||
|
}> {
|
||||||
|
return readAll<SqliteRow>(
|
||||||
|
"SELECT id, run_id, agent_id, seq, event_type, stream, level, message, payload, created_at FROM run_events WHERE run_id = ? AND company_id = ? AND seq > ? ORDER BY seq ASC LIMIT ?",
|
||||||
|
runId, companyId, afterSeq, limit
|
||||||
|
).map((row: SqliteRow) => ({
|
||||||
|
id: row.id,
|
||||||
|
runId: row.run_id,
|
||||||
|
agentId: row.agent_id,
|
||||||
|
seq: row.seq,
|
||||||
|
eventType: row.event_type,
|
||||||
|
stream: row.stream,
|
||||||
|
level: row.level,
|
||||||
|
message: row.message,
|
||||||
|
payload: row.payload ? JSON.parse(row.payload) : null,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { writeFileSync, readFileSync, existsSync, statSync, openSync, readSync, closeSync } from "node:fs";
|
||||||
|
import { appendFile } from "node:fs/promises";
|
||||||
|
import type { DatabaseSync } from "node:sqlite";
|
||||||
|
import log from "electron-log/main.js";
|
||||||
|
import type { RunLogChunk } from "@shared/types";
|
||||||
|
import type { ReadAll, Transaction } from "./helpers";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the last complete UTF-8 character boundary in a buffer.
|
||||||
|
* Returns the number of bytes up to (and including) the last complete character.
|
||||||
|
* This prevents corrupting multi-byte CJK characters when slicing at chunk edges.
|
||||||
|
*/
|
||||||
|
export function findUtf8Boundary(buffer: Buffer, bytesRead: number): number {
|
||||||
|
if (bytesRead === 0) return 0;
|
||||||
|
let pos = bytesRead - 1;
|
||||||
|
while (pos >= 0 && (buffer[pos] & 0xc0) === 0x80) {
|
||||||
|
pos--;
|
||||||
|
}
|
||||||
|
if (pos < 0) return bytesRead;
|
||||||
|
const lead = buffer[pos];
|
||||||
|
let charLen: number;
|
||||||
|
if ((lead & 0x80) === 0) charLen = 1;
|
||||||
|
else if ((lead & 0xe0) === 0xc0) charLen = 2;
|
||||||
|
else if ((lead & 0xf0) === 0xe0) charLen = 3;
|
||||||
|
else if ((lead & 0xf8) === 0xf0) charLen = 4;
|
||||||
|
else return bytesRead;
|
||||||
|
if (pos + charLen <= bytesRead) return bytesRead;
|
||||||
|
return pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run log file-IO helpers extracted from runs.ts. Every method is spread
|
||||||
|
* into the final `buildRunMethods` result so AppDatabase call-sites remain
|
||||||
|
* unchanged. `writeFileSync` is re-exported indirectly through createRun
|
||||||
|
* (still in runs.ts) via the shared logs directory.
|
||||||
|
*/
|
||||||
|
export function buildRunLogMethods(
|
||||||
|
getDb: () => DatabaseSync,
|
||||||
|
_readAll: ReadAll,
|
||||||
|
_transaction: Transaction,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
appendRunLog(runId: string, stream: "stdout" | "stderr", chunk: string) {
|
||||||
|
const run = getDb().prepare("select log_path as logPath from runs where id = ?").get(runId) as { logPath: string | null } | undefined;
|
||||||
|
if (!run?.logPath) return;
|
||||||
|
return appendFile(run.logPath, `[${new Date().toISOString()}][${stream}] ${chunk}`, "utf8");
|
||||||
|
},
|
||||||
|
|
||||||
|
readRunLog(runId: string) {
|
||||||
|
const run = getDb().prepare("select log_path as logPath from runs where id = ?").get(runId) as { logPath: string | null } | undefined;
|
||||||
|
if (!run?.logPath) return "";
|
||||||
|
try {
|
||||||
|
return readFileSync(run.logPath, "utf8");
|
||||||
|
} catch (err) {
|
||||||
|
log.warn(`[database/runs] readFileSync failed for run ${runId} log ${run.logPath}:`, err instanceof Error ? err.message : String(err));
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
readRunLogChunk(runId: string, offset = 0, limit = 65_536): RunLogChunk {
|
||||||
|
const run = getDb().prepare("select log_path as logPath from runs where id = ?").get(runId) as { logPath: string | null } | undefined;
|
||||||
|
if (!run?.logPath || !existsSync(run.logPath)) {
|
||||||
|
return { content: "", offset: 0, nextOffset: 0, totalBytes: 0, eof: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalBytes = statSync(run.logPath).size;
|
||||||
|
const safeOffset = Math.max(0, Math.min(offset, totalBytes));
|
||||||
|
const safeLimit = Math.max(1_024, Math.min(limit, 262_144));
|
||||||
|
const bytesToRead = Math.max(0, Math.min(safeLimit, totalBytes - safeOffset));
|
||||||
|
|
||||||
|
if (bytesToRead === 0) {
|
||||||
|
return { content: "", offset: safeOffset, nextOffset: safeOffset, totalBytes, eof: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileDescriptor = openSync(run.logPath, "r");
|
||||||
|
try {
|
||||||
|
const buffer = Buffer.allocUnsafe(bytesToRead);
|
||||||
|
const bytesRead = readSync(fileDescriptor, buffer, 0, bytesToRead, safeOffset);
|
||||||
|
const safeBytesRead = findUtf8Boundary(buffer, bytesRead);
|
||||||
|
const nextOffset = safeOffset + safeBytesRead;
|
||||||
|
return {
|
||||||
|
content: buffer.subarray(0, safeBytesRead).toString("utf8"),
|
||||||
|
offset: safeOffset,
|
||||||
|
nextOffset,
|
||||||
|
totalBytes,
|
||||||
|
eof: nextOffset >= totalBytes,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
closeSync(fileDescriptor);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-exported so runs.ts can still use writeFileSync on run creation.
|
||||||
|
export { writeFileSync };
|
||||||
+10
-178
@@ -1,34 +1,12 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { writeFileSync, readFileSync, existsSync, statSync, openSync, readSync, closeSync } from "node:fs";
|
import { writeFileSync } from "node:fs";
|
||||||
import { appendFile } from "node:fs/promises";
|
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import type { DatabaseSync, SQLInputValue } from "node:sqlite";
|
import type { DatabaseSync, SQLInputValue } from "node:sqlite";
|
||||||
import log from "electron-log/main.js";
|
import type { ConnectorId, RunRecord } from "@shared/types";
|
||||||
import type { ConnectorId, RunLogChunk, RunRecord } from "@shared/types";
|
import { type ReadAll, type Transaction, nowIso, toBoolean } from "./helpers";
|
||||||
import { type ReadAll, type SqliteRow, type Transaction, nowIso, toBoolean } from "./helpers";
|
import { buildRunLogMethods } from "./run-logs";
|
||||||
|
import { buildRunEventMethods } from "./run-events";
|
||||||
/**
|
import { buildCostEntryMethods } from "./cost-entries";
|
||||||
* Find the last complete UTF-8 character boundary in a buffer.
|
|
||||||
* Returns the number of bytes up to (and including) the last complete character.
|
|
||||||
* This prevents corrupting multi-byte CJK characters when slicing at chunk edges.
|
|
||||||
*/
|
|
||||||
function findUtf8Boundary(buffer: Buffer, bytesRead: number): number {
|
|
||||||
if (bytesRead === 0) return 0;
|
|
||||||
let pos = bytesRead - 1;
|
|
||||||
while (pos >= 0 && (buffer[pos] & 0xc0) === 0x80) {
|
|
||||||
pos--;
|
|
||||||
}
|
|
||||||
if (pos < 0) return bytesRead;
|
|
||||||
const lead = buffer[pos];
|
|
||||||
let charLen: number;
|
|
||||||
if ((lead & 0x80) === 0) charLen = 1;
|
|
||||||
else if ((lead & 0xe0) === 0xc0) charLen = 2;
|
|
||||||
else if ((lead & 0xf0) === 0xe0) charLen = 3;
|
|
||||||
else if ((lead & 0xf8) === 0xf0) charLen = 4;
|
|
||||||
else return bytesRead;
|
|
||||||
if (pos + charLen <= bytesRead) return bytesRead;
|
|
||||||
return pos;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildRunMethods(
|
export function buildRunMethods(
|
||||||
getDb: () => DatabaseSync,
|
getDb: () => DatabaseSync,
|
||||||
@@ -39,6 +17,10 @@ export function buildRunMethods(
|
|||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
|
...buildRunLogMethods(getDb, readAll, transaction),
|
||||||
|
...buildRunEventMethods(getDb, readAll, transaction),
|
||||||
|
...buildCostEntryMethods(getDb, readAll, transaction),
|
||||||
|
|
||||||
getRun(id: string): RunRecord {
|
getRun(id: string): RunRecord {
|
||||||
const row = getDb()
|
const row = getDb()
|
||||||
.prepare(
|
.prepare(
|
||||||
@@ -82,56 +64,6 @@ export function buildRunMethods(
|
|||||||
return { id, logPath };
|
return { id, logPath };
|
||||||
},
|
},
|
||||||
|
|
||||||
appendRunLog(runId: string, stream: "stdout" | "stderr", chunk: string) {
|
|
||||||
const run = getDb().prepare("select log_path as logPath from runs where id = ?").get(runId) as { logPath: string | null } | undefined;
|
|
||||||
if (!run?.logPath) return;
|
|
||||||
return appendFile(run.logPath, `[${new Date().toISOString()}][${stream}] ${chunk}`, "utf8");
|
|
||||||
},
|
|
||||||
|
|
||||||
readRunLog(runId: string) {
|
|
||||||
const run = getDb().prepare("select log_path as logPath from runs where id = ?").get(runId) as { logPath: string | null } | undefined;
|
|
||||||
if (!run?.logPath) return "";
|
|
||||||
try {
|
|
||||||
return readFileSync(run.logPath, "utf8");
|
|
||||||
} catch (err) {
|
|
||||||
log.warn(`[database/runs] readFileSync failed for run ${runId} log ${run.logPath}:`, err instanceof Error ? err.message : String(err));
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
readRunLogChunk(runId: string, offset = 0, limit = 65_536): RunLogChunk {
|
|
||||||
const run = getDb().prepare("select log_path as logPath from runs where id = ?").get(runId) as { logPath: string | null } | undefined;
|
|
||||||
if (!run?.logPath || !existsSync(run.logPath)) {
|
|
||||||
return { content: "", offset: 0, nextOffset: 0, totalBytes: 0, eof: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalBytes = statSync(run.logPath).size;
|
|
||||||
const safeOffset = Math.max(0, Math.min(offset, totalBytes));
|
|
||||||
const safeLimit = Math.max(1_024, Math.min(limit, 262_144));
|
|
||||||
const bytesToRead = Math.max(0, Math.min(safeLimit, totalBytes - safeOffset));
|
|
||||||
|
|
||||||
if (bytesToRead === 0) {
|
|
||||||
return { content: "", offset: safeOffset, nextOffset: safeOffset, totalBytes, eof: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileDescriptor = openSync(run.logPath, "r");
|
|
||||||
try {
|
|
||||||
const buffer = Buffer.allocUnsafe(bytesToRead);
|
|
||||||
const bytesRead = readSync(fileDescriptor, buffer, 0, bytesToRead, safeOffset);
|
|
||||||
const safeBytesRead = findUtf8Boundary(buffer, bytesRead);
|
|
||||||
const nextOffset = safeOffset + safeBytesRead;
|
|
||||||
return {
|
|
||||||
content: buffer.subarray(0, safeBytesRead).toString("utf8"),
|
|
||||||
offset: safeOffset,
|
|
||||||
nextOffset,
|
|
||||||
totalBytes,
|
|
||||||
eof: nextOffset >= totalBytes,
|
|
||||||
};
|
|
||||||
} finally {
|
|
||||||
closeSync(fileDescriptor);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
markRunStatus(runId: string, status: RunRecord["status"], message: string | null) {
|
markRunStatus(runId: string, status: RunRecord["status"], message: string | null) {
|
||||||
const ts = nowIso();
|
const ts = nowIso();
|
||||||
const startedAt = status === "running" ? ts : null;
|
const startedAt = status === "running" ? ts : null;
|
||||||
@@ -270,39 +202,6 @@ export function buildRunMethods(
|
|||||||
return Boolean(row?.present);
|
return Boolean(row?.present);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Single writer for `cost_entries`. CostWriter calls this per
|
|
||||||
* `message_end`; tests also call it to simulate per-turn cost.
|
|
||||||
* finishRun reads SUM(cost_entries) for the run-level aggregate,
|
|
||||||
* so this is the authoritative path for cost attribution.
|
|
||||||
*/
|
|
||||||
recordCostEntry(input: {
|
|
||||||
runId: string;
|
|
||||||
companyId: string;
|
|
||||||
agentId: string;
|
|
||||||
taskId: string | null;
|
|
||||||
connectorId: string;
|
|
||||||
amountUsd: number | null;
|
|
||||||
unattributed: boolean;
|
|
||||||
}): string {
|
|
||||||
const id = randomUUID();
|
|
||||||
const ts = nowIso();
|
|
||||||
const projectRow = input.taskId
|
|
||||||
? getDb().prepare("SELECT project_id FROM tasks WHERE id = ?").get(input.taskId) as { project_id: string | null } | undefined
|
|
||||||
: undefined;
|
|
||||||
const projectId = projectRow?.project_id ?? null;
|
|
||||||
getDb()
|
|
||||||
.prepare(
|
|
||||||
"insert into cost_entries (id, company_id, run_id, task_id, agent_id, connector_id, amount_usd, unattributed, project_id, created_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
||||||
)
|
|
||||||
.run(
|
|
||||||
id, input.companyId, input.runId, input.taskId, input.agentId,
|
|
||||||
input.connectorId, input.amountUsd,
|
|
||||||
input.unattributed ? 1 : 0, projectId, ts,
|
|
||||||
);
|
|
||||||
return id;
|
|
||||||
},
|
|
||||||
|
|
||||||
listQueuedRuns() {
|
listQueuedRuns() {
|
||||||
return readAll<RunRecord>(
|
return readAll<RunRecord>(
|
||||||
`select id, company_id as companyId, task_id as taskId, agent_id as agentId, workspace_id as workspaceId,
|
`select id, company_id as companyId, task_id as taskId, agent_id as agentId, workspace_id as workspaceId,
|
||||||
@@ -337,73 +236,6 @@ export function buildRunMethods(
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
appendRunEvent(input: {
|
|
||||||
companyId: string; runId: string; agentId: string;
|
|
||||||
eventType: string; stream?: string | null; level?: string | null;
|
|
||||||
message?: string | null; payload?: Record<string, unknown> | null;
|
|
||||||
}): void {
|
|
||||||
const ts = nowIso();
|
|
||||||
transaction(() => {
|
|
||||||
const lastSeq = getDb().prepare(
|
|
||||||
"SELECT MAX(seq) as maxSeq FROM run_events WHERE run_id = ?"
|
|
||||||
).get(input.runId) as { maxSeq: number | null } | undefined;
|
|
||||||
const seq = (lastSeq?.maxSeq ?? 0) + 1;
|
|
||||||
getDb().prepare(
|
|
||||||
"INSERT INTO run_events (company_id, run_id, agent_id, seq, event_type, stream, level, message, payload, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
|
||||||
).run(
|
|
||||||
input.companyId, input.runId, input.agentId, seq,
|
|
||||||
input.eventType, input.stream ?? null, input.level ?? null,
|
|
||||||
input.message ?? null, input.payload ? JSON.stringify(input.payload) : null, ts
|
|
||||||
);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
listRunEvents(runId: string, companyId: string, opts?: { offset?: number; limit?: number }): Array<{
|
|
||||||
id: number; runId: string; agentId: string; seq: number; eventType: string;
|
|
||||||
stream: string | null; level: string | null; message: string | null;
|
|
||||||
payload: Record<string, unknown> | null; createdAt: string;
|
|
||||||
}> {
|
|
||||||
const offset = opts?.offset ?? 0;
|
|
||||||
const limit = opts?.limit ?? 500;
|
|
||||||
return readAll<SqliteRow>(
|
|
||||||
"SELECT id, run_id, agent_id, seq, event_type, stream, level, message, payload, created_at FROM run_events WHERE run_id = ? AND company_id = ? ORDER BY seq ASC LIMIT ? OFFSET ?",
|
|
||||||
runId, companyId, limit, offset
|
|
||||||
).map((row: SqliteRow) => ({
|
|
||||||
id: row.id,
|
|
||||||
runId: row.run_id,
|
|
||||||
agentId: row.agent_id,
|
|
||||||
seq: row.seq,
|
|
||||||
eventType: row.event_type,
|
|
||||||
stream: row.stream,
|
|
||||||
level: row.level,
|
|
||||||
message: row.message,
|
|
||||||
payload: row.payload ? JSON.parse(row.payload) : null,
|
|
||||||
createdAt: row.created_at,
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
|
|
||||||
listRunEventsAfter(runId: string, companyId: string, afterSeq: number, limit: number): Array<{
|
|
||||||
id: number; runId: string; agentId: string; seq: number; eventType: string;
|
|
||||||
stream: string | null; level: string | null; message: string | null;
|
|
||||||
payload: Record<string, unknown> | null; createdAt: string;
|
|
||||||
}> {
|
|
||||||
return readAll<SqliteRow>(
|
|
||||||
"SELECT id, run_id, agent_id, seq, event_type, stream, level, message, payload, created_at FROM run_events WHERE run_id = ? AND company_id = ? AND seq > ? ORDER BY seq ASC LIMIT ?",
|
|
||||||
runId, companyId, afterSeq, limit
|
|
||||||
).map((row: SqliteRow) => ({
|
|
||||||
id: row.id,
|
|
||||||
runId: row.run_id,
|
|
||||||
agentId: row.agent_id,
|
|
||||||
seq: row.seq,
|
|
||||||
eventType: row.event_type,
|
|
||||||
stream: row.stream,
|
|
||||||
level: row.level,
|
|
||||||
message: row.message,
|
|
||||||
payload: row.payload ? JSON.parse(row.payload) : null,
|
|
||||||
createdAt: row.created_at,
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
|
|
||||||
updateRunActor(runId: string, actorType: string, actorId: string | null): void {
|
updateRunActor(runId: string, actorType: string, actorId: string | null): void {
|
||||||
getDb().prepare(
|
getDb().prepare(
|
||||||
"UPDATE runs SET actor_type = ?, actor_id = ?, updated_at = ? WHERE id = ?"
|
"UPDATE runs SET actor_type = ?, actor_id = ?, updated_at = ? WHERE id = ?"
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import type { DatabaseSync } from "node:sqlite";
|
||||||
|
import type { TaskRecord } from "@shared/types";
|
||||||
|
import { type ReadAll, type Transaction, taskPriorityOrderSql } from "./helpers";
|
||||||
|
import type { TaskListByStatusesRow } from "./tasks-crud";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task list/aggregate read queries extracted from tasks-crud.ts.
|
||||||
|
*
|
||||||
|
* Every method is spread into the final `buildTaskMethods` result so
|
||||||
|
* AppDatabase call-sites remain unchanged.
|
||||||
|
*/
|
||||||
|
export function buildTaskQueryMethods(
|
||||||
|
getDb: () => DatabaseSync,
|
||||||
|
readAll: ReadAll,
|
||||||
|
_transaction: Transaction,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Returns actionable real work for an agent.
|
||||||
|
*/
|
||||||
|
listAgentTasks(
|
||||||
|
agentId: string,
|
||||||
|
companyId: string,
|
||||||
|
statuses: string[],
|
||||||
|
): TaskRecord[] {
|
||||||
|
const placeholders = statuses.map(() => "?").join(",");
|
||||||
|
return readAll<TaskRecord>(
|
||||||
|
`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, session_json as sessionJson, session_display_id as sessionDisplayId, cost_usd as costUsd, task_type as taskType, created_at as createdAt, updated_at as updatedAt
|
||||||
|
from tasks
|
||||||
|
where company_id = ? and assignee_agent_id = ? and status in (${placeholders})
|
||||||
|
order by
|
||||||
|
case status
|
||||||
|
when 'in_progress' then 0
|
||||||
|
when 'todo' then 1
|
||||||
|
when 'blocked' then 2
|
||||||
|
when 'backlog' then 3
|
||||||
|
else 4
|
||||||
|
end asc,
|
||||||
|
${taskPriorityOrderSql("priority")} desc,
|
||||||
|
created_at asc`,
|
||||||
|
companyId,
|
||||||
|
agentId,
|
||||||
|
...statuses,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the slim task projection (id, title, status, priority) for a
|
||||||
|
* single assignee, ordered by created_at desc.
|
||||||
|
*/
|
||||||
|
listAssigneeTasksForStandup(
|
||||||
|
companyId: string,
|
||||||
|
agentId: string,
|
||||||
|
): Array<{ id: string; title: string; status: string; priority: string }> {
|
||||||
|
return readAll(
|
||||||
|
"select id, title, status, priority from tasks where company_id = ? and assignee_agent_id = ? order by created_at desc",
|
||||||
|
companyId,
|
||||||
|
agentId,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the slim task projection for a set of assignees. Safe on
|
||||||
|
* an empty assignee list (returns []).
|
||||||
|
*/
|
||||||
|
listTasksForAssignees(
|
||||||
|
companyId: string,
|
||||||
|
assigneeIds: string[],
|
||||||
|
): Array<{
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
priority: string;
|
||||||
|
assigneeAgentId: string;
|
||||||
|
}> {
|
||||||
|
if (assigneeIds.length === 0) return [];
|
||||||
|
const placeholders = assigneeIds.map(() => "?").join(",");
|
||||||
|
return readAll(
|
||||||
|
`select id, title, status, priority, assignee_agent_id as assigneeAgentId from tasks where company_id = ? and assignee_agent_id in (${placeholders}) order by created_at desc`,
|
||||||
|
companyId,
|
||||||
|
...assigneeIds,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the slim task projection used by the me/standup aggregation.
|
||||||
|
* Ordered by created_at desc.
|
||||||
|
*/
|
||||||
|
listTasksForStandup(companyId: string): Array<{
|
||||||
|
id: string;
|
||||||
|
companyId: string;
|
||||||
|
title: string;
|
||||||
|
assigneeAgentId: string | null;
|
||||||
|
status: string;
|
||||||
|
priority: string;
|
||||||
|
}> {
|
||||||
|
return readAll(
|
||||||
|
"select id, company_id as companyId, title, assignee_agent_id as assigneeAgentId, status, priority from tasks where company_id = ? order by created_at desc",
|
||||||
|
companyId,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns tasks filtered by status.
|
||||||
|
*/
|
||||||
|
listTasksByStatuses(
|
||||||
|
companyId: string,
|
||||||
|
statuses: string[],
|
||||||
|
): TaskListByStatusesRow[] {
|
||||||
|
const placeholders = statuses.map(() => "?").join(", ");
|
||||||
|
return readAll<TaskListByStatusesRow & { requiresUserReview: number | boolean }>(
|
||||||
|
`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, session_json as sessionJson, session_display_id as sessionDisplayId, cost_usd as costUsd, 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 company_id = ? and status in (${placeholders}) order by created_at desc`,
|
||||||
|
companyId,
|
||||||
|
...statuses,
|
||||||
|
).map((t) => ({
|
||||||
|
...t,
|
||||||
|
requiresUserReview: Boolean(t.requiresUserReview),
|
||||||
|
startedAt: t.startedAt ?? null,
|
||||||
|
completedAt: t.completedAt ?? null,
|
||||||
|
cancelledAt: t.cancelledAt ?? null,
|
||||||
|
})) as TaskListByStatusesRow[];
|
||||||
|
},
|
||||||
|
|
||||||
|
countActiveTasksForCompany(companyId: string): number {
|
||||||
|
const row = getDb()
|
||||||
|
.prepare(
|
||||||
|
"select count(*) as cnt from tasks where company_id = ? and status not in ('done', 'cancelled')",
|
||||||
|
)
|
||||||
|
.get(companyId) as { cnt: number };
|
||||||
|
return row.cnt;
|
||||||
|
},
|
||||||
|
|
||||||
|
listChildTasks(
|
||||||
|
parentId: string,
|
||||||
|
companyId: string,
|
||||||
|
): Array<{ id: string; status: string; assigneeAgentId: string | null }> {
|
||||||
|
return readAll<{ id: string; status: string; assigneeAgentId: string | null }>(
|
||||||
|
`select id, status, assignee_agent_id as assigneeAgentId from tasks where parent_id = ? and company_id = ?`,
|
||||||
|
parentId,
|
||||||
|
companyId,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the set of distinct assignee agent ids on tasks in a project,
|
||||||
|
* excluding unassigned tasks. Used by message-dispatch to fan out wake
|
||||||
|
* targets for a project channel.
|
||||||
|
*/
|
||||||
|
listProjectTaskAssigneeIds(projectId: string, companyId: string): string[] {
|
||||||
|
const rows = readAll<{ assigneeAgentId: string | null }>(
|
||||||
|
"select distinct assignee_agent_id as assigneeAgentId from tasks where project_id = ? and company_id = ? and assignee_agent_id is not null",
|
||||||
|
projectId,
|
||||||
|
companyId,
|
||||||
|
);
|
||||||
|
return rows.map((r) => r.assigneeAgentId).filter((id): id is string => id !== null);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns assignee agent ids for a set of task ids scoped to a company.
|
||||||
|
* Used by task-quality-gates sweep to find reviewers of in-review tasks
|
||||||
|
* that have exceeded the review timeout.
|
||||||
|
*/
|
||||||
|
listReviewTasksForSweep(companyId: string): Array<{
|
||||||
|
id: string;
|
||||||
|
assigneeAgentId: string | null;
|
||||||
|
updatedAt: string;
|
||||||
|
title: string;
|
||||||
|
}> {
|
||||||
|
return readAll(
|
||||||
|
`SELECT id, assignee_agent_id as assigneeAgentId, updated_at as updatedAt, title
|
||||||
|
FROM tasks
|
||||||
|
WHERE company_id = ? AND status = 'in_review'`,
|
||||||
|
companyId,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import type { DatabaseSync } from "node:sqlite";
|
||||||
|
import type { TaskSessionRecord } from "@shared/types";
|
||||||
|
import { type ReadAll, type Transaction, nowIso } from "./helpers";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task-session lifecycle helpers extracted from tasks-crud.ts.
|
||||||
|
*
|
||||||
|
* Covers the agent-scoped session lifecycle:
|
||||||
|
* - updateTaskSession / getTaskSession / getActiveTaskSession
|
||||||
|
* - incrementSessionUsage / archiveSession
|
||||||
|
* - ensureAgentSession / setAgentSessionId / updateTasksActiveSessionForAgent
|
||||||
|
* - createNewSession
|
||||||
|
*
|
||||||
|
* Every method is spread into the final `buildTaskMethods` result, so
|
||||||
|
* call-sites on AppDatabase remain unchanged.
|
||||||
|
*/
|
||||||
|
export function buildTaskSessionMethods(
|
||||||
|
getDb: () => DatabaseSync,
|
||||||
|
_readAll: ReadAll,
|
||||||
|
transaction: Transaction,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
updateTaskSession(
|
||||||
|
taskId: string,
|
||||||
|
sessionJson: Record<string, unknown> | null,
|
||||||
|
sessionDisplayId: string | null,
|
||||||
|
): void {
|
||||||
|
const ts = nowIso();
|
||||||
|
const sessionParamsJson = sessionJson ? JSON.stringify(sessionJson) : null;
|
||||||
|
const taskRow = getDb()
|
||||||
|
.prepare(
|
||||||
|
"select company_id as companyId, assignee_agent_id as assigneeAgentId, active_session_id as activeSessionId from tasks where id = ?",
|
||||||
|
)
|
||||||
|
.get(taskId) as
|
||||||
|
| { companyId: string; assigneeAgentId: string | null; activeSessionId: string | null }
|
||||||
|
| undefined;
|
||||||
|
if (!taskRow) throw new Error(`Task not found: ${taskId}`);
|
||||||
|
if (taskRow.activeSessionId) {
|
||||||
|
getDb()
|
||||||
|
.prepare(
|
||||||
|
"update task_sessions set session_params_json = ?, session_display_id = ?, updated_at = ? where id = ?",
|
||||||
|
)
|
||||||
|
.run(sessionParamsJson, sessionDisplayId, ts, taskRow.activeSessionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sessionParamsJson === null && sessionDisplayId === null) return;
|
||||||
|
const agentId = taskRow.assigneeAgentId ?? "unassigned";
|
||||||
|
const agent = taskRow.assigneeAgentId
|
||||||
|
? (getDb()
|
||||||
|
.prepare("select connector_id as connectorId from agents where id = ?")
|
||||||
|
.get(taskRow.assigneeAgentId) as { connectorId: string } | undefined)
|
||||||
|
: undefined;
|
||||||
|
const adapterType = agent?.connectorId ?? "unknown";
|
||||||
|
const sessionId = randomUUID();
|
||||||
|
getDb()
|
||||||
|
.prepare(
|
||||||
|
`insert into task_sessions (id, company_id, task_id, agent_id, adapter_type, session_params_json, session_display_id, created_at, updated_at)
|
||||||
|
values (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
sessionId,
|
||||||
|
taskRow.companyId,
|
||||||
|
taskId,
|
||||||
|
agentId,
|
||||||
|
adapterType,
|
||||||
|
sessionParamsJson,
|
||||||
|
sessionDisplayId,
|
||||||
|
ts,
|
||||||
|
ts,
|
||||||
|
);
|
||||||
|
getDb()
|
||||||
|
.prepare("update tasks set active_session_id = ?, updated_at = ? where id = ?")
|
||||||
|
.run(sessionId, ts, taskId);
|
||||||
|
},
|
||||||
|
|
||||||
|
getTaskSession(taskId: string): {
|
||||||
|
sessionParams: Record<string, unknown> | null;
|
||||||
|
sessionDisplayId: string | null;
|
||||||
|
} {
|
||||||
|
const taskRow = getDb()
|
||||||
|
.prepare("select active_session_id as activeSessionId from tasks where id = ?")
|
||||||
|
.get(taskId) as { activeSessionId: string | null } | undefined;
|
||||||
|
if (!taskRow?.activeSessionId) {
|
||||||
|
return { sessionParams: null, sessionDisplayId: null };
|
||||||
|
}
|
||||||
|
const row = getDb()
|
||||||
|
.prepare(
|
||||||
|
"select session_params_json as sessionParamsJson, session_display_id as sessionDisplayId from task_sessions where id = ?",
|
||||||
|
)
|
||||||
|
.get(taskRow.activeSessionId) as
|
||||||
|
| { sessionParamsJson: string | null; sessionDisplayId: string | null }
|
||||||
|
| undefined;
|
||||||
|
let sessionParams: Record<string, unknown> | null = null;
|
||||||
|
if (row?.sessionParamsJson) {
|
||||||
|
sessionParams = JSON.parse(row.sessionParamsJson) as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
sessionParams,
|
||||||
|
sessionDisplayId: row?.sessionDisplayId ?? null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
getActiveTaskSession(taskId: string): TaskSessionRecord | null {
|
||||||
|
const task = getDb()
|
||||||
|
.prepare("SELECT active_session_id FROM tasks WHERE id = ?")
|
||||||
|
.get(taskId) as { active_session_id: string | null } | undefined;
|
||||||
|
if (!task?.active_session_id) return null;
|
||||||
|
return (
|
||||||
|
(getDb()
|
||||||
|
.prepare(
|
||||||
|
`SELECT id, company_id as companyId, task_id as taskId, agent_id as agentId,
|
||||||
|
adapter_type as adapterType, session_params_json as sessionParamsJson,
|
||||||
|
session_display_id as sessionDisplayId, last_run_id as lastRunId,
|
||||||
|
last_error as lastError, run_count as runCount,
|
||||||
|
total_input_tokens as totalInputTokens, total_output_tokens as totalOutputTokens,
|
||||||
|
rotated_at as rotatedAt, rotation_reason as rotationReason,
|
||||||
|
handoff_markdown as handoffMarkdown, created_at as createdAt, updated_at as updatedAt
|
||||||
|
FROM task_sessions WHERE id = ?`,
|
||||||
|
)
|
||||||
|
.get(task.active_session_id) as TaskSessionRecord | undefined) ?? null
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
incrementSessionUsage(
|
||||||
|
sessionId: string,
|
||||||
|
inputTokens: number,
|
||||||
|
outputTokens: number,
|
||||||
|
runId: string,
|
||||||
|
): void {
|
||||||
|
const ts = nowIso();
|
||||||
|
getDb()
|
||||||
|
.prepare(
|
||||||
|
`UPDATE task_sessions
|
||||||
|
SET run_count = run_count + 1,
|
||||||
|
total_input_tokens = total_input_tokens + ?,
|
||||||
|
total_output_tokens = total_output_tokens + ?,
|
||||||
|
last_run_id = ?,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
)
|
||||||
|
.run(inputTokens, outputTokens, runId, ts, sessionId);
|
||||||
|
},
|
||||||
|
|
||||||
|
archiveSession(sessionId: string, reason: string, handoffMarkdown: string): void {
|
||||||
|
const ts = nowIso();
|
||||||
|
getDb()
|
||||||
|
.prepare(
|
||||||
|
`UPDATE task_sessions SET rotated_at = ?, rotation_reason = ?, handoff_markdown = ?, updated_at = ? WHERE id = ?`,
|
||||||
|
)
|
||||||
|
.run(ts, reason, handoffMarkdown, ts, sessionId);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the agent's active session id, creating one lazily on first
|
||||||
|
* call. Session is scoped to the agent (not to any particular task);
|
||||||
|
* switching tasks emits a user turn on the ongoing transcript rather
|
||||||
|
* than spawning a new session.
|
||||||
|
*
|
||||||
|
* Existing agents with pre-migration per-task sessions start fresh
|
||||||
|
* after this upgrade: their first run creates a new agent-scoped
|
||||||
|
* session. The old task_sessions + session_entries rows remain in the
|
||||||
|
* DB as historical records but are not loaded for new runs. This is a
|
||||||
|
* one-time transcript reset, accepted over a risky merge on a live
|
||||||
|
* production DB.
|
||||||
|
*/
|
||||||
|
ensureAgentSession(input: {
|
||||||
|
agentId: string;
|
||||||
|
companyId: string;
|
||||||
|
adapterType: string;
|
||||||
|
}): string {
|
||||||
|
const agentRow = getDb()
|
||||||
|
.prepare("SELECT session_id FROM agents WHERE id = ?")
|
||||||
|
.get(input.agentId) as { session_id: string | null } | undefined;
|
||||||
|
if (agentRow?.session_id) return agentRow.session_id;
|
||||||
|
|
||||||
|
const sessionId = randomUUID();
|
||||||
|
const ts = nowIso();
|
||||||
|
transaction(() => {
|
||||||
|
// task_id is left as empty string — the session is agent-scoped;
|
||||||
|
// the column persists for backwards-compatible schema but no
|
||||||
|
// longer keys the session to a particular task.
|
||||||
|
getDb()
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO task_sessions (id, company_id, task_id, agent_id, adapter_type,
|
||||||
|
session_params_json, session_display_id, run_count, total_input_tokens,
|
||||||
|
total_output_tokens, created_at, updated_at)
|
||||||
|
VALUES (?, ?, '', ?, ?, NULL, NULL, 0, 0, 0, ?, ?)`,
|
||||||
|
)
|
||||||
|
.run(sessionId, input.companyId, input.agentId, input.adapterType, ts, ts);
|
||||||
|
getDb()
|
||||||
|
.prepare("UPDATE agents SET session_id = ?, updated_at = ? WHERE id = ?")
|
||||||
|
.run(sessionId, ts, input.agentId);
|
||||||
|
});
|
||||||
|
return sessionId;
|
||||||
|
},
|
||||||
|
|
||||||
|
setAgentSessionId(agentId: string, sessionId: string): void {
|
||||||
|
const ts = nowIso();
|
||||||
|
getDb()
|
||||||
|
.prepare("UPDATE agents SET session_id = ?, updated_at = ? WHERE id = ?")
|
||||||
|
.run(sessionId, ts, agentId);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Point every task currently assigned to an agent at the agent's new
|
||||||
|
* active session. Called after session rotation so UI paths reading
|
||||||
|
* per-task active_session_id still see the agent's current session.
|
||||||
|
*/
|
||||||
|
updateTasksActiveSessionForAgent(agentId: string, sessionId: string): void {
|
||||||
|
const ts = nowIso();
|
||||||
|
getDb()
|
||||||
|
.prepare(
|
||||||
|
"UPDATE tasks SET active_session_id = ?, updated_at = ? WHERE assignee_agent_id = ?",
|
||||||
|
)
|
||||||
|
.run(sessionId, ts, agentId);
|
||||||
|
},
|
||||||
|
|
||||||
|
createNewSession(input: {
|
||||||
|
taskId: string;
|
||||||
|
companyId: string;
|
||||||
|
agentId: string;
|
||||||
|
adapterType: string;
|
||||||
|
sessionParamsJson: string | null;
|
||||||
|
sessionDisplayId: string | null;
|
||||||
|
}): string {
|
||||||
|
const sessionId = randomUUID();
|
||||||
|
const ts = nowIso();
|
||||||
|
getDb()
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO task_sessions (id, company_id, task_id, agent_id, adapter_type,
|
||||||
|
session_params_json, session_display_id, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
sessionId,
|
||||||
|
input.companyId,
|
||||||
|
input.taskId,
|
||||||
|
input.agentId,
|
||||||
|
input.adapterType,
|
||||||
|
input.sessionParamsJson,
|
||||||
|
input.sessionDisplayId,
|
||||||
|
ts,
|
||||||
|
ts,
|
||||||
|
);
|
||||||
|
getDb()
|
||||||
|
.prepare("UPDATE tasks SET active_session_id = ?, updated_at = ? WHERE id = ?")
|
||||||
|
.run(sessionId, ts, input.taskId);
|
||||||
|
return sessionId;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,13 +1,10 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
|
||||||
import type { DatabaseSync } from "node:sqlite";
|
import type { DatabaseSync } from "node:sqlite";
|
||||||
import type { TaskRecord, TaskSessionRecord } from "@shared/types";
|
|
||||||
import {
|
import {
|
||||||
type ReadAll,
|
type ReadAll,
|
||||||
type Transaction,
|
type Transaction,
|
||||||
ensureId,
|
ensureId,
|
||||||
normalizeOptionalId,
|
normalizeOptionalId,
|
||||||
nowIso,
|
nowIso,
|
||||||
taskPriorityOrderSql,
|
|
||||||
} from "./helpers";
|
} from "./helpers";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -168,360 +165,6 @@ export function buildTaskCrudMethods(
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns actionable real work for an agent.
|
|
||||||
*/
|
|
||||||
listAgentTasks(
|
|
||||||
agentId: string,
|
|
||||||
companyId: string,
|
|
||||||
statuses: string[],
|
|
||||||
): TaskRecord[] {
|
|
||||||
const placeholders = statuses.map(() => "?").join(",");
|
|
||||||
return readAll<TaskRecord>(
|
|
||||||
`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, session_json as sessionJson, session_display_id as sessionDisplayId, cost_usd as costUsd, task_type as taskType, created_at as createdAt, updated_at as updatedAt
|
|
||||||
from tasks
|
|
||||||
where company_id = ? and assignee_agent_id = ? and status in (${placeholders})
|
|
||||||
order by
|
|
||||||
case status
|
|
||||||
when 'in_progress' then 0
|
|
||||||
when 'todo' then 1
|
|
||||||
when 'blocked' then 2
|
|
||||||
when 'backlog' then 3
|
|
||||||
else 4
|
|
||||||
end asc,
|
|
||||||
${taskPriorityOrderSql("priority")} desc,
|
|
||||||
created_at asc`,
|
|
||||||
companyId,
|
|
||||||
agentId,
|
|
||||||
...statuses,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the slim task projection (id, title, status, priority) for a
|
|
||||||
* single assignee, ordered by created_at desc.
|
|
||||||
*/
|
|
||||||
listAssigneeTasksForStandup(
|
|
||||||
companyId: string,
|
|
||||||
agentId: string,
|
|
||||||
): Array<{ id: string; title: string; status: string; priority: string }> {
|
|
||||||
return readAll(
|
|
||||||
"select id, title, status, priority from tasks where company_id = ? and assignee_agent_id = ? order by created_at desc",
|
|
||||||
companyId,
|
|
||||||
agentId,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the slim task projection for a set of assignees. Safe on
|
|
||||||
* an empty assignee list (returns []).
|
|
||||||
*/
|
|
||||||
listTasksForAssignees(
|
|
||||||
companyId: string,
|
|
||||||
assigneeIds: string[],
|
|
||||||
): Array<{
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
status: string;
|
|
||||||
priority: string;
|
|
||||||
assigneeAgentId: string;
|
|
||||||
}> {
|
|
||||||
if (assigneeIds.length === 0) return [];
|
|
||||||
const placeholders = assigneeIds.map(() => "?").join(",");
|
|
||||||
return readAll(
|
|
||||||
`select id, title, status, priority, assignee_agent_id as assigneeAgentId from tasks where company_id = ? and assignee_agent_id in (${placeholders}) order by created_at desc`,
|
|
||||||
companyId,
|
|
||||||
...assigneeIds,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the slim task projection used by the me/standup aggregation.
|
|
||||||
* Ordered by created_at desc.
|
|
||||||
*/
|
|
||||||
listTasksForStandup(companyId: string): Array<{
|
|
||||||
id: string;
|
|
||||||
companyId: string;
|
|
||||||
title: string;
|
|
||||||
assigneeAgentId: string | null;
|
|
||||||
status: string;
|
|
||||||
priority: string;
|
|
||||||
}> {
|
|
||||||
return readAll(
|
|
||||||
"select id, company_id as companyId, title, assignee_agent_id as assigneeAgentId, status, priority from tasks where company_id = ? order by created_at desc",
|
|
||||||
companyId,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns tasks filtered by status.
|
|
||||||
*/
|
|
||||||
listTasksByStatuses(
|
|
||||||
companyId: string,
|
|
||||||
statuses: string[],
|
|
||||||
): TaskListByStatusesRow[] {
|
|
||||||
const placeholders = statuses.map(() => "?").join(", ");
|
|
||||||
return readAll<TaskListByStatusesRow & { requiresUserReview: number | boolean }>(
|
|
||||||
`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, session_json as sessionJson, session_display_id as sessionDisplayId, cost_usd as costUsd, 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 company_id = ? and status in (${placeholders}) order by created_at desc`,
|
|
||||||
companyId,
|
|
||||||
...statuses,
|
|
||||||
).map((t) => ({
|
|
||||||
...t,
|
|
||||||
requiresUserReview: Boolean(t.requiresUserReview),
|
|
||||||
startedAt: t.startedAt ?? null,
|
|
||||||
completedAt: t.completedAt ?? null,
|
|
||||||
cancelledAt: t.cancelledAt ?? null,
|
|
||||||
})) as TaskListByStatusesRow[];
|
|
||||||
},
|
|
||||||
|
|
||||||
countActiveTasksForCompany(companyId: string): number {
|
|
||||||
const row = getDb()
|
|
||||||
.prepare(
|
|
||||||
"select count(*) as cnt from tasks where company_id = ? and status not in ('done', 'cancelled')",
|
|
||||||
)
|
|
||||||
.get(companyId) as { cnt: number };
|
|
||||||
return row.cnt;
|
|
||||||
},
|
|
||||||
|
|
||||||
listChildTasks(
|
|
||||||
parentId: string,
|
|
||||||
companyId: string,
|
|
||||||
): Array<{ id: string; status: string; assigneeAgentId: string | null }> {
|
|
||||||
return readAll<{ id: string; status: string; assigneeAgentId: string | null }>(
|
|
||||||
`select id, status, assignee_agent_id as assigneeAgentId from tasks where parent_id = ? and company_id = ?`,
|
|
||||||
parentId,
|
|
||||||
companyId,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
updateTaskSession(
|
|
||||||
taskId: string,
|
|
||||||
sessionJson: Record<string, unknown> | null,
|
|
||||||
sessionDisplayId: string | null,
|
|
||||||
): void {
|
|
||||||
const ts = nowIso();
|
|
||||||
const sessionParamsJson = sessionJson ? JSON.stringify(sessionJson) : null;
|
|
||||||
const taskRow = getDb()
|
|
||||||
.prepare(
|
|
||||||
"select company_id as companyId, assignee_agent_id as assigneeAgentId, active_session_id as activeSessionId from tasks where id = ?",
|
|
||||||
)
|
|
||||||
.get(taskId) as
|
|
||||||
| { companyId: string; assigneeAgentId: string | null; activeSessionId: string | null }
|
|
||||||
| undefined;
|
|
||||||
if (!taskRow) throw new Error(`Task not found: ${taskId}`);
|
|
||||||
if (taskRow.activeSessionId) {
|
|
||||||
getDb()
|
|
||||||
.prepare(
|
|
||||||
"update task_sessions set session_params_json = ?, session_display_id = ?, updated_at = ? where id = ?",
|
|
||||||
)
|
|
||||||
.run(sessionParamsJson, sessionDisplayId, ts, taskRow.activeSessionId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (sessionParamsJson === null && sessionDisplayId === null) return;
|
|
||||||
const agentId = taskRow.assigneeAgentId ?? "unassigned";
|
|
||||||
const agent = taskRow.assigneeAgentId
|
|
||||||
? (getDb()
|
|
||||||
.prepare("select connector_id as connectorId from agents where id = ?")
|
|
||||||
.get(taskRow.assigneeAgentId) as { connectorId: string } | undefined)
|
|
||||||
: undefined;
|
|
||||||
const adapterType = agent?.connectorId ?? "unknown";
|
|
||||||
const sessionId = randomUUID();
|
|
||||||
getDb()
|
|
||||||
.prepare(
|
|
||||||
`insert into task_sessions (id, company_id, task_id, agent_id, adapter_type, session_params_json, session_display_id, created_at, updated_at)
|
|
||||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
)
|
|
||||||
.run(
|
|
||||||
sessionId,
|
|
||||||
taskRow.companyId,
|
|
||||||
taskId,
|
|
||||||
agentId,
|
|
||||||
adapterType,
|
|
||||||
sessionParamsJson,
|
|
||||||
sessionDisplayId,
|
|
||||||
ts,
|
|
||||||
ts,
|
|
||||||
);
|
|
||||||
getDb()
|
|
||||||
.prepare("update tasks set active_session_id = ?, updated_at = ? where id = ?")
|
|
||||||
.run(sessionId, ts, taskId);
|
|
||||||
},
|
|
||||||
|
|
||||||
getTaskSession(taskId: string): {
|
|
||||||
sessionParams: Record<string, unknown> | null;
|
|
||||||
sessionDisplayId: string | null;
|
|
||||||
} {
|
|
||||||
const taskRow = getDb()
|
|
||||||
.prepare("select active_session_id as activeSessionId from tasks where id = ?")
|
|
||||||
.get(taskId) as { activeSessionId: string | null } | undefined;
|
|
||||||
if (!taskRow?.activeSessionId) {
|
|
||||||
return { sessionParams: null, sessionDisplayId: null };
|
|
||||||
}
|
|
||||||
const row = getDb()
|
|
||||||
.prepare(
|
|
||||||
"select session_params_json as sessionParamsJson, session_display_id as sessionDisplayId from task_sessions where id = ?",
|
|
||||||
)
|
|
||||||
.get(taskRow.activeSessionId) as
|
|
||||||
| { sessionParamsJson: string | null; sessionDisplayId: string | null }
|
|
||||||
| undefined;
|
|
||||||
let sessionParams: Record<string, unknown> | null = null;
|
|
||||||
if (row?.sessionParamsJson) {
|
|
||||||
sessionParams = JSON.parse(row.sessionParamsJson) as Record<string, unknown>;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
sessionParams,
|
|
||||||
sessionDisplayId: row?.sessionDisplayId ?? null,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
getActiveTaskSession(taskId: string): TaskSessionRecord | null {
|
|
||||||
const task = getDb()
|
|
||||||
.prepare("SELECT active_session_id FROM tasks WHERE id = ?")
|
|
||||||
.get(taskId) as { active_session_id: string | null } | undefined;
|
|
||||||
if (!task?.active_session_id) return null;
|
|
||||||
return (
|
|
||||||
(getDb()
|
|
||||||
.prepare(
|
|
||||||
`SELECT id, company_id as companyId, task_id as taskId, agent_id as agentId,
|
|
||||||
adapter_type as adapterType, session_params_json as sessionParamsJson,
|
|
||||||
session_display_id as sessionDisplayId, last_run_id as lastRunId,
|
|
||||||
last_error as lastError, run_count as runCount,
|
|
||||||
total_input_tokens as totalInputTokens, total_output_tokens as totalOutputTokens,
|
|
||||||
rotated_at as rotatedAt, rotation_reason as rotationReason,
|
|
||||||
handoff_markdown as handoffMarkdown, created_at as createdAt, updated_at as updatedAt
|
|
||||||
FROM task_sessions WHERE id = ?`,
|
|
||||||
)
|
|
||||||
.get(task.active_session_id) as TaskSessionRecord | undefined) ?? null
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
incrementSessionUsage(
|
|
||||||
sessionId: string,
|
|
||||||
inputTokens: number,
|
|
||||||
outputTokens: number,
|
|
||||||
runId: string,
|
|
||||||
): void {
|
|
||||||
const ts = nowIso();
|
|
||||||
getDb()
|
|
||||||
.prepare(
|
|
||||||
`UPDATE task_sessions
|
|
||||||
SET run_count = run_count + 1,
|
|
||||||
total_input_tokens = total_input_tokens + ?,
|
|
||||||
total_output_tokens = total_output_tokens + ?,
|
|
||||||
last_run_id = ?,
|
|
||||||
updated_at = ?
|
|
||||||
WHERE id = ?`,
|
|
||||||
)
|
|
||||||
.run(inputTokens, outputTokens, runId, ts, sessionId);
|
|
||||||
},
|
|
||||||
|
|
||||||
archiveSession(sessionId: string, reason: string, handoffMarkdown: string): void {
|
|
||||||
const ts = nowIso();
|
|
||||||
getDb()
|
|
||||||
.prepare(
|
|
||||||
`UPDATE task_sessions SET rotated_at = ?, rotation_reason = ?, handoff_markdown = ?, updated_at = ? WHERE id = ?`,
|
|
||||||
)
|
|
||||||
.run(ts, reason, handoffMarkdown, ts, sessionId);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the agent's active session id, creating one lazily on first
|
|
||||||
* call. Session is scoped to the agent (not to any particular task);
|
|
||||||
* switching tasks emits a user turn on the ongoing transcript rather
|
|
||||||
* than spawning a new session.
|
|
||||||
*
|
|
||||||
* Existing agents with pre-migration per-task sessions start fresh
|
|
||||||
* after this upgrade: their first run creates a new agent-scoped
|
|
||||||
* session. The old task_sessions + session_entries rows remain in the
|
|
||||||
* DB as historical records but are not loaded for new runs. This is a
|
|
||||||
* one-time transcript reset, accepted over a risky merge on a live
|
|
||||||
* production DB.
|
|
||||||
*/
|
|
||||||
ensureAgentSession(input: {
|
|
||||||
agentId: string;
|
|
||||||
companyId: string;
|
|
||||||
adapterType: string;
|
|
||||||
}): string {
|
|
||||||
const agentRow = getDb()
|
|
||||||
.prepare("SELECT session_id FROM agents WHERE id = ?")
|
|
||||||
.get(input.agentId) as { session_id: string | null } | undefined;
|
|
||||||
if (agentRow?.session_id) return agentRow.session_id;
|
|
||||||
|
|
||||||
const sessionId = randomUUID();
|
|
||||||
const ts = nowIso();
|
|
||||||
transaction(() => {
|
|
||||||
// task_id is left as empty string — the session is agent-scoped;
|
|
||||||
// the column persists for backwards-compatible schema but no
|
|
||||||
// longer keys the session to a particular task.
|
|
||||||
getDb()
|
|
||||||
.prepare(
|
|
||||||
`INSERT INTO task_sessions (id, company_id, task_id, agent_id, adapter_type,
|
|
||||||
session_params_json, session_display_id, run_count, total_input_tokens,
|
|
||||||
total_output_tokens, created_at, updated_at)
|
|
||||||
VALUES (?, ?, '', ?, ?, NULL, NULL, 0, 0, 0, ?, ?)`,
|
|
||||||
)
|
|
||||||
.run(sessionId, input.companyId, input.agentId, input.adapterType, ts, ts);
|
|
||||||
getDb()
|
|
||||||
.prepare("UPDATE agents SET session_id = ?, updated_at = ? WHERE id = ?")
|
|
||||||
.run(sessionId, ts, input.agentId);
|
|
||||||
});
|
|
||||||
return sessionId;
|
|
||||||
},
|
|
||||||
|
|
||||||
setAgentSessionId(agentId: string, sessionId: string): void {
|
|
||||||
const ts = nowIso();
|
|
||||||
getDb()
|
|
||||||
.prepare("UPDATE agents SET session_id = ?, updated_at = ? WHERE id = ?")
|
|
||||||
.run(sessionId, ts, agentId);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Point every task currently assigned to an agent at the agent's new
|
|
||||||
* active session. Called after session rotation so UI paths reading
|
|
||||||
* per-task active_session_id still see the agent's current session.
|
|
||||||
*/
|
|
||||||
updateTasksActiveSessionForAgent(agentId: string, sessionId: string): void {
|
|
||||||
const ts = nowIso();
|
|
||||||
getDb()
|
|
||||||
.prepare(
|
|
||||||
"UPDATE tasks SET active_session_id = ?, updated_at = ? WHERE assignee_agent_id = ?",
|
|
||||||
)
|
|
||||||
.run(sessionId, ts, agentId);
|
|
||||||
},
|
|
||||||
|
|
||||||
createNewSession(input: {
|
|
||||||
taskId: string;
|
|
||||||
companyId: string;
|
|
||||||
agentId: string;
|
|
||||||
adapterType: string;
|
|
||||||
sessionParamsJson: string | null;
|
|
||||||
sessionDisplayId: string | null;
|
|
||||||
}): string {
|
|
||||||
const sessionId = randomUUID();
|
|
||||||
const ts = nowIso();
|
|
||||||
getDb()
|
|
||||||
.prepare(
|
|
||||||
`INSERT INTO task_sessions (id, company_id, task_id, agent_id, adapter_type,
|
|
||||||
session_params_json, session_display_id, created_at, updated_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
)
|
|
||||||
.run(
|
|
||||||
sessionId,
|
|
||||||
input.companyId,
|
|
||||||
input.taskId,
|
|
||||||
input.agentId,
|
|
||||||
input.adapterType,
|
|
||||||
input.sessionParamsJson,
|
|
||||||
input.sessionDisplayId,
|
|
||||||
ts,
|
|
||||||
ts,
|
|
||||||
);
|
|
||||||
getDb()
|
|
||||||
.prepare("UPDATE tasks SET active_session_id = ?, updated_at = ? WHERE id = ?")
|
|
||||||
.run(sessionId, ts, input.taskId);
|
|
||||||
return sessionId;
|
|
||||||
},
|
|
||||||
|
|
||||||
getRecentRunsForTask(
|
getRecentRunsForTask(
|
||||||
taskId: string,
|
taskId: string,
|
||||||
limit: number,
|
limit: number,
|
||||||
@@ -591,39 +234,6 @@ export function buildTaskCrudMethods(
|
|||||||
.run(ts, taskId);
|
.run(ts, taskId);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the set of distinct assignee agent ids on tasks in a project,
|
|
||||||
* excluding unassigned tasks. Used by message-dispatch to fan out wake
|
|
||||||
* targets for a project channel.
|
|
||||||
*/
|
|
||||||
listProjectTaskAssigneeIds(projectId: string, companyId: string): string[] {
|
|
||||||
const rows = readAll<{ assigneeAgentId: string | null }>(
|
|
||||||
"select distinct assignee_agent_id as assigneeAgentId from tasks where project_id = ? and company_id = ? and assignee_agent_id is not null",
|
|
||||||
projectId,
|
|
||||||
companyId,
|
|
||||||
);
|
|
||||||
return rows.map((r) => r.assigneeAgentId).filter((id): id is string => id !== null);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns assignee agent ids for a set of task ids scoped to a company.
|
|
||||||
* Used by task-quality-gates sweep to find reviewers of in-review tasks
|
|
||||||
* that have exceeded the review timeout.
|
|
||||||
*/
|
|
||||||
listReviewTasksForSweep(companyId: string): Array<{
|
|
||||||
id: string;
|
|
||||||
assigneeAgentId: string | null;
|
|
||||||
updatedAt: string;
|
|
||||||
title: string;
|
|
||||||
}> {
|
|
||||||
return readAll(
|
|
||||||
`SELECT id, assignee_agent_id as assigneeAgentId, updated_at as updatedAt, title
|
|
||||||
FROM tasks
|
|
||||||
WHERE company_id = ? AND status = 'in_review'`,
|
|
||||||
companyId,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns `task_type` for a single task id, or null when the task is
|
* Returns `task_type` for a single task id, or null when the task is
|
||||||
* missing. Used by checkpoint-engine to branch on task type without
|
* missing. Used by checkpoint-engine to branch on task type without
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { DatabaseSync } from "node:sqlite";
|
import type { DatabaseSync } from "node:sqlite";
|
||||||
import type { ReadAll, Transaction } from "./helpers";
|
import type { ReadAll, Transaction } from "./helpers";
|
||||||
import { buildTaskCrudMethods } from "./tasks-crud";
|
import { buildTaskCrudMethods } from "./tasks-crud";
|
||||||
|
import { buildTaskQueryMethods } from "./task-queries";
|
||||||
|
import { buildTaskSessionMethods } from "./task-sessions-crud";
|
||||||
import { buildTaskStatusMethods } from "./tasks-status";
|
import { buildTaskStatusMethods } from "./tasks-status";
|
||||||
|
|
||||||
export function buildTaskMethods(
|
export function buildTaskMethods(
|
||||||
@@ -14,6 +16,8 @@ export function buildTaskMethods(
|
|||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
...buildTaskCrudMethods(getDb, readAll, transaction, deps),
|
...buildTaskCrudMethods(getDb, readAll, transaction, deps),
|
||||||
|
...buildTaskQueryMethods(getDb, readAll, transaction),
|
||||||
|
...buildTaskSessionMethods(getDb, readAll, transaction),
|
||||||
...buildTaskStatusMethods(getDb, readAll, transaction, deps),
|
...buildTaskStatusMethods(getDb, readAll, transaction, deps),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user