diff --git a/.changeset/20260902143524-agent-external-id.md b/.changeset/20260902143524-agent-external-id.md new file mode 100644 index 000000000..a600cad05 --- /dev/null +++ b/.changeset/20260902143524-agent-external-id.md @@ -0,0 +1,5 @@ +--- +"@truefoundry/trueforge": patch +--- + +Add nullable agent `external_id` with a tenant-scoped partial unique index (Postgres and SQLite). diff --git a/.changeset/20260902194500-truefoundry-agent-store.md b/.changeset/20260902194500-truefoundry-agent-store.md new file mode 100644 index 000000000..6c75fdb0d --- /dev/null +++ b/.changeset/20260902194500-truefoundry-agent-store.md @@ -0,0 +1,5 @@ +--- +"@truefoundry/trueforge": patch +--- + +Sync ServiceFoundry remote agents on create/update/delete via TrueFoundryAgentStore and store the remote id in `external_id`. diff --git a/.changeset/20260903010000-drop-agent-metadata.md b/.changeset/20260903010000-drop-agent-metadata.md new file mode 100644 index 000000000..ee4b52f4d --- /dev/null +++ b/.changeset/20260903010000-drop-agent-metadata.md @@ -0,0 +1,5 @@ +--- +"@truefoundry/trueforge": patch +--- + +Drop unused `agent.metadata`; remote identity is stored in `external_id`. diff --git a/.changeset/20260903013000-reserve-agent-names.md b/.changeset/20260903013000-reserve-agent-names.md new file mode 100644 index 000000000..1625e0d12 --- /dev/null +++ b/.changeset/20260903013000-reserve-agent-names.md @@ -0,0 +1,5 @@ +--- +"@truefoundry/trueforge": patch +--- + +Reserve agent names `tfg` and `trueforge` in TrueFoundryAgentStore.createAgent. diff --git a/packages/trueforge/scripts/write-openapi.ts b/packages/trueforge/scripts/write-openapi.ts index 5d227f936..d8d6e71e7 100644 --- a/packages/trueforge/scripts/write-openapi.ts +++ b/packages/trueforge/scripts/write-openapi.ts @@ -59,6 +59,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), @@ -75,7 +76,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 0a3158a40..11c8ac312 100644 --- a/packages/trueforge/src/apis/agents.ts +++ b/packages/trueforge/src/apis/agents.ts @@ -4,7 +4,13 @@ import { OpenAPIHono, type RouteHandler } from '@hono/zod-openapi'; import type { AgentSpec } from '@truefoundry/trueforge-core/agent-session'; import type { Context } from 'hono'; -import { AgentNameConflictError, type AgentRecord, type IAgentStore } from '../db/agentStore'; +import { + AgentExternalIdConflictError, + AgentNameConflictError, + AgentNameReservedError, + 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 +30,7 @@ import { buildAgentCodeSnippets } from './agentCodeSnippets'; import { TENANT_ID } from './sessions'; export interface AgentsRouterDeps { - agentStore: IAgentStore; + resolveAgentStore: (c: Context) => IAgentStore; resolveModelProviderStore: (c: Context) => IModelProviderStore; resolveMcpServerStore: (c: Context) => IMcpServerStore; skillStore: ISkillStore; @@ -65,7 +71,7 @@ async function validateManifest({ export function createAgentsRouter(deps: AgentsRouterDeps) { const listHandler: RouteHandler = async c => { - const records = await deps.agentStore.listAgents(TENANT_ID); + const records = await deps.resolveAgentStore(c).listAgents(TENANT_ID); return c.json({ data: records.map(toWireAgent) }, 200); }; @@ -78,14 +84,17 @@ export function createAgentsRouter(deps: AgentsRouterDeps(deps: AgentsRouterDeps = async c => { const { agent_id: agentId } = c.req.valid('param'); - const record = await deps.agentStore.getAgent({ tenant_id: TENANT_ID, id: agentId }); + const record = await deps.resolveAgentStore(c).getAgent({ tenant_id: TENANT_ID, id: agentId }); if (record === undefined) { return c.json({ error: { message: `Agent not found: ${agentId}` } }, 404); } @@ -103,7 +112,7 @@ export function createAgentsRouter(deps: AgentsRouterDeps = async c => { const { agent_id: agentId } = c.req.valid('param'); - const record = await deps.agentStore.getAgent({ tenant_id: TENANT_ID, id: agentId }); + const record = await deps.resolveAgentStore(c).getAgent({ tenant_id: TENANT_ID, id: agentId }); if (record === undefined) { return c.json({ error: { message: `Agent not found: ${agentId}` } }, 404); } @@ -120,7 +129,7 @@ export function createAgentsRouter(deps: AgentsRouterDeps = async c => { const { agent_id: agentId } = c.req.valid('param'); - await deps.agentStore.deleteAgent({ tenant_id: TENANT_ID, id: agentId }); + await deps.resolveAgentStore(c).deleteAgent({ tenant_id: TENANT_ID, id: agentId }); return c.json({}, 200); }; @@ -133,7 +142,7 @@ export function createAgentsRouter(deps: AgentsRouterDeps { scheduleStore: IScheduleStore; - agentStore: IAgentStore; + resolveAgentStore: (c: Context) => IAgentStore; sessions: Sessions; turnDeps: BeginTurnExecutionDeps; withTransaction: WithTransaction; @@ -185,7 +185,7 @@ export function createSchedulesRouter(deps: SchedulesRouterDeps { await startTurnInProcess({ ...turnParams, deps: deps.turnDeps }); }, @@ -217,7 +217,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; @@ -227,7 +227,7 @@ type InternalSessionsRouterDeps = Pick< | 'resolveModelProviderStore' | 'resolveMcpServerStore' | 'skillStore' - | 'agentStore' + | 'resolveAgentStore' | 'sandboxProviderStore' | 'resolveUserContext' >; @@ -252,7 +252,7 @@ function createGetOrCreateSessionByExternalIdHandler( let agent: SessionRecord['agent']; if (isSessionAgentNameRef(body.agent)) { - const named = await deps.agentStore.getAgent({ tenant_id: TENANT_ID, name: body.agent.name }); + const named = await deps.resolveAgentStore(c).getAgent({ tenant_id: TENANT_ID, name: body.agent.name }); if (named === undefined) { return c.json({ error: { message: `Agent not found: ${body.agent.name}` } }, 404); } @@ -296,7 +296,7 @@ export function createSessionsRouter(deps: SessionsRouterDeps) { const sessionId = newId(); if (isSessionAgentNameRef(body.agent)) { - const agent = await deps.agentStore.getAgent({ tenant_id: TENANT_ID, name: body.agent.name }); + const agent = await deps.resolveAgentStore(c).getAgent({ tenant_id: TENANT_ID, name: body.agent.name }); if (agent === undefined) { return c.json({ error: { message: `Agent not found: ${body.agent.name}` } }, 404); } diff --git a/packages/trueforge/src/apis/turns.ts b/packages/trueforge/src/apis/turns.ts index 420069471..cb001db83 100644 --- a/packages/trueforge/src/apis/turns.ts +++ b/packages/trueforge/src/apis/turns.ts @@ -109,7 +109,7 @@ export interface TurnsRouterDeps { resolveMcpServerStore: (c: Context) => IMcpServerStore; tokenStore: IOAuthTokenStore; skillStore: ISkillStore; - agentStore: IAgentStore; + resolveAgentStore: (c: Context) => IAgentStore; /** Resumable live turn-event transport: create-turn writes, subscribe polls. */ eventSubscriptions: EventSubscriptionRegistry; sandboxProviderStore: ISandboxProviderStore; @@ -119,15 +119,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' | 'tokenStore' | 'skillStore' | 'agentStore' | 'sandboxProviderStore' | 'logger' + 'activeTurns' | 'eventSubscriptions' | 'tokenStore' | 'skillStore' | 'sandboxProviderStore' | 'logger' > & { modelProviderStore: IModelProviderStore; mcpServerStore: IMcpServerStore; + agentStore: IAgentStore; }; /** @@ -675,6 +676,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 930ae409b..373a8db58 100644 --- a/packages/trueforge/src/app.ts +++ b/packages/trueforge/src/app.ts @@ -175,11 +175,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; @@ -273,7 +277,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, @@ -287,7 +291,7 @@ export function createServerApp(deps: ServerDeps) { withAuth( createSchedulesRouter({ scheduleStore: deps.scheduleStore, - agentStore: deps.agentStore, + resolveAgentStore: deps.resolveAgentStore, sessions: deps.sessions, turnDeps: { activeTurns: deps.activeTurns, @@ -296,7 +300,7 @@ export function createServerApp(deps: ServerDeps) { mcpServerStore: deps.resolveMcpServerStore(), tokenStore: deps.tokenStore, skillStore: deps.skillStore, - agentStore: deps.agentStore, + agentStore: deps.resolveAgentStore(), sandboxProviderStore: deps.sandboxProviderStore, logger: deps.logger, }, @@ -328,7 +332,7 @@ export function createServerApp(deps: ServerDeps) { resolveModelProviderStore: deps.resolveModelProviderStore, resolveMcpServerStore: deps.resolveMcpServerStore, skillStore: deps.skillStore, - agentStore: deps.agentStore, + resolveAgentStore: deps.resolveAgentStore, sandboxProviderStore: deps.sandboxProviderStore, resolveUserContext, }), @@ -353,7 +357,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, @@ -373,7 +377,7 @@ export function createServerApp(deps: ServerDeps) { resolveMcpServerStore: deps.resolveMcpServerStore, tokenStore: deps.tokenStore, skillStore: deps.skillStore, - agentStore: deps.agentStore, + resolveAgentStore: deps.resolveAgentStore, eventSubscriptions: deps.eventSubscriptions, sandboxProviderStore: deps.sandboxProviderStore, logger: deps.logger, diff --git a/packages/trueforge/src/auth/middleware.ts b/packages/trueforge/src/auth/middleware.ts index 2d2bf2f94..7b5a75eaf 100644 --- a/packages/trueforge/src/auth/middleware.ts +++ b/packages/trueforge/src/auth/middleware.ts @@ -40,7 +40,7 @@ export function requireAccessToken(c: Context): string { const token = readAccessToken(c); if (!token) { 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 token; diff --git a/packages/trueforge/src/db/agentStore.ts b/packages/trueforge/src/db/agentStore.ts index b741e80ef..f4a309435 100644 --- a/packages/trueforge/src/db/agentStore.ts +++ b/packages/trueforge/src/db/agentStore.ts @@ -5,7 +5,6 @@ * Implementations: PostgresAgentStore and SqliteAgentStore. */ import { AgentSpecSchema, type AgentSpec } from '@truefoundry/trueforge-core/agent-session'; -import type { AgentMetadata } from '../schemas/agentMetadata'; import type { ResourceName } from '../schemas/common'; export interface AgentRecord { @@ -13,7 +12,7 @@ export interface AgentRecord { tenant_id: string; name: ResourceName; manifest: AgentSpec; - metadata: AgentMetadata; + external_id: string | null; /** ISO-8601 UTC instant. */ created_at: string; /** ISO-8601 UTC instant. */ @@ -35,17 +34,18 @@ export interface CreateAgentInput { tenant_id: string; name: ResourceName; manifest: AgentSpec; + external_id?: string | null; } /** - * Patch an existing agent by immutable id. At least one of `manifest` or `metadata` is required. + * Patch an existing agent by immutable id. At least one of `manifest` or `external_id` is required. * Provided fields replace the stored column; omitted fields are left unchanged. */ export interface UpdateAgentInput { tenant_id: string; id: string; manifest?: AgentSpec; - metadata?: AgentMetadata; + external_id?: string | null; } export interface DeleteAgentInput { @@ -66,12 +66,36 @@ export class AgentNameConflictError extends Error { } } +/** Unique `(tenant_id, external_id)` violation when `external_id` is set. */ +export class AgentExternalIdConflictError extends Error { + readonly tenant_id: string; + readonly external_id: string; + + constructor({ tenant_id, external_id }: { tenant_id: string; external_id: string }, options?: ErrorOptions) { + super(`Agent already exists for external id: ${external_id}`, options); + this.name = 'AgentExternalIdConflictError'; + this.tenant_id = tenant_id; + this.external_id = external_id; + } +} + +/** Name reserved for product / control-plane use (TrueFoundryAgentStore). */ +export class AgentNameReservedError extends Error { + readonly agent_name: string; + + constructor({ name }: { name: string }, options?: ErrorOptions) { + super(`Agent name is reserved: ${name}`, options); + this.name = 'AgentNameReservedError'; + this.agent_name = name; + } +} + export interface IAgentStore { listAgents(tenantId: string, transaction?: TTransaction): Promise; getAgent(input: GetAgentInput, transaction?: TTransaction): Promise; - /** Inserts a new agent with a generated ULID. Throws AgentNameConflictError on name clash. */ + /** Inserts a new agent with a generated ULID. Throws AgentNameConflictError or AgentExternalIdConflictError on unique clash. */ createAgent(input: CreateAgentInput, transaction?: TTransaction): Promise; - /** Patches `manifest` and/or `metadata` for an existing id. Returns undefined if missing. */ + /** Patches `manifest` and/or `external_id`. Throws AgentExternalIdConflictError on unique clash. Returns undefined if missing. */ updateAgent(input: UpdateAgentInput, transaction?: TTransaction): Promise; /** Deletes by immutable id. Idempotent if already missing. */ deleteAgent(input: DeleteAgentInput, transaction?: TTransaction): Promise; diff --git a/packages/trueforge/src/db/indexes.ts b/packages/trueforge/src/db/indexes.ts index 1d546d39f..06720ebb7 100644 --- a/packages/trueforge/src/db/indexes.ts +++ b/packages/trueforge/src/db/indexes.ts @@ -1,2 +1,5 @@ /** Partial unique index on `session (tenant_id, external_id) WHERE external_id IS NOT NULL`. */ export const SESSION_EXTERNAL_ID_UQ = 'session_external_id_uq'; + +/** Partial unique index on `agent (tenant_id, external_id) WHERE external_id IS NOT NULL`. */ +export const AGENT_EXTERNAL_ID_UQ = 'agent_external_id_uq'; diff --git a/packages/trueforge/src/db/postgres/agent-store/PostgresAgentStore.ts b/packages/trueforge/src/db/postgres/agent-store/PostgresAgentStore.ts index 0947caf25..ab1d8df86 100644 --- a/packages/trueforge/src/db/postgres/agent-store/PostgresAgentStore.ts +++ b/packages/trueforge/src/db/postgres/agent-store/PostgresAgentStore.ts @@ -1,7 +1,7 @@ import type { Kysely, Selectable, Transaction } from 'kysely'; -import { EMPTY_AGENT_METADATA } from '../../../schemas/agentMetadata'; import { newId } from '../../../utils/id'; import { + AgentExternalIdConflictError, AgentNameConflictError, parseStoredAgentSpec, type AgentRecord, @@ -11,7 +11,8 @@ import { type IAgentStore, type UpdateAgentInput, } from '../../agentStore'; -import { isUniqueViolation } from '../client'; +import { AGENT_EXTERNAL_ID_UQ } from '../../indexes'; +import { isPgConstraint, isUniqueViolation } from '../client'; import { json, now } from '../sqlExpressions'; import type { AgentTable, Database } from '../types'; @@ -21,12 +22,30 @@ function toRecord(row: Selectable): AgentRecord { tenant_id: row.tenant_id, name: row.name, manifest: parseStoredAgentSpec(row.manifest), - metadata: row.metadata, + external_id: row.external_id, created_at: row.created_at.toISOString(), updated_at: row.updated_at.toISOString(), }; } +/** Map unique violations to external_id vs name conflicts. */ +function throwAgentUniqueViolation({ + error, + tenant_id, + name, + external_id, +}: { + error: unknown; + tenant_id: string; + name: string; + external_id: string | null; +}): never { + if (isPgConstraint(error, AGENT_EXTERNAL_ID_UQ) && external_id) { + throw new AgentExternalIdConflictError({ tenant_id, external_id }, { cause: error }); + } + throw new AgentNameConflictError({ tenant_id, name }, { cause: error }); +} + export class PostgresAgentStore implements IAgentStore> { readonly #db: Kysely; @@ -54,6 +73,7 @@ export class PostgresAgentStore implements IAgentStore> { async createAgent(input: CreateAgentInput, transaction?: Transaction): Promise { const db = transaction ?? this.#db; + const external_id = input.external_id ?? null; try { const row = await db .insertInto('agent') @@ -62,7 +82,7 @@ export class PostgresAgentStore implements IAgentStore> { tenant_id: input.tenant_id, name: input.name, manifest: json(input.manifest), - metadata: json(EMPTY_AGENT_METADATA), + external_id, created_at: now(), updated_at: now(), }) @@ -71,29 +91,45 @@ export class PostgresAgentStore implements IAgentStore> { return toRecord(row); } catch (error) { if (isUniqueViolation(error)) { - throw new AgentNameConflictError({ tenant_id: input.tenant_id, name: input.name }, { cause: error }); + throwAgentUniqueViolation({ + error, + tenant_id: input.tenant_id, + name: input.name, + external_id, + }); } throw error; } } async updateAgent(input: UpdateAgentInput, transaction?: Transaction): Promise { - if (input.manifest === undefined && input.metadata === undefined) { - throw new Error('updateAgent requires manifest and/or metadata'); + if (input.manifest === undefined && input.external_id === undefined) { + throw new Error('updateAgent requires manifest and/or external_id'); } const db = transaction ?? this.#db; - const row = await db - .updateTable('agent') - .set({ - ...(input.manifest === undefined ? {} : { manifest: json(input.manifest) }), - ...(input.metadata === undefined ? {} : { metadata: json(input.metadata) }), - updated_at: now(), - }) - .where('tenant_id', '=', input.tenant_id) - .where('id', '=', input.id) - .returningAll() - .executeTakeFirst(); - return row === undefined ? undefined : toRecord(row); + try { + const row = await db + .updateTable('agent') + .set({ + ...(input.manifest === undefined ? {} : { manifest: json(input.manifest) }), + ...(input.external_id === undefined ? {} : { external_id: input.external_id }), + updated_at: now(), + }) + .where('tenant_id', '=', input.tenant_id) + .where('id', '=', input.id) + .returningAll() + .executeTakeFirst(); + return row === undefined ? undefined : toRecord(row); + } catch (error) { + if (isUniqueViolation(error) && input.external_id) { + // external_id already taken in this tenant (name is immutable on update). + throw new AgentExternalIdConflictError( + { tenant_id: input.tenant_id, external_id: input.external_id }, + { cause: error }, + ); + } + throw error; + } } async deleteAgent(input: DeleteAgentInput, transaction?: Transaction): Promise { diff --git a/packages/trueforge/src/db/postgres/migrations/20260902_000002_agent_external_id.ts b/packages/trueforge/src/db/postgres/migrations/20260902_000002_agent_external_id.ts new file mode 100644 index 000000000..af10b144b --- /dev/null +++ b/packages/trueforge/src/db/postgres/migrations/20260902_000002_agent_external_id.ts @@ -0,0 +1,22 @@ +import { sql, type Kysely } from 'kysely'; +import { AGENT_EXTERNAL_ID_UQ } from '../../indexes'; + +/** + * Optional agent `external_id`, unique within a tenant when set. + * NULLs are excluded so agents without an external id do not collide. + */ +export async function up(db: Kysely): Promise { + await sql`SET LOCAL lock_timeout = '5s'`.execute(db); + await db.schema.alterTable('agent').addColumn('external_id', 'text').execute(); + await sql` + CREATE UNIQUE INDEX ${sql.raw(AGENT_EXTERNAL_ID_UQ)} + ON agent (tenant_id, external_id) + WHERE external_id IS NOT NULL + `.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`SET LOCAL lock_timeout = '5s'`.execute(db); + await sql`DROP INDEX IF EXISTS ${sql.raw(AGENT_EXTERNAL_ID_UQ)}`.execute(db); + await db.schema.alterTable('agent').dropColumn('external_id').execute(); +} diff --git a/packages/trueforge/src/db/postgres/migrations/20260903_000001_drop_agent_metadata.ts b/packages/trueforge/src/db/postgres/migrations/20260903_000001_drop_agent_metadata.ts new file mode 100644 index 000000000..a1097a112 --- /dev/null +++ b/packages/trueforge/src/db/postgres/migrations/20260903_000001_drop_agent_metadata.ts @@ -0,0 +1,17 @@ +import { sql, type Kysely } from 'kysely'; + +/** + * Drop agent.metadata — identity lives in external_id; column was never on the public Agent API. + */ +export async function up(db: Kysely): Promise { + await sql`SET LOCAL lock_timeout = '5s'`.execute(db); + await sql`ALTER TABLE agent DROP COLUMN IF EXISTS metadata`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`SET LOCAL lock_timeout = '5s'`.execute(db); + await sql` + ALTER TABLE agent + ADD COLUMN metadata jsonb NOT NULL DEFAULT '{}'::jsonb + `.execute(db); +} diff --git a/packages/trueforge/src/db/postgres/types.ts b/packages/trueforge/src/db/postgres/types.ts index 20d35853f..c3025bb4b 100644 --- a/packages/trueforge/src/db/postgres/types.ts +++ b/packages/trueforge/src/db/postgres/types.ts @@ -21,7 +21,6 @@ import type { } from '@truefoundry/trueforge-core/core'; import type { CurrentContextUsage } from '@truefoundry/trueforge-core/core/runtime/contextUsage'; import type { ColumnType, Generated, JSONColumnType } from 'kysely'; -import type { AgentMetadata } from '../../schemas/agentMetadata'; import type { McpServerManifest } from '../../schemas/mcpServer'; import type { ModelProviderManifest } from '../../schemas/modelProvider'; import type { SandboxBuildMetadata, SandboxBuildStatus, SandboxProviderManifest } from '../../schemas/sandboxProvider'; @@ -376,8 +375,7 @@ export interface AgentTable { name: string; /** AgentSpec document; replaced whole on every upsert */ manifest: JSONColumnType; - /** `agent.metadata` jsonb; default `{}` for existing rows */ - metadata: JSONColumnType; + external_id: string | null; created_at: Date; updated_at: Date; } diff --git a/packages/trueforge/src/db/sqlite/agent-store/SqliteAgentStore.ts b/packages/trueforge/src/db/sqlite/agent-store/SqliteAgentStore.ts index 026f183ee..ed3352d55 100644 --- a/packages/trueforge/src/db/sqlite/agent-store/SqliteAgentStore.ts +++ b/packages/trueforge/src/db/sqlite/agent-store/SqliteAgentStore.ts @@ -1,8 +1,8 @@ import type { AgentSpec } from '@truefoundry/trueforge-core/agent-session'; import type { ExpressionBuilder, Kysely, Transaction } from 'kysely'; -import { EMPTY_AGENT_METADATA, type AgentMetadata } from '../../../schemas/agentMetadata'; import { newId } from '../../../utils/id'; import { + AgentExternalIdConflictError, AgentNameConflictError, parseStoredAgentSpec, type AgentRecord, @@ -23,7 +23,7 @@ function recordColumns(eb: ExpressionBuilder) { 'tenant_id' as const, 'name' as const, jsonText(eb.ref('manifest')).as('manifest'), - jsonText(eb.ref('metadata')).as('metadata'), + 'external_id' as const, 'created_at' as const, 'updated_at' as const, ]; @@ -34,7 +34,7 @@ function toRecord(row: { tenant_id: string; name: AgentRecord['name']; manifest: AgentSpec; - metadata: AgentMetadata; + external_id: string | null; created_at: string; updated_at: string; }): AgentRecord { @@ -74,6 +74,7 @@ export class SqliteAgentStore implements IAgentStore> { async createAgent(input: CreateAgentInput, transaction?: Transaction): Promise { const db = transaction ?? this.#db; const timestamp = nowIso(); + const external_id = input.external_id ?? null; try { const row = await db .insertInto('agent') @@ -82,7 +83,7 @@ export class SqliteAgentStore implements IAgentStore> { tenant_id: input.tenant_id, name: input.name, manifest: jsonbBind(input.manifest), - metadata: jsonbBind(EMPTY_AGENT_METADATA), + external_id, created_at: timestamp, updated_at: timestamp, }) @@ -91,33 +92,79 @@ export class SqliteAgentStore implements IAgentStore> { return toRecord(row); } catch (error) { if (isUniqueViolation(error)) { - throw new AgentNameConflictError({ tenant_id: input.tenant_id, name: input.name }, { cause: error }); + await this.throwCreateUnique({ + error, + tenant_id: input.tenant_id, + name: input.name, + external_id, + ...(transaction === undefined ? {} : { transaction }), + }); } throw error; } } async updateAgent(input: UpdateAgentInput, transaction?: Transaction): Promise { - if (input.manifest === undefined && input.metadata === undefined) { - throw new Error('updateAgent requires manifest and/or metadata'); + if (input.manifest === undefined && input.external_id === undefined) { + throw new Error('updateAgent requires manifest and/or external_id'); } const db = transaction ?? this.#db; - const row = await db - .updateTable('agent') - .set({ - ...(input.manifest === undefined ? {} : { manifest: jsonbBind(input.manifest) }), - ...(input.metadata === undefined ? {} : { metadata: jsonbBind(input.metadata) }), - updated_at: nowIso(), - }) - .where('tenant_id', '=', input.tenant_id) - .where('id', '=', input.id) - .returning(recordColumns) - .executeTakeFirst(); - return row === undefined ? undefined : toRecord(row); + try { + const row = await db + .updateTable('agent') + .set({ + ...(input.manifest === undefined ? {} : { manifest: jsonbBind(input.manifest) }), + ...(input.external_id === undefined ? {} : { external_id: input.external_id }), + updated_at: nowIso(), + }) + .where('tenant_id', '=', input.tenant_id) + .where('id', '=', input.id) + .returning(recordColumns) + .executeTakeFirst(); + return row === undefined ? undefined : toRecord(row); + } catch (error) { + if (isUniqueViolation(error) && input.external_id) { + // external_id already taken in this tenant (name is immutable on update). + throw new AgentExternalIdConflictError( + { tenant_id: input.tenant_id, external_id: input.external_id }, + { cause: error }, + ); + } + throw error; + } } async deleteAgent(input: DeleteAgentInput, transaction?: Transaction): Promise { const db = transaction ?? this.#db; await db.deleteFrom('agent').where('tenant_id', '=', input.tenant_id).where('id', '=', input.id).execute(); } + + /** Map unique violations to external_id vs name conflicts. */ + private async throwCreateUnique({ + error, + tenant_id, + name, + external_id, + transaction, + }: { + error: unknown; + tenant_id: string; + name: string; + external_id: string | null; + transaction?: Transaction; + }): Promise { + if (external_id !== null) { + const db = transaction ?? this.#db; + const owner = await db + .selectFrom('agent') + .select('id') + .where('tenant_id', '=', tenant_id) + .where('external_id', '=', external_id) + .executeTakeFirst(); + if (owner !== undefined) { + throw new AgentExternalIdConflictError({ tenant_id, external_id }, { cause: error }); + } + } + throw new AgentNameConflictError({ tenant_id, name }, { cause: error }); + } } diff --git a/packages/trueforge/src/db/sqlite/migrations/20260902_000002_agent_external_id.ts b/packages/trueforge/src/db/sqlite/migrations/20260902_000002_agent_external_id.ts new file mode 100644 index 000000000..2bde0b4cb --- /dev/null +++ b/packages/trueforge/src/db/sqlite/migrations/20260902_000002_agent_external_id.ts @@ -0,0 +1,24 @@ +import { type Kysely, sql } from 'kysely'; +import { AGENT_EXTERNAL_ID_UQ } from '../../indexes'; + +/** + * Optional agent `external_id`, unique within a tenant when set. + * NULLs are excluded so agents without an external id do not collide. + */ +export async function up(db: Kysely): Promise { + await db.transaction().execute(async trx => { + await sql`ALTER TABLE agent ADD COLUMN external_id TEXT`.execute(trx); + await sql` + CREATE UNIQUE INDEX ${sql.raw(AGENT_EXTERNAL_ID_UQ)} + ON agent (tenant_id, external_id) + WHERE external_id IS NOT NULL + `.execute(trx); + }); +} + +export async function down(db: Kysely): Promise { + await db.transaction().execute(async trx => { + await sql`DROP INDEX IF EXISTS ${sql.raw(AGENT_EXTERNAL_ID_UQ)}`.execute(trx); + await sql`ALTER TABLE agent DROP COLUMN external_id`.execute(trx); + }); +} diff --git a/packages/trueforge/src/db/sqlite/migrations/20260903_000001_drop_agent_metadata.ts b/packages/trueforge/src/db/sqlite/migrations/20260903_000001_drop_agent_metadata.ts new file mode 100644 index 000000000..4391f2925 --- /dev/null +++ b/packages/trueforge/src/db/sqlite/migrations/20260903_000001_drop_agent_metadata.ts @@ -0,0 +1,116 @@ +import { type Kysely, sql } from 'kysely'; +import { AGENT_EXTERNAL_ID_UQ } from '../../indexes'; + +/** + * Drop agent.metadata. Rebuild: ADD/DROP COLUMN cannot take DEFAULT (jsonb(...)) / STRICT drop. + * Keeps external_id + partial unique index from 20260902_000002. + * DROP TABLE agent needs FKs off (`schedule` REFERENCES it). + * PRAGMA foreign_keys is a no-op inside a txn. + */ +export async function up(db: Kysely): Promise { + await sql`PRAGMA foreign_keys = OFF`.execute(db); + try { + await db.transaction().execute(async trx => { + await sql` + CREATE TABLE agent_new ( + id TEXT NOT NULL, + tenant_id TEXT NOT NULL, + name TEXT NOT NULL, + manifest BLOB NOT NULL, + external_id TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (id), + UNIQUE (tenant_id, name) + ) STRICT + `.execute(trx); + + await sql` + INSERT INTO agent_new ( + id, + tenant_id, + name, + manifest, + external_id, + created_at, + updated_at + ) + SELECT + id, + tenant_id, + name, + manifest, + external_id, + created_at, + updated_at + FROM agent + `.execute(trx); + + await sql`DROP TABLE agent`.execute(trx); + await sql`ALTER TABLE agent_new RENAME TO agent`.execute(trx); + await sql` + CREATE UNIQUE INDEX ${sql.raw(AGENT_EXTERNAL_ID_UQ)} + ON agent (tenant_id, external_id) + WHERE external_id IS NOT NULL + `.execute(trx); + }); + } finally { + await sql`PRAGMA foreign_keys = ON`.execute(db); + } +} + +export async function down(db: Kysely): Promise { + await sql`PRAGMA foreign_keys = OFF`.execute(db); + try { + await db.transaction().execute(async trx => { + await sql`DROP INDEX IF EXISTS ${sql.raw(AGENT_EXTERNAL_ID_UQ)}`.execute(trx); + await sql` + CREATE TABLE agent_old ( + id TEXT NOT NULL, + tenant_id TEXT NOT NULL, + name TEXT NOT NULL, + manifest BLOB NOT NULL, + metadata BLOB NOT NULL DEFAULT (jsonb('{}')), + external_id TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (id), + UNIQUE (tenant_id, name) + ) STRICT + `.execute(trx); + + await sql` + INSERT INTO agent_old ( + id, + tenant_id, + name, + manifest, + metadata, + external_id, + created_at, + updated_at + ) + SELECT + id, + tenant_id, + name, + manifest, + jsonb('{}'), + external_id, + created_at, + updated_at + FROM agent + `.execute(trx); + + await sql`DROP TABLE agent`.execute(trx); + await sql`ALTER TABLE agent_old RENAME TO agent`.execute(trx); + await sql` + CREATE UNIQUE INDEX ${sql.raw(AGENT_EXTERNAL_ID_UQ)} + ON agent (tenant_id, external_id) + WHERE external_id IS NOT NULL + `.execute(trx); + }); + } finally { + await sql`PRAGMA foreign_keys = ON`.execute(db); + } +} diff --git a/packages/trueforge/src/db/sqlite/types.ts b/packages/trueforge/src/db/sqlite/types.ts index 522f9b71c..57343d86a 100644 --- a/packages/trueforge/src/db/sqlite/types.ts +++ b/packages/trueforge/src/db/sqlite/types.ts @@ -24,7 +24,6 @@ import type { } from '@truefoundry/trueforge-core/core'; import type { CurrentContextUsage } from '@truefoundry/trueforge-core/core/runtime/contextUsage'; import type { ColumnType, Generated, JSONColumnType } from 'kysely'; -import type { AgentMetadata } from '../../schemas/agentMetadata'; import type { McpServerManifest } from '../../schemas/mcpServer'; import type { ModelProviderManifest } from '../../schemas/modelProvider'; import type { SandboxBuildMetadata, SandboxBuildStatus, SandboxProviderManifest } from '../../schemas/sandboxProvider'; @@ -222,8 +221,7 @@ export interface AgentTable { name: string; /** AgentSpec document; replaced whole on every upsert */ manifest: JsonbColumn; - /** `agent.metadata` jsonb; default `{}` for existing rows */ - metadata: JsonbColumn; + external_id: string | null; created_at: string; updated_at: string; } diff --git a/packages/trueforge/src/main.ts b/packages/trueforge/src/main.ts index b3874a51c..abad9c3ef 100644 --- a/packages/trueforge/src/main.ts +++ b/packages/trueforge/src/main.ts @@ -73,6 +73,7 @@ import { PACKAGE_VERSION } from './packageVersion'; import { ActiveTurnRegistry } from './runtime/activeTurns'; import { EventSubscriptionRegistry } from './runtime/event-subscription'; import { printStandaloneStartupBanner } from './startupBanner'; +import { TrueFoundryAgentStore } from './truefoundry/TrueFoundryAgentStore'; import { TrueFoundryMcpServerStore } from './truefoundry/TrueFoundryMcpServerStore'; import { TrueFoundryModelProviderStore } from './truefoundry/TrueFoundryModelProviderStore'; import { TrueFoundryServiceFoundryServerClient } from './truefoundry/TrueFoundryServiceFoundryServerClient'; @@ -83,38 +84,49 @@ 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; } +/** + * 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, + 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: requireAccessToken(c) }) - : options.persistenceStore; + : persistenceStore; } /** @@ -126,27 +138,45 @@ 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 => c ? new TrueFoundryMcpServerStore({ client, accessToken: requireAccessToken(c) }) : withAuthPersistence; } +/** + * 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: IAgentStore; + client: TrueFoundryServiceFoundryServerClient | undefined; +}): (c?: Context) => IAgentStore { + const { persistenceStore, client } = options; + if (!client) { + return () => persistenceStore; + } + return c => + c + ? new TrueFoundryAgentStore({ + inner: persistenceStore, + client, + accessToken: requireAccessToken(c), + }) + : persistenceStore; +} + /** SQLite stores; Redis unused (executor peering disabled). */ async function createStandalonePersistence(options: { sqlitePath: string; @@ -187,23 +217,28 @@ async function createStandalonePersistence(options: { logger.info('Standalone mode: executor peering disabled and Redis unused'); const tokenStore = new SqliteOAuthTokenStore(db); + const agentStore = new SqliteAgentStore(db); + const serviceFoundryClient = createServiceFoundryServerClient(logger); return { sessionStore: new SqliteSessionStore(db), sessionMetricsStore: new SqliteSessionMetricsStore(db), resolveModelProviderStore: buildResolveModelProviderStore({ persistenceStore: new SqliteModelProviderStore(db), - logger, + client: serviceFoundryClient, }), resolveMcpServerStore: buildResolveMcpServerStore({ persistenceStore: new SqliteMcpServerStore(db), tokenStore, - logger, + client: serviceFoundryClient, + }), + resolveAgentStore: buildResolveAgentStore({ + persistenceStore: agentStore, + client: serviceFoundryClient, }), 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, @@ -264,23 +299,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 }), @@ -294,11 +334,11 @@ async function createServerRuntime(persistence: ServerPersistence< sessionMetricsStore, resolveModelProviderStore, resolveMcpServerStore, + resolveAgentStore, withTransaction, tokenStore, skillStore, sandboxProviderStore, - agentStore, scheduleStore, destroyDb, redis, @@ -333,11 +373,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 dd14dac44..0c484c67f 100644 --- a/packages/trueforge/src/schemas/agent.ts +++ b/packages/trueforge/src/schemas/agent.ts @@ -15,7 +15,7 @@ export const CreateAgentRequestSchema = z .strict() .openapi('CreateAgentRequest'); -/** PUT body: full manifest replacement only (metadata is store-internal, not on the wire). */ +/** PUT body: full manifest replacement only. */ export const UpdateAgentRequestSchema = z .object({ manifest: AgentSpecSchema, diff --git a/packages/trueforge/src/schemas/agentMetadata.ts b/packages/trueforge/src/schemas/agentMetadata.ts deleted file mode 100644 index 3b9f7e7cd..000000000 --- a/packages/trueforge/src/schemas/agentMetadata.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Agent-row metadata jsonb (internal only; not on the public Agent API). - * Empty until keys are whitelisted here. - */ -export type AgentMetadata = Record; - -export const EMPTY_AGENT_METADATA: AgentMetadata = {}; diff --git a/packages/trueforge/src/truefoundry/AGENTS.md b/packages/trueforge/src/truefoundry/AGENTS.md new file mode 100644 index 000000000..398cfdbe5 --- /dev/null +++ b/packages/trueforge/src/truefoundry/AGENTS.md @@ -0,0 +1 @@ +- ServiceFoundry shape mappers under this directory MUST use `map*` for SF → domain (read/list parse) and `to*` for domain → SF/wire (request body build). Examples: `mapSfyMcpServers`, `mapEnabledModels`, `toPutRemoteAgentPayload`, `toTrueFoundryMcpManifest`. diff --git a/packages/trueforge/src/truefoundry/CLAUDE.md b/packages/trueforge/src/truefoundry/CLAUDE.md new file mode 100644 index 000000000..43c994c2d --- /dev/null +++ b/packages/trueforge/src/truefoundry/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/packages/trueforge/src/truefoundry/TrueFoundryAgentStore.ts b/packages/trueforge/src/truefoundry/TrueFoundryAgentStore.ts new file mode 100644 index 000000000..1264b4cf1 --- /dev/null +++ b/packages/trueforge/src/truefoundry/TrueFoundryAgentStore.ts @@ -0,0 +1,135 @@ +import { + AgentNameConflictError, + AgentNameReservedError, + type AgentRecord, + type CreateAgentInput, + type DeleteAgentInput, + type GetAgentInput, + type IAgentStore, + type UpdateAgentInput, +} from '../db/agentStore'; +import { toPutRemoteAgentPayload } from './toPutRemoteAgentPayload'; +import { TrueFoundryServiceFoundryServerClient } from './TrueFoundryServiceFoundryServerClient'; + +/** Reserved for product / control-plane use — not creatable via TrueFoundryAgentStore. */ +const RESERVED_AGENT_NAMES = new Set(['tfg', 'trueforge']); + +function asError(value: unknown): Error { + return value instanceof Error ? value : new Error(String(value)); +} + +/** + * create: getByName → putRemote → createDB(external_id) | on non-conflict DB fail → deleteRemote + * update: putRemote(new) → updateDB | on DB fail → putRemote(old) | both fail → AggregateError + * delete: deleteRemote(404 ok) → deleteDB + */ +export class TrueFoundryAgentStore implements IAgentStore { + readonly #inner: IAgentStore; + readonly #client: TrueFoundryServiceFoundryServerClient; + readonly #accessToken: string; + + constructor(input: { + inner: IAgentStore; + client: TrueFoundryServiceFoundryServerClient; + accessToken: string; + }) { + this.#inner = input.inner; + this.#client = input.client; + this.#accessToken = input.accessToken; + } + + listAgents(tenantId: string, transaction?: TTransaction): Promise { + return this.#inner.listAgents(tenantId, transaction); + } + + getAgent(input: GetAgentInput, transaction?: TTransaction): Promise { + return this.#inner.getAgent(input, transaction); + } + + async createAgent(input: CreateAgentInput, transaction?: TTransaction): Promise { + if (RESERVED_AGENT_NAMES.has(input.name)) { + throw new AgentNameReservedError({ name: input.name }); + } + + // SF PUT upserts by name — skip if local name exists (avoids overwrite/delete of e.g. research→sf-1). + const existing = await this.#inner.getAgent({ tenant_id: input.tenant_id, name: input.name }, transaction); + if (existing !== undefined) { + throw new AgentNameConflictError({ tenant_id: input.tenant_id, name: input.name }); + } + + const { remoteAgentId } = await this.#client.putRemoteAgent({ + accessToken: this.#accessToken, + ...toPutRemoteAgentPayload({ name: input.name, manifest: input.manifest }), + }); + try { + return await this.#inner.createAgent({ ...input, external_id: remoteAgentId }, transaction); + } catch (error) { + // Race: peer create won the name and owns this remote (1:1) — do not delete it. + if (!(error instanceof AgentNameConflictError)) { + try { + await this.#client.deleteRemoteAgent({ accessToken: this.#accessToken, remoteAgentId }); + } catch (cleanupError) { + throw new AggregateError( + [asError(error), asError(cleanupError)], + 'createAgent failed and ServiceFoundry cleanup also failed', + { cause: cleanupError }, + ); + } + } + throw error; + } + } + + async updateAgent(input: UpdateAgentInput, transaction?: TTransaction): Promise { + if (input.manifest === undefined) { + return this.#inner.updateAgent(input, transaction); + } + + const previous = await this.#inner.getAgent({ tenant_id: input.tenant_id, id: input.id }, transaction); + if (previous === undefined) { + return undefined; + } + + const { remoteAgentId } = await this.#client.putRemoteAgent({ + accessToken: this.#accessToken, + ...toPutRemoteAgentPayload({ name: previous.name, manifest: input.manifest }), + }); + + try { + return await this.#inner.updateAgent( + { + tenant_id: input.tenant_id, + id: input.id, + manifest: input.manifest, + ...(remoteAgentId === previous.external_id ? {} : { external_id: remoteAgentId }), + }, + transaction, + ); + } 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 { + const previous = await this.#inner.getAgent({ tenant_id: input.tenant_id, id: input.id }, transaction); + if (previous?.external_id) { + await this.#client.deleteRemoteAgent({ + accessToken: this.#accessToken, + remoteAgentId: previous.external_id, + }); + } + await this.#inner.deleteAgent(input, transaction); + } +} diff --git a/packages/trueforge/src/truefoundry/TrueFoundryServiceFoundryServerClient.ts b/packages/trueforge/src/truefoundry/TrueFoundryServiceFoundryServerClient.ts index 1f40ca014..3297e7709 100644 --- a/packages/trueforge/src/truefoundry/TrueFoundryServiceFoundryServerClient.ts +++ b/packages/trueforge/src/truefoundry/TrueFoundryServiceFoundryServerClient.ts @@ -9,6 +9,7 @@ import { createInternalTlsDispatcher, normalizeInternalTlsUrl, type InternalTlsO 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 INTEGRATIONS_PAGE_SIZE = 1000; const MCP_SERVERS_PAGE_SIZE = 100; @@ -26,6 +27,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 { + remoteAgentId: string; +} + +export interface DeleteRemoteAgentInput { + accessToken: string; + remoteAgentId: string; +} + async function readServiceFoundryErrorMessage( response: Awaited>, ): Promise { @@ -137,6 +160,42 @@ 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', + body: { + name: input.name, + description: input.description, + model: input.model, + ...(input.mcp_servers === undefined ? {} : { 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 { remoteAgentId: 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.remoteAgentId)}`), + accessToken: input.accessToken, + method: 'DELETE', + notFoundOk: true, + }); + } + #parseListResponse(payload: unknown): ListResponse { const parsed = ListResponseSchema.safeParse(payload); if (!parsed.success) { @@ -161,21 +220,35 @@ export class TrueFoundryServiceFoundryServerClient { return url; } - async #getJson(url: URL, accessToken: string): Promise { + #getJson(url: URL, accessToken: string): Promise { + return this.#requestJson({ url, accessToken, method: 'GET' }); + } + + async #requestJson(input: { + url: URL; + accessToken: string; + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + body?: unknown; + /** Treat HTTP 404 as success (idempotent DELETE). */ + notFoundOk?: boolean; + }): Promise { const startedAt = Date.now(); let response: Awaited>; try { - response = await undiciFetch(url, { - method: 'GET', + response = await undiciFetch(input.url, { + method: input.method, headers: { accept: 'application/json', - authorization: `Bearer ${accessToken}`, + authorization: `Bearer ${input.accessToken}`, + ...(input.body === undefined ? {} : { 'content-type': 'application/json' }), }, + ...(input.body === undefined ? {} : { body: JSON.stringify(input.body) }), ...(this.#dispatcher ? { dispatcher: this.#dispatcher } : {}), }); } catch (error) { this.#logger?.warn('TrueFoundry ServiceFoundry server request failed', { - url: url.href, + url: input.url.href, + method: input.method, durationMs: Date.now() - startedAt, ...extractErrorLogFields(error), }); @@ -185,7 +258,8 @@ export class TrueFoundryServiceFoundryServerClient { }); } this.#logger?.info('TrueFoundry ServiceFoundry server request completed', { - url: url.href, + url: input.url.href, + method: input.method, status: response.status, durationMs: Date.now() - startedAt, }); @@ -194,12 +268,29 @@ 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, { message: `TrueFoundry ServiceFoundry server request failed: ${detail ?? `HTTP ${String(response.status)}`}`, }); } - return response.json(); + if (response.status === 204) { + return undefined; + } + const text = await response.text(); + if (text.length === 0) { + return undefined; + } + try { + return JSON.parse(text); + } catch (error) { + throw new HTTPException(424, { + message: 'TrueFoundry ServiceFoundry server returned invalid JSON', + cause: error, + }); + } } } diff --git a/packages/trueforge/src/truefoundry/toPutRemoteAgentPayload.ts b/packages/trueforge/src/truefoundry/toPutRemoteAgentPayload.ts new file mode 100644 index 000000000..f27d7380f --- /dev/null +++ b/packages/trueforge/src/truefoundry/toPutRemoteAgentPayload.ts @@ -0,0 +1,21 @@ +/** + * AgentSpec → PUT `/internal/tfg/agents` body fields (domain → SF; inverse of mapSfyMcpServers / mapEnabledModels). + */ +import type { AgentSpec } from '@truefoundry/trueforge-core/agent-session'; + +import type { PutRemoteAgentInput } from './TrueFoundryServiceFoundryServerClient'; + +export function toPutRemoteAgentPayload({ + name, + manifest, +}: { + name: string; + manifest: AgentSpec; +}): Omit { + return { + name, + description: manifest.instructions ?? name, + model: manifest.model.name, + ...(manifest.mcp_servers === undefined ? {} : { mcp_servers: manifest.mcp_servers.map(server => server.name) }), + }; +} diff --git a/packages/trueforge/tests/db/agentStoreContractSuite.ts b/packages/trueforge/tests/db/agentStoreContractSuite.ts index 65844b64b..d4bad032b 100644 --- a/packages/trueforge/tests/db/agentStoreContractSuite.ts +++ b/packages/trueforge/tests/db/agentStoreContractSuite.ts @@ -3,7 +3,7 @@ * Runs under jest against a fresh store per test (see backend test files). */ import { AgentSpecSchema, type AgentSpec } from '@truefoundry/trueforge-core/agent-session'; -import { AgentNameConflictError, type IAgentStore } from '../../src/db/agentStore'; +import { AgentExternalIdConflictError, AgentNameConflictError, type IAgentStore } from '../../src/db/agentStore'; const TENANT = 'default'; @@ -30,7 +30,7 @@ export function runAgentStoreContractSuite(getStore: () => IAgentStore): void { expect(created.name).toBe('research'); expect(created.id.length).toBeGreaterThan(0); expect(created.manifest).toEqual(manifest()); - expect(created.metadata).toEqual({}); + expect(created.external_id).toBeNull(); expect(created.created_at).toMatch(ISO_UTC); expect(created.updated_at).toBe(created.created_at); @@ -47,7 +47,7 @@ export function runAgentStoreContractSuite(getStore: () => IAgentStore): void { expect(await store.getAgent({ tenant_id: TENANT, name: 'missing' })).toBeUndefined(); }); - it('updateAgent by id replaces manifest but keeps id, name, metadata, and created_at', async () => { + it('updateAgent by id replaces manifest but keeps id, name, and created_at', async () => { const store = getStore(); const created = await store.createAgent({ tenant_id: TENANT, @@ -67,7 +67,6 @@ export function runAgentStoreContractSuite(getStore: () => IAgentStore): void { id: created.id, name: 'research', manifest: replacement, - metadata: {}, created_at: created.created_at, }), ); @@ -80,48 +79,6 @@ export function runAgentStoreContractSuite(getStore: () => IAgentStore): void { expect(await store.getAgent({ tenant_id: TENANT, name: 'research' })).toEqual(updated); }); - it('updateAgent can patch metadata without changing manifest', async () => { - const store = getStore(); - const created = await store.createAgent({ - tenant_id: TENANT, - name: 'research', - manifest: manifest(), - }); - - const updated = await store.updateAgent({ - tenant_id: TENANT, - id: created.id, - metadata: {}, - }); - - expect(updated).toEqual( - expect.objectContaining({ - id: created.id, - name: 'research', - manifest: created.manifest, - metadata: {}, - created_at: created.created_at, - }), - ); - expect(updated).toBeDefined(); - if (updated === undefined) { - throw new Error('expected updateAgent to return a record'); - } - expect(Date.parse(updated.updated_at)).toBeGreaterThanOrEqual(Date.parse(created.updated_at)); - expect(await store.getAgent({ tenant_id: TENANT, id: created.id })).toEqual(updated); - }); - - it('updateAgent returns undefined for unknown ids when patching metadata', async () => { - const store = getStore(); - expect( - await store.updateAgent({ - tenant_id: TENANT, - id: 'missing', - metadata: {}, - }), - ).toBeUndefined(); - }); - it('updateAgent returns undefined for unknown ids', async () => { const store = getStore(); expect( @@ -167,4 +124,73 @@ export function runAgentStoreContractSuite(getStore: () => IAgentStore): void { expect(await store.getAgent({ tenant_id: 'other-tenant', id: created.id })).toBeUndefined(); }); + + it('createAgent persists external_id', async () => { + const store = getStore(); + const created = await store.createAgent({ + tenant_id: TENANT, + name: 'research', + manifest: manifest(), + external_id: 'sf-agent-1', + }); + expect(created.external_id).toBe('sf-agent-1'); + expect(await store.getAgent({ tenant_id: TENANT, id: created.id })).toEqual(created); + }); + + it('createAgent unique external_id within a tenant; nulls and other tenants do not collide', async () => { + const store = getStore(); + await store.createAgent({ + tenant_id: TENANT, + name: 'alpha', + manifest: manifest(), + external_id: 'shared-key', + }); + await expect( + store.createAgent({ + tenant_id: TENANT, + name: 'beta', + manifest: manifest(), + external_id: 'shared-key', + }), + ).rejects.toBeInstanceOf(AgentExternalIdConflictError); + await store.createAgent({ + tenant_id: 'other-tenant', + name: 'alpha', + manifest: manifest(), + external_id: 'shared-key', + }); + await store.createAgent({ tenant_id: TENANT, name: 'gamma', manifest: manifest(), external_id: null }); + await store.createAgent({ tenant_id: TENANT, name: 'delta', manifest: manifest(), external_id: null }); + }); + + it('updateAgent can write and clear external_id', async () => { + const store = getStore(); + const created = await store.createAgent({ tenant_id: TENANT, name: 'research', manifest: manifest() }); + const updated = await store.updateAgent({ + tenant_id: TENANT, + id: created.id, + external_id: 'sf-agent-1', + }); + expect(updated?.external_id).toBe('sf-agent-1'); + const cleared = await store.updateAgent({ + tenant_id: TENANT, + id: created.id, + external_id: null, + }); + expect(cleared?.external_id).toBeNull(); + }); + + it('updateAgent throws AgentExternalIdConflictError when external_id is taken', async () => { + const store = getStore(); + await store.createAgent({ + tenant_id: TENANT, + name: 'alpha', + manifest: manifest(), + external_id: 'shared-key', + }); + const beta = await store.createAgent({ tenant_id: TENANT, name: 'beta', manifest: manifest() }); + await expect( + store.updateAgent({ tenant_id: TENANT, id: beta.id, external_id: 'shared-key' }), + ).rejects.toBeInstanceOf(AgentExternalIdConflictError); + }); } diff --git a/packages/trueforge/tests/unit/apis/agents.test.ts b/packages/trueforge/tests/unit/apis/agents.test.ts index 0305c84d7..550bce5dc 100644 --- a/packages/trueforge/tests/unit/apis/agents.test.ts +++ b/packages/trueforge/tests/unit/apis/agents.test.ts @@ -78,7 +78,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), @@ -109,9 +109,6 @@ describe('agents router', () => { }); expect(createdJson.data).not.toHaveProperty('metadata'); - const beforePut = await agentStore.getAgent({ tenant_id: 'default', id: createdJson.data.id }); - expect(beforePut?.metadata).toEqual({}); - const updated = await router.request(`/${createdJson.data.id}`, jsonInit('PUT', updateBody)); expect(updated.status).toBe(200); const updatedJson = (await updated.json()) as { data: WireAgent }; @@ -119,9 +116,6 @@ describe('agents router', () => { expect(updatedJson.data.name).toBe('research'); expect(updatedJson.data.manifest.instructions).toBe('Updated instructions.'); expect(updatedJson.data).not.toHaveProperty('metadata'); - - const afterPut = await agentStore.getAgent({ tenant_id: 'default', id: createdJson.data.id }); - expect(afterPut?.metadata).toEqual(beforePut?.metadata); }); it('PUT rejects metadata in the request body', async () => { diff --git a/packages/trueforge/tests/unit/apis/deletedSessionCrud.test.ts b/packages/trueforge/tests/unit/apis/deletedSessionCrud.test.ts index d8f7a5d65..4026eb3e4 100644 --- a/packages/trueforge/tests/unit/apis/deletedSessionCrud.test.ts +++ b/packages/trueforge/tests/unit/apis/deletedSessionCrud.test.ts @@ -43,7 +43,7 @@ describe('public CRUD after session deletion', () => { resolveModelProviderStore: () => modelProviderStore, resolveMcpServerStore: () => mcpServerStore, skillStore, - agentStore, + resolveAgentStore: () => agentStore, sandboxProviderStore, redis: createClient(), requestReplyRouter: new RequestReplyRouter(), @@ -61,7 +61,7 @@ describe('public CRUD after session deletion', () => { resolveMcpServerStore: () => mcpServerStore, tokenStore, 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 a70d6107a..d959be3c5 100644 --- a/packages/trueforge/tests/unit/apis/sandboxFileDownload.test.ts +++ b/packages/trueforge/tests/unit/apis/sandboxFileDownload.test.ts @@ -42,7 +42,7 @@ async function buildApp() { resolveMcpServerStore: () => new SqliteMcpServerStore(db), tokenStore: new SqliteOAuthTokenStore(db), 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 abdfff846..abeac3bcf 100644 --- a/packages/trueforge/tests/unit/apis/schedules.test.ts +++ b/packages/trueforge/tests/unit/apis/schedules.test.ts @@ -50,7 +50,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 3f1767bc8..7eab02116 100644 --- a/packages/trueforge/tests/unit/apis/sessionHttp.test.ts +++ b/packages/trueforge/tests/unit/apis/sessionHttp.test.ts @@ -82,7 +82,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 2a7b6f76a..460e0be36 100644 --- a/packages/trueforge/tests/unit/apis/turnHarnessErrorStatus.test.ts +++ b/packages/trueforge/tests/unit/apis/turnHarnessErrorStatus.test.ts @@ -59,7 +59,7 @@ async function postTurnRejectingWith(error: AgentHarnessError): Promise new SqliteMcpServerStore(db), tokenStore: new SqliteOAuthTokenStore(db), 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/turns.test.ts b/packages/trueforge/tests/unit/apis/turns.test.ts index 24cfe0f4e..eeac198ed 100644 --- a/packages/trueforge/tests/unit/apis/turns.test.ts +++ b/packages/trueforge/tests/unit/apis/turns.test.ts @@ -56,7 +56,7 @@ describe('turns', () => { resolveMcpServerStore: () => new SqliteMcpServerStore(db), tokenStore: new SqliteOAuthTokenStore(db), skillStore: new SqliteSkillStore(db), - agentStore: new SqliteAgentStore(db), + resolveAgentStore: () => new SqliteAgentStore(db), eventSubscriptions: new EventSubscriptionRegistry(undefined), sandboxProviderStore: new SqliteSandboxProviderStore(db), logger: createLogger({ silent: true }), @@ -172,7 +172,7 @@ describe('turns', () => { sessionStore: new SqliteSessionStore(db), activeTurns: new ActiveTurnRegistry(), resolveModelProviderStore: () => modelProviderStore, - agentStore: new SqliteAgentStore(db), + resolveAgentStore: () => new SqliteAgentStore(db), resolveMcpServerStore: () => new SqliteMcpServerStore(db), tokenStore: new SqliteOAuthTokenStore(db), skillStore: new SqliteSkillStore(db), @@ -275,7 +275,7 @@ describe('turns', () => { resolveMcpServerStore: () => new SqliteMcpServerStore(db), tokenStore: new SqliteOAuthTokenStore(db), 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..ba51d09df --- /dev/null +++ b/packages/trueforge/tests/unit/truefoundry/TrueFoundryAgentStore.test.ts @@ -0,0 +1,497 @@ +import { AgentSpecSchema } from '@truefoundry/trueforge-core/agent-session'; + +import type { AgentRecord, IAgentStore } from '../../../src/db/agentStore'; +import { AgentNameConflictError, AgentNameReservedError } 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'; + +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_at: now, + updated_at: now, + ...overrides, + }; +} + +function mockInner(overrides: Partial = {}): IAgentStore { + return { + listAgents: jest.fn(), + getAgent: jest.fn(), + createAgent: jest.fn(), + updateAgent: jest.fn(), + deleteAgent: jest.fn(), + ...overrides, + }; +} + +function mockClient( + overrides: { + putRemoteAgent?: TrueFoundryServiceFoundryServerClient['putRemoteAgent']; + deleteRemoteAgent?: TrueFoundryServiceFoundryServerClient['deleteRemoteAgent']; + } = {}, +): TrueFoundryServiceFoundryServerClient { + const client = new TrueFoundryServiceFoundryServerClient({ + serviceFoundryServerUrl: 'http://servicefoundry.test', + }); + client.putRemoteAgent = + overrides.putRemoteAgent ?? (async (): Promise => ({ remoteAgentId: '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)).resolves.toBe(agents); + await expect(store.getAgent({ tenant_id: TENANT, id: 'agent-1' })).resolves.toBe(agents[0]); + expect(listAgents).toHaveBeenCalledWith(TENANT, undefined); + expect(getAgent).toHaveBeenCalledWith({ tenant_id: TENANT, id: 'agent-1' }, undefined); + }); + + it('createAgent puts then inserts with external_id', async () => { + const created = 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 { remoteAgentId: 'sf-1' }; + }); + const getAgent = jest.fn(async () => undefined); + const createAgent = jest.fn(async () => created); + const store = new TrueFoundryAgentStore({ + inner: mockInner({ getAgent, createAgent }), + client: mockClient({ putRemoteAgent }), + accessToken: TOKEN, + }); + + await expect( + store.createAgent({ + tenant_id: TENANT, + name: 'research', + manifest: manifest({ mcp_servers: [{ name: 'slack' }] }), + }), + ).resolves.toBe(created); + expect(getAgent).toHaveBeenCalledWith({ tenant_id: TENANT, name: 'research' }, undefined); + expect(createAgent).toHaveBeenCalledWith( + expect.objectContaining({ + tenant_id: TENANT, + name: 'research', + external_id: 'sf-1', + }), + undefined, + ); + }); + + it('createAgent rejects a duplicate local name before calling ServiceFoundry', async () => { + const getAgent = jest.fn(async () => record({ external_id: 'sf-existing' })); + const createAgent = jest.fn(); + const putRemoteAgent = jest.fn(); + const deleteRemoteAgent = jest.fn(); + const store = new TrueFoundryAgentStore({ + inner: mockInner({ getAgent, createAgent }), + client: mockClient({ putRemoteAgent, deleteRemoteAgent }), + accessToken: TOKEN, + }); + + await expect( + store.createAgent({ tenant_id: TENANT, name: 'research', manifest: manifest() }), + ).rejects.toMatchObject({ name: 'AgentNameConflictError' }); + expect(putRemoteAgent).not.toHaveBeenCalled(); + expect(createAgent).not.toHaveBeenCalled(); + expect(deleteRemoteAgent).not.toHaveBeenCalled(); + }); + + it('createAgent rejects reserved names before calling ServiceFoundry', async () => { + const getAgent = jest.fn(); + const createAgent = jest.fn(); + const putRemoteAgent = jest.fn(); + const store = new TrueFoundryAgentStore({ + inner: mockInner({ getAgent, createAgent }), + client: mockClient({ putRemoteAgent }), + accessToken: TOKEN, + }); + + await expect( + store.createAgent({ tenant_id: TENANT, name: 'tfg', manifest: manifest() }), + ).rejects.toBeInstanceOf(AgentNameReservedError); + await expect( + store.createAgent({ tenant_id: TENANT, name: 'trueforge', manifest: manifest() }), + ).rejects.toBeInstanceOf(AgentNameReservedError); + expect(getAgent).not.toHaveBeenCalled(); + expect(putRemoteAgent).not.toHaveBeenCalled(); + expect(createAgent).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'); + return { remoteAgentId: 'sf-1' }; + }); + const createAgent = jest.fn(async () => record({ external_id: 'sf-1' })); + const store = new TrueFoundryAgentStore({ + inner: mockInner({ createAgent }), + client: mockClient({ putRemoteAgent }), + accessToken: TOKEN, + }); + + await store.createAgent({ + tenant_id: TENANT, + name: 'research', + manifest: AgentSpecSchema.parse({ model: { name: 'openai-gateway/gpt-5' } }), + }); + expect(putRemoteAgent).toHaveBeenCalled(); + }); + + it('createAgent rolls back SF when DB insert fails', async () => { + const deleteRemoteAgent = jest.fn(async () => undefined); + const createAgent = jest.fn(async () => { + throw new Error('db failed'); + }); + const store = new TrueFoundryAgentStore({ + inner: mockInner({ createAgent }), + client: mockClient({ deleteRemoteAgent }), + accessToken: TOKEN, + }); + + await expect(store.createAgent({ tenant_id: TENANT, name: 'research', manifest: manifest() })).rejects.toThrow( + 'db failed', + ); + expect(deleteRemoteAgent).toHaveBeenCalledWith({ accessToken: TOKEN, remoteAgentId: 'sf-1' }); + }); + + it('createAgent does not delete remote on local name conflict after put', async () => { + const deleteRemoteAgent = jest.fn(); + const createAgent = jest.fn(async () => { + throw new AgentNameConflictError({ tenant_id: TENANT, name: 'research' }); + }); + const store = new TrueFoundryAgentStore({ + inner: mockInner({ createAgent }), + client: mockClient({ deleteRemoteAgent }), + accessToken: TOKEN, + }); + + await expect( + store.createAgent({ tenant_id: TENANT, name: 'research', manifest: manifest() }), + ).rejects.toBeInstanceOf(AgentNameConflictError); + expect(deleteRemoteAgent).not.toHaveBeenCalled(); + }); + + it('createAgent still throws when SF rollback fails', async () => { + const deleteRemoteAgent = jest.fn(async () => { + throw new Error('cleanup failed'); + }); + const createAgent = jest.fn(async () => { + throw new Error('db failed'); + }); + const store = new TrueFoundryAgentStore({ + inner: mockInner({ createAgent }), + client: mockClient({ deleteRemoteAgent }), + accessToken: TOKEN, + }); + + await expect( + store.createAgent({ tenant_id: TENANT, name: 'research', manifest: manifest() }), + ).rejects.toMatchObject({ + message: 'createAgent failed and ServiceFoundry cleanup also failed', + errors: [ + expect.objectContaining({ message: 'db failed' }), + expect.objectContaining({ message: 'cleanup failed' }), + ], + }); + expect(deleteRemoteAgent).toHaveBeenCalledWith({ accessToken: TOKEN, remoteAgentId: 'sf-1' }); + }); + + it('createAgent does not insert when putRemoteAgent fails', async () => { + const createAgent = jest.fn(); + const putRemoteAgent = jest.fn(async () => { + throw new Error('sf failed'); + }); + const store = new TrueFoundryAgentStore({ + inner: mockInner({ createAgent }), + client: mockClient({ putRemoteAgent }), + accessToken: TOKEN, + }); + + await expect(store.createAgent({ tenant_id: TENANT, name: 'research', manifest: manifest() })).rejects.toThrow( + 'sf failed', + ); + expect(createAgent).not.toHaveBeenCalled(); + }); + + it('updateAgent without manifest passes through to the inner store', async () => { + const updated = record({ external_id: 'sf-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-1' })).resolves.toBe(updated); + expect(putRemoteAgent).not.toHaveBeenCalled(); + expect(updateAgent).toHaveBeenCalledWith({ tenant_id: TENANT, id: 'agent-1', external_id: 'sf-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 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 () => ({ remoteAgentId: '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, + }, + undefined, + ); + 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 () => ({ remoteAgentId: '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, + }); + 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', + }, + undefined, + ); + }); + + 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({ remoteAgentId: 'sf-new' }) + .mockResolvedValueOnce({ remoteAgentId: '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({ remoteAgentId: '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 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, remoteAgentId: 'sf-1' }); + expect(deleteAgent).toHaveBeenCalledWith({ tenant_id: TENANT, id: previous.id }, undefined); + 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' }, undefined); + 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(); + }); +});