diff --git a/.changeset/20260901165900-agent-name-hyphen-only.md b/.changeset/20260901165900-agent-name-hyphen-only.md new file mode 100644 index 000000000..d1e65565d --- /dev/null +++ b/.changeset/20260901165900-agent-name-hyphen-only.md @@ -0,0 +1,5 @@ +--- +'@truefoundry/trueforge': patch +--- + +Constrain agent names to hyphen-only 2–64 and rename existing "."/"_" agent names. diff --git a/.github/fern/openapi/openapi.json b/.github/fern/openapi/openapi.json index 18bd31b3f..321261e6d 100644 --- a/.github/fern/openapi/openapi.json +++ b/.github/fern/openapi/openapi.json @@ -35,7 +35,7 @@ "$ref": "#/components/schemas/AgentSpec" }, "name": { - "$ref": "#/components/schemas/ResourceName" + "$ref": "#/components/schemas/AgentName" } }, "required": [ @@ -141,6 +141,12 @@ ], "type": "object" }, + "AgentName": { + "maxLength": 64, + "minLength": 2, + "pattern": "^[a-z][a-z0-9-]{0,62}[a-z0-9]$", + "type": "string" + }, "AgentParent": { "properties": { "thread_id": { @@ -959,7 +965,7 @@ "$ref": "#/components/schemas/AgentSpec" }, "name": { - "$ref": "#/components/schemas/ResourceName" + "$ref": "#/components/schemas/AgentName" } }, "required": [ @@ -996,7 +1002,7 @@ "additionalProperties": false, "properties": { "agent_name": { - "$ref": "#/components/schemas/ResourceName" + "$ref": "#/components/schemas/AgentName" }, "manifest": { "$ref": "#/components/schemas/ScheduleManifest" @@ -3012,7 +3018,7 @@ "additionalProperties": false, "properties": { "agent_name": { - "$ref": "#/components/schemas/ResourceName" + "$ref": "#/components/schemas/AgentName" }, "created_at": { "format": "date-time", @@ -3221,7 +3227,7 @@ "additionalProperties": false, "properties": { "name": { - "$ref": "#/components/schemas/ResourceName" + "$ref": "#/components/schemas/AgentName" } }, "required": [ @@ -5785,7 +5791,7 @@ "name": "agent_name", "required": false, "schema": { - "$ref": "#/components/schemas/ResourceName" + "$ref": "#/components/schemas/AgentName" } } ], diff --git a/docs/openapi.json b/docs/openapi.json index 18bd31b3f..321261e6d 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -35,7 +35,7 @@ "$ref": "#/components/schemas/AgentSpec" }, "name": { - "$ref": "#/components/schemas/ResourceName" + "$ref": "#/components/schemas/AgentName" } }, "required": [ @@ -141,6 +141,12 @@ ], "type": "object" }, + "AgentName": { + "maxLength": 64, + "minLength": 2, + "pattern": "^[a-z][a-z0-9-]{0,62}[a-z0-9]$", + "type": "string" + }, "AgentParent": { "properties": { "thread_id": { @@ -959,7 +965,7 @@ "$ref": "#/components/schemas/AgentSpec" }, "name": { - "$ref": "#/components/schemas/ResourceName" + "$ref": "#/components/schemas/AgentName" } }, "required": [ @@ -996,7 +1002,7 @@ "additionalProperties": false, "properties": { "agent_name": { - "$ref": "#/components/schemas/ResourceName" + "$ref": "#/components/schemas/AgentName" }, "manifest": { "$ref": "#/components/schemas/ScheduleManifest" @@ -3012,7 +3018,7 @@ "additionalProperties": false, "properties": { "agent_name": { - "$ref": "#/components/schemas/ResourceName" + "$ref": "#/components/schemas/AgentName" }, "created_at": { "format": "date-time", @@ -3221,7 +3227,7 @@ "additionalProperties": false, "properties": { "name": { - "$ref": "#/components/schemas/ResourceName" + "$ref": "#/components/schemas/AgentName" } }, "required": [ @@ -5785,7 +5791,7 @@ "name": "agent_name", "required": false, "schema": { - "$ref": "#/components/schemas/ResourceName" + "$ref": "#/components/schemas/AgentName" } } ], diff --git a/packages/trueforge-sdk/src/api/resources/agents/client/requests/CreateAgentRequest.ts b/packages/trueforge-sdk/src/api/resources/agents/client/requests/CreateAgentRequest.ts index 0648583e5..f261e6b6c 100644 --- a/packages/trueforge-sdk/src/api/resources/agents/client/requests/CreateAgentRequest.ts +++ b/packages/trueforge-sdk/src/api/resources/agents/client/requests/CreateAgentRequest.ts @@ -15,5 +15,5 @@ import type * as TrueForge from "../../../../index.js"; */ export interface CreateAgentRequest { manifest: TrueForge.AgentSpec; - name: TrueForge.ResourceName; + name: TrueForge.AgentName; } diff --git a/packages/trueforge-sdk/src/api/resources/schedules/client/requests/CreateScheduleRequest.ts b/packages/trueforge-sdk/src/api/resources/schedules/client/requests/CreateScheduleRequest.ts index 0eead5748..bab0bd933 100644 --- a/packages/trueforge-sdk/src/api/resources/schedules/client/requests/CreateScheduleRequest.ts +++ b/packages/trueforge-sdk/src/api/resources/schedules/client/requests/CreateScheduleRequest.ts @@ -14,7 +14,7 @@ import type * as TrueForge from "../../../../index.js"; * } */ export interface CreateScheduleRequest { - agentName: TrueForge.ResourceName; + agentName: TrueForge.AgentName; manifest: TrueForge.ScheduleManifest; name: TrueForge.ResourceName; } diff --git a/packages/trueforge-sdk/src/api/resources/schedules/client/requests/ListSchedulesRequest.ts b/packages/trueforge-sdk/src/api/resources/schedules/client/requests/ListSchedulesRequest.ts index 9cdfd1c37..2188c1acc 100644 --- a/packages/trueforge-sdk/src/api/resources/schedules/client/requests/ListSchedulesRequest.ts +++ b/packages/trueforge-sdk/src/api/resources/schedules/client/requests/ListSchedulesRequest.ts @@ -7,5 +7,5 @@ import type * as TrueForge from "../../../../index.js"; * {} */ export interface ListSchedulesRequest { - agentName?: TrueForge.ResourceName; + agentName?: TrueForge.AgentName; } diff --git a/packages/trueforge-sdk/src/api/types/Agent.ts b/packages/trueforge-sdk/src/api/types/Agent.ts index 7e653aa70..6d519c435 100644 --- a/packages/trueforge-sdk/src/api/types/Agent.ts +++ b/packages/trueforge-sdk/src/api/types/Agent.ts @@ -6,5 +6,5 @@ export interface Agent { /** Immutable server-generated agent identifier. */ id: string; manifest: TrueForge.AgentSpec; - name: TrueForge.ResourceName; + name: TrueForge.AgentName; } diff --git a/packages/trueforge-sdk/src/api/types/AgentName.ts b/packages/trueforge-sdk/src/api/types/AgentName.ts new file mode 100644 index 000000000..fb6fe7abe --- /dev/null +++ b/packages/trueforge-sdk/src/api/types/AgentName.ts @@ -0,0 +1,3 @@ +// This file was auto-generated by Fern from our API Definition. + +export type AgentName = string; diff --git a/packages/trueforge-sdk/src/api/types/Schedule.ts b/packages/trueforge-sdk/src/api/types/Schedule.ts index 5d79a4fad..3670c4506 100644 --- a/packages/trueforge-sdk/src/api/types/Schedule.ts +++ b/packages/trueforge-sdk/src/api/types/Schedule.ts @@ -3,7 +3,7 @@ import type * as TrueForge from "../index.js"; export interface Schedule { - agentName: TrueForge.ResourceName; + agentName: TrueForge.AgentName; createdAt: Date; createdBy: string; id: string; diff --git a/packages/trueforge-sdk/src/api/types/SessionAgentNameRef.ts b/packages/trueforge-sdk/src/api/types/SessionAgentNameRef.ts index 1a9cf43f3..d6f5d1b78 100644 --- a/packages/trueforge-sdk/src/api/types/SessionAgentNameRef.ts +++ b/packages/trueforge-sdk/src/api/types/SessionAgentNameRef.ts @@ -3,5 +3,5 @@ import type * as TrueForge from "../index.js"; export interface SessionAgentNameRef { - name: TrueForge.ResourceName; + name: TrueForge.AgentName; } diff --git a/packages/trueforge-sdk/src/api/types/index.ts b/packages/trueforge-sdk/src/api/types/index.ts index 1175f7407..038fe5405 100644 --- a/packages/trueforge-sdk/src/api/types/index.ts +++ b/packages/trueforge-sdk/src/api/types/index.ts @@ -4,6 +4,7 @@ export * from "./AgentCodeSnippet.js"; export * from "./AgentCodeSnippetSampleCode.js"; export * from "./AgentCodeSnippets.js"; export * from "./AgentInfo.js"; +export * from "./AgentName.js"; export * from "./AgentParent.js"; export * from "./AgentSpec.js"; export * from "./AlibabaModelProvider.js"; diff --git a/packages/trueforge-sdk/src/serialization/resources/agents/client/requests/CreateAgentRequest.ts b/packages/trueforge-sdk/src/serialization/resources/agents/client/requests/CreateAgentRequest.ts index 27456816a..7e1c632cd 100644 --- a/packages/trueforge-sdk/src/serialization/resources/agents/client/requests/CreateAgentRequest.ts +++ b/packages/trueforge-sdk/src/serialization/resources/agents/client/requests/CreateAgentRequest.ts @@ -3,20 +3,20 @@ import type * as TrueForge from "../../../../../api/index.js"; import * as core from "../../../../../core/index.js"; import type * as serializers from "../../../../index.js"; +import { AgentName } from "../../../../types/AgentName.js"; import { AgentSpec } from "../../../../types/AgentSpec.js"; -import { ResourceName } from "../../../../types/ResourceName.js"; export const CreateAgentRequest: core.serialization.Schema< serializers.CreateAgentRequest.Raw, TrueForge.CreateAgentRequest > = core.serialization.object({ manifest: AgentSpec, - name: ResourceName, + name: AgentName, }); export declare namespace CreateAgentRequest { export interface Raw { manifest: AgentSpec.Raw; - name: ResourceName.Raw; + name: AgentName.Raw; } } diff --git a/packages/trueforge-sdk/src/serialization/resources/schedules/client/requests/CreateScheduleRequest.ts b/packages/trueforge-sdk/src/serialization/resources/schedules/client/requests/CreateScheduleRequest.ts index 8c408e2ee..3fe559bbe 100644 --- a/packages/trueforge-sdk/src/serialization/resources/schedules/client/requests/CreateScheduleRequest.ts +++ b/packages/trueforge-sdk/src/serialization/resources/schedules/client/requests/CreateScheduleRequest.ts @@ -3,6 +3,7 @@ import type * as TrueForge from "../../../../../api/index.js"; import * as core from "../../../../../core/index.js"; import type * as serializers from "../../../../index.js"; +import { AgentName } from "../../../../types/AgentName.js"; import { ResourceName } from "../../../../types/ResourceName.js"; import { ScheduleManifest } from "../../../../types/ScheduleManifest.js"; @@ -10,14 +11,14 @@ export const CreateScheduleRequest: core.serialization.Schema< serializers.CreateScheduleRequest.Raw, TrueForge.CreateScheduleRequest > = core.serialization.object({ - agentName: core.serialization.property("agent_name", ResourceName), + agentName: core.serialization.property("agent_name", AgentName), manifest: ScheduleManifest, name: ResourceName, }); export declare namespace CreateScheduleRequest { export interface Raw { - agent_name: ResourceName.Raw; + agent_name: AgentName.Raw; manifest: ScheduleManifest.Raw; name: ResourceName.Raw; } diff --git a/packages/trueforge-sdk/src/serialization/types/Agent.ts b/packages/trueforge-sdk/src/serialization/types/Agent.ts index 1b8a7159b..38bfea2bd 100644 --- a/packages/trueforge-sdk/src/serialization/types/Agent.ts +++ b/packages/trueforge-sdk/src/serialization/types/Agent.ts @@ -3,14 +3,14 @@ import type * as TrueForge from "../../api/index.js"; import * as core from "../../core/index.js"; import type * as serializers from "../index.js"; +import { AgentName } from "./AgentName.js"; import { AgentSpec } from "./AgentSpec.js"; -import { ResourceName } from "./ResourceName.js"; export const Agent: core.serialization.ObjectSchema = core.serialization.object( { id: core.serialization.string(), manifest: AgentSpec, - name: ResourceName, + name: AgentName, }, ); @@ -18,6 +18,6 @@ export declare namespace Agent { export interface Raw { id: string; manifest: AgentSpec.Raw; - name: ResourceName.Raw; + name: AgentName.Raw; } } diff --git a/packages/trueforge-sdk/src/serialization/types/AgentName.ts b/packages/trueforge-sdk/src/serialization/types/AgentName.ts new file mode 100644 index 000000000..b341a9796 --- /dev/null +++ b/packages/trueforge-sdk/src/serialization/types/AgentName.ts @@ -0,0 +1,12 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as TrueForge from "../../api/index.js"; +import * as core from "../../core/index.js"; +import type * as serializers from "../index.js"; + +export const AgentName: core.serialization.Schema = + core.serialization.string(); + +export declare namespace AgentName { + export type Raw = string; +} diff --git a/packages/trueforge-sdk/src/serialization/types/Schedule.ts b/packages/trueforge-sdk/src/serialization/types/Schedule.ts index ad0f46e3d..e47f032e6 100644 --- a/packages/trueforge-sdk/src/serialization/types/Schedule.ts +++ b/packages/trueforge-sdk/src/serialization/types/Schedule.ts @@ -3,12 +3,13 @@ import type * as TrueForge from "../../api/index.js"; import * as core from "../../core/index.js"; import type * as serializers from "../index.js"; +import { AgentName } from "./AgentName.js"; import { ResourceName } from "./ResourceName.js"; import { ScheduleManifest } from "./ScheduleManifest.js"; export const Schedule: core.serialization.ObjectSchema = core.serialization.object({ - agentName: core.serialization.property("agent_name", ResourceName), + agentName: core.serialization.property("agent_name", AgentName), createdAt: core.serialization.property("created_at", core.serialization.date()), createdBy: core.serialization.property("created_by", core.serialization.string()), id: core.serialization.string(), @@ -19,7 +20,7 @@ export const Schedule: core.serialization.ObjectSchema = core.serialization.object({ - name: ResourceName, + name: AgentName, }); export declare namespace SessionAgentNameRef { export interface Raw { - name: ResourceName.Raw; + name: AgentName.Raw; } } diff --git a/packages/trueforge-sdk/src/serialization/types/index.ts b/packages/trueforge-sdk/src/serialization/types/index.ts index 1175f7407..038fe5405 100644 --- a/packages/trueforge-sdk/src/serialization/types/index.ts +++ b/packages/trueforge-sdk/src/serialization/types/index.ts @@ -4,6 +4,7 @@ export * from "./AgentCodeSnippet.js"; export * from "./AgentCodeSnippetSampleCode.js"; export * from "./AgentCodeSnippets.js"; export * from "./AgentInfo.js"; +export * from "./AgentName.js"; export * from "./AgentParent.js"; export * from "./AgentSpec.js"; export * from "./AlibabaModelProvider.js"; diff --git a/packages/trueforge/src/db/agentStore.ts b/packages/trueforge/src/db/agentStore.ts index e30863dcc..0162b0f86 100644 --- a/packages/trueforge/src/db/agentStore.ts +++ b/packages/trueforge/src/db/agentStore.ts @@ -5,12 +5,12 @@ * Implementations: PostgresAgentStore and SqliteAgentStore. */ import { AgentSpecSchema, type AgentSpec } from '@truefoundry/trueforge-core/agent-session'; -import type { ResourceName } from '../schemas/common'; +import type { AgentName } from '../schemas/common'; export interface AgentRecord { id: string; tenant_id: string; - name: ResourceName; + name: AgentName; manifest: AgentSpec; /** ISO-8601 UTC instant. */ created_at: string; @@ -31,7 +31,7 @@ export type GetAgentInput = { tenant_id: string } & ({ id: string } | { name: st export interface CreateAgentInput { tenant_id: string; - name: ResourceName; + name: AgentName; manifest: AgentSpec; } diff --git a/packages/trueforge/src/db/planAgentNameHyphenRenames.ts b/packages/trueforge/src/db/planAgentNameHyphenRenames.ts new file mode 100644 index 000000000..9600d9fff --- /dev/null +++ b/packages/trueforge/src/db/planAgentNameHyphenRenames.ts @@ -0,0 +1,64 @@ +/** + * Plans one-shot renames of agent registry names that still contain "." or "_" + * into the SVC-aligned hyphen-only form (`AgentNameSchema`). + * + * Examples: `my.agent` → `my-agent`; `a.b` + existing `a-b` → `a-b-2`; + * 64-char collision → truncate the base so `-{n}` still fits (names may be inconsistent). + */ +export interface AgentNameRow { + id: string; + tenant_id: string; + name: string; +} + +export interface AgentNameRename { + id: string; + tenant_id: string; + from: string; + to: string; +} + +const NEEDS_RENAME = /[._]/; +const MAX_LEN = 64; + +export function hyphenateAgentNameSeparators(name: string): string { + return name.replace(/[._]/g, '-'); +} + +export function planAgentNameHyphenRenames(agents: readonly AgentNameRow[]): AgentNameRename[] { + const byTenant = new Map(); + for (const agent of agents) { + const list = byTenant.get(agent.tenant_id); + if (list === undefined) { + byTenant.set(agent.tenant_id, [agent]); + } else { + list.push(agent); + } + } + + const renames: AgentNameRename[] = []; + for (const [tenantId, rows] of [...byTenant.entries()].sort(([a], [b]) => a.localeCompare(b))) { + const occupied = new Set(rows.map(row => row.name)); + const ordered = [...rows].sort((a, b) => a.id.localeCompare(b.id)); + for (const agent of ordered) { + if (!NEEDS_RENAME.test(agent.name)) { + continue; + } + const base = hyphenateAgentNameSeparators(agent.name); + let candidate = base; + let n = 2; + while (occupied.has(candidate) && candidate !== agent.name) { + const suffix = `-${String(n)}`; + const truncatedBase = base.slice(0, MAX_LEN - suffix.length); + candidate = `${truncatedBase}${suffix}`; + n += 1; + } + occupied.delete(agent.name); + occupied.add(candidate); + if (candidate !== agent.name) { + renames.push({ id: agent.id, tenant_id: tenantId, from: agent.name, to: candidate }); + } + } + } + return renames; +} diff --git a/packages/trueforge/src/db/postgres/migrations/20260901_000001_agent_name_hyphen_only.ts b/packages/trueforge/src/db/postgres/migrations/20260901_000001_agent_name_hyphen_only.ts new file mode 100644 index 000000000..141a2ef08 --- /dev/null +++ b/packages/trueforge/src/db/postgres/migrations/20260901_000001_agent_name_hyphen_only.ts @@ -0,0 +1,47 @@ +import { sql, type Kysely } from 'kysely'; +import { planAgentNameHyphenRenames, type AgentNameRow } from '../../planAgentNameHyphenRenames'; + +const MIGRATION_ID = '20260901_000001_agent_name_hyphen_only'; + +/** + * Rewrite agent names that contain "." or "_" to hyphen-only 2–64 form (AGE-2065). + * Updates `agent.name` and denormalized `session.agent_name`. + * Irreversible: originals are not retained. + */ +export async function up(db: Kysely): Promise { + try { + await sql`SET LOCAL lock_timeout = '5s'`.execute(db); + + const agents = await sql` + SELECT id, tenant_id, name FROM agent + `.execute(db); + const renames = planAgentNameHyphenRenames(agents.rows); + if (renames.length === 0) { + return; + } + + for (const rename of renames) { + await sql` + UPDATE agent + SET name = ${rename.to}, updated_at = now() + WHERE id = ${rename.id} + `.execute(db); + await sql` + UPDATE session + SET agent_name = ${rename.to} + WHERE tenant_id = ${rename.tenant_id} AND agent_name = ${rename.from} + `.execute(db); + } + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error( + `Failed agent name hyphen migration (${MIGRATION_ID}): ${detail}; rename colliding agents manually and retry`, + { cause: error }, + ); + } +} + +export async function down(db: Kysely): Promise { + void db; + return Promise.reject(new Error(`${MIGRATION_ID} is not reversible`)); +} diff --git a/packages/trueforge/src/db/sqlite/migrations/20260901_000001_agent_name_hyphen_only.ts b/packages/trueforge/src/db/sqlite/migrations/20260901_000001_agent_name_hyphen_only.ts new file mode 100644 index 000000000..cd8d57733 --- /dev/null +++ b/packages/trueforge/src/db/sqlite/migrations/20260901_000001_agent_name_hyphen_only.ts @@ -0,0 +1,46 @@ +import { sql, type Kysely } from 'kysely'; +import { planAgentNameHyphenRenames, type AgentNameRow } from '../../planAgentNameHyphenRenames'; + +const MIGRATION_ID = '20260901_000001_agent_name_hyphen_only'; + +/** + * SQLite mirror of postgres/migrations/20260901_000001_agent_name_hyphen_only.ts. + */ +export async function up(db: Kysely): Promise { + try { + const agents = await sql` + SELECT id, tenant_id, name FROM agent + `.execute(db); + const renames = planAgentNameHyphenRenames(agents.rows); + if (renames.length === 0) { + return; + } + + const now = new Date().toISOString(); + await db.transaction().execute(async trx => { + for (const rename of renames) { + await sql` + UPDATE agent + SET name = ${rename.to}, updated_at = ${now} + WHERE id = ${rename.id} + `.execute(trx); + await sql` + UPDATE session + SET agent_name = ${rename.to} + WHERE tenant_id = ${rename.tenant_id} AND agent_name = ${rename.from} + `.execute(trx); + } + }); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error( + `Failed agent name hyphen migration (${MIGRATION_ID}): ${detail}; rename colliding agents manually and retry`, + { cause: error }, + ); + } +} + +export async function down(db: Kysely): Promise { + void db; + return Promise.reject(new Error(`${MIGRATION_ID} is not reversible`)); +} diff --git a/packages/trueforge/src/routes/scheduleRoutes.ts b/packages/trueforge/src/routes/scheduleRoutes.ts index 7fa082ec5..249d94e63 100644 --- a/packages/trueforge/src/routes/scheduleRoutes.ts +++ b/packages/trueforge/src/routes/scheduleRoutes.ts @@ -3,7 +3,7 @@ * Handlers are registered in apis/schedules.ts. */ import { createRoute, z } from '@hono/zod-openapi'; -import { NameSchema } from '../schemas/common'; +import { AgentNameSchema } from '../schemas/common'; import { RequestErrorResponseSchema } from '../schemas/errors'; import { CreateScheduleRequestSchema, @@ -21,7 +21,7 @@ export const ScheduleIdParamsSchema = z.object({ export const ListSchedulesQuerySchema = z .object({ - agent_name: NameSchema.optional(), + agent_name: AgentNameSchema.optional(), }) .openapi('ListSchedulesQuery'); diff --git a/packages/trueforge/src/schemas/agent.ts b/packages/trueforge/src/schemas/agent.ts index b6db2b7d6..f3873b1b8 100644 --- a/packages/trueforge/src/schemas/agent.ts +++ b/packages/trueforge/src/schemas/agent.ts @@ -4,12 +4,12 @@ */ import { z } from '@hono/zod-openapi'; import { AgentSpecSchema } from '@truefoundry/trueforge-core/agent-session'; -import { NameSchema } from './common'; +import { AgentNameSchema } from './common'; /** Create body: unique immutable `name` plus manifest. `id` is never client-supplied. */ export const CreateAgentRequestSchema = z .object({ - name: NameSchema, + name: AgentNameSchema, manifest: AgentSpecSchema, }) .strict() @@ -27,7 +27,7 @@ export const UpdateAgentRequestSchema = z export const AgentSchema = z .object({ id: z.string().min(1).describe('Immutable server-generated agent identifier.'), - name: NameSchema, + name: AgentNameSchema, manifest: AgentSpecSchema, }) .strict() diff --git a/packages/trueforge/src/schemas/common.ts b/packages/trueforge/src/schemas/common.ts index f16f4b43f..e7973399c 100644 --- a/packages/trueforge/src/schemas/common.ts +++ b/packages/trueforge/src/schemas/common.ts @@ -19,6 +19,19 @@ export const NameSchema = z export type ResourceName = z.infer; +/** Agent registry names: matches SVC agent-identity / agent v2 (hyphen-only, 2–64). */ +export const AgentNameSchema = z + .string() + .min(2) + .max(64) + .regex( + /^[a-z][a-z0-9-]{0,62}[a-z0-9]$/, + 'must be 2–64 lowercase chars: start with a letter, end with alphanumeric, hyphens only in between', + ) + .openapi('AgentName'); + +export type AgentName = z.infer; + /** Sessions and turns list page size (default = max). */ export const PAGE_LIMIT = 25; /** Session/turn event list page size (default = max). */ diff --git a/packages/trueforge/src/schemas/schedule.ts b/packages/trueforge/src/schemas/schedule.ts index b02acd343..75f2ea362 100644 --- a/packages/trueforge/src/schemas/schedule.ts +++ b/packages/trueforge/src/schemas/schedule.ts @@ -1,5 +1,5 @@ import { z } from '@hono/zod-openapi'; -import { NameSchema } from './common'; +import { AgentNameSchema, NameSchema } from './common'; /** * Minimum gap between two triggers of one schedule. @@ -91,7 +91,7 @@ export const ScheduleManifestSchema = ScheduleManifestObjectSchema.openapi('Sche export const ScheduleSchema = z .object({ id: z.string(), - agent_name: NameSchema, + agent_name: AgentNameSchema, name: NameSchema, manifest: ScheduleManifestSchema, created_by: z.string(), @@ -103,7 +103,7 @@ export const ScheduleSchema = z export const CreateScheduleRequestSchema = z .object({ - agent_name: NameSchema, + agent_name: AgentNameSchema, name: NameSchema, manifest: ScheduleManifestSchema, }) diff --git a/packages/trueforge/src/schemas/session.ts b/packages/trueforge/src/schemas/session.ts index ab4943fca..3cf3c4118 100644 --- a/packages/trueforge/src/schemas/session.ts +++ b/packages/trueforge/src/schemas/session.ts @@ -1,10 +1,10 @@ /** Server session wire schemas. Core Session lives in agentSession. */ import { z } from '@hono/zod-openapi'; import { AgentSpecSchema, SessionSchema, TokenPaginationSchema } from '@truefoundry/trueforge-core/agent-session'; -import { NameSchema, PAGE_LIMIT } from './common'; +import { AgentNameSchema, PAGE_LIMIT } from './common'; /** Create arm: bind by unique registry agent name. */ -export const SessionAgentNameRefSchema = z.object({ name: NameSchema }).strict().openapi('SessionAgentNameRef'); +export const SessionAgentNameRefSchema = z.object({ name: AgentNameSchema }).strict().openapi('SessionAgentNameRef'); /** * Create/update body arm wrapping an AgentSpec. diff --git a/packages/trueforge/tests/db/postgres/migrations/20260901_000001_agent_name_hyphen_only.test.ts b/packages/trueforge/tests/db/postgres/migrations/20260901_000001_agent_name_hyphen_only.test.ts new file mode 100644 index 000000000..16e0f0924 --- /dev/null +++ b/packages/trueforge/tests/db/postgres/migrations/20260901_000001_agent_name_hyphen_only.test.ts @@ -0,0 +1,75 @@ +import { sql } from 'kysely'; + +import { up } from '../../../../src/db/postgres/migrations/20260901_000001_agent_name_hyphen_only'; +import { createPostgresTestDatabase, type PostgresTestDatabase } from '../testDatabase'; + +const describePg = process.env['PG_STORE_TESTS_ENABLED'] === '1' ? describe : describe.skip; +const PREVIOUS_MIGRATION = '20260831_000001_session_external_id'; + +describePg('Postgres agent name hyphen migration', () => { + let env: PostgresTestDatabase | undefined; + + beforeAll(async () => { + env = await createPostgresTestDatabase(PREVIOUS_MIGRATION); + if (env === undefined) { + throw new Error('Postgres test environment unavailable despite globalSetup probe'); + } + }, 120_000); + + afterAll(async () => { + await env?.teardown(); + }); + + it('renames agent and session.agent_name', async () => { + if (env === undefined) { + throw new Error('Postgres test environment not initialized'); + } + + const manifest = JSON.stringify({ model: { name: 'provider/model' } }); + await sql` + INSERT INTO agent (id, tenant_id, name, manifest, created_at, updated_at) + VALUES ('agent-1', 'tenant-1', 'my.agent', ${manifest}::jsonb, now(), now()) + `.execute(env.db); + await sql` + INSERT INTO session ( + tenant_id, + session_id, + created_by, + agent_id, + agent_name, + agent_spec, + title, + last_turn_id, + custom, + last_activity_timestamp_ms, + created_at, + updated_at + ) + VALUES ( + 'tenant-1', + 'session-1', + 'user-1', + 'agent-1', + 'my.agent', + NULL, + NULL, + NULL, + NULL, + 0, + now(), + now() + ) + `.execute(env.db); + + await env.db.transaction().execute(async transaction => { + await up(transaction); + }); + + const agent = await sql<{ name: string }>`SELECT name FROM agent WHERE id = 'agent-1'`.execute(env.db); + const session = await sql<{ agent_name: string | null }>` + SELECT agent_name FROM session WHERE session_id = 'session-1' + `.execute(env.db); + expect(agent.rows[0]?.name).toBe('my-agent'); + expect(session.rows[0]?.agent_name).toBe('my-agent'); + }); +}); diff --git a/packages/trueforge/tests/unit/apis/agents.test.ts b/packages/trueforge/tests/unit/apis/agents.test.ts index f1adefb7f..c28ba1c7a 100644 --- a/packages/trueforge/tests/unit/apis/agents.test.ts +++ b/packages/trueforge/tests/unit/apis/agents.test.ts @@ -155,6 +155,12 @@ describe('agents router', () => { const badName = await router.request('/', jsonInit('POST', { ...writeBody, name: 'Not A Name' })); expect(badName.status).toBe(400); + const dotted = await router.request('/', jsonInit('POST', { ...writeBody, name: 'my.agent' })); + expect(dotted.status).toBe(400); + + const underscored = await router.request('/', jsonInit('POST', { ...writeBody, name: 'my_agent' })); + expect(underscored.status).toBe(400); + const unknownModel = await router.request( '/', jsonInit('POST', { diff --git a/packages/trueforge/tests/unit/db/planAgentNameHyphenRenames.test.ts b/packages/trueforge/tests/unit/db/planAgentNameHyphenRenames.test.ts new file mode 100644 index 000000000..62088b862 --- /dev/null +++ b/packages/trueforge/tests/unit/db/planAgentNameHyphenRenames.test.ts @@ -0,0 +1,49 @@ +import { hyphenateAgentNameSeparators, planAgentNameHyphenRenames } from '../../../src/db/planAgentNameHyphenRenames'; + +describe('planAgentNameHyphenRenames', () => { + it('replaces "." and "_" with "-"', () => { + expect(hyphenateAgentNameSeparators('my.agent_name')).toBe('my-agent-name'); + expect(planAgentNameHyphenRenames([{ id: '1', tenant_id: 't', name: 'my.agent_name' }])).toEqual([ + { id: '1', tenant_id: 't', from: 'my.agent_name', to: 'my-agent-name' }, + ]); + }); + + it('skips names that are already hyphen-only', () => { + expect(planAgentNameHyphenRenames([{ id: '1', tenant_id: 't', name: 'my-agent' }])).toEqual([]); + }); + + it('suffixes on collision within a tenant and isolates tenants', () => { + expect( + planAgentNameHyphenRenames([ + { id: 'b', tenant_id: 't1', name: 'a_b' }, + { id: 'a', tenant_id: 't1', name: 'a.b' }, + { id: 'c', tenant_id: 't1', name: 'a-b' }, + { id: 'd', tenant_id: 't2', name: 'a.b' }, + ]), + ).toEqual([ + { id: 'a', tenant_id: 't1', from: 'a.b', to: 'a-b-2' }, + { id: 'b', tenant_id: 't1', from: 'a_b', to: 'a-b-3' }, + { id: 'd', tenant_id: 't2', from: 'a.b', to: 'a-b' }, + ]); + }); + + it('truncates the base so a collision suffix fits in 64 characters', () => { + const target = `a${'b'.repeat(61)}-b`; + const from = `a${'b'.repeat(61)}.b`; + expect(target).toHaveLength(64); + expect(from).toHaveLength(64); + expect( + planAgentNameHyphenRenames([ + { id: '1', tenant_id: 't', name: target }, + { id: '2', tenant_id: 't', name: from }, + ]), + ).toEqual([ + { + id: '2', + tenant_id: 't', + from, + to: `${target.slice(0, 62)}-2`, + }, + ]); + }); +}); diff --git a/packages/trueforge/tests/unit/db/sqlite/migrations/20260901_000001_agent_name_hyphen_only.test.ts b/packages/trueforge/tests/unit/db/sqlite/migrations/20260901_000001_agent_name_hyphen_only.test.ts new file mode 100644 index 000000000..4d63a7f9f --- /dev/null +++ b/packages/trueforge/tests/unit/db/sqlite/migrations/20260901_000001_agent_name_hyphen_only.test.ts @@ -0,0 +1,52 @@ +import { sql } from 'kysely'; +import { createSqliteDb } from '../../../../../src/db/sqlite/client'; +import { up } from '../../../../../src/db/sqlite/migrations/20260901_000001_agent_name_hyphen_only'; + +describe('SQLite agent name hyphen migration', () => { + it('renames agent and session.agent_name', async () => { + const db = createSqliteDb(':memory:'); + try { + await sql` + CREATE TABLE agent ( + id TEXT NOT NULL, + tenant_id TEXT NOT NULL, + name TEXT NOT NULL, + manifest BLOB NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (id), + UNIQUE (tenant_id, name) + ) STRICT + `.execute(db); + await sql` + CREATE TABLE session ( + tenant_id TEXT NOT NULL, + session_id TEXT NOT NULL, + agent_name TEXT, + PRIMARY KEY (session_id) + ) STRICT + `.execute(db); + + const now = '2026-09-01T00:00:00.000Z'; + await sql` + INSERT INTO agent (id, tenant_id, name, manifest, created_at, updated_at) + VALUES ('agent-1', 'tenant-1', 'my.agent', jsonb('{}'), ${now}, ${now}) + `.execute(db); + await sql` + INSERT INTO session (tenant_id, session_id, agent_name) + VALUES ('tenant-1', 'session-1', 'my.agent') + `.execute(db); + + await up(db); + + const agent = await sql<{ name: string }>`SELECT name FROM agent WHERE id = 'agent-1'`.execute(db); + const session = await sql<{ agent_name: string | null }>` + SELECT agent_name FROM session WHERE session_id = 'session-1' + `.execute(db); + expect(agent.rows[0]?.name).toBe('my-agent'); + expect(session.rows[0]?.agent_name).toBe('my-agent'); + } finally { + await db.destroy(); + } + }); +}); diff --git a/packages/trueforge/tests/unit/schemas/agentName.test.ts b/packages/trueforge/tests/unit/schemas/agentName.test.ts new file mode 100644 index 000000000..5850b8d5d --- /dev/null +++ b/packages/trueforge/tests/unit/schemas/agentName.test.ts @@ -0,0 +1,25 @@ +import { AgentNameSchema, NameSchema } from '../../../src/schemas/common'; + +describe('AgentNameSchema', () => { + it('accepts hyphen-only names of length 2–64', () => { + expect(AgentNameSchema.parse('ab')).toBe('ab'); + expect(AgentNameSchema.parse('a-b')).toBe('a-b'); + expect(AgentNameSchema.parse(`a${'b'.repeat(62)}`)).toHaveLength(63); + expect(AgentNameSchema.parse(`a${'b'.repeat(63)}`)).toHaveLength(64); + expect(AgentNameSchema.parse(`a${'-'.repeat(62)}b`)).toHaveLength(64); + }); + + it('rejects ".", "_", leading/trailing hyphen, and out-of-range length', () => { + expect(AgentNameSchema.safeParse('a.b').success).toBe(false); + expect(AgentNameSchema.safeParse('a_b').success).toBe(false); + expect(AgentNameSchema.safeParse('-ab').success).toBe(false); + expect(AgentNameSchema.safeParse('ab-').success).toBe(false); + expect(AgentNameSchema.safeParse('a').success).toBe(false); + expect(AgentNameSchema.safeParse(`a${'b'.repeat(64)}`).success).toBe(false); + }); + + it('leaves NameSchema allowing "." and "_" for non-agent resources', () => { + expect(NameSchema.parse('a.b')).toBe('a.b'); + expect(NameSchema.parse('a_b')).toBe('a_b'); + }); +});