This commit is contained in:
ZacharyZhang-NY committed 2026-04-05 20:14:48 -04:00
1 parent 09971cabc2
commit 9145572422
163 files changed
+3348 -2874

No files matched your search

+2 -2
View File
@@ -112,8 +112,8 @@ export function buildAgentCrudMethods(
};
const changedKeys = Object.keys(afterConfig).filter(
(k) =>
JSON.stringify((afterConfig as any)[k]) !==
JSON.stringify((beforeConfig as any)[k]),
JSON.stringify((afterConfig as Record<string, unknown>)[k]) !==
JSON.stringify((beforeConfig as Record<string, unknown>)[k]),
);
if (changedKeys.length > 0) {
try {
+1 -3
View File
@@ -1,11 +1,9 @@
import { randomUUID } from "node:crypto";
import type { DatabaseSync } from "node:sqlite";
import type { WorkflowPipelineRecord } from "@shared/types";
import type { ReadAll } from "./helpers";
import type { ReadAll, SqliteRow } from "./helpers";
import { ensureId, nowIso } from "./helpers";
type SqliteRow = Record<string, any>;
export function buildAutomationMethods(
getDb: () => DatabaseSync,
readAll: ReadAll,
+1 -3
View File
@@ -1,11 +1,9 @@
import { randomUUID } from "node:crypto";
import type { DatabaseSync, SQLInputValue } from "node:sqlite";
import type { BudgetIncidentRecord, BudgetPolicyRecord } from "@shared/types";
import type { ReadAll, Transaction } from "./helpers";
import type { ReadAll, SqliteRow, Transaction } from "./helpers";
import { nowIso } from "./helpers";
type SqliteRow = Record<string, any>;
function mapPolicyRow(r: SqliteRow): BudgetPolicyRecord {
return {
id: r.id,
+5 -2
View File
@@ -52,13 +52,16 @@ export function buildCompanySettingsMethods(
).length;
// Avg run duration
const finishedRuns = agentRuns.filter((r) => r.startedAt && r.finishedAt);
const finishedRuns = agentRuns.filter(
(r): r is typeof r & { startedAt: string; finishedAt: string } =>
Boolean(r.startedAt && r.finishedAt),
);
const avgDuration =
finishedRuns.length > 0
? finishedRuns.reduce(
(sum, r) =>
sum +
(new Date(r.finishedAt!).getTime() - new Date(r.startedAt!).getTime()) / 1000,
(new Date(r.finishedAt).getTime() - new Date(r.startedAt).getTime()) / 1000,
0,
) / finishedRuns.length
: 0;
@@ -0,0 +1,80 @@
/**
* Pure helpers for crash-recovery logic, extracted for testability.
*
* These encode the behavioral contract of the corresponding AppDatabase methods:
* - interruptStaleRuns
* - retryInterruptedTask
* - markInterruptedTaskFailed
*
* The helpers take narrowly-typed "statement runner" interfaces so they can be
* exercised in unit tests without a live SQLite connection.
*/
export interface StaleRunDb {
/** Update all running runs to 'interrupted' and return number of rows changed. */
interruptRunningRuns(ts: string): number;
/** Mark tasks associated with interrupted runs as 'blocked'. */
blockInterruptedTasks(ts: string): void;
/** Clear active_run_id on tasks whose run is now interrupted. */
clearActiveRunIds(): void;
/** Reset all running agents to idle. */
resetAgentsToIdle(ts: string): void;
/** Release all workspace locks. */
releaseAllWorkspaceLocks(): void;
}
export interface RetryTaskDb {
/** Set task status to 'todo'. */
setTaskTodo(ts: string, taskId: string): void;
/** Reset retry_count and last_failure_at. */
resetTaskRetryFields(ts: string, taskId: string): void;
}
export interface FailTaskDb {
/** Set task status to 'cancelled'. */
setTaskCancelled(ts: string, taskId: string): void;
}
/**
* Transactionally marks all running runs as interrupted and associated tasks
* as blocked. Returns the number of interrupted runs.
*/
export function interruptStaleRunsLogic(
db: StaleRunDb,
transaction: (fn: () => void) => void,
nowIso: () => string,
): number {
let interruptedCount = 0;
transaction(() => {
const ts = nowIso();
interruptedCount = db.interruptRunningRuns(ts);
if (interruptedCount > 0) {
db.blockInterruptedTasks(ts);
}
db.clearActiveRunIds();
db.resetAgentsToIdle(ts);
db.releaseAllWorkspaceLocks();
});
return interruptedCount;
}
/** Resets a task to 'todo' and clears its retry state. */
export function retryInterruptedTaskLogic(
db: RetryTaskDb,
taskId: string,
nowIso: () => string,
): void {
const ts = nowIso();
db.setTaskTodo(ts, taskId);
db.resetTaskRetryFields(ts, taskId);
}
/** Marks a task as 'cancelled'. */
export function markInterruptedTaskFailedLogic(
db: FailTaskDb,
taskId: string,
nowIso: () => string,
): void {
const ts = nowIso();
db.setTaskCancelled(ts, taskId);
}
+23 -10
View File
@@ -1,11 +1,9 @@
import { randomUUID } from "node:crypto";
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, parseJson } from "./helpers";
type SqliteRow = Record<string, any>;
export function buildDocumentMethods(
getDb: () => DatabaseSync,
readAll: ReadAll,
@@ -218,10 +216,10 @@ export function buildDocumentMethods(
source: string; rolledBackFromRevisionId: string | null;
createdByAgentId: string | null; createdByUserId: string | null; createdAt: string;
}> {
return readAll<any>(
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: SqliteRow) => ({
).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,
@@ -238,7 +236,12 @@ export function buildDocumentMethods(
} {
const row = getDb().prepare(
"SELECT * FROM agent_config_revisions WHERE id = ? AND company_id = ?"
).get(revisionId, companyId) as any;
).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,
@@ -252,14 +255,24 @@ export function buildDocumentMethods(
},
rollbackConfigRevision(agentId: string, revisionId: string, companyId: string, selfDeps: {
getConfigRevision: (revisionId: string, companyId: string) => any;
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: any) => string;
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, any>;
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 = ?"
@@ -270,7 +283,7 @@ export function buildDocumentMethods(
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 as any).department, capabilities: agent.capabilities, connectorId: agent.connectorId, model: agent.model, budgetMonthlyUsd: agent.budgetMonthlyUsd, metadataJson: agent.metadataJson };
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,
+14 -3
View File
@@ -15,7 +15,7 @@ export function buildEntityCrudMethods(
getTask(id: string): TaskRecord {
const row = getDb()
.prepare(
"select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, active_run_id as activeRunId, 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 id = ?",
"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, requires_user_review as requiresUserReview, started_at as startedAt, completed_at as completedAt, cancelled_at as cancelledAt, created_at as createdAt, updated_at as updatedAt from tasks where id = ?",
)
.get(id) as TaskRecord | undefined;
if (!row) throw new Error(`Task not found: ${id}`);
@@ -152,8 +152,19 @@ export function buildEntityCrudMethods(
budgetMonthlyUsd: number; payloadSummary: string; impactSummary: string;
payloadJson?: string | null;
}, selfDeps: {
saveAgent: (input: any) => string;
requestApproval: (input: any) => string;
saveAgent: (input: {
companyId: string; name: string; role: string; title: string;
department: string | null; status: AgentRecord["status"];
reportsTo: string | null; connectorId: ConnectorId;
workspaceId: string | null; model: string | null;
metadataJson: string; capabilities: string; budgetMonthlyUsd: number;
}) => string;
requestApproval: (input: {
companyId: string; requestedByAgentId: string | null;
relatedAgentId: string; type: string;
payloadSummary: string; impactSummary: string;
payloadJson: string | null;
}) => string;
}) {
return transaction(() => {
const agentId = selfDeps.saveAgent({
+2 -4
View File
@@ -4,9 +4,7 @@ import { appendFile } from "node:fs/promises";
import { join } from "node:path";
import type { DatabaseSync, SQLInputValue } from "node:sqlite";
import type { ConnectorId, RunLogChunk, RunRecord } from "@shared/types";
import { type ReadAll, type Transaction, nowIso, toBoolean } from "./helpers";
type SqliteRow = Record<string, any>;
import { type ReadAll, type SqliteRow, type Transaction, nowIso, toBoolean } from "./helpers";
/**
* Find the last complete UTF-8 character boundary in a buffer.
@@ -325,7 +323,7 @@ export function buildRunMethods(
}> {
const offset = opts?.offset ?? 0;
const limit = opts?.limit ?? 500;
return readAll<any>(
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) => ({
+2 -1
View File
@@ -182,7 +182,8 @@ export function runMigrations(
let skipped = 0;
for (const task of tasksWithSessions) {
try {
JSON.parse(task.sessionJson!);
if (!task.sessionJson) continue;
JSON.parse(task.sessionJson);
const agent = task.assigneeAgentId ? db.prepare("SELECT connector_id FROM agents WHERE id = ?").get(task.assigneeAgentId) as { connector_id: string } | undefined : undefined;
const adapterType = agent?.connector_id ?? "unknown";
const sessionId = randomUUID();
+1 -3
View File
@@ -1,10 +1,8 @@
import type { DatabaseSync } from "node:sqlite";
import type { SkillRecord, FileInventoryEntry, SkillSourceType, TrustLevel } from "../skill-types.js";
import type { ReadAll } from "./helpers";
import type { ReadAll, SqliteRow } from "./helpers";
import { nowIso, parseJson } from "./helpers";
type SqliteRow = Record<string, any>;
export function buildSkillMethods(
getDb: () => DatabaseSync,
readAll: ReadAll,
+5 -6
View File
@@ -5,6 +5,7 @@ import type {
ApprovalRecord,
CompanyRecord,
CommentRecord,
ConnectorRecord,
CostRecord,
GoalRecord,
ProfileSnapshot,
@@ -14,9 +15,7 @@ import type {
TaskRecord,
WorkspaceRecord,
} from "@shared/types";
import { type ReadAll, toBoolean } from "./helpers";
type SqliteRow = Record<string, any>;
import { type ReadAll, type SqliteRow, toBoolean } from "./helpers";
const BOARD_READER_ID = "__board__";
export function buildSnapshotMethods(
@@ -25,7 +24,7 @@ export function buildSnapshotMethods(
deps: {
getCurrentCompanyId: () => string | null;
getSetting: (key: string) => string | null;
readConnectors: () => any[];
readConnectors: () => ConnectorRecord[];
profileDir: string;
vaultBackendLabel: () => string;
},
@@ -64,7 +63,7 @@ export function buildSnapshotMethods(
`select id, company_id as companyId, name, local_path as localPath, repo_url as repoUrl, repo_ref as repoRef, project_id as projectId, state, is_primary as isPrimary, created_at as createdAt, updated_at as updatedAt from workspaces${companyWhere} order by created_at desc`,
...companyArgs,
).map((record) => ({ ...record, isPrimary: toBoolean(record.isPrimary) })) as WorkspaceRecord[],
agents: readAll<any>(
agents: readAll<SqliteRow>(
`select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, created_at as createdAt, updated_at as updatedAt from agents${companyWhere} order by created_at asc`,
...companyArgs,
).map((record: SqliteRow) => ({ ...record, heartbeatEnabled: toBoolean(record.heartbeatEnabled) })) as AgentRecord[],
@@ -77,7 +76,7 @@ export function buildSnapshotMethods(
...companyArgs,
),
tasks: 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, 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${companyWhere} order by created_at desc`,
`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, 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${companyWhere} order by created_at desc`,
...companyArgs,
).map((record) => ({ ...record, requiresUserReview: toBoolean(record.requiresUserReview), startedAt: record.startedAt ?? null, completedAt: record.completedAt ?? null, cancelledAt: record.cancelledAt ?? null })) as TaskRecord[],
approvals: readAll<ApprovalRecord>(
+1 -3
View File
@@ -1,11 +1,9 @@
import { randomUUID } from "node:crypto";
import type { DatabaseSync, SQLInputValue } from "node:sqlite";
import type { BrowserActionRecord, SocialAccountRecord } from "@shared/types";
import type { ReadAll, Transaction } from "./helpers";
import type { ReadAll, SqliteRow, Transaction } from "./helpers";
import { ensureId, nowIso, toBoolean } from "./helpers";
type SqliteRow = Record<string, any>;
export function buildSocialMethods(
getDb: () => DatabaseSync,
readAll: ReadAll,
+1 -1
View File
@@ -141,7 +141,7 @@ export function buildTaskCrudMethods(
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, created_at as createdAt, updated_at as updatedAt
`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
+1 -7
View File
@@ -13,13 +13,7 @@ import type {
ThreadWaitingFor,
ThreadWaitStateRecord,
} from "@shared/types";
type SqliteRow = Record<string, any>;
type ReadAll = <T>(sql: string, ...params: SQLInputValue[]) => T[];
function nowIso(): string {
return new Date().toISOString();
}
import { type ReadAll, type SqliteRow, nowIso } from "./helpers";
export function buildThreadMethods(getDb: () => DatabaseSync, readAll: ReadAll) {
function getThread(threadId: string, companyId: string): ConversationThreadRecord | null {
+5 -4
View File
@@ -132,13 +132,14 @@ export function buildWakeupMethods(
promoted = deferred.length;
} else {
const [first, ...rest] = deferred;
if (!first) return 0;
getDb().prepare(
`UPDATE wakeup_requests SET status = 'queued', updated_at = ? WHERE id = ?`,
).run(ts, first!.id);
).run(ts, first.id);
if (rest.length > 0) {
const firstTriggers: Array<{ trigger: string; at: string; detail: string }> = first!.trigger_detail ? JSON.parse(first!.trigger_detail) : [];
const firstTriggers: Array<{ trigger: string; at: string; detail: string }> = first.trigger_detail ? JSON.parse(first.trigger_detail) : [];
let addedCount = 0;
let mergedCtx = first!.context_snapshot;
let mergedCtx = first.context_snapshot;
for (const d of rest) {
const dTriggers: Array<{ trigger: string; at: string; detail: string }> = d.trigger_detail ? JSON.parse(d.trigger_detail) : [];
firstTriggers.push(...dTriggers);
@@ -147,7 +148,7 @@ export function buildWakeupMethods(
}
getDb().prepare(
`UPDATE wakeup_requests SET trigger_detail = ?, coalesced_count = coalesced_count + ?, context_snapshot = ?, updated_at = ? WHERE id = ?`,
).run(JSON.stringify(firstTriggers), addedCount, mergedCtx, ts, first!.id);
).run(JSON.stringify(firstTriggers), addedCount, mergedCtx, ts, first.id);
for (const d of rest) {
getDb().prepare("DELETE FROM wakeup_requests WHERE id = ?").run(d.id);
}