import fs from 'node:fs' import { createLogger } from '../../utils/logger' import { createStudioSession } from '../domain/factories' import type { StudioEventBus, StudioKind, StudioPermissionLevel, StudioPermissionReply, StudioPermissionRequest, StudioSession, StudioTask, StudioToolChoice, StudioWork, StudioWorkResult, } from '../domain/types' import { InMemoryStudioEventBus, type StudioEventListener } from '../events/event-bus' import { adaptStudioEvent, type StudioExternalEvent } from '../events/studio-event-adapter' import { registerManimStudioTools } from '../manim/register-manim-tools' import type { StudioPersistence } from '../persistence/studio-persistence' import { registerPlotStudioTools } from '../plot/register-plot-tools' import type { StudioPermissionService } from '../permissions/permission-service' import { defaultRulesForLevel } from '../permissions/policy' import { resolveStudioPermissionMode, type StudioPermissionMode } from '../session-control/permission-modes' import { registerSharedStudioTools } from '../shared/register-shared-tools' import { buildStudioContinueInputText, buildStudioContinuationRunMetadata, isStudioRunResumable, readStudioRunAutonomyMetadata, } from '../runs/autonomy-policy' import { createLocalStudioSkillResolver } from '../skills/local-skill-resolver' import type { StudioBlobStore } from '../storage/studio-blob-store' import { StudioToolRegistry } from '../tools/registry' import { StudioBuilderRuntime } from './builder-runtime' import { createStudioDefaultTurnPlanResolver } from './default-turn-plan-resolver' import { syncStudioRenderTask } from './render-task-sync' import { createStudioSessionMetadata } from './session-agent-config' import { flushTerminalSessionEventsToAssistant } from './session-event-inbox' import type { StudioWorkspaceProvider } from '../workspace/studio-workspace-provider' const logger = createLogger('StudioRuntimeService') interface SubscribableStudioEventBus extends StudioEventBus { subscribe: (listener: StudioEventListener) => () => void } interface CreateStudioRuntimeServiceInput { persistence: StudioPersistence permissionService: StudioPermissionService workspaceProvider: StudioWorkspaceProvider blobStore: StudioBlobStore registry?: StudioToolRegistry eventBus?: SubscribableStudioEventBus } export interface StudioRuntimeService { registry: StudioToolRegistry runtime: StudioBuilderRuntime permissionService: StudioPermissionService workspaceProvider: StudioWorkspaceProvider blobStore: StudioBlobStore sessionStore: StudioPersistence['sessionStore'] messageStore: StudioPersistence['messageStore'] partStore: StudioPersistence['partStore'] runStore: StudioPersistence['runStore'] taskStore: StudioPersistence['taskStore'] workStore: StudioPersistence['workStore'] workResultStore: StudioPersistence['workResultStore'] sessionEventStore: StudioPersistence['sessionEventStore'] eventBus: StudioEventBus createSession: (sessionInput: { projectId: string directory: string useDedicatedWorkspace?: boolean title?: string studioKind?: StudioKind agentType?: StudioSession['agentType'] permissionLevel?: StudioPermissionLevel workspaceId?: string toolChoice?: StudioToolChoice }) => Promise getSession: (sessionId: string) => Promise updateSession: (sessionId: string, patch: { permissionMode?: StudioPermissionMode }) => Promise startRun: (input: { projectId: string session: StudioSession inputText: string customApiConfig?: import('../../types').CustomApiConfig toolChoice?: StudioToolChoice }) => Promise<{ run: import('../domain/types').StudioRun; assistantMessage: import('../domain/types').StudioAssistantMessage } | null> continueRun: (input: { projectId: string sourceRunId: string inputText?: string customApiConfig?: import('../../types').CustomApiConfig toolChoice?: StudioToolChoice }) => Promise<{ status: 'started' session: StudioSession run: import('../domain/types').StudioRun assistantMessage: import('../domain/types').StudioAssistantMessage } | { status: 'conflict' | 'not_found' | 'not_resumable' session?: StudioSession run?: import('../domain/types').StudioRun }> syncSession: (sessionId: string) => Promise listWorkResultsBySessionId: (sessionId: string) => Promise listExternalEvents: () => StudioExternalEvent[] subscribeExternalEvents: (listener: (event: StudioExternalEvent) => void) => () => void listPendingPermissions: () => StudioPermissionRequest[] replyPermission: (replyInput: StudioPermissionReply) => boolean } export function createStudioRuntimeService(input: CreateStudioRuntimeServiceInput): StudioRuntimeService { const registry = input.registry ?? new StudioToolRegistry() const eventBus: SubscribableStudioEventBus = input.eventBus ?? new InMemoryStudioEventBus() const externalEventLog: StudioExternalEvent[] = [] const activeSessionRuns = new Map() registerSharedStudioTools(registry) registerManimStudioTools(registry) registerPlotStudioTools(registry) const resolveSkill = createLocalStudioSkillResolver() const resolveTurnPlan = createStudioDefaultTurnPlanResolver({ registry }) const runtime = new StudioBuilderRuntime({ registry, messageStore: input.persistence.messageStore, partStore: input.persistence.partStore, runStore: input.persistence.runStore, sessionStore: input.persistence.sessionStore, taskStore: input.persistence.taskStore, workStore: input.persistence.workStore, workResultStore: input.persistence.workResultStore, sessionEventStore: input.persistence.sessionEventStore, permissionService: input.permissionService, resolveTurnPlan, resolveSkill, eventBus, }) eventBus.subscribe((event) => { const adapted = adaptStudioEvent(event) if (adapted) { externalEventLog.push(adapted) } }) async function startBackgroundRunLocked(runInput: { projectId: string session: StudioSession inputText: string customApiConfig?: import('../../types').CustomApiConfig toolChoice?: StudioToolChoice runMetadata?: Record }) { if (activeSessionRuns.has(runInput.session.id)) { return null } const handle = await runtime.startBackgroundRun(runInput) activeSessionRuns.set(runInput.session.id, handle.run.id) void handle.completion .catch((error) => { logger.warn('Background studio run failed', { sessionId: runInput.session.id, runId: handle.run.id, error: error instanceof Error ? error.message : String(error) }) }) .finally(() => { if (activeSessionRuns.get(runInput.session.id) === handle.run.id) { activeSessionRuns.delete(runInput.session.id) } }) return { run: handle.run, assistantMessage: handle.assistantMessage } } return { registry, runtime, permissionService: input.permissionService, workspaceProvider: input.workspaceProvider, blobStore: input.blobStore, sessionStore: input.persistence.sessionStore, messageStore: input.persistence.messageStore, partStore: input.persistence.partStore, runStore: input.persistence.runStore, taskStore: input.persistence.taskStore, workStore: input.persistence.workStore, workResultStore: input.persistence.workResultStore, sessionEventStore: input.persistence.sessionEventStore, eventBus, async createSession(sessionInput) { const permissionLevel = sessionInput.permissionLevel ?? 'L2' const studioKind = sessionInput.studioKind ?? 'manim' const normalizedDirectory = input.workspaceProvider.normalizeDirectory(sessionInput.directory) const session = createStudioSession({ projectId: sessionInput.projectId, workspaceId: sessionInput.workspaceId, studioKind, agentType: sessionInput.agentType ?? 'builder', title: sessionInput.title ?? getDefaultSessionTitle(studioKind), directory: normalizedDirectory, permissionLevel, permissionRules: defaultRulesForLevel(permissionLevel), metadata: createStudioSessionMetadata({ existing: { studioKind }, agentConfig: { toolChoice: sessionInput.toolChoice, }, }), }) if (sessionInput.useDedicatedWorkspace !== false) { session.directory = input.workspaceProvider.normalizeDirectory( `${studioKind}-studio/${session.id}`, { session }, ) } fs.mkdirSync(session.directory, { recursive: true }) return input.persistence.sessionStore.create(session) }, getSession(sessionId: string) { return input.persistence.sessionStore.getById(sessionId) }, async updateSession(sessionId, patch) { const session = await input.persistence.sessionStore.getById(sessionId) if (!session) { return null } if (patch.permissionMode) { const nextMode = resolveStudioPermissionMode(patch.permissionMode, session) return input.persistence.sessionStore.update(sessionId, { permissionLevel: nextMode.permissionLevel, permissionRules: nextMode.permissionRules, metadata: nextMode.metadata, }) } return session }, async startRun(runInput) { return startBackgroundRunLocked(runInput) }, async continueRun(runInput) { const sourceRun = await input.persistence.runStore.getById(runInput.sourceRunId) if (!sourceRun) { return { status: 'not_found' as const } } const session = await input.persistence.sessionStore.getById(sourceRun.sessionId) if (!session) { return { status: 'not_found' as const, run: sourceRun } } if (!isStudioRunResumable(sourceRun)) { return { status: 'not_resumable' as const, session, run: sourceRun } } if (activeSessionRuns.has(session.id)) { return { status: 'conflict' as const, session, run: sourceRun } } const autonomy = readStudioRunAutonomyMetadata(sourceRun.metadata) const started = await startBackgroundRunLocked({ projectId: runInput.projectId, session, inputText: runInput.inputText?.trim() || buildStudioContinueInputText(autonomy.stopReason), customApiConfig: runInput.customApiConfig, toolChoice: runInput.toolChoice, runMetadata: buildStudioContinuationRunMetadata({ sourceRunId: sourceRun.id, sourceMetadata: sourceRun.metadata, }), }) if (!started) { return { status: 'conflict' as const, session, run: sourceRun } } return { status: 'started' as const, session, run: started.run, assistantMessage: started.assistantMessage, } }, async syncSession(sessionId: string): Promise { const tasks = await input.persistence.taskStore.listBySessionId(sessionId) for (const task of tasks) { await syncTaskState({ task, persistence: input.persistence, eventBus, blobStore: input.blobStore, }) } await flushTerminalSessionEventsToAssistant({ sessionId, sessionEventStore: input.persistence.sessionEventStore, messageStore: input.persistence.messageStore, partStore: input.persistence.partStore, }) }, async listWorkResultsBySessionId(sessionId: string): Promise { const works = await input.persistence.workStore.listBySessionId(sessionId) return collectWorkResults(works, input.persistence) }, listExternalEvents(): StudioExternalEvent[] { return [...externalEventLog] }, subscribeExternalEvents(listener: (event: StudioExternalEvent) => void): () => void { return eventBus.subscribe((event) => { const adapted = adaptStudioEvent(event) if (adapted) { listener(adapted) } }) }, listPendingPermissions(): StudioPermissionRequest[] { return input.permissionService.listPending() }, replyPermission(replyInput: StudioPermissionReply): boolean { return input.permissionService.reply(replyInput) }, } } async function syncTaskState(input: { task: StudioTask persistence: StudioPersistence eventBus: StudioEventBus blobStore: StudioBlobStore }): Promise { if (input.task.type !== 'render') { return } await syncStudioRenderTask({ task: input.task, taskStore: input.persistence.taskStore, workStore: input.persistence.workStore, workResultStore: input.persistence.workResultStore, sessionStore: input.persistence.sessionStore, sessionEventStore: input.persistence.sessionEventStore, messageStore: input.persistence.messageStore, partStore: input.persistence.partStore, eventBus: input.eventBus, blobStore: input.blobStore, }) } async function collectWorkResults(works: StudioWork[], persistence: StudioPersistence): Promise { const resultSets = await Promise.all(works.map((work) => persistence.workResultStore.listByWorkId(work.id))) return resultSets.flat() } function getDefaultSessionTitle(studioKind: StudioKind): string { return studioKind === 'plot' ? 'Plot Studio Session' : 'Manim Studio Session' }