diff --git a/.changeset/20260902194500-truefoundry-agent-store.md b/.changeset/20260902194500-truefoundry-agent-store.md new file mode 100644 index 000000000..bbadca89b --- /dev/null +++ b/.changeset/20260902194500-truefoundry-agent-store.md @@ -0,0 +1,5 @@ +--- +"@truefoundry/trueforge": patch +--- + +Sync ServiceFoundry remote agents on create/update/delete and store the remote id in `external_id`. Filter `listAgents` by `external_ids`. Keep general ServiceFoundry HTTP at 10s and agent CRUD calls at 3s. diff --git a/.changeset/20260903013000-reserve-agent-names.md b/.changeset/20260903013000-reserve-agent-names.md new file mode 100644 index 000000000..a5de3d31b --- /dev/null +++ b/.changeset/20260903013000-reserve-agent-names.md @@ -0,0 +1,5 @@ +--- +"@truefoundry/trueforge": patch +--- + +Reject reserved agent names `tfg` and `trueforge` in create requests. diff --git a/packages/trueforge/.env.example b/packages/trueforge/.env.example index 3f832bd19..5f6198325 100644 --- a/packages/trueforge/.env.example +++ b/packages/trueforge/.env.example @@ -27,6 +27,10 @@ PORT=8790 ## ServiceFoundry server (caller token) and turns call the tenant's default AI Gateway. Unset = local ## API-key model catalog. Mutually exclusive with OIDC. # TRUEFOUNDRY_SERVICEFOUNDRY_SERVER_URL=https://servicefoundry-server.truefoundry.svc.cluster.local +## Max ms for non-agent ServiceFoundry HTTP calls. Default 10000. +# TRUEFOUNDRY_SERVICEFOUNDRY_HTTP_TIMEOUT_MS=10000 +## Max ms for ServiceFoundry agent create/update/delete calls. Default 3000. +# TRUEFOUNDRY_SERVICEFOUNDRY_HTTP_AGENT_TIMEOUT_MS=3000 ## Internal mutual TLS for calls to the ServiceFoundry server. When true, trueforge presents its client ## certificate (and upgrades a mesh-direct http:// peer URL to https://). Only meaningful in-cluster, diff --git a/packages/trueforge/scripts/write-openapi.ts b/packages/trueforge/scripts/write-openapi.ts index ee491b2ec..50249169b 100644 --- a/packages/trueforge/scripts/write-openapi.ts +++ b/packages/trueforge/scripts/write-openapi.ts @@ -60,6 +60,7 @@ function canonicalise(value: unknown): unknown { const sessionStore = new InMemorySessionStore(); const db = createSqliteDb(':memory:'); const tokenStore = new SqliteOAuthTokenStore(db); +const agentStore = new SqliteAgentStore(db); const app = createServerApp({ modelCatalog: ModelCatalog.load(), resolveModelProviderStore: () => new SqliteModelProviderStore(db), @@ -76,7 +77,7 @@ const app = createServerApp({ skillStore: new SqliteSkillStore(db), sandboxCatalog: SandboxCatalog.load(), sandboxProviderStore: new SqliteSandboxProviderStore(db), - agentStore: new SqliteAgentStore(db), + resolveAgentStore: () => agentStore, scheduleStore: new SqliteScheduleStore(db), sessionStore, sessionMetricsStore: new SqliteSessionMetricsStore(db), diff --git a/packages/trueforge/src/apis/agents.ts b/packages/trueforge/src/apis/agents.ts index c17152971..02fc2f315 100644 --- a/packages/trueforge/src/apis/agents.ts +++ b/packages/trueforge/src/apis/agents.ts @@ -5,7 +5,12 @@ import { OpenAPIHono, type RouteHandler } from '@hono/zod-openapi'; import type { AgentSpec } from '@truefoundry/trueforge-core/agent-session'; import type { Context } from 'hono'; import { createdBySubjectFromRequestContext, type ResolveRequestContext } from '../auth/identity'; -import { AgentNameConflictError, type AgentRecord, type IAgentStore } from '../db/agentStore'; +import { + AgentExternalIdConflictError, + AgentNameConflictError, + type AgentRecord, + type IAgentStore, +} from '../db/agentStore'; import type { IMcpServerStore } from '../db/mcpServerStore'; import type { IModelProviderStore } from '../db/modelProviderStore'; import type { ISandboxProviderStore } from '../db/sandboxProviderStore'; @@ -24,7 +29,7 @@ import { type Agent, type CreateAgentRequest } from '../schemas/agent'; import { buildAgentCodeSnippets } from './agentCodeSnippets'; export interface AgentsRouterDeps { - agentStore: IAgentStore; + resolveAgentStore: (c: Context) => IAgentStore; resolveModelProviderStore: (c: Context) => IModelProviderStore; resolveMcpServerStore: (c: Context) => IMcpServerStore; skillStore: ISkillStore; @@ -70,7 +75,7 @@ async function validateManifest({ export function createAgentsRouter(deps: AgentsRouterDeps) { const listHandler: RouteHandler = async c => { const requestContext = deps.resolveRequestContext(c); - const records = await deps.agentStore.listAgents(requestContext.tenant_id); + const records = await deps.resolveAgentStore(c).listAgents({ tenant_id: requestContext.tenant_id }); return c.json({ data: records.map(toWireAgent) }, 200); }; @@ -85,7 +90,7 @@ export function createAgentsRouter(deps: AgentsRouterDeps(deps: AgentsRouterDeps(deps: AgentsRouterDeps = async c => { const { agent_id: agentId } = c.req.valid('param'); const requestContext = deps.resolveRequestContext(c); - const record = await deps.agentStore.getAgent({ + const record = await deps.resolveAgentStore(c).getAgent({ tenant_id: requestContext.tenant_id, id: agentId, }); @@ -117,7 +122,7 @@ export function createAgentsRouter(deps: AgentsRouterDeps = async c => { const { agent_id: agentId } = c.req.valid('param'); const requestContext = deps.resolveRequestContext(c); - const record = await deps.agentStore.getAgent({ + const record = await deps.resolveAgentStore(c).getAgent({ tenant_id: requestContext.tenant_id, id: agentId, }); @@ -138,7 +143,7 @@ export function createAgentsRouter(deps: AgentsRouterDeps = async c => { const { agent_id: agentId } = c.req.valid('param'); const requestContext = deps.resolveRequestContext(c); - await deps.agentStore.deleteAgent({ tenant_id: requestContext.tenant_id, id: agentId }); + await deps.resolveAgentStore(c).deleteAgent({ tenant_id: requestContext.tenant_id, id: agentId }); return c.json({}, 200); }; @@ -153,7 +158,7 @@ export function createAgentsRouter(deps: AgentsRouterDeps { scheduleStore: IScheduleStore; - agentStore: IAgentStore; + resolveAgentStore: (c: Context) => IAgentStore; sessions: Sessions; turnDeps: BeginTurnExecutionDeps; withTransaction: WithTransaction; @@ -200,7 +201,7 @@ export function createSchedulesRouter(deps: SchedulesRouterDeps { await startTurnInProcess({ ...turnParams, deps: deps.turnDeps }); }, @@ -235,7 +236,7 @@ export function createSchedulesRouter(deps: SchedulesRouterDeps IModelProviderStore; resolveMcpServerStore: (c: Context) => IMcpServerStore; skillStore: ISkillStore; - agentStore: IAgentStore; + resolveAgentStore: (c: Context) => IAgentStore; sandboxProviderStore: ISandboxProviderStore; redis?: RedisClientType | undefined; requestReplyRouter: RequestReplyRouter; @@ -230,7 +230,7 @@ type InternalSessionsRouterDeps = Pick< | 'resolveModelProviderStore' | 'resolveMcpServerStore' | 'skillStore' - | 'agentStore' + | 'resolveAgentStore' | 'sandboxProviderStore' | 'resolveRequestContext' >; @@ -260,7 +260,7 @@ function createGetOrCreateSessionByExternalIdHandler( let agent: SessionRecord['agent']; if (isSessionAgentNameRef(body.agent)) { - const named = await deps.agentStore.getAgent({ + const named = await deps.resolveAgentStore(c).getAgent({ tenant_id: requestContext.tenant_id, name: body.agent.name, }); @@ -314,7 +314,7 @@ export function createSessionsRouter(deps: SessionsRouterDeps) { const requestContext = deps.resolveRequestContext(c); if (isSessionAgentNameRef(body.agent)) { - const agent = await deps.agentStore.getAgent({ + const agent = await deps.resolveAgentStore(c).getAgent({ tenant_id: requestContext.tenant_id, name: body.agent.name, }); diff --git a/packages/trueforge/src/apis/turns.ts b/packages/trueforge/src/apis/turns.ts index a04b538c5..b4ca00eba 100644 --- a/packages/trueforge/src/apis/turns.ts +++ b/packages/trueforge/src/apis/turns.ts @@ -106,7 +106,7 @@ export interface TurnsRouterDeps { resolveModelProviderStore: (c: Context) => IModelProviderStore; resolveMcpServerStore: (c: Context) => IMcpServerWithAuthStore; skillStore: ISkillStore; - agentStore: IAgentStore; + resolveAgentStore: (c: Context) => IAgentStore; /** Resumable live turn-event transport: create-turn writes, subscribe polls. */ eventSubscriptions: EventSubscriptionRegistry; sandboxProviderStore: ISandboxProviderStore; @@ -116,15 +116,16 @@ export interface TurnsRouterDeps { /** * Deps needed to create a turn and drain events in-process (no HTTP). Unlike the HTTP path, this - * carries already-resolved `modelProviderStore` / `mcpServerStore` (the scheduler has no request + * carries already-resolved `modelProviderStore` / `mcpServerStore` / `agentStore` (the scheduler has no request * context to resolve them). */ export type BeginTurnExecutionDeps = Pick< TurnsRouterDeps, - 'activeTurns' | 'eventSubscriptions' | 'skillStore' | 'agentStore' | 'sandboxProviderStore' | 'logger' + 'activeTurns' | 'eventSubscriptions' | 'skillStore' | 'sandboxProviderStore' | 'logger' > & { modelProviderStore: IModelProviderStore; mcpServerStore: IMcpServerWithAuthStore; + agentStore: IAgentStore; }; /** @@ -721,6 +722,7 @@ export function createTurnsRouter(deps: TurnsRouterDeps) { ...deps, modelProviderStore: deps.resolveModelProviderStore(c), mcpServerStore: deps.resolveMcpServerStore(c), + agentStore: deps.resolveAgentStore(c), }, }; diff --git a/packages/trueforge/src/app.ts b/packages/trueforge/src/app.ts index 79c4d28ad..6094aedc8 100644 --- a/packages/trueforge/src/app.ts +++ b/packages/trueforge/src/app.ts @@ -174,11 +174,15 @@ export interface ServerDeps { * Called without a context (e.g. the scheduler / OAuth callback) it returns the DB persistence store. */ resolveMcpServerStore: (c?: Context) => IMcpServerWithAuthStore; + /** + * Per-request store: DB singleton, or a token-bound TrueFoundry decorator in TrueFoundry mode. + * Called without a context (e.g. the scheduler) it returns the DB persistence store. + */ + resolveAgentStore: (c?: Context) => IAgentStore; withTransaction: WithTransaction; tokenStore: IOAuthTokenStore; skillStore: ISkillStore; sandboxProviderStore: ISandboxProviderStore; - agentStore: IAgentStore; scheduleStore: IScheduleStore; sessionStore: ISessionStore; sessionMetricsStore: ISessionMetricsStore; @@ -291,7 +295,7 @@ export function createServerApp(deps: ServerDeps) { '/api/v1/agents', withAuth( createAgentsRouter({ - agentStore: deps.agentStore, + resolveAgentStore: deps.resolveAgentStore, resolveModelProviderStore: deps.resolveModelProviderStore, resolveMcpServerStore: deps.resolveMcpServerStore, skillStore: deps.skillStore, @@ -307,7 +311,7 @@ export function createServerApp(deps: ServerDeps) { withAuth( createSchedulesRouter({ scheduleStore: deps.scheduleStore, - agentStore: deps.agentStore, + resolveAgentStore: deps.resolveAgentStore, sessions: deps.sessions, turnDeps: { activeTurns: deps.activeTurns, @@ -315,7 +319,7 @@ export function createServerApp(deps: ServerDeps) { modelProviderStore: deps.resolveModelProviderStore(), mcpServerStore: deps.resolveMcpServerStore(), skillStore: deps.skillStore, - agentStore: deps.agentStore, + agentStore: deps.resolveAgentStore(), sandboxProviderStore: deps.sandboxProviderStore, logger: deps.logger, }, @@ -349,7 +353,7 @@ export function createServerApp(deps: ServerDeps) { resolveModelProviderStore: deps.resolveModelProviderStore, resolveMcpServerStore: deps.resolveMcpServerStore, skillStore: deps.skillStore, - agentStore: deps.agentStore, + resolveAgentStore: deps.resolveAgentStore, sandboxProviderStore: deps.sandboxProviderStore, resolveRequestContext, }), @@ -376,7 +380,7 @@ export function createServerApp(deps: ServerDeps) { resolveModelProviderStore: deps.resolveModelProviderStore, resolveMcpServerStore: deps.resolveMcpServerStore, skillStore: deps.skillStore, - agentStore: deps.agentStore, + resolveAgentStore: deps.resolveAgentStore, sandboxProviderStore: deps.sandboxProviderStore, redis: deps.redis, requestReplyRouter: deps.requestReplyRouter, @@ -396,7 +400,7 @@ export function createServerApp(deps: ServerDeps) { resolveModelProviderStore: deps.resolveModelProviderStore, resolveMcpServerStore: deps.resolveMcpServerStore, skillStore: deps.skillStore, - agentStore: deps.agentStore, + resolveAgentStore: deps.resolveAgentStore, eventSubscriptions: deps.eventSubscriptions, sandboxProviderStore: deps.sandboxProviderStore, logger: deps.logger, diff --git a/packages/trueforge/src/config.ts b/packages/trueforge/src/config.ts index 88de79e30..b156c53e6 100644 --- a/packages/trueforge/src/config.ts +++ b/packages/trueforge/src/config.ts @@ -489,6 +489,10 @@ export interface SharedServerConfiguration { * Env: `TRUEFOUNDRY_SERVICEFOUNDRY_SERVER_URL`. */ TRUEFOUNDRY_SERVICEFOUNDRY_SERVER_URL: string | undefined; + /** Max ms for non-agent ServiceFoundry HTTP calls. Env: `TRUEFOUNDRY_SERVICEFOUNDRY_HTTP_TIMEOUT_MS`. Default 10000. */ + TRUEFOUNDRY_SERVICEFOUNDRY_HTTP_TIMEOUT_MS: number; + /** Max ms for agent CRUD ServiceFoundry HTTP calls. Env: `TRUEFOUNDRY_SERVICEFOUNDRY_HTTP_AGENT_TIMEOUT_MS`. Default 3000. */ + TRUEFOUNDRY_SERVICEFOUNDRY_HTTP_AGENT_TIMEOUT_MS: number; /** * Present this pod's client certificate on outbound calls to the ServiceFoundry server (internal * mutual TLS) and upgrade a mesh-direct peer URL from http to https. Off by default, so an @@ -664,6 +668,16 @@ const shared: SharedServerConfiguration = { SERVER_URL: getEnv('SERVER_URL', { defaultValue: `http://localhost:${String(port)}` }) ?? `http://localhost:${String(port)}`, TRUEFOUNDRY_SERVICEFOUNDRY_SERVER_URL: getEnv('TRUEFOUNDRY_SERVICEFOUNDRY_SERVER_URL', { required: false }), + TRUEFOUNDRY_SERVICEFOUNDRY_HTTP_TIMEOUT_MS: parsePositiveInt({ + envKey: 'TRUEFOUNDRY_SERVICEFOUNDRY_HTTP_TIMEOUT_MS', + raw: getEnv('TRUEFOUNDRY_SERVICEFOUNDRY_HTTP_TIMEOUT_MS'), + defaultValue: 10_000, + }), + TRUEFOUNDRY_SERVICEFOUNDRY_HTTP_AGENT_TIMEOUT_MS: parsePositiveInt({ + envKey: 'TRUEFOUNDRY_SERVICEFOUNDRY_HTTP_AGENT_TIMEOUT_MS', + raw: getEnv('TRUEFOUNDRY_SERVICEFOUNDRY_HTTP_AGENT_TIMEOUT_MS'), + defaultValue: 3_000, + }), TRUEFOUNDRY_MTLS_ENABLED: parseBoolean({ envKey: 'TRUEFOUNDRY_MTLS_ENABLED', raw: getEnv('TRUEFOUNDRY_MTLS_ENABLED'), diff --git a/packages/trueforge/src/db/agentStore.ts b/packages/trueforge/src/db/agentStore.ts index f043b53f3..cbbe6f1fb 100644 --- a/packages/trueforge/src/db/agentStore.ts +++ b/packages/trueforge/src/db/agentStore.ts @@ -31,6 +31,12 @@ export function parseStoredAgentSpec(manifest: unknown): AgentSpec { /** Look up by immutable id or unique name within a tenant. */ export type GetAgentInput = { tenant_id: string } & ({ id: string } | { name: string }); +export interface ListAgentsInput { + tenant_id: string; + /** When set, only agents whose `external_id` is in this list. */ + external_ids?: readonly string[]; +} + export interface CreateAgentInput { tenant_id: string; name: ResourceName; @@ -82,7 +88,7 @@ export class AgentExternalIdConflictError extends Error { } export interface IAgentStore { - listAgents(tenantId: string, transaction?: TTransaction): Promise; + listAgents(input: ListAgentsInput, transaction?: TTransaction): Promise; getAgent(input: GetAgentInput, transaction?: TTransaction): Promise; /** Inserts a new agent with a generated ULID. Throws AgentNameConflictError or AgentExternalIdConflictError on unique clash. */ createAgent(input: CreateAgentInput, transaction?: TTransaction): Promise; diff --git a/packages/trueforge/src/db/postgres/agent-store/PostgresAgentStore.ts b/packages/trueforge/src/db/postgres/agent-store/PostgresAgentStore.ts index b32e4fbdc..d47085c35 100644 --- a/packages/trueforge/src/db/postgres/agent-store/PostgresAgentStore.ts +++ b/packages/trueforge/src/db/postgres/agent-store/PostgresAgentStore.ts @@ -9,6 +9,7 @@ import { type DeleteAgentInput, type GetAgentInput, type IAgentStore, + type ListAgentsInput, type UpdateAgentInput, } from '../../agentStore'; import { parseStoredCreatedBySubject } from '../../createdBySubject'; @@ -55,9 +56,16 @@ export class PostgresAgentStore implements IAgentStore> { this.#db = db; } - async listAgents(tenantId: string, transaction?: Transaction): Promise { + async listAgents(input: ListAgentsInput, transaction?: Transaction): Promise { + if (input.external_ids?.length === 0) { + return []; + } const db = transaction ?? this.#db; - const rows = await db.selectFrom('agent').selectAll().where('tenant_id', '=', tenantId).orderBy('name').execute(); + let query = db.selectFrom('agent').selectAll().where('tenant_id', '=', input.tenant_id); + if (input.external_ids !== undefined) { + query = query.where('external_id', 'in', [...input.external_ids]); + } + const rows = await query.orderBy('name').execute(); return rows.map(toRecord); } @@ -73,6 +81,10 @@ export class PostgresAgentStore implements IAgentStore> { return row === undefined ? undefined : toRecord(row); } + withTransaction(fn: (transaction: Transaction) => Promise): Promise { + return this.#db.transaction().execute(fn); + } + async createAgent(input: CreateAgentInput, transaction?: Transaction): Promise { const db = transaction ?? this.#db; try { diff --git a/packages/trueforge/src/db/sqlite/agent-store/SqliteAgentStore.ts b/packages/trueforge/src/db/sqlite/agent-store/SqliteAgentStore.ts index 3045917a0..3cedfebc1 100644 --- a/packages/trueforge/src/db/sqlite/agent-store/SqliteAgentStore.ts +++ b/packages/trueforge/src/db/sqlite/agent-store/SqliteAgentStore.ts @@ -10,6 +10,7 @@ import { type DeleteAgentInput, type GetAgentInput, type IAgentStore, + type ListAgentsInput, type UpdateAgentInput, } from '../../agentStore'; import { parseStoredCreatedBySubject } from '../../createdBySubject'; @@ -55,14 +56,16 @@ export class SqliteAgentStore implements IAgentStore> { this.#db = db; } - async listAgents(tenantId: string, transaction?: Transaction): Promise { + async listAgents(input: ListAgentsInput, transaction?: Transaction): Promise { + if (input.external_ids?.length === 0) { + return []; + } const db = transaction ?? this.#db; - const rows = await db - .selectFrom('agent') - .select(recordColumns) - .where('tenant_id', '=', tenantId) - .orderBy('name') - .execute(); + let query = db.selectFrom('agent').select(recordColumns).where('tenant_id', '=', input.tenant_id); + if (input.external_ids !== undefined) { + query = query.where('external_id', 'in', [...input.external_ids]); + } + const rows = await query.orderBy('name').execute(); return rows.map(toRecord); } diff --git a/packages/trueforge/src/main.ts b/packages/trueforge/src/main.ts index 59c05d10b..62a6d1611 100644 --- a/packages/trueforge/src/main.ts +++ b/packages/trueforge/src/main.ts @@ -69,6 +69,7 @@ import type { IAgentStore } from './db/agentStore'; import type { IMcpServerStore, IMcpServerWithAuthStore } from './db/mcpServerStore'; import { McpServerWithAuthStore } from './db/McpServerWithAuthStore'; import type { IModelProviderStore } from './db/modelProviderStore'; +import type { PostgresAgentStore } from './db/postgres/agent-store/PostgresAgentStore'; import type { Database as PostgresDatabase } from './db/postgres/types'; import type { ISandboxProviderStore } from './db/sandboxProviderStore'; import type { IScheduleStore } from './db/scheduleStore'; @@ -84,6 +85,7 @@ import { ActiveTurnRegistry } from './runtime/activeTurns'; import { EventSubscriptionRegistry } from './runtime/event-subscription'; import { printStandaloneStartupBanner } from './startupBanner'; import { parsePerServerMcpHeaders, X_TFG_MCP_HEADERS } from './truefoundry/perServerMcpHeaders'; +import { TrueFoundryAgentStore } from './truefoundry/TrueFoundryAgentStore'; import { TrueFoundryMcpServerStore } from './truefoundry/TrueFoundryMcpServerStore'; import { TrueFoundryModelProviderStore } from './truefoundry/TrueFoundryModelProviderStore'; import { TrueFoundryServiceFoundryServerClient } from './truefoundry/TrueFoundryServiceFoundryServerClient'; @@ -94,11 +96,11 @@ interface ServerPersistence { sessionMetricsStore: ISessionMetricsStore; resolveModelProviderStore: (c?: Context) => IModelProviderStore; resolveMcpServerStore: (c?: Context) => IMcpServerWithAuthStore; + resolveAgentStore: (c?: Context) => IAgentStore; withTransaction: WithTransaction; tokenStore: IOAuthTokenStore; skillStore: ISkillStore; sandboxProviderStore: ISandboxProviderStore; - agentStore: IAgentStore; scheduleStore: IScheduleStore; destroyDb: () => Promise; redis: RedisClientType | undefined; @@ -108,34 +110,47 @@ function requireRequestCredentialToken(c: Context): string { const credential = resolveRequestContext(c).user_credential; if (credential === null) { throw new HTTPException(401, { - message: 'Authentication token required to list or call TrueFoundry models and MCP servers', + message: 'Authentication token required to list or call TrueFoundry models, MCP servers, and agents', }); } return credential; } +/** + * Shared ServiceFoundry HTTP client for TrueFoundry-mode store resolvers (models, MCP, agents). + * Undefined when TrueFoundry mode is off — resolvers then use persistence only. + */ +function createServiceFoundryServerClient(logger: Logger): TrueFoundryServiceFoundryServerClient | undefined { + if (!isTrueFoundryModeEnabled(configuration)) { + return undefined; + } + return new TrueFoundryServiceFoundryServerClient({ + serviceFoundryServerUrl: configuration.TRUEFOUNDRY_SERVICEFOUNDRY_SERVER_URL, + logger, + httpTimeoutMs: configuration.TRUEFOUNDRY_SERVICEFOUNDRY_HTTP_TIMEOUT_MS, + httpAgentTimeoutMs: configuration.TRUEFOUNDRY_SERVICEFOUNDRY_HTTP_AGENT_TIMEOUT_MS, + tls: { enabled: configuration.TRUEFOUNDRY_MTLS_ENABLED, dir: configuration.TRUEFOUNDRY_MTLS_CERTS_DIR }, + }); +} + /** * Per-request model-provider store resolver. In TrueFoundry mode every request gets a token-bound * store over a shared (mTLS) ServiceFoundry client; otherwise the persistence store is reused as-is. */ function buildResolveModelProviderStore(options: { persistenceStore: IModelProviderStore; - logger: Logger; + client: TrueFoundryServiceFoundryServerClient | undefined; }): (c?: Context) => IModelProviderStore { - if (!isTrueFoundryModeEnabled(configuration)) { - return () => options.persistenceStore; + const { persistenceStore, client } = options; + if (!client) { + return () => persistenceStore; } - const client = new TrueFoundryServiceFoundryServerClient({ - serviceFoundryServerUrl: configuration.TRUEFOUNDRY_SERVICEFOUNDRY_SERVER_URL, - logger: options.logger, - tls: { enabled: configuration.TRUEFOUNDRY_MTLS_ENABLED, dir: configuration.TRUEFOUNDRY_MTLS_CERTS_DIR }, - }); // No request context (e.g. the scheduler) means no caller token, so TrueFoundry models are // unavailable there; fall back to the persistence store. return c => c ? new TrueFoundryModelProviderStore({ client, accessToken: requireRequestCredentialToken(c) }) - : options.persistenceStore; + : persistenceStore; } /** @@ -147,21 +162,17 @@ function buildResolveModelProviderStore(options: { function buildResolveMcpServerStore(options: { persistenceStore: IMcpServerStore; tokenStore: IOAuthTokenStore; - logger: Logger; + client: TrueFoundryServiceFoundryServerClient | undefined; }): (c?: Context) => IMcpServerWithAuthStore { const withAuthPersistence = new McpServerWithAuthStore({ store: options.persistenceStore, tokenStore: options.tokenStore, clientName: configuration.MCP_DCR_OAUTH_CLIENT_NAME, }); - if (!isTrueFoundryModeEnabled(configuration)) { + const { client } = options; + if (!client) { return () => withAuthPersistence; } - const client = new TrueFoundryServiceFoundryServerClient({ - serviceFoundryServerUrl: configuration.TRUEFOUNDRY_SERVICEFOUNDRY_SERVER_URL, - logger: options.logger, - tls: { enabled: configuration.TRUEFOUNDRY_MTLS_ENABLED, dir: configuration.TRUEFOUNDRY_MTLS_CERTS_DIR }, - }); return c => { if (!c) { return withAuthPersistence; @@ -177,6 +188,29 @@ function buildResolveMcpServerStore(options: { }; } +/** + * Per-request agent store resolver. In TrueFoundry mode every request gets a token-bound + * decorator over DB persistence; otherwise the persistence store is reused as-is. + */ +function buildResolveAgentStore(options: { + persistenceStore: PostgresAgentStore; + client: TrueFoundryServiceFoundryServerClient | undefined; +}): (c?: Context) => IAgentStore> { + const { persistenceStore, client } = options; + if (!client) { + return () => persistenceStore; + } + // No request context (e.g. the scheduler) means no caller token, so fall back to persistence. + return c => + c + ? new TrueFoundryAgentStore({ + inner: persistenceStore, + client, + accessToken: requireRequestCredentialToken(c), + }) + : persistenceStore; +} + /** SQLite stores; Redis unused (executor peering disabled). */ async function createStandalonePersistence(options: { sqlitePath: string; @@ -217,23 +251,24 @@ async function createStandalonePersistence(options: { logger.info('Standalone mode: executor peering disabled and Redis unused'); const tokenStore = new SqliteOAuthTokenStore(db); + const agentStore = new SqliteAgentStore(db); return { sessionStore: new SqliteSessionStore(db), sessionMetricsStore: new SqliteSessionMetricsStore(db), resolveModelProviderStore: buildResolveModelProviderStore({ persistenceStore: new SqliteModelProviderStore(db), - logger, + client: undefined, }), resolveMcpServerStore: buildResolveMcpServerStore({ persistenceStore: new SqliteMcpServerStore(db), tokenStore, - logger, + client: undefined, }), + resolveAgentStore: () => agentStore, withTransaction: callback => db.transaction().execute(callback), tokenStore, skillStore: new SqliteSkillStore(db), sandboxProviderStore: new SqliteSandboxProviderStore(db), - agentStore: new SqliteAgentStore(db), scheduleStore: new SqliteScheduleStore(db), destroyDb: () => db.destroy(), redis: undefined, @@ -294,23 +329,28 @@ async function createDistributedPersistence(options: { logger.info(`Executor id: ${executorId}`); const tokenStore = new PostgresOAuthTokenStore(db); + const agentStore = new PostgresAgentStore(db); + const serviceFoundryClient = createServiceFoundryServerClient(logger); return { sessionStore: new PostgresSessionStore(db), sessionMetricsStore: new PostgresSessionMetricsStore(db), resolveModelProviderStore: buildResolveModelProviderStore({ persistenceStore: new PostgresModelProviderStore(db), - logger, + client: serviceFoundryClient, }), resolveMcpServerStore: buildResolveMcpServerStore({ persistenceStore: new PostgresMcpServerStore(db), tokenStore, - logger, + client: serviceFoundryClient, + }), + resolveAgentStore: buildResolveAgentStore({ + persistenceStore: agentStore, + client: serviceFoundryClient, }), withTransaction: callback => db.transaction().execute(callback), tokenStore, skillStore: new PostgresSkillStore(db), sandboxProviderStore: new PostgresSandboxProviderStore(db), - agentStore: new PostgresAgentStore(db), scheduleStore: new PostgresScheduleStore(db), destroyDb: () => db.destroy(), redis: await connectRedis({ url: redisUrl, logger }), @@ -324,11 +364,11 @@ async function createServerRuntime(persistence: ServerPersistence< sessionMetricsStore, resolveModelProviderStore, resolveMcpServerStore, + resolveAgentStore, withTransaction, tokenStore, skillStore, sandboxProviderStore, - agentStore, scheduleStore, destroyDb, redis, @@ -357,6 +397,8 @@ async function createServerRuntime(persistence: ServerPersistence< trueFoundryClient: new TrueFoundryServiceFoundryServerClient({ serviceFoundryServerUrl: configuration.TRUEFOUNDRY_SERVICEFOUNDRY_SERVER_URL, logger, + httpTimeoutMs: configuration.TRUEFOUNDRY_SERVICEFOUNDRY_HTTP_TIMEOUT_MS, + httpAgentTimeoutMs: configuration.TRUEFOUNDRY_SERVICEFOUNDRY_HTTP_AGENT_TIMEOUT_MS, tls: { enabled: configuration.TRUEFOUNDRY_MTLS_ENABLED, dir: configuration.TRUEFOUNDRY_MTLS_CERTS_DIR }, }), }); @@ -383,11 +425,11 @@ async function createServerRuntime(persistence: ServerPersistence< sandboxCatalog: SandboxCatalog.load(), resolveModelProviderStore, resolveMcpServerStore, + resolveAgentStore, withTransaction, tokenStore, skillStore, sandboxProviderStore, - agentStore, scheduleStore, sessionStore, sessionMetricsStore, diff --git a/packages/trueforge/src/schemas/agent.ts b/packages/trueforge/src/schemas/agent.ts index 7ab0d5ebc..8dc3f0068 100644 --- a/packages/trueforge/src/schemas/agent.ts +++ b/packages/trueforge/src/schemas/agent.ts @@ -6,10 +6,14 @@ import { z } from '@hono/zod-openapi'; import { AgentSpecSchema, CreatedBySubjectSchema } from '@truefoundry/trueforge-core/agent-session'; import { NameSchema } from './common'; +const RESERVED_AGENT_NAMES = new Set(['tfg', 'trueforge']); + /** Create body: unique immutable `name` plus manifest. `id` is never client-supplied. */ export const CreateAgentRequestSchema = z .object({ - name: NameSchema, + name: NameSchema.refine(name => !RESERVED_AGENT_NAMES.has(name), { + message: 'Agent name is reserved, cannot be used', + }), manifest: AgentSpecSchema, }) .strict() diff --git a/packages/trueforge/src/truefoundry/TrueFoundryAgentStore.ts b/packages/trueforge/src/truefoundry/TrueFoundryAgentStore.ts new file mode 100644 index 000000000..2da1d0fc5 --- /dev/null +++ b/packages/trueforge/src/truefoundry/TrueFoundryAgentStore.ts @@ -0,0 +1,184 @@ +import type { AgentSpec } from '@truefoundry/trueforge-core/agent-session'; +import { sql, type Transaction } from 'kysely'; +import { + type AgentRecord, + type CreateAgentInput, + type DeleteAgentInput, + type GetAgentInput, + type IAgentStore, + type ListAgentsInput, + type UpdateAgentInput, +} from '../db/agentStore'; +import type { Database } from '../db/postgres/types'; +import { + TrueFoundryServiceFoundryServerClient, + type PutRemoteAgentInput, +} from './TrueFoundryServiceFoundryServerClient'; + +function asError(value: unknown): Error { + return value instanceof Error ? value : new Error(String(value)); +} + +function toPutRemoteAgentPayload({ + name, + manifest, +}: { + name: string; + manifest: AgentSpec; +}): Omit { + return { + name, + description: manifest.instructions ?? name, + model: manifest.model.name, + mcp_servers: (manifest.mcp_servers ?? []).map(server => server.name), + }; +} + +type TrueFoundryAgentInner = IAgentStore & { + withTransaction(fn: (transaction: TTransaction) => Promise): Promise; +}; + +/** + * create: createDB(null) → putRemote → updateDB(external_id) | on put/update fail → deleteDB (+ deleteRemote if put ok) + * update: lock → get → putRemote(new) → updateDB | on DB fail → putRemote(old) | both fail → AggregateError + * delete: lock → get → deleteRemote(404 ok) → deleteDB + */ +export class TrueFoundryAgentStore< + TTransaction extends Transaction = Transaction, +> implements IAgentStore { + readonly #inner: TrueFoundryAgentInner; + readonly #client: TrueFoundryServiceFoundryServerClient; + readonly #accessToken: string; + + constructor(input: { + inner: TrueFoundryAgentInner; + client: TrueFoundryServiceFoundryServerClient; + accessToken: string; + }) { + this.#inner = input.inner; + this.#client = input.client; + this.#accessToken = input.accessToken; + } + + listAgents(input: ListAgentsInput, transaction?: TTransaction): Promise { + return this.#inner.listAgents(input, transaction); + } + + getAgent(input: GetAgentInput, transaction?: TTransaction): Promise { + return this.#inner.getAgent(input, transaction); + } + + // Serializes updates for this tenant ID and agent id. + // Prevents concurrent MCP writes from desyncing the remote agent. + #withUpdateLock( + input: { tenant_id: string; id: string }, + transaction: TTransaction | undefined, + fn: (transaction: TTransaction) => Promise, + ): Promise { + const run = async (txn: TTransaction) => { + const key = `tf:agent:${input.tenant_id}:${input.id}`; + await sql`SELECT pg_advisory_xact_lock(hashtext(${key}))`.execute(txn); + return fn(txn); + }; + return transaction !== undefined ? run(transaction) : this.#inner.withTransaction(run); + } + + async createAgent(input: CreateAgentInput, transaction?: TTransaction): Promise { + // Lets the DB unique constraint pick one winner for this tenant ID and name. + // Prevents concurrent requests from both creating the same remote agent. + const created = await this.#inner.createAgent({ ...input, external_id: null }, transaction); + + let externalId: string | undefined; + try { + ({ externalId } = await this.#client.putRemoteAgent({ + accessToken: this.#accessToken, + ...toPutRemoteAgentPayload({ name: input.name, manifest: input.manifest }), + })); + const updated = await this.#inner.updateAgent( + { tenant_id: input.tenant_id, id: created.id, external_id: externalId }, + transaction, + ); + if (updated === undefined) { + throw new Error(`Internal error: createAgent lost the row after insert: ${created.id}`); + } + return updated; + } catch (error) { + const failures = [asError(error)]; + if (externalId !== undefined) { + try { + await this.#client.deleteRemoteAgent({ accessToken: this.#accessToken, externalId }); + } catch (cleanupError) { + failures.push(asError(cleanupError)); + } + } + try { + await this.#inner.deleteAgent({ tenant_id: input.tenant_id, id: created.id }, transaction); + } catch (cleanupError) { + failures.push(asError(cleanupError)); + } + if (failures.length > 1) { + throw new AggregateError(failures, 'createAgent failed and cleanup also failed', { cause: error }); + } + throw error; + } + } + + async updateAgent(input: UpdateAgentInput, transaction?: TTransaction): Promise { + const nextManifest = input.manifest; + if (nextManifest === undefined) { + // No manifest means only `external_id` changed; pass through to the inner store. + return this.#inner.updateAgent(input, transaction); + } + + return this.#withUpdateLock(input, transaction, async txn => { + const previous = await this.#inner.getAgent({ tenant_id: input.tenant_id, id: input.id }, txn); + if (previous === undefined) { + return undefined; + } + + const { externalId } = await this.#client.putRemoteAgent({ + accessToken: this.#accessToken, + ...toPutRemoteAgentPayload({ name: previous.name, manifest: nextManifest }), + }); + + try { + return await this.#inner.updateAgent( + { + tenant_id: input.tenant_id, + id: input.id, + manifest: nextManifest, + ...(externalId === previous.external_id ? {} : { external_id: externalId }), + }, + txn, + ); + } catch (error) { + try { + await this.#client.putRemoteAgent({ + accessToken: this.#accessToken, + ...toPutRemoteAgentPayload({ name: previous.name, manifest: previous.manifest }), + }); + } catch (restoreError) { + throw new AggregateError( + [asError(error), asError(restoreError)], + 'updateAgent failed and ServiceFoundry restore also failed', + { cause: restoreError }, + ); + } + throw error; + } + }); + } + + async deleteAgent(input: DeleteAgentInput, transaction?: TTransaction): Promise { + return this.#withUpdateLock(input, transaction, async txn => { + const previous = await this.#inner.getAgent({ tenant_id: input.tenant_id, id: input.id }, txn); + if (previous?.external_id) { + await this.#client.deleteRemoteAgent({ + accessToken: this.#accessToken, + externalId: previous.external_id, + }); + } + await this.#inner.deleteAgent(input, txn); + }); + } +} diff --git a/packages/trueforge/src/truefoundry/TrueFoundryServiceFoundryServerClient.ts b/packages/trueforge/src/truefoundry/TrueFoundryServiceFoundryServerClient.ts index cacc40992..927cdf8e5 100644 --- a/packages/trueforge/src/truefoundry/TrueFoundryServiceFoundryServerClient.ts +++ b/packages/trueforge/src/truefoundry/TrueFoundryServiceFoundryServerClient.ts @@ -11,6 +11,7 @@ import { parseSfyMcpAuthStatus, parseSfyMcpAuthorizeResult, type SfyMcpAuthSourc const INTEGRATIONS_PATH = 'v1/provider-integrations'; const INSTALLATIONS_PATH = 'v1/llm-gateway/installations'; const MCP_SERVERS_PATH = 'v1/mcp'; +const TFG_AGENTS_PATH = 'internal/tfg/agents'; const SESSION_PATH = 'v1/session'; const INTEGRATIONS_PAGE_SIZE = 1000; @@ -55,6 +56,28 @@ const ServiceFoundryErrorSchema = z.object({ message: z.union([z.string(), z.array(z.string())]).optional(), }); +/** Wire shape from PUT `/internal/tfg/agents` — keep `agentId` only here. */ +const PutRemoteAgentResponseSchema = z.object({ + agentId: z.string().min(1), +}); + +export interface PutRemoteAgentInput { + accessToken: string; + name: string; + description: string; + model: string; + mcp_servers: string[]; +} + +export interface PutRemoteAgentResult { + externalId: string; +} + +export interface DeleteRemoteAgentInput { + accessToken: string; + externalId: string; +} + async function readServiceFoundryErrorMessage( response: Awaited>, ): Promise { @@ -74,17 +97,27 @@ function listPaginationTotal(response: ListResponse): number | undefined { export class TrueFoundryServiceFoundryServerClient { readonly #baseUrl: string; - readonly #logger: Logger | undefined; + readonly #logger: Logger; readonly #dispatcher: Dispatcher | undefined; + readonly #httpTimeoutMs: number; + readonly #httpAgentTimeoutMs: number; - constructor(input: { serviceFoundryServerUrl: string; logger?: Logger; tls?: InternalTlsOptions }) { - const tls = input.tls ?? { enabled: false, dir: '' }; + constructor(input: { + serviceFoundryServerUrl: string; + logger: Logger; + tls: InternalTlsOptions; + httpTimeoutMs: number; + httpAgentTimeoutMs: number; + }) { + const tls = input.tls; this.#baseUrl = normalizeInternalTlsUrl({ url: input.serviceFoundryServerUrl, enabled: tls.enabled }).replace( /\/+$/, '', ); this.#dispatcher = createInternalTlsDispatcher(tls); this.#logger = input.logger; + this.#httpTimeoutMs = input.httpTimeoutMs; + this.#httpAgentTimeoutMs = input.httpAgentTimeoutMs; } async listProviderIntegrations(accessToken: string): Promise { @@ -160,7 +193,7 @@ export class TrueFoundryServiceFoundryServerClient { }); const rows = listPage(this.#parseListResponse(payload)); if (rows.length > 1) { - this.#logger?.warn('TrueFoundry ServiceFoundry MCP name filter returned multiple rows', { + this.#logger.warn('TrueFoundry ServiceFoundry MCP name filter returned multiple rows', { name: input.name, count: rows.length, }); @@ -168,6 +201,44 @@ export class TrueFoundryServiceFoundryServerClient { return rows[0]; } + /** PUT `/internal/tfg/agents` — create/reuse remote agent + sync model/MCP grants. */ + async putRemoteAgent(input: PutRemoteAgentInput): Promise { + const payload = await this.#requestJson({ + url: this.#url(TFG_AGENTS_PATH), + accessToken: input.accessToken, + method: 'PUT', + timeoutMs: this.#httpAgentTimeoutMs, + body: { + name: input.name, + description: input.description, + model: input.model, + mcp_servers: input.mcp_servers, + }, + }); + const parsed = PutRemoteAgentResponseSchema.safeParse(payload); + if (!parsed.success) { + this.#logger.error('TrueFoundry ServiceFoundry put remote agent returned an unexpected response', { + ...extractErrorLogFields(parsed.error), + }); + throw new HTTPException(424, { + message: 'TrueFoundry ServiceFoundry put remote agent returned an unexpected response', + cause: parsed.error, + }); + } + return { externalId: parsed.data.agentId }; + } + + /** DELETE `/internal/tfg/agents/:id` — remove remote agent. Missing agent (404) is success. */ + async deleteRemoteAgent(input: DeleteRemoteAgentInput): Promise { + await this.#requestJson({ + url: this.#url(`${TFG_AGENTS_PATH}/${encodeURIComponent(input.externalId)}`), + accessToken: input.accessToken, + method: 'DELETE', + timeoutMs: this.#httpAgentTimeoutMs, + notFoundOk: true, + }); + } + /** Per-subject authorize; includes a consent URL when auth is required. */ async getMcpAuthorize(input: { accessToken: string; @@ -190,7 +261,7 @@ export class TrueFoundryServiceFoundryServerClient { try { return parseSfyMcpAuthorizeResult(payload); } catch (error) { - this.#logger?.error('TrueFoundry ServiceFoundry MCP authorize returned an unexpected response', { + this.#logger.error('TrueFoundry ServiceFoundry MCP authorize returned an unexpected response', { mcpServerId: input.mcpServerId, ...extractErrorLogFields(error), }); @@ -219,7 +290,7 @@ export class TrueFoundryServiceFoundryServerClient { try { return parseSfyMcpAuthStatus(payload); } catch (error) { - this.#logger?.error('TrueFoundry ServiceFoundry MCP auth status returned an unexpected response', { + this.#logger.error('TrueFoundry ServiceFoundry MCP auth status returned an unexpected response', { mcpServerId: input.mcpServerId, ...extractErrorLogFields(error), }); @@ -287,7 +358,7 @@ export class TrueFoundryServiceFoundryServerClient { #parseListResponse(payload: unknown): ListResponse { const parsed = ListResponseSchema.safeParse(payload); if (!parsed.success) { - this.#logger?.error('TrueFoundry ServiceFoundry server returned an unexpected list response', { + this.#logger.error('TrueFoundry ServiceFoundry server returned an unexpected list response', { ...extractErrorLogFields(parsed.error), }); throw new HTTPException(424, { @@ -313,8 +384,12 @@ export class TrueFoundryServiceFoundryServerClient { accessToken: string; method: 'GET' | 'DELETE' | 'POST' | 'PUT'; body?: unknown; + timeoutMs?: number; + /** Treat HTTP 404 as success (idempotent DELETE). */ + notFoundOk?: boolean; }): Promise { const startedAt = Date.now(); + const timeoutMs = input.timeoutMs ?? this.#httpTimeoutMs; const headers: Record = { accept: 'application/json', authorization: `Bearer ${input.accessToken}`, @@ -330,21 +405,26 @@ export class TrueFoundryServiceFoundryServerClient { method: input.method, headers, ...(body !== undefined ? { body } : {}), + signal: AbortSignal.timeout(timeoutMs), ...(this.#dispatcher ? { dispatcher: this.#dispatcher } : {}), }); } catch (error) { - this.#logger?.warn('TrueFoundry ServiceFoundry server request failed', { + const timedOut = error instanceof Error && error.name === 'TimeoutError'; + this.#logger.warn('TrueFoundry ServiceFoundry server request failed', { url: input.url.href, method: input.method, durationMs: Date.now() - startedAt, + timedOut, ...extractErrorLogFields(error), }); throw new HTTPException(500, { - message: 'TrueFoundry ServiceFoundry server request failed', + message: timedOut + ? `TrueFoundry ServiceFoundry server request timed out after ${String(timeoutMs / 1000)}s` + : 'TrueFoundry ServiceFoundry server request failed', cause: error, }); } - this.#logger?.info('TrueFoundry ServiceFoundry server request completed', { + this.#logger.info('TrueFoundry ServiceFoundry server request completed', { url: input.url.href, method: input.method, status: response.status, @@ -355,6 +435,9 @@ export class TrueFoundryServiceFoundryServerClient { message: 'TrueFoundry ServiceFoundry server rejected the request', }); } + if (response.status === 404 && input.notFoundOk) { + return undefined; + } if (!response.ok) { const detail = await readServiceFoundryErrorMessage(response); throw new HTTPException(424, { @@ -371,7 +454,7 @@ export class TrueFoundryServiceFoundryServerClient { try { return JSON.parse(text) as unknown; } catch (error) { - this.#logger?.error('TrueFoundry ServiceFoundry server returned non-JSON', { + this.#logger.error('TrueFoundry ServiceFoundry server returned non-JSON', { url: input.url.href, ...extractErrorLogFields(error), }); diff --git a/packages/trueforge/tests/db/agentStoreContractSuite.ts b/packages/trueforge/tests/db/agentStoreContractSuite.ts index 70cef21fc..2d26f7693 100644 --- a/packages/trueforge/tests/db/agentStoreContractSuite.ts +++ b/packages/trueforge/tests/db/agentStoreContractSuite.ts @@ -146,11 +146,45 @@ export function runAgentStoreContractSuite(getStore: () => IAgentStore): void { external_id: null, }); - const agents = await store.listAgents(TENANT); + const agents = await store.listAgents({ tenant_id: TENANT }); expect(agents.map(agent => agent.name)).toEqual(['alpha', 'zeta']); expect(agents.every(agent => agent.tenant_id === TENANT)).toBe(true); }); + it('listAgents can filter by external_ids', async () => { + const store = getStore(); + await store.createAgent({ + tenant_id: TENANT, + created_by_subject: CREATED_BY_SUBJECT, + name: 'local-only', + manifest: manifest(), + external_id: null, + }); + const linked = await store.createAgent({ + tenant_id: TENANT, + created_by_subject: CREATED_BY_SUBJECT, + name: 'linked', + manifest: manifest(), + external_id: 'sf-agent-1', + }); + await store.createAgent({ + tenant_id: TENANT, + created_by_subject: CREATED_BY_SUBJECT, + name: 'other-linked', + manifest: manifest(), + external_id: 'sf-agent-2', + }); + + expect(await store.listAgents({ tenant_id: TENANT, external_ids: ['sf-agent-1'] })).toEqual([linked]); + expect( + (await store.listAgents({ tenant_id: TENANT, external_ids: ['sf-agent-1', 'sf-agent-2'] })).map( + agent => agent.name, + ), + ).toEqual(['linked', 'other-linked']); + expect(await store.listAgents({ tenant_id: TENANT, external_ids: ['missing'] })).toEqual([]); + expect(await store.listAgents({ tenant_id: TENANT, external_ids: [] })).toEqual([]); + }); + it('getAgent by id is tenant-scoped', async () => { const store = getStore(); const created = await store.createAgent({ diff --git a/packages/trueforge/tests/unit/apis/agents.test.ts b/packages/trueforge/tests/unit/apis/agents.test.ts index 1e3a39463..2f5475632 100644 --- a/packages/trueforge/tests/unit/apis/agents.test.ts +++ b/packages/trueforge/tests/unit/apis/agents.test.ts @@ -79,7 +79,7 @@ describe('agents router', () => { await modelProviderStore.upsertProvider({ tenant_id: 'default', name: 'anthropic', manifest: modelProvider }); agentStore = new SqliteAgentStore(db); router = createAgentsRouter({ - agentStore, + resolveAgentStore: () => agentStore, resolveModelProviderStore: () => modelProviderStore, resolveMcpServerStore: () => new SqliteMcpServerStore(db), skillStore: new SqliteSkillStore(db), @@ -170,6 +170,12 @@ describe('agents router', () => { const badName = await router.request('/', jsonInit('POST', { ...writeBody, name: 'Not A Name' })); expect(badName.status).toBe(400); + const reservedTfg = await router.request('/', jsonInit('POST', { ...writeBody, name: 'tfg' })); + expect(reservedTfg.status).toBe(400); + + const reservedTrueforge = await router.request('/', jsonInit('POST', { ...writeBody, name: 'trueforge' })); + expect(reservedTrueforge.status).toBe(400); + const unknownModel = await router.request( '/', jsonInit('POST', { diff --git a/packages/trueforge/tests/unit/apis/deletedSessionCrud.test.ts b/packages/trueforge/tests/unit/apis/deletedSessionCrud.test.ts index e0900f60a..fb1a4d058 100644 --- a/packages/trueforge/tests/unit/apis/deletedSessionCrud.test.ts +++ b/packages/trueforge/tests/unit/apis/deletedSessionCrud.test.ts @@ -48,7 +48,7 @@ describe('public CRUD after session deletion', () => { resolveModelProviderStore: () => modelProviderStore, resolveMcpServerStore: () => mcpServerStore, skillStore, - agentStore, + resolveAgentStore: () => agentStore, sandboxProviderStore, redis: createClient(), requestReplyRouter: new RequestReplyRouter(), @@ -65,7 +65,7 @@ describe('public CRUD after session deletion', () => { resolveModelProviderStore: () => modelProviderStore, resolveMcpServerStore: () => mcpServerStore, skillStore, - agentStore, + resolveAgentStore: () => agentStore, eventSubscriptions: new EventSubscriptionRegistry(undefined), sandboxProviderStore, logger: createLogger({ silent: true }), diff --git a/packages/trueforge/tests/unit/apis/sandboxFileDownload.test.ts b/packages/trueforge/tests/unit/apis/sandboxFileDownload.test.ts index 0ccf52b74..fb6a9f2a6 100644 --- a/packages/trueforge/tests/unit/apis/sandboxFileDownload.test.ts +++ b/packages/trueforge/tests/unit/apis/sandboxFileDownload.test.ts @@ -47,7 +47,7 @@ async function buildApp() { clientName: 'test-client', }), skillStore: new SqliteSkillStore(db), - agentStore: new SqliteAgentStore(db), + resolveAgentStore: () => new SqliteAgentStore(db), eventSubscriptions: new EventSubscriptionRegistry(undefined), sandboxProviderStore: new SqliteSandboxProviderStore(db), logger: createLogger({ silent: true }), diff --git a/packages/trueforge/tests/unit/apis/schedules.test.ts b/packages/trueforge/tests/unit/apis/schedules.test.ts index fc2290cf9..6d5b3d243 100644 --- a/packages/trueforge/tests/unit/apis/schedules.test.ts +++ b/packages/trueforge/tests/unit/apis/schedules.test.ts @@ -70,7 +70,7 @@ async function setup() { '/', createSchedulesRouter({ scheduleStore, - agentStore, + resolveAgentStore: () => agentStore, sessions: { getOrCreateByExternalId: () => Promise.reject(new Error('sessions stub: unexpected call')), } as never, diff --git a/packages/trueforge/tests/unit/apis/sessionHttp.test.ts b/packages/trueforge/tests/unit/apis/sessionHttp.test.ts index d6a0ce67d..e7ad30b62 100644 --- a/packages/trueforge/tests/unit/apis/sessionHttp.test.ts +++ b/packages/trueforge/tests/unit/apis/sessionHttp.test.ts @@ -81,7 +81,7 @@ describe('sessions HTTP agent binding', () => { resolveModelProviderStore: () => modelProviderStore, resolveMcpServerStore: () => mcpServerStore, skillStore, - agentStore, + resolveAgentStore: () => agentStore, sandboxProviderStore, redis: createClient(), requestReplyRouter: new RequestReplyRouter(), diff --git a/packages/trueforge/tests/unit/apis/turnHarnessErrorStatus.test.ts b/packages/trueforge/tests/unit/apis/turnHarnessErrorStatus.test.ts index 5f61487a9..af43ae0a1 100644 --- a/packages/trueforge/tests/unit/apis/turnHarnessErrorStatus.test.ts +++ b/packages/trueforge/tests/unit/apis/turnHarnessErrorStatus.test.ts @@ -74,7 +74,7 @@ async function postTurnRejectingWith(error: AgentHarnessError): Promise new SqliteAgentStore(db), eventSubscriptions: new EventSubscriptionRegistry(undefined), sandboxProviderStore: new SqliteSandboxProviderStore(db), logger: createLogger({ silent: true }), diff --git a/packages/trueforge/tests/unit/apis/turns.test.ts b/packages/trueforge/tests/unit/apis/turns.test.ts index c8ff7f4a2..50dc51e6f 100644 --- a/packages/trueforge/tests/unit/apis/turns.test.ts +++ b/packages/trueforge/tests/unit/apis/turns.test.ts @@ -66,7 +66,7 @@ describe('turns', () => { resolveModelProviderStore: () => new SqliteModelProviderStore(db), resolveMcpServerStore: () => mcpServerStoreWithAuth(db, tokenStore), skillStore: new SqliteSkillStore(db), - agentStore: new SqliteAgentStore(db), + resolveAgentStore: () => new SqliteAgentStore(db), eventSubscriptions: new EventSubscriptionRegistry(undefined), sandboxProviderStore: new SqliteSandboxProviderStore(db), logger: createLogger({ silent: true }), @@ -191,7 +191,7 @@ describe('turns', () => { sessionStore: new SqliteSessionStore(db), activeTurns: new ActiveTurnRegistry(), resolveModelProviderStore: () => modelProviderStore, - agentStore: new SqliteAgentStore(db), + resolveAgentStore: () => new SqliteAgentStore(db), resolveMcpServerStore: () => mcpServerStoreWithAuth(db, tokenStore), skillStore: new SqliteSkillStore(db), eventSubscriptions, @@ -299,7 +299,7 @@ describe('turns', () => { resolveModelProviderStore: () => modelProviderStore, resolveMcpServerStore: () => mcpServerStoreWithAuth(db, tokenStore), skillStore: new SqliteSkillStore(db), - agentStore: new SqliteAgentStore(db), + resolveAgentStore: () => new SqliteAgentStore(db), eventSubscriptions: new EventSubscriptionRegistry(undefined), sandboxProviderStore: new SqliteSandboxProviderStore(db), logger, diff --git a/packages/trueforge/tests/unit/truefoundry/TrueFoundryAgentStore.test.ts b/packages/trueforge/tests/unit/truefoundry/TrueFoundryAgentStore.test.ts new file mode 100644 index 000000000..fc9ade104 --- /dev/null +++ b/packages/trueforge/tests/unit/truefoundry/TrueFoundryAgentStore.test.ts @@ -0,0 +1,602 @@ +import { AgentSpecSchema, type CreatedBySubject } from '@truefoundry/trueforge-core/agent-session'; +import { createLogger } from 'winston'; + +import type { AgentRecord } from '../../../src/db/agentStore'; +import { AgentNameConflictError } from '../../../src/db/agentStore'; +import { TrueFoundryAgentStore } from '../../../src/truefoundry/TrueFoundryAgentStore'; +import { + TrueFoundryServiceFoundryServerClient, + type DeleteRemoteAgentInput, + type PutRemoteAgentInput, + type PutRemoteAgentResult, +} from '../../../src/truefoundry/TrueFoundryServiceFoundryServerClient'; + +const TENANT = 'default'; +const TOKEN = 'test-token'; +const LOGGER = createLogger({ silent: true }); +const CREATED_BY_SUBJECT: CreatedBySubject = { + subject_id: 'tester', + subject_type: 'user', + subject_display_name: 'tester', +}; + +function mockTransaction() { + const executor = { + transformQuery(node: unknown) { + return node; + }, + compileQuery() { + return { sql: 'select 1', parameters: [] }; + }, + executeQuery: jest.fn(async () => ({ rows: [] })), + withPlugins() { + return executor; + }, + }; + return { + getExecutor() { + return executor; + }, + }; +} + +const TXN = mockTransaction(); + +function manifest(overrides: { instructions?: string; mcp_servers?: { name: string }[] } = {}) { + return AgentSpecSchema.parse({ + model: { name: 'openai-gateway/gpt-5' }, + instructions: overrides.instructions ?? 'Be helpful.', + ...(overrides.mcp_servers === undefined ? {} : { mcp_servers: overrides.mcp_servers }), + }); +} + +function record(overrides: Partial = {}): AgentRecord { + const now = '2026-09-02T00:00:00.000Z'; + return { + id: 'agent-1', + tenant_id: TENANT, + name: 'research', + manifest: manifest(), + external_id: null, + created_by_subject: CREATED_BY_SUBJECT, + created_at: now, + updated_at: now, + ...overrides, + }; +} + +function mockInner(overrides = {}) { + return { + listAgents: jest.fn(), + getAgent: jest.fn(), + createAgent: jest.fn(), + updateAgent: jest.fn(), + deleteAgent: jest.fn(), + withTransaction: jest.fn(async fn => fn(TXN)), + ...overrides, + }; +} + +function mockClient( + overrides: { + putRemoteAgent?: TrueFoundryServiceFoundryServerClient['putRemoteAgent']; + deleteRemoteAgent?: TrueFoundryServiceFoundryServerClient['deleteRemoteAgent']; + } = {}, +): TrueFoundryServiceFoundryServerClient { + const client = new TrueFoundryServiceFoundryServerClient({ + serviceFoundryServerUrl: 'http://servicefoundry.test', + logger: LOGGER, + tls: { enabled: false, dir: '' }, + httpTimeoutMs: 10_000, + httpAgentTimeoutMs: 3_000, + }); + client.putRemoteAgent = + overrides.putRemoteAgent ?? (async (): Promise => ({ externalId: 'sf-1' })); + client.deleteRemoteAgent = overrides.deleteRemoteAgent ?? (async (_input: DeleteRemoteAgentInput) => undefined); + return client; +} + +function firstInvocationOrder(mock: jest.Mock): number { + const order = mock.mock.invocationCallOrder[0]; + if (order === undefined) { + throw new Error('expected mock to have been called'); + } + return order; +} + +describe('TrueFoundryAgentStore', () => { + it('listAgents and getAgent pass through to the inner store', async () => { + const agents = [record()]; + const listAgents = jest.fn(async () => agents); + const getAgent = jest.fn(async () => agents[0]); + const store = new TrueFoundryAgentStore({ + inner: mockInner({ listAgents, getAgent }), + client: mockClient(), + accessToken: TOKEN, + }); + + await expect(store.listAgents({ tenant_id: TENANT }, TXN)).resolves.toBe(agents); + await expect(store.getAgent({ tenant_id: TENANT, id: 'agent-1' }, TXN)).resolves.toBe(agents[0]); + expect(listAgents).toHaveBeenCalledWith({ tenant_id: TENANT }, TXN); + expect(getAgent).toHaveBeenCalledWith({ tenant_id: TENANT, id: 'agent-1' }, TXN); + }); + + it('createAgent inserts locally, puts remote, then sets external_id', async () => { + const local = record({ external_id: null }); + const linked = record({ external_id: 'sf-1' }); + const putRemoteAgent = jest.fn(async (input: PutRemoteAgentInput) => { + expect(input).toEqual({ + accessToken: TOKEN, + name: 'research', + description: 'Be helpful.', + model: 'openai-gateway/gpt-5', + mcp_servers: ['slack'], + }); + return { externalId: 'sf-1' }; + }); + const createAgent = jest.fn(async () => local); + const updateAgent = jest.fn(async () => linked); + const store = new TrueFoundryAgentStore({ + inner: mockInner({ createAgent, updateAgent }), + client: mockClient({ putRemoteAgent }), + accessToken: TOKEN, + }); + + await expect( + store.createAgent( + { + tenant_id: TENANT, + created_by_subject: CREATED_BY_SUBJECT, + name: 'research', + manifest: manifest({ mcp_servers: [{ name: 'slack' }] }), + external_id: null, + }, + TXN, + ), + ).resolves.toBe(linked); + expect(createAgent).toHaveBeenCalledWith( + expect.objectContaining({ + tenant_id: TENANT, + name: 'research', + external_id: null, + }), + TXN, + ); + expect(updateAgent).toHaveBeenCalledWith({ tenant_id: TENANT, id: local.id, external_id: 'sf-1' }, TXN); + expect(firstInvocationOrder(createAgent)).toBeLessThan(firstInvocationOrder(putRemoteAgent)); + expect(firstInvocationOrder(putRemoteAgent)).toBeLessThan(firstInvocationOrder(updateAgent)); + }); + + it('createAgent rejects a duplicate local name before calling ServiceFoundry', async () => { + const createAgent = jest.fn(async () => { + throw new AgentNameConflictError({ tenant_id: TENANT, name: 'research' }); + }); + const putRemoteAgent = jest.fn(); + const updateAgent = jest.fn(); + const deleteRemoteAgent = jest.fn(); + const store = new TrueFoundryAgentStore({ + inner: mockInner({ createAgent, updateAgent }), + client: mockClient({ putRemoteAgent, deleteRemoteAgent }), + accessToken: TOKEN, + }); + + await expect( + store.createAgent( + { + tenant_id: TENANT, + created_by_subject: CREATED_BY_SUBJECT, + name: 'research', + manifest: manifest(), + external_id: null, + }, + TXN, + ), + ).rejects.toBeInstanceOf(AgentNameConflictError); + expect(putRemoteAgent).not.toHaveBeenCalled(); + expect(updateAgent).not.toHaveBeenCalled(); + expect(deleteRemoteAgent).not.toHaveBeenCalled(); + }); + + it('createAgent uses agent name as description when instructions are omitted', async () => { + const putRemoteAgent = jest.fn(async (input: PutRemoteAgentInput) => { + expect(input.description).toBe('research'); + expect(input.mcp_servers).toEqual([]); + return { externalId: 'sf-1' }; + }); + const createAgent = jest.fn(async () => record({ external_id: null })); + const updateAgent = jest.fn(async () => record({ external_id: 'sf-1' })); + const store = new TrueFoundryAgentStore({ + inner: mockInner({ createAgent, updateAgent }), + client: mockClient({ putRemoteAgent }), + accessToken: TOKEN, + }); + + await store.createAgent( + { + tenant_id: TENANT, + created_by_subject: CREATED_BY_SUBJECT, + name: 'research', + manifest: AgentSpecSchema.parse({ model: { name: 'openai-gateway/gpt-5' } }), + external_id: null, + }, + TXN, + ); + expect(putRemoteAgent).toHaveBeenCalled(); + }); + + it('createAgent deletes the local row when putRemoteAgent fails', async () => { + const local = record({ external_id: null }); + const createAgent = jest.fn(async () => local); + const deleteAgent = jest.fn(async () => undefined); + const updateAgent = jest.fn(); + const putRemoteAgent = jest.fn(async () => { + throw new Error('sf failed'); + }); + const deleteRemoteAgent = jest.fn(); + const store = new TrueFoundryAgentStore({ + inner: mockInner({ createAgent, updateAgent, deleteAgent }), + client: mockClient({ putRemoteAgent, deleteRemoteAgent }), + accessToken: TOKEN, + }); + + await expect( + store.createAgent( + { + tenant_id: TENANT, + created_by_subject: CREATED_BY_SUBJECT, + name: 'research', + manifest: manifest(), + external_id: null, + }, + TXN, + ), + ).rejects.toThrow('sf failed'); + expect(updateAgent).not.toHaveBeenCalled(); + expect(deleteRemoteAgent).not.toHaveBeenCalled(); + expect(deleteAgent).toHaveBeenCalledWith({ tenant_id: TENANT, id: local.id }, TXN); + }); + + it('createAgent rolls back remote and local when updateAgent fails', async () => { + const local = record({ external_id: null }); + const createAgent = jest.fn(async () => local); + const updateAgent = jest.fn(async () => { + throw new Error('db update failed'); + }); + const deleteAgent = jest.fn(async () => undefined); + const deleteRemoteAgent = jest.fn(async () => undefined); + const store = new TrueFoundryAgentStore({ + inner: mockInner({ createAgent, updateAgent, deleteAgent }), + client: mockClient({ deleteRemoteAgent }), + accessToken: TOKEN, + }); + + await expect( + store.createAgent( + { + tenant_id: TENANT, + created_by_subject: CREATED_BY_SUBJECT, + name: 'research', + manifest: manifest(), + external_id: null, + }, + TXN, + ), + ).rejects.toThrow('db update failed'); + expect(deleteRemoteAgent).toHaveBeenCalledWith({ accessToken: TOKEN, externalId: 'sf-1' }); + expect(deleteAgent).toHaveBeenCalledWith({ tenant_id: TENANT, id: local.id }, TXN); + }); + + it('createAgent still throws when cleanup fails', async () => { + const local = record({ external_id: null }); + const createAgent = jest.fn(async () => local); + const updateAgent = jest.fn(async () => { + throw new Error('db update failed'); + }); + const deleteAgent = jest.fn(async () => { + throw new Error('local cleanup failed'); + }); + const deleteRemoteAgent = jest.fn(async () => { + throw new Error('remote cleanup failed'); + }); + const store = new TrueFoundryAgentStore({ + inner: mockInner({ createAgent, updateAgent, deleteAgent }), + client: mockClient({ deleteRemoteAgent }), + accessToken: TOKEN, + }); + + await expect( + store.createAgent( + { + tenant_id: TENANT, + created_by_subject: CREATED_BY_SUBJECT, + name: 'research', + manifest: manifest(), + external_id: null, + }, + TXN, + ), + ).rejects.toMatchObject({ + message: 'createAgent failed and cleanup also failed', + errors: [ + expect.objectContaining({ message: 'db update failed' }), + expect.objectContaining({ message: 'remote cleanup failed' }), + expect.objectContaining({ message: 'local cleanup failed' }), + ], + }); + }); + + it('updateAgent without manifest passes through to the inner store', async () => { + const updated = record({ external_id: 'sf-agent-1' }); + const updateAgent = jest.fn(async () => updated); + const putRemoteAgent = jest.fn(); + const store = new TrueFoundryAgentStore({ + inner: mockInner({ updateAgent }), + client: mockClient({ putRemoteAgent }), + accessToken: TOKEN, + }); + + await expect(store.updateAgent({ tenant_id: TENANT, id: 'agent-1', external_id: 'sf-agent-1' })).resolves.toBe( + updated, + ); + expect(putRemoteAgent).not.toHaveBeenCalled(); + expect(updateAgent).toHaveBeenCalledWith( + { tenant_id: TENANT, id: 'agent-1', external_id: 'sf-agent-1' }, + undefined, + ); + }); + + it('updateAgent returns undefined for a missing agent without calling putRemoteAgent', async () => { + const getAgent = jest.fn(async () => undefined); + const updateAgent = jest.fn(); + const putRemoteAgent = jest.fn(); + const store = new TrueFoundryAgentStore({ + inner: mockInner({ getAgent, updateAgent }), + client: mockClient({ putRemoteAgent }), + accessToken: TOKEN, + }); + + await expect( + store.updateAgent({ tenant_id: TENANT, id: 'missing', manifest: manifest({ instructions: 'Updated.' }) }), + ).resolves.toBeUndefined(); + expect(updateAgent).not.toHaveBeenCalled(); + expect(putRemoteAgent).not.toHaveBeenCalled(); + }); + + it('updateAgent reads the agent inside the transaction for manifest updates', async () => { + const previous = record({ external_id: 'sf-1' }); + const updatedManifest = manifest({ instructions: 'Updated.' }); + const updated = record({ manifest: updatedManifest, external_id: 'sf-1' }); + const getAgent = jest.fn(async () => previous); + const updateAgent = jest.fn(async () => updated); + const withTransaction = jest.fn(async fn => fn(TXN)); + const store = new TrueFoundryAgentStore({ + inner: mockInner({ getAgent, updateAgent, withTransaction }), + client: mockClient(), + accessToken: TOKEN, + }); + + await store.updateAgent({ tenant_id: TENANT, id: previous.id, manifest: updatedManifest }); + expect(withTransaction).toHaveBeenCalled(); + expect(getAgent).toHaveBeenCalledWith({ tenant_id: TENANT, id: previous.id }, TXN); + }); + + it('updateAgent puts remote agent then writes manifest when putRemoteAgent returns the same id', async () => { + const previous = record({ external_id: 'sf-1' }); + const updatedManifest = manifest({ instructions: 'Updated.' }); + const updated = record({ manifest: updatedManifest, external_id: 'sf-1' }); + const getAgent = jest.fn(async () => previous); + const updateAgent = jest.fn(async () => updated); + const putRemoteAgent = jest.fn(async () => ({ externalId: 'sf-1' })); + const store = new TrueFoundryAgentStore({ + inner: mockInner({ getAgent, updateAgent }), + client: mockClient({ putRemoteAgent }), + accessToken: TOKEN, + }); + + await expect(store.updateAgent({ tenant_id: TENANT, id: previous.id, manifest: updatedManifest })).resolves.toBe( + updated, + ); + expect(putRemoteAgent).toHaveBeenCalledTimes(1); + expect(updateAgent).toHaveBeenCalledTimes(1); + expect(updateAgent).toHaveBeenCalledWith( + { + tenant_id: TENANT, + id: previous.id, + manifest: updatedManifest, + }, + TXN, + ); + expect(firstInvocationOrder(putRemoteAgent)).toBeLessThan(firstInvocationOrder(updateAgent)); + }); + + it('updateAgent puts remote agent then writes manifest and external_id when it changes', async () => { + const previous = record({ external_id: 'sf-old' }); + const updatedManifest = manifest({ instructions: 'Updated.' }); + const updated = record({ manifest: updatedManifest, external_id: 'sf-new' }); + const getAgent = jest.fn(async () => previous); + const updateAgent = jest.fn(async () => updated); + const putRemoteAgent = jest.fn(async () => ({ externalId: 'sf-new' })); + const store = new TrueFoundryAgentStore({ + inner: mockInner({ getAgent, updateAgent }), + client: mockClient({ putRemoteAgent }), + accessToken: TOKEN, + }); + + const result = await store.updateAgent( + { + tenant_id: TENANT, + id: previous.id, + manifest: updatedManifest, + }, + TXN, + ); + expect(result?.external_id).toBe('sf-new'); + expect(updateAgent).toHaveBeenCalledTimes(1); + expect(updateAgent).toHaveBeenCalledWith( + { + tenant_id: TENANT, + id: previous.id, + manifest: updatedManifest, + external_id: 'sf-new', + }, + TXN, + ); + }); + + it('updateAgent keeps the DB row when putRemoteAgent fails', async () => { + const previous = record({ external_id: 'sf-old' }); + const getAgent = jest.fn(async () => previous); + const updateAgent = jest.fn(); + const putRemoteAgent = jest.fn(async () => { + throw new Error('sf failed'); + }); + const store = new TrueFoundryAgentStore({ + inner: mockInner({ getAgent, updateAgent }), + client: mockClient({ putRemoteAgent }), + accessToken: TOKEN, + }); + + await expect( + store.updateAgent({ tenant_id: TENANT, id: previous.id, manifest: manifest({ instructions: 'Updated.' }) }), + ).rejects.toThrow('sf failed'); + expect(updateAgent).not.toHaveBeenCalled(); + }); + + it('updateAgent restores ServiceFoundry when the DB write fails', async () => { + const previous = record({ external_id: 'sf-old' }); + const updatedManifest = manifest({ instructions: 'Updated.' }); + const getAgent = jest.fn(async () => previous); + const updateAgent = jest.fn(async () => { + throw new Error('db write failed'); + }); + const putRemoteAgent = jest + .fn() + .mockResolvedValueOnce({ externalId: 'sf-new' }) + .mockResolvedValueOnce({ externalId: 'sf-old' }); + const store = new TrueFoundryAgentStore({ + inner: mockInner({ getAgent, updateAgent }), + client: mockClient({ putRemoteAgent }), + accessToken: TOKEN, + }); + + await expect(store.updateAgent({ tenant_id: TENANT, id: previous.id, manifest: updatedManifest })).rejects.toThrow( + 'db write failed', + ); + expect(putRemoteAgent).toHaveBeenCalledTimes(2); + expect(putRemoteAgent).toHaveBeenLastCalledWith( + expect.objectContaining({ + accessToken: TOKEN, + name: previous.name, + description: previous.manifest.instructions ?? previous.name, + model: previous.manifest.model.name, + }), + ); + }); + + it('updateAgent still throws when ServiceFoundry restore fails', async () => { + const previous = record({ external_id: 'sf-old' }); + const getAgent = jest.fn(async () => previous); + const updateAgent = jest.fn(async () => { + throw new Error('db write failed'); + }); + const putRemoteAgent = jest + .fn() + .mockResolvedValueOnce({ externalId: 'sf-new' }) + .mockRejectedValueOnce(new Error('sf restore failed')); + const store = new TrueFoundryAgentStore({ + inner: mockInner({ getAgent, updateAgent }), + client: mockClient({ putRemoteAgent }), + accessToken: TOKEN, + }); + + await expect( + store.updateAgent({ tenant_id: TENANT, id: previous.id, manifest: manifest({ instructions: 'Updated.' }) }), + ).rejects.toMatchObject({ + message: 'updateAgent failed and ServiceFoundry restore also failed', + errors: [ + expect.objectContaining({ message: 'db write failed' }), + expect.objectContaining({ message: 'sf restore failed' }), + ], + }); + }); + + it('deleteAgent reads the agent inside the transaction', async () => { + const previous = record({ external_id: 'sf-1' }); + const getAgent = jest.fn(async () => previous); + const deleteAgent = jest.fn(async () => undefined); + const store = new TrueFoundryAgentStore({ + inner: mockInner({ getAgent, deleteAgent }), + client: mockClient(), + accessToken: TOKEN, + }); + + await store.deleteAgent({ tenant_id: TENANT, id: previous.id }); + expect(getAgent).toHaveBeenCalledWith({ tenant_id: TENANT, id: previous.id }, TXN); + }); + + it('deleteAgent deletes ServiceFoundry then DB when external_id is set', async () => { + const previous = record({ external_id: 'sf-1' }); + const getAgent = jest.fn(async () => previous); + const deleteAgent = jest.fn(async () => undefined); + const deleteRemoteAgent = jest.fn(async () => undefined); + const store = new TrueFoundryAgentStore({ + inner: mockInner({ getAgent, deleteAgent }), + client: mockClient({ deleteRemoteAgent }), + accessToken: TOKEN, + }); + + await store.deleteAgent({ tenant_id: TENANT, id: previous.id }); + expect(deleteRemoteAgent).toHaveBeenCalledWith({ accessToken: TOKEN, externalId: 'sf-1' }); + expect(deleteAgent).toHaveBeenCalledWith({ tenant_id: TENANT, id: previous.id }, TXN); + expect(firstInvocationOrder(deleteRemoteAgent)).toBeLessThan(firstInvocationOrder(deleteAgent)); + }); + + it('deleteAgent skips ServiceFoundry when external_id is null', async () => { + const previous = record({ external_id: null }); + const getAgent = jest.fn(async () => previous); + const deleteAgent = jest.fn(async () => undefined); + const deleteRemoteAgent = jest.fn(); + const store = new TrueFoundryAgentStore({ + inner: mockInner({ getAgent, deleteAgent }), + client: mockClient({ deleteRemoteAgent }), + accessToken: TOKEN, + }); + + await store.deleteAgent({ tenant_id: TENANT, id: previous.id }); + expect(deleteAgent).toHaveBeenCalled(); + expect(deleteRemoteAgent).not.toHaveBeenCalled(); + }); + + it('deleteAgent skips ServiceFoundry when the agent is already missing', async () => { + const getAgent = jest.fn(async () => undefined); + const deleteAgent = jest.fn(async () => undefined); + const deleteRemoteAgent = jest.fn(); + const store = new TrueFoundryAgentStore({ + inner: mockInner({ getAgent, deleteAgent }), + client: mockClient({ deleteRemoteAgent }), + accessToken: TOKEN, + }); + + await store.deleteAgent({ tenant_id: TENANT, id: 'missing' }); + expect(deleteAgent).toHaveBeenCalledWith({ tenant_id: TENANT, id: 'missing' }, TXN); + expect(deleteRemoteAgent).not.toHaveBeenCalled(); + }); + + it('deleteAgent keeps the DB row when ServiceFoundry delete fails', async () => { + const previous = record({ external_id: 'sf-1' }); + const getAgent = jest.fn(async () => previous); + const deleteAgent = jest.fn(async () => undefined); + const deleteRemoteAgent = jest.fn(async () => { + throw new Error('sf delete failed'); + }); + const store = new TrueFoundryAgentStore({ + inner: mockInner({ getAgent, deleteAgent }), + client: mockClient({ deleteRemoteAgent }), + accessToken: TOKEN, + }); + + await expect(store.deleteAgent({ tenant_id: TENANT, id: previous.id })).rejects.toThrow('sf delete failed'); + expect(deleteRemoteAgent).toHaveBeenCalled(); + expect(deleteAgent).not.toHaveBeenCalled(); + }); +});