diff --git a/.changeset/session-import-api.md b/.changeset/session-import-api.md new file mode 100644 index 000000000..5e087e333 --- /dev/null +++ b/.changeset/session-import-api.md @@ -0,0 +1,5 @@ +--- +"@truefoundry/trueforge": patch +--- + +Add ops-only POST /api/v1/settings/sessions/import for historical one-session backfill (skip-if-exists). diff --git a/.github/fern/openapi/openapi.json b/.github/fern/openapi/openapi.json index aeed990c5..1aaf1c514 100644 --- a/.github/fern/openapi/openapi.json +++ b/.github/fern/openapi/openapi.json @@ -1395,6 +1395,229 @@ ], "type": "object" }, + "ImportSessionSnapshotRequest": { + "properties": { + "session": { + "additionalProperties": {}, + "properties": { + "agent_spec": { + "additionalProperties": {}, + "type": "object" + }, + "created_at": { + "minLength": 1, + "type": "string" + }, + "created_by": { + "minLength": 1, + "type": "string" + }, + "custom": { + "additionalProperties": {}, + "type": [ + "object", + "null" + ] + }, + "last_activity_timestamp_ms": { + "type": "number" + }, + "last_turn_id": { + "type": [ + "string", + "null" + ] + }, + "session_id": { + "minLength": 1, + "type": "string" + }, + "tenant_id": { + "minLength": 1, + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "updated_at": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "session_id", + "tenant_id", + "created_by", + "agent_spec", + "title", + "last_turn_id", + "custom", + "last_activity_timestamp_ms", + "created_at", + "updated_at" + ], + "type": "object" + }, + "turns": { + "items": { + "additionalProperties": {}, + "properties": { + "ancestor_ids": { + "items": { + "type": "string" + }, + "type": "array" + }, + "checkpoint": { + "properties": { + "mcp_servers": {}, + "sandbox_info": {} + }, + "type": "object" + }, + "created_at": { + "minLength": 1, + "type": "string" + }, + "custom": { + "additionalProperties": {}, + "type": [ + "object", + "null" + ] + }, + "events": { + "items": { + "additionalProperties": {}, + "properties": { + "created_at": { + "minLength": 1, + "type": "string" + }, + "id": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "id", + "created_at" + ], + "type": "object" + }, + "type": "array" + }, + "first_turn_id": { + "minLength": 1, + "type": "string" + }, + "input": { + "items": {}, + "type": "array" + }, + "previous_turn_id": { + "type": [ + "string", + "null" + ] + }, + "state": {}, + "threads": { + "items": { + "additionalProperties": {}, + "properties": { + "agent_info": {}, + "capability_state": { + "additionalProperties": {}, + "type": [ + "object", + "null" + ] + }, + "completion": {}, + "context": { + "items": {}, + "type": "array" + }, + "current_context_usage": {}, + "parent": {}, + "thread_id": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "thread_id", + "context", + "capability_state" + ], + "type": "object" + }, + "type": "array" + }, + "turn_id": { + "minLength": 1, + "type": "string" + }, + "updated_at": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "turn_id", + "first_turn_id", + "previous_turn_id", + "ancestor_ids", + "input", + "checkpoint", + "custom", + "created_at", + "updated_at", + "threads", + "events" + ], + "type": "object" + }, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "session", + "turns" + ], + "type": "object" + }, + "ImportSessionSnapshotResponse": { + "properties": { + "data": { + "$ref": "#/components/schemas/ImportSessionSnapshotResult" + } + }, + "required": [ + "data" + ], + "type": "object" + }, + "ImportSessionSnapshotResult": { + "properties": { + "imported": { + "type": "boolean" + }, + "session_id": { + "type": "string" + } + }, + "required": [ + "imported", + "session_id" + ], + "type": "object" + }, "InitialUserMessage": { "properties": { "content": { @@ -7304,6 +7527,68 @@ "x-fern-sdk-method-name": "create_or_update" } }, + "/api/v1/settings/sessions/import": { + "post": { + "description": "Ops/backfill only. Skip if session_id exists; else insert in one transaction.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportSessionSnapshotRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportSessionSnapshotResponse" + } + } + }, + "description": "Skipped — already exists." + }, + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportSessionSnapshotResponse" + } + } + }, + "description": "Imported." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestErrorResponse" + } + } + }, + "description": "Invalid body." + }, + "501": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestErrorResponse" + } + } + }, + "description": "Import requires Postgres (not available in standalone)." + } + }, + "summary": "Import one historical session snapshot", + "tags": [ + "Agent Sessions" + ], + "x-fern-ignore": true + } + }, "/api/v1/settings/skills": { "get": { "description": "All configured skills with nested manifests (settings / admin projection).", diff --git a/docs/openapi.json b/docs/openapi.json index aeed990c5..1aaf1c514 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -1395,6 +1395,229 @@ ], "type": "object" }, + "ImportSessionSnapshotRequest": { + "properties": { + "session": { + "additionalProperties": {}, + "properties": { + "agent_spec": { + "additionalProperties": {}, + "type": "object" + }, + "created_at": { + "minLength": 1, + "type": "string" + }, + "created_by": { + "minLength": 1, + "type": "string" + }, + "custom": { + "additionalProperties": {}, + "type": [ + "object", + "null" + ] + }, + "last_activity_timestamp_ms": { + "type": "number" + }, + "last_turn_id": { + "type": [ + "string", + "null" + ] + }, + "session_id": { + "minLength": 1, + "type": "string" + }, + "tenant_id": { + "minLength": 1, + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "updated_at": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "session_id", + "tenant_id", + "created_by", + "agent_spec", + "title", + "last_turn_id", + "custom", + "last_activity_timestamp_ms", + "created_at", + "updated_at" + ], + "type": "object" + }, + "turns": { + "items": { + "additionalProperties": {}, + "properties": { + "ancestor_ids": { + "items": { + "type": "string" + }, + "type": "array" + }, + "checkpoint": { + "properties": { + "mcp_servers": {}, + "sandbox_info": {} + }, + "type": "object" + }, + "created_at": { + "minLength": 1, + "type": "string" + }, + "custom": { + "additionalProperties": {}, + "type": [ + "object", + "null" + ] + }, + "events": { + "items": { + "additionalProperties": {}, + "properties": { + "created_at": { + "minLength": 1, + "type": "string" + }, + "id": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "id", + "created_at" + ], + "type": "object" + }, + "type": "array" + }, + "first_turn_id": { + "minLength": 1, + "type": "string" + }, + "input": { + "items": {}, + "type": "array" + }, + "previous_turn_id": { + "type": [ + "string", + "null" + ] + }, + "state": {}, + "threads": { + "items": { + "additionalProperties": {}, + "properties": { + "agent_info": {}, + "capability_state": { + "additionalProperties": {}, + "type": [ + "object", + "null" + ] + }, + "completion": {}, + "context": { + "items": {}, + "type": "array" + }, + "current_context_usage": {}, + "parent": {}, + "thread_id": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "thread_id", + "context", + "capability_state" + ], + "type": "object" + }, + "type": "array" + }, + "turn_id": { + "minLength": 1, + "type": "string" + }, + "updated_at": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "turn_id", + "first_turn_id", + "previous_turn_id", + "ancestor_ids", + "input", + "checkpoint", + "custom", + "created_at", + "updated_at", + "threads", + "events" + ], + "type": "object" + }, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "session", + "turns" + ], + "type": "object" + }, + "ImportSessionSnapshotResponse": { + "properties": { + "data": { + "$ref": "#/components/schemas/ImportSessionSnapshotResult" + } + }, + "required": [ + "data" + ], + "type": "object" + }, + "ImportSessionSnapshotResult": { + "properties": { + "imported": { + "type": "boolean" + }, + "session_id": { + "type": "string" + } + }, + "required": [ + "imported", + "session_id" + ], + "type": "object" + }, "InitialUserMessage": { "properties": { "content": { @@ -7304,6 +7527,68 @@ "x-fern-sdk-method-name": "create_or_update" } }, + "/api/v1/settings/sessions/import": { + "post": { + "description": "Ops/backfill only. Skip if session_id exists; else insert in one transaction.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportSessionSnapshotRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportSessionSnapshotResponse" + } + } + }, + "description": "Skipped — already exists." + }, + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportSessionSnapshotResponse" + } + } + }, + "description": "Imported." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestErrorResponse" + } + } + }, + "description": "Invalid body." + }, + "501": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestErrorResponse" + } + } + }, + "description": "Import requires Postgres (not available in standalone)." + } + }, + "summary": "Import one historical session snapshot", + "tags": [ + "Agent Sessions" + ], + "x-fern-ignore": true + } + }, "/api/v1/settings/skills": { "get": { "description": "All configured skills with nested manifests (settings / admin projection).", diff --git a/packages/trueforge/scripts/write-openapi.ts b/packages/trueforge/scripts/write-openapi.ts index afb058658..b322b84b3 100644 --- a/packages/trueforge/scripts/write-openapi.ts +++ b/packages/trueforge/scripts/write-openapi.ts @@ -68,6 +68,7 @@ const app = createServerApp({ sandboxProviderStore: new SqliteSandboxProviderStore(db), agentStore: new SqliteAgentStore(db), scheduleStore: new SqliteScheduleStore(db), + sessionSnapshotImporter: undefined, sessionStore, sessions: new Sessions({ sessionStore }), activeTurns: new ActiveTurnRegistry(), diff --git a/packages/trueforge/src/apis/sessionImport.ts b/packages/trueforge/src/apis/sessionImport.ts new file mode 100644 index 000000000..65d986261 --- /dev/null +++ b/packages/trueforge/src/apis/sessionImport.ts @@ -0,0 +1,30 @@ +/** + * Admin session snapshot import under /api/v1/settings/sessions. + * Postgres-only; returns 501 when no importer is wired (standalone). + */ +import { OpenAPIHono, type RouteHandler } from '@hono/zod-openapi'; +import { HTTPException } from 'hono/http-exception'; +import type { ISessionSnapshotImporter } from '../db/sessionSnapshotImport'; +import { importSessionSnapshotRoute } from '../routes/sessionImportRoutes'; + +export interface SessionImportRouterDeps { + sessionSnapshotImporter: ISessionSnapshotImporter | undefined; +} + +export function createSessionImportRouter(deps: SessionImportRouterDeps) { + const router = new OpenAPIHono(); + + const importHandler: RouteHandler = async c => { + if (deps.sessionSnapshotImporter === undefined) { + throw new HTTPException(501, { + message: 'Session import requires Postgres (STANDALONE=false)', + }); + } + const body = c.req.valid('json'); + const result = await deps.sessionSnapshotImporter.importSessionSnapshot(body); + return c.json({ data: result }, result.imported ? 201 : 200); + }; + + router.openapi(importSessionSnapshotRoute, importHandler); + return router; +} diff --git a/packages/trueforge/src/apis/settings.ts b/packages/trueforge/src/apis/settings.ts index 8738d2d2d..8a31de81d 100644 --- a/packages/trueforge/src/apis/settings.ts +++ b/packages/trueforge/src/apis/settings.ts @@ -1,6 +1,6 @@ /** * Admin/settings API surface under /api/v1/settings. - * Sub-routers (model-providers, mcp-servers, skills, sandbox-providers) mount here. + * Sub-routers (model-providers, mcp-servers, skills, sandbox-providers, sessions) mount here. * Auth is applied at the /api/v1/settings mount boundary in app.ts (admin when auth is enabled). */ import { OpenAPIHono } from '@hono/zod-openapi'; @@ -9,12 +9,14 @@ import type { ResolveUserContext } from '../auth/identity'; import type { IMcpServerStore } from '../db/mcpServerStore'; import type { IModelProviderStore } from '../db/modelProviderStore'; import type { ISandboxProviderStore } from '../db/sandboxProviderStore'; +import type { ISessionSnapshotImporter } from '../db/sessionSnapshotImport'; import type { ISkillStore } from '../db/skillStore'; import type { WithTransaction } from '../db/transaction'; import type { IOAuthTokenStore } from '../mcp/auth/types'; import { createSettingsMcpServersRouter } from './mcpServers'; import { createModelProvidersRouter } from './modelProviders'; import { createSandboxProvidersRouter } from './sandboxProviders'; +import { createSessionImportRouter } from './sessionImport'; import { createSkillsRouter } from './skills'; export interface SettingsRouterDeps { @@ -23,6 +25,7 @@ export interface SettingsRouterDeps { tokenStore: IOAuthTokenStore; skillStore: ISkillStore; sandboxProviderStore: ISandboxProviderStore; + sessionSnapshotImporter: ISessionSnapshotImporter | undefined; withTransaction: WithTransaction; logger: Logger; resolveUserContext: ResolveUserContext; @@ -62,5 +65,11 @@ export function createSettingsRouter(deps: SettingsRouterDeps { sandboxProviderStore: ISandboxProviderStore; agentStore: IAgentStore; scheduleStore: IScheduleStore; + sessionSnapshotImporter: ISessionSnapshotImporter | undefined; sessionStore: ISessionStore; sessions: Sessions; activeTurns: ActiveTurnRegistry; @@ -271,6 +273,7 @@ export function createServerApp(deps: ServerDeps) { tokenStore: deps.tokenStore, skillStore: deps.skillStore, sandboxProviderStore: deps.sandboxProviderStore, + sessionSnapshotImporter: deps.sessionSnapshotImporter, withTransaction: deps.withTransaction, logger: deps.logger, resolveUserContext, diff --git a/packages/trueforge/src/db/postgres/session-store/importSessionSnapshot.ts b/packages/trueforge/src/db/postgres/session-store/importSessionSnapshot.ts new file mode 100644 index 000000000..4bf12c17e --- /dev/null +++ b/packages/trueforge/src/db/postgres/session-store/importSessionSnapshot.ts @@ -0,0 +1,145 @@ +/** + * Postgres historical session snapshot insert (skip-if-exists). + */ +import type { Kysely } from 'kysely'; +import type { ImportSessionSnapshotRequest, ImportSessionSnapshotResult } from '../../../schemas/sessionImport'; +import { isContextPrefix, type ISessionSnapshotImporter } from '../../sessionSnapshotImport'; +import { json, jsonUnknown } from '../sqlExpressions'; +import type { Database } from '../types'; + +export class PostgresSessionSnapshotImporter implements ISessionSnapshotImporter { + constructor(private readonly db: Kysely) {} + + async importSessionSnapshot(input: ImportSessionSnapshotRequest): Promise { + const sessionId = input.session.session_id; + return this.db.transaction().execute(async trx => { + const { session, turns } = input; + const insertedSession = await trx + .insertInto('session') + .values({ + tenant_id: session.tenant_id, + session_id: sessionId, + created_by: session.created_by, + agent_id: null, + agent_name: null, + agent_spec: jsonUnknown(session.agent_spec), + title: session.title, + last_turn_id: session.last_turn_id, + custom: session.custom !== null ? json(session.custom) : null, + last_activity_timestamp_ms: session.last_activity_timestamp_ms, + created_at: new Date(session.created_at), + updated_at: new Date(session.updated_at), + }) + .onConflict(oc => oc.column('session_id').doNothing()) + .returning('session_id') + .executeTakeFirst(); + if (insertedSession === undefined) { + return { imported: false, session_id: sessionId }; + } + + const prevContextByThread = new Map(); + const prevContextIdsByThread = new Map(); + + for (const turn of turns) { + await trx + .insertInto('turn') + .values({ + session_id: sessionId, + turn_id: turn.turn_id, + first_turn_id: turn.first_turn_id, + previous_turn_id: turn.previous_turn_id, + ancestor_ids: turn.ancestor_ids, + input: jsonUnknown(turn.input), + state: jsonUnknown(turn.state), + checkpoint: jsonUnknown(turn.checkpoint), + custom: turn.custom !== null ? json(turn.custom) : null, + created_at: new Date(turn.created_at), + updated_at: new Date(turn.updated_at), + }) + .execute(); + + for (const thread of turn.threads) { + const prevCtx = prevContextByThread.get(thread.thread_id) ?? []; + const prevIds = prevContextIdsByThread.get(thread.thread_id) ?? []; + const appendOnly = isContextPrefix({ prefix: prevCtx, full: thread.context }); + const newMessages = appendOnly ? thread.context.slice(prevCtx.length) : thread.context; + const reusedIds = appendOnly ? prevIds : []; + + const newIds: number[] = []; + if (newMessages.length > 0) { + const inserted = await trx + .insertInto('thread_context_log') + .values( + newMessages.map(msg => ({ + session_id: sessionId, + thread_id: thread.thread_id, + turn_id: turn.turn_id, + body: jsonUnknown(msg), + created_at: new Date(turn.updated_at), + })), + ) + .returning(['append_id']) + .execute(); + for (const row of inserted) { + newIds.push(row.append_id); + } + } + + const contextIds = [...reusedIds, ...newIds]; + await trx + .insertInto('turn_thread') + .values({ + session_id: sessionId, + turn_id: turn.turn_id, + thread_id: thread.thread_id, + checkpoint: jsonUnknown({ parent: thread.parent, completion: thread.completion }), + agent_info: thread.agent_info !== null ? jsonUnknown(thread.agent_info) : null, + current_context_usage: jsonUnknown(thread.current_context_usage), + context_ids: contextIds, + updated_at: new Date(turn.updated_at), + }) + .execute(); + + prevContextByThread.set(thread.thread_id, thread.context); + prevContextIdsByThread.set(thread.thread_id, contextIds); + + if (thread.capability_state !== null) { + const capEntries = Object.entries(thread.capability_state); + if (capEntries.length > 0) { + await trx + .insertInto('thread_capability_state') + .values( + capEntries.map(([key, state]) => ({ + session_id: sessionId, + turn_id: turn.turn_id, + thread_id: thread.thread_id, + key, + state: jsonUnknown(state), + updated_at: new Date(turn.updated_at), + })), + ) + .execute(); + } + } + } + + if (turn.events.length > 0) { + await trx + .insertInto('session_event') + .values( + turn.events.map(event => ({ + session_id: sessionId, + turn_id: turn.turn_id, + event_id: event.id, + event: jsonUnknown(event), + created_at: new Date(event.created_at), + })), + ) + .execute(); + } + } + + return { imported: true, session_id: sessionId }; + }); + } +} diff --git a/packages/trueforge/src/db/postgres/sqlExpressions.ts b/packages/trueforge/src/db/postgres/sqlExpressions.ts index 90e44bc8d..c1e956ebc 100644 --- a/packages/trueforge/src/db/postgres/sqlExpressions.ts +++ b/packages/trueforge/src/db/postgres/sqlExpressions.ts @@ -3,9 +3,17 @@ */ import { sql, type Expression, type RawBuilder } from 'kysely'; +function asJsonb(value: unknown): RawBuilder { + return sql`${JSON.stringify(value)}::jsonb`; +} + /** Bind a JS value as jsonb (stringified + cast). Required for arrays and for `||` / jsonb_set operands. */ export function json(value: T): RawBuilder { - return sql`${JSON.stringify(value)}::jsonb`; + return asJsonb(value); +} + +export function jsonUnknown(value: unknown): RawBuilder { + return asJsonb(value); } /** diff --git a/packages/trueforge/src/db/sessionSnapshotImport.ts b/packages/trueforge/src/db/sessionSnapshotImport.ts new file mode 100644 index 000000000..68a83b47a --- /dev/null +++ b/packages/trueforge/src/db/sessionSnapshotImport.ts @@ -0,0 +1,20 @@ +/** + * Ops-only session snapshot import. Postgres historical backfill — not ISessionStore. + */ +import type { ImportSessionSnapshotRequest, ImportSessionSnapshotResult } from '../schemas/sessionImport'; + +export interface ISessionSnapshotImporter { + importSessionSnapshot(input: ImportSessionSnapshotRequest): Promise; +} + +export function isContextPrefix({ prefix, full }: { prefix: unknown[]; full: unknown[] }): boolean { + if (prefix.length > full.length) { + return false; + } + for (let i = 0; i < prefix.length; i++) { + if (JSON.stringify(prefix[i]) !== JSON.stringify(full[i])) { + return false; + } + } + return true; +} diff --git a/packages/trueforge/src/main.ts b/packages/trueforge/src/main.ts index 047d0f7a1..a3a8f9c2b 100644 --- a/packages/trueforge/src/main.ts +++ b/packages/trueforge/src/main.ts @@ -59,6 +59,7 @@ import type { IModelProviderStore } from './db/modelProviderStore'; import type { Database as PostgresDatabase } from './db/postgres/types'; import type { ISandboxProviderStore } from './db/sandboxProviderStore'; import type { IScheduleStore } from './db/scheduleStore'; +import type { ISessionSnapshotImporter } from './db/sessionSnapshotImport'; import type { ISkillStore } from './db/skillStore'; import type { Database as SqliteDatabase } from './db/sqlite/types'; import type { WithTransaction } from './db/transaction'; @@ -81,6 +82,7 @@ interface ServerPersistence { sandboxProviderStore: ISandboxProviderStore; agentStore: IAgentStore; scheduleStore: IScheduleStore; + sessionSnapshotImporter: ISessionSnapshotImporter | undefined; destroyDb: () => Promise; redis: RedisClientType | undefined; } @@ -132,6 +134,7 @@ async function createStandalonePersistence(options: { sandboxProviderStore: new SqliteSandboxProviderStore(db), agentStore: new SqliteAgentStore(db), scheduleStore: new SqliteScheduleStore(db), + sessionSnapshotImporter: undefined, destroyDb: () => db.destroy(), redis: undefined, }; @@ -165,6 +168,7 @@ async function createDistributedPersistence(options: { import('./db/postgres/sandbox-provider-store/PostgresSandboxProviderStore'), import('./db/postgres/agent-store/PostgresAgentStore'), import('./db/postgres/schedule-store/PostgresScheduleStore'), + import('./db/postgres/session-store/importSessionSnapshot'), ]), ]); const [ @@ -176,6 +180,7 @@ async function createDistributedPersistence(options: { { PostgresSandboxProviderStore }, { PostgresAgentStore }, { PostgresScheduleStore }, + { PostgresSessionSnapshotImporter }, ] = postgresStores; const db = createDb({ @@ -198,6 +203,7 @@ async function createDistributedPersistence(options: { sandboxProviderStore: new PostgresSandboxProviderStore(db), agentStore: new PostgresAgentStore(db), scheduleStore: new PostgresScheduleStore(db), + sessionSnapshotImporter: new PostgresSessionSnapshotImporter(db), destroyDb: () => db.destroy(), redis: await connectRedis({ url: redisUrl, logger }), }; @@ -215,6 +221,7 @@ async function createServerRuntime(persistence: ServerPersistence< sandboxProviderStore, agentStore, scheduleStore, + sessionSnapshotImporter, destroyDb, redis, } = persistence; @@ -259,6 +266,7 @@ async function createServerRuntime(persistence: ServerPersistence< sandboxProviderStore, agentStore, scheduleStore, + sessionSnapshotImporter, sessionStore, sessions: new Sessions({ sessionStore }), activeTurns, diff --git a/packages/trueforge/src/routes/sessionImportRoutes.ts b/packages/trueforge/src/routes/sessionImportRoutes.ts new file mode 100644 index 000000000..bf737ec1d --- /dev/null +++ b/packages/trueforge/src/routes/sessionImportRoutes.ts @@ -0,0 +1,40 @@ +/** + * Ops session import route (mounted at /api/v1/settings/sessions/import). + */ +import { createRoute } from '@hono/zod-openapi'; +import { RequestErrorResponseSchema } from '../schemas/errors'; +import { ImportSessionSnapshotRequestSchema, ImportSessionSnapshotResponseSchema } from '../schemas/sessionImport'; +import { OpenApiTag } from './openapiTags'; + +export const importSessionSnapshotRoute = createRoute({ + method: 'post', + path: '/import', + tags: [OpenApiTag.AGENT_SESSIONS], + summary: 'Import one historical session snapshot', + description: 'Ops/backfill only. Skip if session_id exists; else insert in one transaction.', + 'x-fern-ignore': true, + request: { + body: { + content: { 'application/json': { schema: ImportSessionSnapshotRequestSchema } }, + required: true, + }, + }, + responses: { + 200: { + content: { 'application/json': { schema: ImportSessionSnapshotResponseSchema } }, + description: 'Skipped — already exists.', + }, + 201: { + content: { 'application/json': { schema: ImportSessionSnapshotResponseSchema } }, + description: 'Imported.', + }, + 400: { + content: { 'application/json': { schema: RequestErrorResponseSchema } }, + description: 'Invalid body.', + }, + 501: { + content: { 'application/json': { schema: RequestErrorResponseSchema } }, + description: 'Import requires Postgres (not available in standalone).', + }, + }, +}); diff --git a/packages/trueforge/src/schemas/sessionImport.ts b/packages/trueforge/src/schemas/sessionImport.ts new file mode 100644 index 000000000..187c75982 --- /dev/null +++ b/packages/trueforge/src/schemas/sessionImport.ts @@ -0,0 +1,83 @@ +/** + * Wire schema for POST /api/v1/settings/sessions/import (one-shot ops backfill). + * Loose validation — caller (k8s-controller) owns massaging. + */ +import { z } from '@hono/zod-openapi'; + +export const ImportSessionSnapshotRequestSchema = z + .object({ + session: z + .object({ + session_id: z.string().min(1), + tenant_id: z.string().min(1), + created_by: z.string().min(1), + agent_spec: z.record(z.string(), z.unknown()), + title: z.string().nullable(), + last_turn_id: z.string().nullable(), + custom: z.record(z.string(), z.unknown()).nullable(), + last_activity_timestamp_ms: z.number(), + created_at: z.string().min(1), + updated_at: z.string().min(1), + }) + .loose(), + turns: z + .array( + z + .object({ + turn_id: z.string().min(1), + first_turn_id: z.string().min(1), + previous_turn_id: z.string().nullable(), + ancestor_ids: z.array(z.string()), + input: z.array(z.unknown()), + state: z.unknown(), + checkpoint: z.object({ + mcp_servers: z.unknown().nullable(), + sandbox_info: z.unknown().nullable(), + }), + custom: z.record(z.string(), z.unknown()).nullable(), + created_at: z.string().min(1), + updated_at: z.string().min(1), + threads: z.array( + z + .object({ + thread_id: z.string().min(1), + context: z.array(z.unknown()), + current_context_usage: z.unknown(), + parent: z.unknown().nullable(), + completion: z.unknown().nullable(), + agent_info: z.unknown().nullable(), + capability_state: z.record(z.string(), z.unknown()).nullable(), + }) + .loose(), + ), + events: z.array( + z + .object({ + id: z.string().min(1), + created_at: z.string().min(1), + }) + .loose(), + ), + }) + .loose(), + ) + .min(1), + }) + .openapi('ImportSessionSnapshotRequest'); + +export const ImportSessionSnapshotResultSchema = z + .object({ + imported: z.boolean(), + session_id: z.string(), + }) + .openapi('ImportSessionSnapshotResult'); + +export const ImportSessionSnapshotResponseSchema = z + .object({ + data: ImportSessionSnapshotResultSchema, + }) + .openapi('ImportSessionSnapshotResponse'); + +export type ImportSessionSnapshotRequest = z.infer; +export type ImportSessionSnapshotResult = z.infer; +export type ImportSessionSnapshotResponse = z.infer; diff --git a/packages/trueforge/tests/unit/apis/modelProviders.test.ts b/packages/trueforge/tests/unit/apis/modelProviders.test.ts index 29247b273..b206c3b38 100644 --- a/packages/trueforge/tests/unit/apis/modelProviders.test.ts +++ b/packages/trueforge/tests/unit/apis/modelProviders.test.ts @@ -99,6 +99,7 @@ async function createRouters(): Promise<{ tokenStore: new SqliteOAuthTokenStore(db), skillStore: new SqliteSkillStore(db), sandboxProviderStore: new SqliteSandboxProviderStore(db), + sessionSnapshotImporter: undefined, withTransaction: callback => db.transaction().execute(callback), logger: winston.createLogger({ silent: true }), resolveUserContext: () => LOCAL_USER_CONTEXT,