mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:37:55 +01:00
perf(plugins): read runtime identity in one statement without a row lock (#20192)
## 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 <noreply@anthropic.com>
This commit is contained in:
1 parent
3964ee1fca
commit
b4a77b8915
4 files changed
+147
-29
No files matched your search
@@ -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() }]) {
|
||||
|
||||
@@ -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<unknown>) => 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<ReturnType<typeof pluginAgentRuntimeState>> | { 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<ReturnType<typeof pluginAgentRuntimeIdentity>>;
|
||||
};
|
||||
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<unknown>, () => Promise<unknown>]> = [
|
||||
[() => 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<void>(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<void>(resolve => { release = resolve; locked(); });
|
||||
});
|
||||
await held;
|
||||
try {
|
||||
const read = pluginAgentRuntimeIdentity(db, pluginId, companyId, agentId);
|
||||
const timeout = new Promise<never>((_, 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));
|
||||
|
||||
@@ -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<PluginAgentRuntimeIdentity | null>;
|
||||
async function readState(db: Db, pluginId: string, companyId: string, agentId: string, identityOnly?: false): Promise<Omit<PluginAgentRuntimeState, "skills" | "connectionAssignments"> | null>;
|
||||
async function readState(db: Db, pluginId: string, companyId: string, agentId: string, identityOnly = false): Promise<PluginAgentRuntimeIdentity | Omit<PluginAgentRuntimeState, "skills" | "connectionAssignments"> | null> {
|
||||
function assertOwningLifecyclePlugin(plugin: Pick<typeof plugins.$inferSelect, "status" | "manifestJson"> | null | undefined,
|
||||
settings: Pick<typeof pluginCompanySettings.$inferSelect, "enabled"> | 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<Omit<PluginAgentRuntimeState, "skills" | "connectionAssignments"> | 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<PluginAgentRuntimeIdentity | null> {
|
||||
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<PluginAgentRuntimeState | null> {
|
||||
|
||||
@@ -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<RuntimeSecretResolution> {
|
||||
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<Record<string, string>> {
|
||||
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<string, string> = 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;
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user