From b4a77b8915c80a39995dd390d0fd49baf8b72b0e Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Sun, 8 Nov 2026 22:49:43 -0800 Subject: [PATCH] perf(plugins): read runtime identity in one statement without a row lock (#20192) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Lifecycle plugins read the agent runtime identity through the plugin host > - The identity read used the full-state path. That path locks the agent row and runs 11 statements > - Plugins call the identity read many times as a freshness fence, so each round trip adds to the run latency > - This pull request makes the identity read one statement on the request pool, without a row lock, and loads instance secrets with their versions in one statement > - The benefit is fewer round trips per read (11 to 4) and no wait behind writers that hold the agent row ## Linked Issues or Issue Description **What existing behavior does this improve?** The plugin host read of the agent runtime identity (`pluginAgentRuntimeIdentity`). **Subsystem affected** Server: plugin agent runtime state and instance secrets (`server/src/services/plugin-agent-runtime-state.ts`, `server/src/services/secrets.ts`). **Current behavior** The identity read shares the full-state transaction. It opens a transaction on the file-storage pool and locks the agent row with `FOR UPDATE`. It reads the plugin, the company settings and the latest lifecycle event in separate statements. Then it reloads each instance secret and its version one at a time. One read costs 11 statements. It also waits behind any writer that holds the agent row, for example managed file publication. **Proposed behavior** The identity read is one statement on the request pool. The statement joins the plugin and the company settings, and reads the latest lifecycle event id in a subquery. The agent fields, the authorization inputs and the revision come from one snapshot. Instance secrets load together with their latest versions in one statement. One read costs 4 statements. **Reason and benefit** A resumed agent run makes about 7 identity reads on its critical path in a hosted deployment. With a 4 ms round trip, the modeled saving is about 330 ms per run at P50. ## What Changed - `pluginAgentRuntimeIdentity` reads the agent, plugin, company settings and latest lifecycle event id in one statement on the request pool. It does not take a row lock. - The authorization check moves into `assertOwningLifecyclePlugin`. The identity path and the full-state path both use it, so both fail with the same forbidden error. - A missing agent still returns `null` before authorization runs. - The full-state path (`readState`) keeps its transaction and its `FOR UPDATE` lock. It no longer has an identity-only branch. - `instanceSecrets.read` loads each secret with its latest version through a left join in one statement. `resolveSecretValueInternal` accepts these preloaded rows and skips the reload when the secret and the version match. It still reloads when they do not match. - The two audit writes per resolved secret run concurrently, as before. - Tests: - `agent-runtime-hooks.test.ts` compares the identity read with the full read across revisions, lifecycle changes and secret changes, and across every forbidden case. It confirms that forbidden reads record no secret access. It confirms that the identity read does not wait for a held agent row lock. - `agent-instance-secrets.test.ts` confirms that a read of rotated secrets uses one select and returns the latest values. It confirms that each read records an access event. It confirms that a revoked version fails with `version_inactive` and a missing version fails with `version_missing`. ### Why dropping the lock is safe The lock exists for the full-state path. That path projects instruction files under the agent lock. The identity path does no projection. It also released the lock before it returned, so no caller could rely on the lock. ### Unchanged behavior - Authorization: the same plugin, settings, capability and required-plugin checks, with the same forbidden error. - Decryption: the same provider resolution from the version material. - Scope checks and version status checks: the same checks on the same rows. - Audit: the same `lastResolvedAt` update and access event for each resolved secret. ## Verification - `pnpm --filter @paperclipai/shared --filter @paperclipai/plugin-sdk build` - `pnpm --dir server run typecheck`: pass. - Targeted tests: pass (6 files, 174 tests). ``` pnpm --dir server exec vitest run \ src/__tests__/agent-runtime-hooks.test.ts \ src/__tests__/agent-instance-secrets.test.ts \ src/__tests__/project-instance-secrets.test.ts \ src/__tests__/secrets-service.test.ts \ src/__tests__/agent-runtime-assignments.test.ts \ src/__tests__/agent-runtime-skills.test.ts ``` - CI runs the full suite. ### Benchmark A local benchmark ran against the embedded test Postgres. A delay proxy simulated network round trips. | Condition | Before | After | |---|---|---| | Statements per read | 11 | 4 | | P50, no added delay | 2.38 ms | 1.47 ms | | P50, 4 ms round trip | 104.0 ms | 32.2 ms | | P50, 4 ms round trip, prepared statements | 51.9 ms | 16.3 ms | The new read costs about 0.3× the old read in round trips. In a hosted deployment, a resumed agent run makes about 7 of these reads on its critical path. The modeled saving is about 330 ms per run at P50. ## Risks - Low risk. - The identity read no longer runs in the same snapshot as a concurrent writer that holds the agent row. It can return the state from before that write commits. The `revision` field still comes from the same snapshot as the agent fields, so callers that use it as a freshness fence see a consistent pair. - The identity read now runs on the request pool, not the file-storage pool. It adds one short query to the request pool and removes one transaction from the file-storage pool. - The preloaded secret path applies only when the secret and the version match the rows from the join. Any other case falls back to the existing reload. - No migration. No API change. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, through Claude Code with tool use and code execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- .../__tests__/agent-instance-secrets.test.ts | 29 ++++++- .../src/__tests__/agent-runtime-hooks.test.ts | 81 ++++++++++++++++++- .../services/plugin-agent-runtime-state.ts | 46 +++++++---- server/src/services/secrets.ts | 20 +++-- 4 files changed, 147 insertions(+), 29 deletions(-) diff --git a/server/src/__tests__/agent-instance-secrets.test.ts b/server/src/__tests__/agent-instance-secrets.test.ts index 2ccadbac7d2..03d2a181d4e 100644 --- a/server/src/__tests__/agent-instance-secrets.test.ts +++ b/server/src/__tests__/agent-instance-secrets.test.ts @@ -1,7 +1,7 @@ import { randomBytes, randomUUID } from "node:crypto"; -import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; -import { eq, sql } from "drizzle-orm"; -import { agents, companies, companySecrets, companySecretVersions, activityLog, plugins, toolApplications, toolConnections, createDb, type Db } from "@paperclipai/db"; +import { afterAll, beforeAll, describe, expect, it, onTestFinished, vi } from "vitest"; +import { and, eq, sql } from "drizzle-orm"; +import { agents, companies, companySecrets, companySecretVersions, activityLog, plugins, secretAccessEvents, toolApplications, toolConnections, createDb, type Db } from "@paperclipai/db"; import type { AgentLifecycleRequest } from "@paperclipai/shared"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; import { pluginAgentRuntimeIdentity } from "../services/plugin-agent-runtime-state.js"; @@ -118,6 +118,29 @@ const support = await getEmbeddedPostgresTestSupport(); await expect(secrets.createBinding({ companyId, secretId: stored.id, targetType: "agent", targetId: f.agent.id, configPath: "env.RUNTIME" })).rejects.toThrow("company-scoped"); }); + it("reads each secret with its latest version in one statement, auditing every read and keeping version failures", async () => { + const f = await fixture(); onTestFinished(async () => { await db.delete(agents).where(eq(agents.id, f.agent.id)); }); + await f.service.apply(f.request, pluginId, { runtime, launcher: "launcher value" }); + await f.service.apply(f.request, pluginId, { launcher: "rotated launcher" }); + const [stored] = await db.select().from(companySecrets).where(and(eq(companySecrets.ownerAgentId, f.agent.id), eq(companySecrets.name, "launcher"))); + expect(stored.latestVersion).toBe(2); + const audit = () => db.select().from(secretAccessEvents).where(eq(secretAccessEvents.secretId, stored.id)).orderBy(secretAccessEvents.createdAt); + const selects = vi.spyOn(db, "select"); + expect(await f.service.read(f.scope)).toEqual({ runtime, launcher: "rotated launcher" }); + expect(selects).toHaveBeenCalledTimes(1); + selects.mockRestore(); + expect((await audit()).at(-1)).toMatchObject({ outcome: "success", version: 2, consumerType: "plugin_worker", consumerId: pluginId, pluginId, actorType: "plugin" }); + const audited = (await audit()).length; + expect(await f.service.read(f.scope)).toEqual({ runtime, launcher: "rotated launcher" }); + expect(await audit()).toHaveLength(audited + 1); + await db.update(companySecretVersions).set({ revokedAt: new Date() }).where(and(eq(companySecretVersions.secretId, stored.id), eq(companySecretVersions.version, 2))); + await expect(f.service.read(f.scope)).rejects.toThrow("Agent instance secret unavailable"); + expect((await audit()).at(-1)).toMatchObject({ outcome: "failure", errorCode: "version_inactive", version: 2 }); + await db.delete(companySecretVersions).where(and(eq(companySecretVersions.secretId, stored.id), eq(companySecretVersions.version, 2))); + await expect(f.service.read(f.scope)).rejects.toThrow("Agent instance secret unavailable"); + expect((await audit()).at(-1)).toMatchObject({ outcome: "failure", errorCode: "version_missing", version: 2 }); + }); + it("fences stale writes and removals, and cascades credentials when the owning agent is deleted", async () => { const f = await fixture(); await f.service.apply(f.request, pluginId, { runtime }); for (const patch of [{ version: 2 }, { operationId: randomUUID() }, { phase: "cleaning_up" }, { companyId: randomUUID() }, { agentId: randomUUID() }]) { diff --git a/server/src/__tests__/agent-runtime-hooks.test.ts b/server/src/__tests__/agent-runtime-hooks.test.ts index 9ed76d192ab..0ec53c4f061 100644 --- a/server/src/__tests__/agent-runtime-hooks.test.ts +++ b/server/src/__tests__/agent-runtime-hooks.test.ts @@ -1,12 +1,12 @@ import * as databaseModule from "@paperclipai/db"; -import { randomUUID } from "node:crypto"; +import { randomBytes, randomUUID } from "node:crypto"; import { createServer, connect, type Socket } from "node:net"; import { EventEmitter } from "node:events"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { and, eq, sql } from "drizzle-orm"; -import { activityLog, agents, companies, applyPendingMigrations, closeFileStorageDb, createDb, pluginCompanySettings, pluginConfig, plugins, resourceLifecycleEvents, type Db } from "@paperclipai/db"; +import { activityLog, agents, companies, applyPendingMigrations, closeFileStorageDb, createDb, pluginCompanySettings, pluginConfig, plugins, resourceLifecycleEvents, secretAccessEvents, type Db } from "@paperclipai/db"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; import * as instructionModule from "../services/agent-instructions.js"; @@ -18,6 +18,7 @@ import { idleWorkSnapshot } from "../services/task-admission.js"; import { resolveManagedInstructionsRoot } from "../services/agent-instructions.js"; import { agentFileStore } from "../services/agent-file-store.js"; import { agentInstructionRevisionService } from "../services/agent-instruction-revisions.js"; +import { secretService } from "../services/secrets.js"; const externalUrl = process.env.PAPERCLIP_AGENT_RUNTIME_HOOKS_TEST_DATABASE_URL; const expectedDatabase = process.env.PAPERCLIP_AGENT_RUNTIME_HOOKS_EXPECTED_DATABASE; @@ -221,6 +222,82 @@ describeDatabase("agent runtime synchronization hooks", () => { await expect(pluginAgentRuntimeIdentity(db, pluginId, companyId, agentId)).rejects.toMatchObject({ status: 403 }); }); + it("returns the same identity, revision, secrets and failures as the full runtime read", async () => { + vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", randomBytes(32).toString("base64")); + const scope = { companyId, agentId, pluginId }; + const outcome = (read: Promise) => read.then(value => value, (error: { status?: number }) => ({ status: error.status })); + const same = async (company = companyId, agent = agentId, plugin = pluginId) => { + const state = await outcome(pluginAgentRuntimeState(db, plugin, company, agent)) as Awaited> | { status: number }; + const identity = await outcome(pluginAgentRuntimeIdentity(db, plugin, company, agent)); + expect(identity).toEqual(state && "revision" in state ? { revision: state.revision, instanceSecrets: state.instanceSecrets, agent: { + id: state.agent.id, companyId: state.agent.companyId, status: state.agent.status, lifecycleState: state.agent.lifecycleState, lifecycleVersion: state.agent.lifecycleVersion } } : state); + return identity as Awaited>; + }; + await secretService(db).agentInstance.set(scope, "launcher", "first launcher"); + const first = await same(); + expect(first).toEqual({ revision: String((await events()).at(-1)!.id), instanceSecrets: { launcher: "first launcher" }, + agent: { id: agentId, companyId, status: "idle", lifecycleState: "ready", lifecycleVersion: 0 } }); + await db.insert(resourceLifecycleEvents).values([{ companyId, resourceType: "agent", resourceId: agentId, action: "pause" }, + { companyId, resourceType: "agent_runtime", resourceId: randomUUID(), action: "update" }]); + expect((await same())!.revision).toBe(first!.revision); + const [latest] = await db.insert(resourceLifecycleEvents).values({ companyId, resourceType: "agent_runtime", resourceId: agentId, action: "update" }).returning(); + expect((await same())!.revision).toBe(String(latest.id)); + await db.update(agents).set({ lifecycleState: "preparing", lifecycleVersion: 3 }).where(eq(agents.id, agentId)); + expect((await same())!.agent).toMatchObject({ lifecycleState: "preparing", lifecycleVersion: 3 }); + await secretService(db).agentInstance.set(scope, "launcher", "second launcher"); + expect((await same())!.instanceSecrets).toEqual({ launcher: "second launcher" }); + await secretService(db).agentInstance.set(scope, "launcher", null); + expect((await same())!.instanceSecrets).toEqual({}); + await secretService(db).agentInstance.set(scope, "launcher", "third launcher"); + const accessEvents = async () => (await db.select().from(secretAccessEvents).where(eq(secretAccessEvents.companyId, companyId))).length; + const audited = await accessEvents(); + expect(await same(randomUUID())).toBeNull(); + expect(await same(companyId, randomUUID())).toBeNull(); + expect(await same(companyId, agentId, randomUUID())).toEqual({ status: 403 }); + const manifest = { resourceLifecycle: ["agent_runtime"], capabilities: ["events.subscribe", "agents.lifecycle.manage"] }; + const denials: Array<[() => Promise, () => Promise]> = [ + [() => db.update(plugins).set({ status: "error" }).where(eq(plugins.id, pluginId)), () => db.update(plugins).set({ status: "ready" }).where(eq(plugins.id, pluginId))], + [() => db.update(plugins).set({ manifestJson: { ...manifest, capabilities: ["events.subscribe"] } as never }).where(eq(plugins.id, pluginId)), + () => db.update(plugins).set({ manifestJson: manifest as never }).where(eq(plugins.id, pluginId))], + [() => db.update(plugins).set({ manifestJson: { ...manifest, resourceLifecycle: [] } as never }).where(eq(plugins.id, pluginId)), + () => db.update(plugins).set({ manifestJson: manifest as never }).where(eq(plugins.id, pluginId))], + [() => db.insert(pluginCompanySettings).values({ companyId, pluginId, enabled: false }), () => db.delete(pluginCompanySettings).where(eq(pluginCompanySettings.pluginId, pluginId))], + [() => db.update(agents).set({ lifecycleRequiredPluginIds: [randomUUID()] }).where(eq(agents.id, agentId)), + () => db.update(agents).set({ lifecycleRequiredPluginIds: [pluginId] }).where(eq(agents.id, agentId))], + [() => db.update(agents).set({ lifecycleRequiredPluginIds: null }).where(eq(agents.id, agentId)), + () => db.update(agents).set({ lifecycleRequiredPluginIds: [pluginId] }).where(eq(agents.id, agentId))], + ]; + for (const [deny, restore] of denials) { + await deny(); + expect(await same()).toEqual({ status: 403 }); + await restore(); + } + expect(await accessEvents()).toBe(audited); + await db.insert(pluginCompanySettings).values({ companyId, pluginId, enabled: true }); + expect((await same())!.instanceSecrets).toEqual({ launcher: "third launcher" }); + }); + + it("reads runtime identity from a snapshot without waiting for an agent row lock", async () => { + let locked!: () => void; + let release!: () => void; + const held = new Promise(resolve => { locked = resolve; }); + const writer = db.transaction(async tx => { + await tx.select().from(agents).where(eq(agents.id, agentId)).for("update"); + await tx.update(agents).set({ lifecycleState: "pausing", lifecycleVersion: 5 }).where(eq(agents.id, agentId)); + await new Promise(resolve => { release = resolve; locked(); }); + }); + await held; + try { + const read = pluginAgentRuntimeIdentity(db, pluginId, companyId, agentId); + const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error("identity read waited for the agent lock")), 5_000)); + expect((await Promise.race([read, timeout]))!.agent).toMatchObject({ lifecycleState: "ready", lifecycleVersion: 0 }); + } finally { + release(); + await writer; + } + expect((await pluginAgentRuntimeIdentity(db, pluginId, companyId, agentId))!.agent).toMatchObject({ lifecycleState: "pausing", lifecycleVersion: 5 }); + }); + it("uses the company reporting graph for runtime health data", async () => { const [manager] = await db.insert(agents).values({ companyId, name: "Manager" }).returning(); await db.update(agents).set({ reportsTo: manager.id }).where(eq(agents.id, agentId)); diff --git a/server/src/services/plugin-agent-runtime-state.ts b/server/src/services/plugin-agent-runtime-state.ts index 1478ba98399..57ee68cea44 100644 --- a/server/src/services/plugin-agent-runtime-state.ts +++ b/server/src/services/plugin-agent-runtime-state.ts @@ -1,4 +1,4 @@ -import { and, desc, eq } from "drizzle-orm"; +import { and, desc, eq, sql } from "drizzle-orm"; import { agents, environments, plugins, pluginCompanySettings, resourceLifecycleEvents, fileStorageDb, type Db } from "@paperclipai/db"; import type { PluginAgentRuntimeState, PluginAgentRuntimeIdentity } from "@paperclipai/plugin-sdk"; import { agentRecordQueries } from "../lib/agent-records.js"; @@ -13,28 +13,25 @@ import { instanceSettingsService } from "./instance-settings.js"; import { agentInstanceSecretService } from "./agent-instance-secrets.js"; import { environmentSkillConfig } from "./environment-skills.js"; -async function readState(db: Db, pluginId: string, companyId: string, agentId: string, identityOnly: true): Promise; -async function readState(db: Db, pluginId: string, companyId: string, agentId: string, identityOnly?: false): Promise | null>; -async function readState(db: Db, pluginId: string, companyId: string, agentId: string, identityOnly = false): Promise | null> { +function assertOwningLifecyclePlugin(plugin: Pick | null | undefined, + settings: Pick | null | undefined, requiredPluginIds: string[] | null | undefined, pluginId: string) { + if (plugin?.status !== "ready" || settings?.enabled === false || !plugin?.manifestJson.capabilities?.includes("agents.lifecycle.manage") + || !plugin.manifestJson.resourceLifecycle?.includes("agent_runtime") || !requiredPluginIds?.includes(pluginId)) { + throw forbidden("Agent runtime state requires the owning lifecycle plugin"); + } +} + +async function readState(db: Db, pluginId: string, companyId: string, agentId: string): Promise | null> { return fileStorageDb(db).transaction(async tx => { const [agent] = await tx.select().from(agents) .where(and(eq(agents.companyId, companyId), eq(agents.id, agentId))).for("update"); if (!agent) return null; const [plugin] = await tx.select().from(plugins).where(eq(plugins.id, pluginId)); const [settings] = await tx.select().from(pluginCompanySettings).where(and(eq(pluginCompanySettings.companyId, companyId), eq(pluginCompanySettings.pluginId, pluginId))); - if (plugin?.status !== "ready" || settings?.enabled === false || !plugin?.manifestJson.capabilities?.includes("agents.lifecycle.manage") - || !plugin.manifestJson.resourceLifecycle?.includes("agent_runtime") || !agent.lifecycleRequiredPluginIds?.includes(pluginId)) { - throw forbidden("Agent runtime state requires the owning lifecycle plugin"); - } + assertOwningLifecyclePlugin(plugin, settings, agent.lifecycleRequiredPluginIds, pluginId); const [latest] = await tx.select({ id: resourceLifecycleEvents.id }).from(resourceLifecycleEvents) .where(and(eq(resourceLifecycleEvents.companyId, companyId), eq(resourceLifecycleEvents.resourceType, "agent_runtime"), eq(resourceLifecycleEvents.resourceId, agentId))) .orderBy(desc(resourceLifecycleEvents.id)).limit(1); - if (identityOnly) return { - revision: String(latest?.id ?? 0), - agent: { id: agent.id, companyId: agent.companyId, status: agent.status as PluginAgentRuntimeIdentity["agent"]["status"], - lifecycleState: agent.lifecycleState, lifecycleVersion: agent.lifecycleVersion }, - instanceSecrets: await agentInstanceSecretService(tx as unknown as Db).read({ companyId, agentId, pluginId }), - }; const instructions = await agentInstructionsService(tx as unknown as Db).getBundleLocation(agent); const records = agentRecordQueries(tx as unknown as Db); const companyAgents = await records.listCompanyAgentEligibilityRows(companyId); @@ -46,9 +43,26 @@ async function readState(db: Db, pluginId: string, companyId: string, agentId: s }); } - export async function pluginAgentRuntimeIdentity(db: Db, pluginId: string, companyId: string, agentId: string): Promise { - return readState(db, pluginId, companyId, agentId, true); + const [row] = await db.select({ + id: agents.id, companyId: agents.companyId, status: agents.status, lifecycleState: agents.lifecycleState, + lifecycleVersion: agents.lifecycleVersion, requiredPluginIds: agents.lifecycleRequiredPluginIds, + plugin: { status: plugins.status, manifestJson: plugins.manifestJson }, settings: { enabled: pluginCompanySettings.enabled }, + revision: sql`(${db.select({ id: resourceLifecycleEvents.id }).from(resourceLifecycleEvents) + .where(and(eq(resourceLifecycleEvents.companyId, companyId), eq(resourceLifecycleEvents.resourceType, "agent_runtime"), eq(resourceLifecycleEvents.resourceId, agentId))) + .orderBy(desc(resourceLifecycleEvents.id)).limit(1)})`.mapWith(resourceLifecycleEvents.id), + }).from(agents) + .leftJoin(plugins, eq(plugins.id, pluginId)) + .leftJoin(pluginCompanySettings, and(eq(pluginCompanySettings.companyId, companyId), eq(pluginCompanySettings.pluginId, pluginId))) + .where(and(eq(agents.companyId, companyId), eq(agents.id, agentId))); + if (!row) return null; + assertOwningLifecyclePlugin(row.plugin, row.settings, row.requiredPluginIds, pluginId); + return { + revision: String(row.revision ?? 0), + agent: { id: row.id, companyId: row.companyId, status: row.status as PluginAgentRuntimeIdentity["agent"]["status"], + lifecycleState: row.lifecycleState, lifecycleVersion: row.lifecycleVersion }, + instanceSecrets: await agentInstanceSecretService(db).read({ companyId, agentId, pluginId }), + }; } export async function pluginAgentRuntimeState(db: Db, pluginId: string, companyId: string, agentId: string, environmentId?: string, installEnvironmentSkills = false): Promise { diff --git a/server/src/services/secrets.ts b/server/src/services/secrets.ts index 2730f1bc7c9..730c15c9059 100644 --- a/server/src/services/secrets.ts +++ b/server/src/services/secrets.ts @@ -1359,10 +1359,11 @@ export function secretService(db: Db | DbTransaction) { version: number | "latest", options?: SecretResolutionOptions, readOnlyEnvironmentInspection = false, + preloaded?: { secret: typeof companySecrets.$inferSelect; version: typeof companySecretVersions.$inferSelect | null }, ): Promise { const bindingContext = options?.bindingContext; const accessContext = options?.accessContext ?? bindingContext; - const secret = await getById(secretId); + const secret = preloaded && preloaded.secret.id === secretId ? preloaded.secret : await getById(secretId); if (!secret) throw notFound("Secret not found"); if (secret.companyId !== companyId) throw unprocessable("Secret must belong to same company"); const instanceOwned = secret.scope === "instance" && options?.instanceOwner && @@ -1384,7 +1385,8 @@ export function secretService(db: Db | DbTransaction) { throw unprocessable("Secret is not active", { code: "secret_inactive" }); } const binding = await assertBindingContext(companyId, secret.id, bindingContext); - const versionRow = await getSecretVersion(secret.id, resolvedVersion); + const versionRow = preloaded?.secret === secret && preloaded.version?.version === resolvedVersion + ? preloaded.version : await getSecretVersion(secret.id, resolvedVersion); if (!versionRow) throw new HttpError(404, "Secret version not found", { code: "version_missing" }); if (versionRow.status === "disabled" || versionRow.status === "destroyed" || versionRow.revokedAt) { throw unprocessable("Secret version is not active", { code: "version_inactive" }); @@ -3513,16 +3515,18 @@ export function secretService(db: Db | DbTransaction) { const instanceSecrets = { async read(scope: InstanceSecretScope): Promise> { - const rows = await db.select().from(companySecrets).where(and(eq(companySecrets.companyId, scope.companyId), - eq(companySecrets.scope, "instance"), scope.agentId !== undefined ? eq(companySecrets.ownerAgentId, scope.agentId) : eq(companySecrets.ownerProjectId, scope.projectId), - eq(companySecrets.ownerPluginId, scope.pluginId), ne(companySecrets.status, "deleted"))); + const rows = await db.select({ secret: companySecrets, version: companySecretVersions }).from(companySecrets) + .leftJoin(companySecretVersions, and(eq(companySecretVersions.secretId, companySecrets.id), eq(companySecretVersions.version, companySecrets.latestVersion))) + .where(and(eq(companySecrets.companyId, scope.companyId), + eq(companySecrets.scope, "instance"), scope.agentId !== undefined ? eq(companySecrets.ownerAgentId, scope.agentId) : eq(companySecrets.ownerProjectId, scope.projectId), + eq(companySecrets.ownerPluginId, scope.pluginId), ne(companySecrets.status, "deleted"))); const values: Record = Object.create(null); for (const row of rows) { - const resolved = await resolveSecretValueInternal(scope.companyId, row.id, "latest", { + const resolved = await resolveSecretValueInternal(scope.companyId, row.secret.id, "latest", { instanceOwner: scope, accessContext: { consumerType: "plugin_worker", consumerId: scope.pluginId, actorType: "plugin", actorId: scope.pluginId, pluginId: scope.pluginId }, - }); - values[row.name] = resolved.value; + }, false, row); + values[row.secret.name] = resolved.value; } return values; },