diff --git a/src/main/api-server/index.ts b/src/main/api-server/index.ts index fef64baf..348683bc 100644 --- a/src/main/api-server/index.ts +++ b/src/main/api-server/index.ts @@ -21,12 +21,10 @@ export { AgentApiServer }; */ export function createApiServer( db: AppDatabase, - onDomainChanged: () => void, - onWakeAgent: (agentId: string, companyId: string, trigger: string) => string | null, - callbacks?: Partial, + callbacks: ApiServerEventCallbacks, allowedOrigins?: Iterable, ): AgentApiServer { - const server = new AgentApiServer(db, onDomainChanged, onWakeAgent, callbacks, allowedOrigins); + const server = new AgentApiServer(db, callbacks, allowedOrigins); registerAgentRoutes(server); registerAgentControlRoutes(server); registerAgentStandupRoute(server); diff --git a/src/main/api-server/server-core.ts b/src/main/api-server/server-core.ts index ccd47426..0c9f2602 100644 --- a/src/main/api-server/server-core.ts +++ b/src/main/api-server/server-core.ts @@ -83,6 +83,35 @@ export interface ApiServerEventCallbacks { notify: (options: { title: string; body: string; urgency: "critical" | "informational"; navigation?: { section: SectionId; entityId?: string }; batchKey?: string }) => void; } +const REQUIRED_API_SERVER_CALLBACKS: readonly (keyof ApiServerEventCallbacks)[] = [ + "onDomainChanged", + "onWakeAgent", + "onTaskCreated", + "onTaskStatusChanged", + "onCommentPosted", + "onApprovalCreated", + "onApprovalResolved", + "createDocument", + "onAutoAssignTask", + "onHumanMessage", + "injectRuntimeMessage", + "emitEvent", + "notify", +]; + +function assertCompleteApiServerCallbacks( + callbacks: Partial | null | undefined, +): asserts callbacks is ApiServerEventCallbacks { + if (!callbacks) { + throw new Error("Missing AgentApiServer callbacks."); + } + for (const name of REQUIRED_API_SERVER_CALLBACKS) { + if (typeof callbacks[name] !== "function") { + throw new Error(`Missing AgentApiServer callback: ${name}`); + } + } +} + /** * Token-bucket rate limiter scoped per (companyId, agentId). * @@ -139,32 +168,16 @@ export class AgentApiServer { constructor( db: AppDatabase, - onDomainChanged: () => void, - onWakeAgent: (agentId: string, companyId: string, trigger: string) => string | null, - callbacks?: Partial, + callbacks: ApiServerEventCallbacks, allowedOrigins: Iterable = ["app://-"], ) { + assertCompleteApiServerCallbacks(callbacks); this._db = db; - this._onDomainChangedCb = onDomainChanged; - this._onWakeAgentCb = onWakeAgent; - this._emitEventCb = callbacks?.emitEvent ?? (() => {}); + this._onDomainChangedCb = callbacks.onDomainChanged; + this._onWakeAgentCb = callbacks.onWakeAgent; + this._emitEventCb = callbacks.emitEvent; this._allowedOrigins = new Set(Array.from(allowedOrigins).filter(Boolean)); - this._callbacks = { - onDomainChanged, - onWakeAgent, - onTaskCreated: callbacks?.onTaskCreated ?? (() => {}), - onTaskStatusChanged: callbacks?.onTaskStatusChanged ?? (() => {}), - onCommentPosted: callbacks?.onCommentPosted ?? (() => {}), - onApprovalCreated: callbacks?.onApprovalCreated ?? (() => {}), - onApprovalResolved: callbacks?.onApprovalResolved ?? (() => {}), - createDocument: callbacks?.createDocument - ?? (() => { throw new Error("createDocument callback is not wired."); }), - onAutoAssignTask: callbacks?.onAutoAssignTask ?? (() => null), - onHumanMessage: callbacks?.onHumanMessage ?? (() => {}), - injectRuntimeMessage: callbacks?.injectRuntimeMessage ?? (() => false), - emitEvent: callbacks?.emitEvent ?? (() => {}), - notify: callbacks?.notify ?? (() => {}), - }; + this._callbacks = { ...callbacks }; } setBrowserActionHandler(handler: (actionId: string, companyId: string) => void) { diff --git a/src/main/bootstrap/wire-api-server.ts b/src/main/bootstrap/wire-api-server.ts index 15965f2e..60561c97 100644 --- a/src/main/bootstrap/wire-api-server.ts +++ b/src/main/bootstrap/wire-api-server.ts @@ -1,17 +1,13 @@ import log from "electron-log/main.js"; -import type { AgentApiServer } from "../api-server"; +import type { AgentApiServer, ApiServerEventCallbacks } from "../api-server"; import type { AppDatabase } from "../database"; -import type { AgentMessageRecord, DesktopEvent, SectionId } from "@shared/types"; +import type { DesktopEvent } from "@shared/types"; import type { NotificationOptions } from "../notification-service"; import type { createServiceGraph } from "../service-graph"; import type { createThreadIntegration } from "../thread-integration"; import type { createCheckpointEngine } from "../checkpoint-engine"; import type { AutomationEventContext } from "../automation/types"; -import { - createDocumentWithLifecycle, - type DocumentLifecycleInput, - type DocumentLifecycleOutcome, -} from "../document-lifecycle"; +import { createDocumentWithLifecycle } from "../document-lifecycle"; import type { DocumentLifecycleProcessor } from "../document-lifecycle-processor"; type ServiceGraph = ReturnType; @@ -19,21 +15,7 @@ type ThreadIntegration = ReturnType; type CheckpointEngine = ReturnType; export interface CreateApiServerFn { - (db: AppDatabase, callbacks: { - onDomainChanged: () => void; - onWakeAgent: (agentId: string, companyId: string, trigger: string) => string | null; - onTaskCreated: (taskId: string, companyId: string) => void; - onTaskStatusChanged: (taskId: string, companyId: string, prev: string, next: string) => void; - onCommentPosted: (taskId: string, companyId: string, authorAgentId: string | null) => void; - onApprovalCreated: (approvalId: string, companyId: string, taskId: string | null, approvalType: string, summary: string) => void; - onApprovalResolved: (approvalId: string, companyId: string, decision: string) => void; - createDocument: (input: DocumentLifecycleInput) => DocumentLifecycleOutcome; - onAutoAssignTask: (taskId: string, companyId: string) => string | null; - onHumanMessage: (companyId: string, toAgentId?: string | null, threadId?: string | null, waitStateId?: string | null) => void; - injectRuntimeMessage: (recipientAgentId: string, companyId: string, message: AgentMessageRecord) => boolean; - emitEvent: (event: DesktopEvent) => void; - notify: (options: { title: string; body: string; urgency: "critical" | "informational"; navigation?: { section: SectionId; entityId?: string }; batchKey?: string }) => void; - }, allowedOrigins: string[]): AgentApiServer; + (db: AppDatabase, callbacks: ApiServerEventCallbacks, allowedOrigins: string[]): AgentApiServer; } export interface WireApiServerDeps { diff --git a/src/main/main-runtime.ts b/src/main/main-runtime.ts index ba327130..664e369c 100644 --- a/src/main/main-runtime.ts +++ b/src/main/main-runtime.ts @@ -69,13 +69,7 @@ export function createMainRuntime({ devServerUrl, workerPath, createApiServer: (db, callbacks, allowedOrigins) => - createApiServer( - db, - support.publishDomainChanged, - (agentId, companyId, trigger) => callbacks.onWakeAgent(agentId, companyId, trigger), - callbacks, - allowedOrigins, - ), + createApiServer(db, callbacks, allowedOrigins), createRunToken: createAgentToken, seedDefaultAutomationRules: support.seedDefaultAutomationRules, isQuittingRef: { diff --git a/tests/integration/api-recursive-org-execution.spec.ts b/tests/integration/api-recursive-org-execution.spec.ts index 4d816ae0..a2c6cc18 100644 --- a/tests/integration/api-recursive-org-execution.spec.ts +++ b/tests/integration/api-recursive-org-execution.spec.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { createAgentToken, initializeAgentTokenSigningKey } from "../../src/main/agent-auth"; import { createApiServer } from "../../src/main/api-server"; import { AppDatabase } from "../../src/main/database"; +import { createTestApiServerCallbacks } from "../support/api-server-callbacks"; const tempDirs: string[] = []; @@ -83,7 +84,7 @@ async function createHarness() { }); const run = db.runs.createRun({ companyId, taskId, agentId: ctoId, workspaceId, connectorId: "openai-codex" }); const token = createAgentToken(ctoId, companyId, run.id); - const server = createApiServer(db, () => {}, () => null); + const server = createApiServer(db, createTestApiServerCallbacks()); const port = await server.start(); return { diff --git a/tests/integration/api-run-events.spec.ts b/tests/integration/api-run-events.spec.ts index 5bce4c1c..83d01e47 100644 --- a/tests/integration/api-run-events.spec.ts +++ b/tests/integration/api-run-events.spec.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { createAgentToken, initializeAgentTokenSigningKey } from "../../src/main/agent-auth"; import { createApiServer } from "../../src/main/api-server"; import { AppDatabase } from "../../src/main/database"; +import { createTestApiServerCallbacks } from "../support/api-server-callbacks"; const tempDirs: string[] = []; @@ -57,7 +58,7 @@ async function createHarness() { }); const run = db.runs.createRun({ companyId, taskId, agentId, workspaceId, connectorId: "openai-codex" }); const token = createAgentToken(agentId, companyId, run.id); - const server = createApiServer(db, () => {}, () => null); + const server = createApiServer(db, createTestApiServerCallbacks()); const port = await server.start(); return { diff --git a/tests/integration/document-run-provenance.spec.ts b/tests/integration/document-run-provenance.spec.ts index 4f34e836..be78cc5b 100644 --- a/tests/integration/document-run-provenance.spec.ts +++ b/tests/integration/document-run-provenance.spec.ts @@ -7,6 +7,7 @@ import { createApiServer } from "../../src/main/api-server"; import { AppDatabase } from "../../src/main/database"; import { processRunCompletion } from "../../src/main/automation/automation-run-completion"; import { createDocumentWithLifecycle } from "../../src/main/document-lifecycle"; +import { createTestApiServerCallbacks } from "../support/api-server-callbacks"; const tempDirs: string[] = []; @@ -92,7 +93,7 @@ async function createHarness() { const token = createAgentToken(agentId, companyId, run.id); const silentLogger = { warn: () => {}, error: () => {} }; const noopLifecycleProcessor = { drainForDocument: () => {} }; - const server = createApiServer(db, () => {}, () => null, { + const server = createApiServer(db, createTestApiServerCallbacks({ createDocument: (input) => createDocumentWithLifecycle( { @@ -107,7 +108,7 @@ async function createHarness() { }, input, ), - }); + })); const port = await server.start(); return { diff --git a/tests/support/api-server-callbacks.ts b/tests/support/api-server-callbacks.ts new file mode 100644 index 00000000..35ccc25c --- /dev/null +++ b/tests/support/api-server-callbacks.ts @@ -0,0 +1,24 @@ +import type { ApiServerEventCallbacks } from "../../src/main/api-server"; + +export function createTestApiServerCallbacks( + overrides: Partial = {}, +): ApiServerEventCallbacks { + return { + onDomainChanged: () => {}, + onWakeAgent: () => null, + onTaskCreated: () => {}, + onTaskStatusChanged: () => {}, + onCommentPosted: () => {}, + onApprovalCreated: () => {}, + onApprovalResolved: () => {}, + createDocument: () => { + throw new Error("createDocument test callback was not provided."); + }, + onAutoAssignTask: () => null, + onHumanMessage: () => {}, + injectRuntimeMessage: () => false, + emitEvent: () => {}, + notify: () => {}, + ...overrides, + }; +} diff --git a/tests/unit/api-server-callbacks.test.ts b/tests/unit/api-server-callbacks.test.ts new file mode 100644 index 00000000..761f93de --- /dev/null +++ b/tests/unit/api-server-callbacks.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { createApiServer, type ApiServerEventCallbacks } from "../../src/main/api-server"; +import type { AppDatabase } from "../../src/main/database"; +import { createTestApiServerCallbacks } from "../support/api-server-callbacks"; + +describe("api server callback contract", () => { + it("fails during construction when the callback object is missing", () => { + expect(() => { + createApiServer({} as AppDatabase, undefined as unknown as ApiServerEventCallbacks); + }).toThrow("Missing AgentApiServer callbacks."); + }); + + it("fails during construction when a required callback is missing", () => { + const callbacks: Partial = createTestApiServerCallbacks(); + delete callbacks.onTaskCreated; + + expect(() => { + createApiServer({} as AppDatabase, callbacks as ApiServerEventCallbacks); + }).toThrow("Missing AgentApiServer callback: onTaskCreated"); + }); + + it("constructs the server when every callback is explicit", () => { + const callbacks = createTestApiServerCallbacks(); + const server = createApiServer({} as AppDatabase, callbacks); + + expect(server.callbacks.onTaskCreated).toBe(callbacks.onTaskCreated); + expect(server.onDomainChanged).toBe(callbacks.onDomainChanged); + expect(server.onWakeAgent).toBe(callbacks.onWakeAgent); + }); +});