import { isDeepStrictEqual } from "node:util"; import { conflict } from "../../../errors.js"; import { agentConfigurationRevision } from "../../../lib/agent-configuration-revision.js"; import { randomUUID } from "node:crypto"; import { eq } from "drizzle-orm"; import { agents, type Db } from "@paperclipai/db"; import { agentRecordQueries, AGENT_CONFIGURATION_FIELDS, jsonEqual, type AgentConfigurationPatch, type UpdateAgentOptions } from "../../../lib/agent-records.js"; import type { ActivityPublication } from "../../../types/activity-publication.js"; import { AgentLifecycleConflict } from "../domain/policy.js"; import type { LifecycleEffects } from "./effects.js"; export async function updateAgentConfiguration(tx: Db, effects: LifecycleEffects, id: string, data: AgentConfigurationPatch, options?: UpdateAgentOptions, publications: ActivityPublication[] = []) { if (!("nestedIndex" in tx)) throw new AgentLifecycleConflict("Configuration integration requires a database transaction"); assertConfigurationFields(data); const [current] = await tx.select().from(agents).where(eq(agents.id, id)).for("no key update"); if (!current) return null; if (options?.expectedConfigurationRevision && options.expectedConfigurationRevision !== agentConfigurationRevision(current)) { throw conflict("Agent configuration changed while you were editing. Reload the saved configuration and apply your changes again."); } if (options?.expectedConfiguration && Object.entries(options.expectedConfiguration).some(([key, value]) => !isDeepStrictEqual(current[key as keyof typeof current], value))) { throw conflict("Agent configuration changed while saving. Refresh the roster and review the changes again."); } const patch = await effects.prepareConfiguration(tx, current, data, options); assertConfigurationFields(patch); const changed = ["adapterType", "adapterConfig", "runtimeConfig", "defaultEnvironmentId"].some(key => Object.prototype.hasOwnProperty.call(patch, key) && !jsonEqual(patch[key as keyof typeof patch], current[key as keyof typeof current])); const prepareSetup = changed && current.lifecycleHolds.length === 1 && current.lifecycleHolds[0] === "setup" && ["paused", "preparing", "verifying"].includes(current.lifecycleState) && await effects.setupRequiresPreparation(tx, current.companyId, Object.prototype.hasOwnProperty.call(patch, "defaultEnvironmentId") ? patch.defaultEnvironmentId : current.defaultEnvironmentId); const invalidate = changed && (["preparing", "verifying", "resuming"].includes(current.lifecycleState) || prepareSetup); if (invalidate) await effects.notifyWork(tx); const [updated] = await tx.update(agents).set({ ...patch, updatedAt: new Date(), ...(invalidate ? { lifecycleVersion: current.lifecycleVersion + 1, lifecycleError: null, ...(prepareSetup ? { lifecycleState: "preparing" as const } : {}), ...(!prepareSetup && current.lifecycleHolds.includes("setup") && ["preparing", "verifying"].includes(current.lifecycleState) ? { lifecycleState: "pausing" as const } : {}), lifecycleOperation: { ...current.lifecycleOperation!, id: randomUUID(), hostComplete: false, completedPluginIds: [], attempts: 0, leaseOwner: undefined, leaseUntil: undefined, retryAt: undefined, retryRequired: undefined, verificationFailures: undefined }, } : {}) }).where(eq(agents.id, id)).returning(); await effects.completeConfiguration(tx, current, updated, patch, options, publications); return agentRecordQueries(tx).getById(id); } function assertConfigurationFields(data: AgentConfigurationPatch) { if (Object.keys(data).some(field => !AGENT_CONFIGURATION_FIELDS.some(allowed => allowed === field))) { throw new AgentLifecycleConflict("Unsupported configuration field; use the command that owns this field, such as a lifecycle command"); } }