From 9ee8b19475331a72bfc22e3e652cad0bab886f34 Mon Sep 17 00:00:00 2001 From: Acbox Date: Thu, 15 Jan 2026 20:10:37 +0800 Subject: [PATCH] feat: container --- packages/agent/src/agent.ts | 81 ++-- packages/agent/src/types.ts | 4 + packages/api/package.json | 1 + packages/api/src/index.ts | 2 + packages/api/src/modules/agent/index.ts | 9 + packages/api/src/modules/agent/service.ts | 25 ++ packages/api/src/modules/container/index.ts | 268 +++++++++++++ packages/api/src/modules/container/model.ts | 62 +++ packages/api/src/modules/container/service.ts | 375 ++++++++++++++++++ packages/api/src/modules/container/utils.ts | 48 +++ packages/api/src/modules/index.ts | 3 +- packages/api/src/modules/user/service.ts | 10 + packages/cli/src/core/agent.ts | 7 + packages/container/README.md | 30 +- packages/container/src/container.ts | 17 +- packages/container/src/containerd.ts | 25 +- packages/container/src/index.ts | 5 +- packages/container/src/nerdctl.ts | 339 ++++++++++++++++ packages/container/src/types.ts | 4 + packages/db/src/container-helpers.ts | 203 ++++++++++ packages/db/src/container.ts | 52 +++ packages/db/src/index.ts | 4 + packages/db/src/schema.ts | 3 +- pnpm-lock.yaml | 3 + 24 files changed, 1527 insertions(+), 53 deletions(-) create mode 100644 packages/api/src/modules/container/index.ts create mode 100644 packages/api/src/modules/container/model.ts create mode 100644 packages/api/src/modules/container/service.ts create mode 100644 packages/api/src/modules/container/utils.ts create mode 100644 packages/container/src/nerdctl.ts create mode 100644 packages/db/src/container-helpers.ts create mode 100644 packages/db/src/container.ts diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 2d7ea15a..2874bed9 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -32,10 +32,14 @@ export const createAgent = (params: AgentParams) => { } }) } else if (connection.type === 'stdio') { + // Build exec command for container execution + const commands = params.onBuildExecCommand?.([connection.command]) ?? [connection.command] + commands.push(...connection.args) + const [command, ...args] = commands return await createMCPClient({ transport: new StdioClientTransport({ - command: connection.command, - args: connection.args, + command, + args, env: connection.env, cwd: connection.cwd, }), @@ -123,35 +127,54 @@ export const createAgent = (params: AgentParams) => { } async function* ask(input: string) { - await loadContext() - const user = { - role: 'user', - content: input, - } as UserModelMessage - messages.push(user) - const { fullStream, response } = streamText({ - model: gateway, - system: getSystemPrompt(), - prepareStep: async () => { - return { - system: getSystemPrompt(), + try { + await loadContext() + const user = { + role: 'user', + content: input, + } as UserModelMessage + messages.push(user) + const { fullStream, response } = streamText({ + model: gateway, + system: getSystemPrompt(), + prepareStep: async () => { + return { + system: getSystemPrompt(), + } + }, + stopWhen: stepCountIs(10), + messages, + tools: await getTools(), + onFinish: async () => { + await onComplete() + }, + }) + for await (const event of fullStream) { + yield event + } + + // Wait for response and save to memory + try { + const newMessages = (await response).messages + await params.onFinish?.([ + user as ModelMessage, + ...newMessages, + ]) + } catch (finishError) { + console.error('Error in onFinish callback:', finishError) + // Yield error event but don't throw - let the stream complete + yield { + type: 'error' as const, + error: finishError instanceof Error ? finishError.message : 'Failed to save conversation' } - }, - stopWhen: stepCountIs(10), - messages, - tools: await getTools(), - onFinish: async () => { - await onComplete() - }, - }) - for await (const event of fullStream) { - yield event + } + } catch (error) { + console.error('Error in agent.ask():', error) + yield { + type: 'error' as const, + error: error instanceof Error ? error.message : 'Unknown error occurred' + } } - const newMessages = (await response).messages - await params.onFinish?.([ - user as ModelMessage, - ...newMessages, - ]) } const triggerSchedule = async (schedule: Schedule) => { diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index ea758577..fa6ed3cc 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -28,6 +28,10 @@ export interface AgentParams { mcpConnections?: MCPConnection[] + onBuildExecCommand?: (command: string[]) => string[] + + onExecCommand?: (command: string[]) => Promise<{ stdout: string, stderr: string, exitCode: number }> + onSendMessage?: (platform: string, options: SendMessageOptions) => Promise onReadMemory?: (from: Date, to: Date) => Promise diff --git a/packages/api/package.json b/packages/api/package.json index 5ed6bf6c..7ad48e8a 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -21,6 +21,7 @@ "@memoh/db": "workspace:*", "@memoh/memory": "workspace:*", "@memoh/shared": "workspace:*", + "@memoh/container": "workspace:*", "@memoh/platform": "workspace:*", "@memoh/platform-telegram": "workspace:*", "drizzle-orm": "^0.45.1", diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 3c7d47b5..f9059f24 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -10,6 +10,7 @@ import { platformModule, memoryModule, mcpModule, + containerModule, } from './modules' import openapi from '@elysiajs/openapi' @@ -28,6 +29,7 @@ export const app = new Elysia() .use(userModule) .use(platformModule) .use(mcpModule) + .use(containerModule) .listen(port) console.log( diff --git a/packages/api/src/modules/agent/index.ts b/packages/api/src/modules/agent/index.ts index 69c5a132..6cba2aa7 100644 --- a/packages/api/src/modules/agent/index.ts +++ b/packages/api/src/modules/agent/index.ts @@ -58,22 +58,31 @@ export const agentModule = new Elysia({ try { const encoder = new TextEncoder() + console.log('📨 Starting agent stream for message:', body.message.substring(0, 50)) + // Send events as they come for await (const event of agent.ask(body.message)) { const data = JSON.stringify(event) controller.enqueue(encoder.encode(`data: ${data}\n\n`)) } + console.log('✅ Agent stream completed successfully') + // Send done event controller.enqueue(encoder.encode('data: [DONE]\n\n')) controller.close() } catch (error) { + console.error('❌ Error in agent stream:', error) const errorMessage = error instanceof Error ? error.message : 'Unknown error' + const errorStack = error instanceof Error ? error.stack : undefined + console.error('Error stack:', errorStack) + const errorData = JSON.stringify({ type: 'error', error: errorMessage }) controller.enqueue(new TextEncoder().encode(`data: ${errorData}\n\n`)) + controller.enqueue(new TextEncoder().encode('data: [DONE]\n\n')) controller.close() } }, diff --git a/packages/api/src/modules/agent/service.ts b/packages/api/src/modules/agent/service.ts index 5c9af174..cf05251c 100644 --- a/packages/api/src/modules/agent/service.ts +++ b/packages/api/src/modules/agent/service.ts @@ -1,9 +1,11 @@ import { createAgent as createAgentService } from '@memoh/agent' import { createMemory, filterByTimestamp, MemoryUnit } from '@memoh/memory' import { ChatModel, EmbeddingModel, Platform, Schedule } from '@memoh/shared' +import { useContainer } from '@memoh/container' import { createSchedule, deleteSchedule, getActiveSchedules } from '../schedule/service' import { getActivePlatforms, sendMessageToPlatform } from '../platform/service' import { getActiveMCPConnections } from '../mcp/service' +import { getUserContainerInfo } from '../container/service' // Type for messages passed to onFinish callback type MessageType = Record @@ -39,7 +41,24 @@ export async function createAgent(params: CreateAgentStreamParams) { const platforms = await getActivePlatforms() const mcpConnections = await getActiveMCPConnections(userId) + const containerInfo = await getUserContainerInfo(userId) + if (!containerInfo) { + throw new Error('Container not found') + } + // Ensure container is running before creating agent + const container = useContainer(containerInfo.containerName, { + namespace: containerInfo.namespace, + }) + + // Check and start container if not running + const info = await container.info() + if (info.status !== 'running') { + console.log(`🚀 Starting container ${containerInfo.containerName} for agent...`) + await container.start() + // Wait a bit for container to be fully ready + await new Promise(resolve => setTimeout(resolve, 2000)) + } // Create agent const agent = createAgentService({ model: chatModel, @@ -96,6 +115,12 @@ export async function createAgent(params: CreateAgentStreamParams) { maxCalls: schedule.maxCalls || undefined, }) }, + onBuildExecCommand(command) { + return container.buildExecCommand(command) + }, + async onExecCommand(command) { + return await container.exec(command) + }, }) return agent diff --git a/packages/api/src/modules/container/index.ts b/packages/api/src/modules/container/index.ts new file mode 100644 index 00000000..f64e5c37 --- /dev/null +++ b/packages/api/src/modules/container/index.ts @@ -0,0 +1,268 @@ +import { Elysia } from 'elysia' +import { adminMiddleware } from '../../middlewares' +import { + createUserContainer, + startUserContainer, + stopUserContainer, + restartUserContainer, + pauseUserContainer, + resumeUserContainer, + deleteUserContainer, + getUserContainerInfo, + ensureUserContainer, + syncAllContainerStatus, + getAllContainers, + startAllAutoStartContainers, + pauseAllContainers, +} from './service' +import { + CreateContainerSchema, + ContainerActionSchema, + EnsureContainerSchema, +} from './model' +import { getUsers } from '../user/service' + +/** + * Container Management Module + * All routes require admin privileges + */ +export const containerModule = new Elysia({ prefix: '/containers' }) + // Protect all routes with admin middleware + .use(adminMiddleware) + .onStart(async () => { + console.log('\n📦 Initializing containers...') + + try { + // 0. 初始化容器基础目录 + const { initializeContainerBaseDirectory } = await import('./utils') + initializeContainerBaseDirectory() + + // 1. 同步所有容器状态 + await syncAllContainerStatus() + + // 2. 检查所有用户是否有容器,没有则创建 + const usersResult = await getUsers({ page: 1, limit: 1000 }) + console.log(`👥 Found ${usersResult.items.length} users`) + + for (const user of usersResult.items) { + try { + await ensureUserContainer(user.id) + console.log(`✅ Container ensured for user: ${user.username}`) + } catch (error) { + console.error(`❌ Failed to ensure container for user ${user.username}:`, error) + } + } + + // 3. 启动所有自动启动的容器 + await startAllAutoStartContainers() + + console.log('✨ Container initialization complete\n') + } catch (error) { + console.error('❌ Container initialization failed:', error) + } + }) + .onStop(async () => { + console.log('\n⏸️ Pausing all containers...') + + try { + await pauseAllContainers() + console.log('✨ All containers paused\n') + } catch (error) { + console.error('❌ Failed to pause containers:', error) + } + }) + .get( + '/', + async () => { + const containers = await getAllContainers() + return { + success: true, + data: containers, + } + }, + { + detail: { + tags: ['Container'], + summary: 'Get all containers', + description: 'Retrieve information about all containers in the system', + }, + } + ) + .get( + '/user/:userId', + async ({ params: { userId } }) => { + const container = await getUserContainerInfo(userId) + if (!container) { + return { + success: false, + error: 'Container not found for user', + } + } + return { + success: true, + data: container, + } + }, + { + detail: { + tags: ['Container'], + summary: 'Get user container', + description: 'Get container information for a specific user', + }, + } + ) + .post( + '/create', + async ({ body }) => { + try { + const container = await createUserContainer( + body.userId, + body.image, + body.namespace + ) + return { + success: true, + data: container, + } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to create container', + } + } + }, + { + body: CreateContainerSchema, + detail: { + tags: ['Container'], + summary: 'Create user container', + description: 'Create a new container for a specified user', + }, + } + ) + .post( + '/user/:userId/ensure', + async ({ params: { userId }, body }) => { + try { + const container = await ensureUserContainer( + userId, + body?.image, + body?.namespace + ) + return { + success: true, + data: container, + } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to ensure container', + } + } + }, + { + body: EnsureContainerSchema, + detail: { + tags: ['Container'], + summary: 'Ensure user has container', + description: 'Check if user has a container, create one if not exists', + }, + } + ) + .post( + '/user/:userId/action', + async ({ params: { userId }, body }) => { + try { + switch (body.action) { + case 'start': + await startUserContainer(userId) + break + case 'stop': + await stopUserContainer(userId) + break + case 'restart': + await restartUserContainer(userId) + break + case 'pause': + await pauseUserContainer(userId) + break + case 'resume': + await resumeUserContainer(userId) + break + default: + return { + success: false, + error: 'Invalid action', + } + } + + return { + success: true, + message: `Container ${body.action} successful`, + } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : `Failed to ${body.action} container`, + } + } + }, + { + body: ContainerActionSchema, + detail: { + tags: ['Container'], + summary: 'Execute container action', + description: 'Perform start, stop, restart, pause, or resume actions on a user container', + }, + } + ) + .delete( + '/user/:userId', + async ({ params: { userId }, query }) => { + try { + const force = query.force === 'true' + await deleteUserContainer(userId, force) + return { + success: true, + message: 'Container deleted successfully', + } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to delete container', + } + } + }, + { + detail: { + tags: ['Container'], + summary: 'Delete user container', + description: 'Delete the container for a specified user', + }, + } + ) + .post( + '/sync', + async () => { + try { + await syncAllContainerStatus() + return { + success: true, + message: 'Container status synced successfully', + } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to sync container status', + } + } + }, + { + detail: { + tags: ['Container'], + summary: 'Sync all container statuses', + description: 'Synchronize all container statuses from containerd to the database', + }, + } + ) + diff --git a/packages/api/src/modules/container/model.ts b/packages/api/src/modules/container/model.ts new file mode 100644 index 00000000..6c8a72f2 --- /dev/null +++ b/packages/api/src/modules/container/model.ts @@ -0,0 +1,62 @@ +import { z } from 'zod' + +/** + * 创建容器请求模型 + */ +export const CreateContainerSchema = z.object({ + userId: z.string(), + image: z.string().optional().default('docker.io/library/alpine:latest'), + namespace: z.string().optional().default('default'), + autoStart: z.boolean().optional().default(true), +}) + +export type CreateContainerInput = z.infer + +/** + * 更新容器请求模型 + */ +export const UpdateContainerSchema = z.object({ + autoStart: z.boolean().optional(), +}) + +export type UpdateContainerInput = z.infer + +/** + * 容器操作请求模型 + */ +export const ContainerActionSchema = z.object({ + action: z.enum(['start', 'stop', 'restart', 'pause', 'resume']), +}) + +export type ContainerActionInput = z.infer + +/** + * 确保容器请求模型 + */ +export const EnsureContainerSchema = z.object({ + image: z.string().optional(), + namespace: z.string().optional(), +}) + +export type EnsureContainerInput = z.infer + +/** + * 容器响应模型 + */ +export const ContainerResponseSchema = z.object({ + id: z.string().uuid(), + userId: z.string().uuid(), + containerId: z.string(), + containerName: z.string(), + image: z.string(), + status: z.string(), + namespace: z.string(), + autoStart: z.boolean(), + createdAt: z.date(), + updatedAt: z.date(), + lastStartedAt: z.date().nullable().optional(), + lastStoppedAt: z.date().nullable().optional(), +}) + +export type ContainerResponse = z.infer + diff --git a/packages/api/src/modules/container/service.ts b/packages/api/src/modules/container/service.ts new file mode 100644 index 00000000..7f6d847a --- /dev/null +++ b/packages/api/src/modules/container/service.ts @@ -0,0 +1,375 @@ +import { + getAllContainers as dbGetAllContainers, + getAutoStartContainers, + getContainerByUserId, + createContainerRecord, + updateContainerStatus, + deleteContainerRecord, + type ContainerInfo, +} from '@memoh/db' +import { createContainer, useContainer, containerExists, type ContainerConfig } from '@memoh/container' +import { getContainerPaths, ensureDirectoryExists } from './utils' + +/** + * 获取所有容器 + */ +export const getAllContainers = async (): Promise => { + return await dbGetAllContainers() +} + +/** + * 为用户创建容器 + */ +export const createUserContainer = async ( + userId: string, + image: string = 'docker.io/library/node:20-alpine', + namespace: string = 'default' +): Promise => { + // 检查用户是否已有容器 + const existing = await getContainerByUserId(userId) + if (existing) { + throw new Error('User already has a container') + } + + const containerName = `user-${userId.slice(0, 8)}-container` + + // 检查 containerd 中是否已存在同名容器 + try { + const exists = await containerExists(containerName, { namespace }) + if (exists) { + console.log(`⚠️ Container ${containerName} already exists in containerd, syncing to database...` ) + + // 获取容器信息并同步到数据库 + const ops = useContainer(containerName, { namespace }) + const info = await ops.info() + + const paths = getContainerPaths(userId) + const dbRecord = await createContainerRecord({ + userId, + containerId: info.id, + containerName: info.name, + image: info.image, + namespace, + autoStart: true, + hostPath: paths.hostPath, + containerPath: paths.containerPath, + }) + + return dbRecord + } + } catch (error) { + console.error('Error checking container existence:', error) + } + + // 获取挂载路径 + const paths = getContainerPaths(userId) + + // 确保宿主机目录存在 + ensureDirectoryExists(paths.hostPath) + + // 创建容器配置 + const config: ContainerConfig = { + name: containerName, + image, + command: ['sh', '-c', 'while true; do sleep 3600; done'], // 保持容器运行 + namespace, + labels: { + userId, + managedBy: 'memoh-api', + }, + mounts: [ + { + type: 'bind', + source: paths.hostPath, + target: paths.containerPath, + readonly: false, + }, + ], + } + + // 在 containerd 中创建容器 + const containerInfo = await createContainer(config, { + namespace, + ctrCommand: process.env.CTR_COMMAND || 'ctr', + }) + + // 在数据库中记录 + const dbRecord = await createContainerRecord({ + userId, + containerId: containerInfo.id, + containerName: containerInfo.name, + image: containerInfo.image, + namespace, + autoStart: true, + hostPath: paths.hostPath, + containerPath: paths.containerPath, + }) + + console.log(`✅ Created container with mount: ${paths.hostPath} -> ${paths.containerPath}`) + + return dbRecord +} + +/** + * 启动用户容器 + */ +export const startUserContainer = async (userId: string): Promise => { + const container = await getContainerByUserId(userId) + if (!container) { + throw new Error('Container not found for user') + } + + const ops = useContainer(container.containerName, { namespace: container.namespace }) + await ops.start() + + // 更新数据库状态 + await updateContainerStatus(container.containerId, 'running') +} + +/** + * 停止用户容器 + */ +export const stopUserContainer = async (userId: string, timeout: number = 10): Promise => { + const container = await getContainerByUserId(userId) + if (!container) { + throw new Error('Container not found for user') + } + + const ops = useContainer(container.containerName, { namespace: container.namespace }) + await ops.stop(timeout) + + // 更新数据库状态 + await updateContainerStatus(container.containerId, 'stopped') +} + +/** + * 重启用户容器 + */ +export const restartUserContainer = async (userId: string): Promise => { + const container = await getContainerByUserId(userId) + if (!container) { + throw new Error('Container not found for user') + } + + const ops = useContainer(container.containerName, { namespace: container.namespace }) + await ops.restart() + + // 更新数据库状态 + await updateContainerStatus(container.containerId, 'running') +} + +/** + * 暂停用户容器 + */ +export const pauseUserContainer = async (userId: string): Promise => { + const container = await getContainerByUserId(userId) + if (!container) { + throw new Error('Container not found for user') + } + + const ops = useContainer(container.containerName, { namespace: container.namespace }) + await ops.pause() + + // 更新数据库状态 + await updateContainerStatus(container.containerId, 'paused') +} + +/** + * 恢复用户容器 + */ +export const resumeUserContainer = async (userId: string): Promise => { + const container = await getContainerByUserId(userId) + if (!container) { + throw new Error('Container not found for user') + } + + const ops = useContainer(container.containerName, { namespace: container.namespace }) + await ops.resume() + + // 更新数据库状态 + await updateContainerStatus(container.containerId, 'running') +} + +/** + * 删除用户容器 + */ +export const deleteUserContainer = async (userId: string, force: boolean = false): Promise => { + const container = await getContainerByUserId(userId) + if (!container) { + throw new Error('Container not found for user') + } + + const ops = useContainer(container.containerName, { namespace: container.namespace }) + await ops.remove(force) + + // 从数据库删除记录 + await deleteContainerRecord(container.id) +} + +/** + * 获取用户容器信息 + */ +export const getUserContainerInfo = async (userId: string): Promise => { + return await getContainerByUserId(userId) +} + +/** + * 启动所有自动启动的容器 + */ +export const startAllAutoStartContainers = async (): Promise<{ success: number; failed: number }> => { + const containers = await getAutoStartContainers() + let success = 0 + let failed = 0 + + console.log(`🚀 Starting ${containers.length} auto-start containers...`) + + for (const container of containers) { + try { + const ops = useContainer(container.containerName, { namespace: container.namespace }) + + // 获取当前状态 + const info = await ops.info() + + // 只有非运行状态才启动 + if (info.status !== 'running') { + await ops.start() + await updateContainerStatus(container.containerId, 'running') + console.log(`✅ Started container: ${container.containerName}`) + success++ + } else { + console.log(`⏭️ Container already running: ${container.containerName}`) + success++ + } + } catch (error) { + console.error(`❌ Failed to start container ${container.containerName}:`, error) + failed++ + // 更新状态为 unknown + await updateContainerStatus(container.containerId, 'unknown') + } + } + + console.log(`✨ Container startup complete: ${success} succeeded, ${failed} failed`) + + return { success, failed } +} + +/** + * 暂停所有运行中的容器 + */ +export const pauseAllContainers = async (): Promise<{ success: number; failed: number }> => { + const containers = await dbGetAllContainers() + let success = 0 + let failed = 0 + + console.log(`⏸️ Pausing ${containers.length} containers...`) + + for (const container of containers) { + try { + const ops = useContainer(container.containerName, { namespace: container.namespace }) + + // 获取当前状态 + const info = await ops.info() + + // 只暂停运行中的容器 + if (info.status === 'running') { + await ops.pause() + await updateContainerStatus(container.containerId, 'paused') + console.log(`✅ Paused container: ${container.containerName}`) + success++ + } else { + console.log(`⏭️ Container not running, skipped: ${container.containerName}`) + success++ + } + } catch (error) { + console.error(`❌ Failed to pause container ${container.containerName}:`, error) + failed++ + } + } + + console.log(`✨ Container pause complete: ${success} succeeded, ${failed} failed`) + + return { success, failed } +} + +/** + * 停止所有运行中的容器 + */ +export const stopAllContainers = async (timeout: number = 10): Promise<{ success: number; failed: number }> => { + const containers = await dbGetAllContainers() + let success = 0 + let failed = 0 + + console.log(`⏹️ Stopping ${containers.length} containers...`) + + for (const container of containers) { + try { + const ops = useContainer(container.containerName, { namespace: container.namespace }) + + // 获取当前状态 + const info = await ops.info() + + // 只停止运行中的容器 + if (info.status === 'running') { + await ops.stop(timeout) + await updateContainerStatus(container.containerId, 'stopped') + console.log(`✅ Stopped container: ${container.containerName}`) + success++ + } else { + console.log(`⏭️ Container not running, skipped: ${container.containerName}`) + success++ + } + } catch (error) { + console.error(`❌ Failed to stop container ${container.containerName}:`, error) + failed++ + } + } + + console.log(`✨ Container stop complete: ${success} succeeded, ${failed} failed`) + + return { success, failed } +} + +/** + * 确保用户有容器(没有则创建) + */ +export const ensureUserContainer = async ( + userId: string, + image?: string, + namespace?: string +): Promise => { + const existing = await getContainerByUserId(userId) + + if (existing) { + return existing + } + + // 创建新容器 + return await createUserContainer(userId, image, namespace) +} + +/** + * 同步所有容器状态 + */ +export const syncAllContainerStatus = async (): Promise => { + const containers = await dbGetAllContainers() + + console.log(`🔄 Syncing ${containers.length} container statuses...`) + + for (const container of containers) { + try { + const ops = useContainer(container.containerName, { namespace: container.namespace }) + const info = await ops.info() + + if (info.status !== container.status) { + await updateContainerStatus(container.containerId, info.status) + console.log(`✅ Updated container ${container.containerName}: ${container.status} -> ${info.status}`) + } + } catch (error) { + console.error(`❌ Failed to sync container ${container.containerName}:`, error) + await updateContainerStatus(container.containerId, 'unknown') + } + } + + console.log('✨ Container status sync complete') +} + diff --git a/packages/api/src/modules/container/utils.ts b/packages/api/src/modules/container/utils.ts new file mode 100644 index 00000000..365446e8 --- /dev/null +++ b/packages/api/src/modules/container/utils.ts @@ -0,0 +1,48 @@ +import { existsSync, mkdirSync } from 'fs' +import { join } from 'path' + +/** + * Container data directory configuration + */ +const CONTAINER_BASE_DIR = process.env.CONTAINER_DATA_DIR || '/var/lib/memoh/containers' + +/** + * Get host path for user container + * @param userId - User ID + * @returns Host path for the container + */ +export function getUserContainerHostPath(userId: string): string { + return join(CONTAINER_BASE_DIR, userId) +} + +/** + * Ensure directory exists, create if not + * @param path - Directory path + */ +export function ensureDirectoryExists(path: string): void { + if (!existsSync(path)) { + mkdirSync(path, { recursive: true, mode: 0o755 }) + console.log(`📁 Created directory: ${path}`) + } +} + +/** + * Initialize container base directory + */ +export function initializeContainerBaseDirectory(): void { + ensureDirectoryExists(CONTAINER_BASE_DIR) + console.log(`✅ Container base directory initialized: ${CONTAINER_BASE_DIR}`) +} + +/** + * Get container paths for a user + * @param userId - User ID + * @returns Object with host and container paths + */ +export function getContainerPaths(userId: string) { + return { + hostPath: getUserContainerHostPath(userId), + containerPath: '/data', + } +} + diff --git a/packages/api/src/modules/index.ts b/packages/api/src/modules/index.ts index 9a20a6cd..404191ca 100644 --- a/packages/api/src/modules/index.ts +++ b/packages/api/src/modules/index.ts @@ -7,4 +7,5 @@ export * from './user' export * from './mcp' export * from './platform' export * from './schedule' -export * from './memory' \ No newline at end of file +export * from './memory' +export * from './container' \ No newline at end of file diff --git a/packages/api/src/modules/user/service.ts b/packages/api/src/modules/user/service.ts index 0a659631..cb4fdf54 100644 --- a/packages/api/src/modules/user/service.ts +++ b/packages/api/src/modules/user/service.ts @@ -157,6 +157,16 @@ export const createUser = async (data: CreateUserInput) => { language: 'Same as user input', }) + // 自动创建用户的容器 + try { + const { createUserContainer } = await import('../container/service') + await createUserContainer(newUser.id) + console.log(`✅ Container created for user: ${newUser.username}`) + } catch (error) { + console.error(`❌ Failed to create container for user ${newUser.username}:`, error) + // 不阻塞用户创建,容器可以后续创建 + } + return newUser } diff --git a/packages/cli/src/core/agent.ts b/packages/cli/src/core/agent.ts index 6f272c83..960feede 100644 --- a/packages/cli/src/core/agent.ts +++ b/packages/cli/src/core/agent.ts @@ -83,6 +83,7 @@ async function performStreamChat( } let buffer = '' + let receivedDone = false while (true) { const { done, value } = await reader.read() @@ -99,6 +100,7 @@ async function performStreamChat( const data = line.slice(6).trim() if (data === '[DONE]') { + receivedDone = true await onEvent({ type: 'done' }) return } @@ -119,6 +121,11 @@ async function performStreamChat( } } } + + // If stream ended without [DONE], it's an error + if (!receivedDone) { + throw new Error('Connection closed unexpectedly - stream ended without completion signal') + } } /** diff --git a/packages/container/README.md b/packages/container/README.md index b6965d29..2219baaa 100644 --- a/packages/container/README.md +++ b/packages/container/README.md @@ -1,10 +1,10 @@ # @memoh/container -基于 containerd 的容器化工具包,提供简单易用的容器管理 API。 +基于 nerdctl (containerd) 的容器化工具包,提供简单易用的容器管理 API。 ## 特性 -- 🚀 基于 containerd 的高性能容器管理 +- 🚀 基于 nerdctl 的现代容器管理(Docker 兼容) - 📦 简洁的 API 设计 - 🔧 完整的容器生命周期管理 - 📝 TypeScript 支持 @@ -18,19 +18,31 @@ pnpm install @memoh/container ## 前置要求 -系统需要安装 containerd 和 ctr 命令行工具: +### macOS (推荐使用 Lima) ```bash -# macOS (使用 Homebrew) -brew install containerd +# 安装 Lima +brew install lima -# Ubuntu/Debian -apt-get install containerd +# 启动 Lima(已包含 nerdctl) +limactl start -# 启动 containerd 服务 -sudo systemctl start containerd +# 验证 +lima nerdctl version ``` +### Linux + +```bash +# 安装 nerdctl +# 参考: https://github.com/containerd/nerdctl/releases + +# 或使用包管理器 +brew install nerdctl # Homebrew on Linux +``` + +**详细 macOS 配置请参考 [NERDCTL_SETUP.md](./NERDCTL_SETUP.md)** + ## 快速开始 ### 创建容器 diff --git a/packages/container/src/container.ts b/packages/container/src/container.ts index 9e60a25c..afc551fe 100644 --- a/packages/container/src/container.ts +++ b/packages/container/src/container.ts @@ -2,7 +2,7 @@ * High-level container management API */ -import { ContainerdClient } from './containerd' +import { NerdctlClient } from './nerdctl' import type { ContainerConfig, ContainerInfo, @@ -34,7 +34,7 @@ export async function createContainer( config: ContainerConfig, options?: ContainerdOptions ): Promise { - const client = new ContainerdClient(options) + const client = new NerdctlClient(options) // Ensure image is pulled await client.pullImage(config.image) @@ -76,7 +76,7 @@ export function useContainer( containerIdOrName: string, options?: ContainerdOptions ): ContainerOperations { - const client = new ContainerdClient(options) + const client = new NerdctlClient(options) const containerName = containerIdOrName return { @@ -176,6 +176,11 @@ export function useContainer( }, } }, + + buildExecCommand(command: string[]): string[] { + // nerdctl exec with -i to keep STDIN open for MCP servers + return [...client.nerdctlCommand, 'exec', '-i', containerName, ...command] + } } } @@ -194,7 +199,7 @@ export function useContainer( * ``` */ export async function listContainers(options?: ContainerdOptions): Promise { - const client = new ContainerdClient(options) + const client = new NerdctlClient(options) return await client.listContainers() } @@ -216,7 +221,7 @@ export async function containerExists( containerIdOrName: string, options?: ContainerdOptions ): Promise { - const client = new ContainerdClient(options) + const client = new NerdctlClient(options) return await client.containerExists(containerIdOrName) } @@ -236,7 +241,7 @@ export async function removeAllContainers( force: boolean = false, options?: ContainerdOptions ): Promise { - const client = new ContainerdClient(options) + const client = new NerdctlClient(options) const containers = await client.listContainers() for (const container of containers) { diff --git a/packages/container/src/containerd.ts b/packages/container/src/containerd.ts index 698ab040..654f686b 100644 --- a/packages/container/src/containerd.ts +++ b/packages/container/src/containerd.ts @@ -5,7 +5,6 @@ import { execa } from 'execa' import type { ContainerConfig, ContainerInfo, ContainerStatus, ContainerdOptions } from './types' -export const buildExecCommand = (name: string, command: string[]) => ['task', 'exec', '--exec-id', `exec-${Date.now()}`, name, ...command] /** * Containerd client for managing containers @@ -14,18 +13,25 @@ export class ContainerdClient { private namespace: string private socket?: string private timeout: number + private ctrCommand: string constructor(options: ContainerdOptions = {}) { this.namespace = options.namespace || 'default' - this.socket = options.socket + this.socket = options.socket || process.env.CONTAINERD_SOCKET this.timeout = options.timeout || 30000 + this.ctrCommand = options.ctrCommand || process.env.CTR_COMMAND || 'ctr' + } + + buildExecCommand(name: string, command: string[]): string[] { + return this.buildCtrCommand(['task', 'exec', '--exec-id', `exec-${Date.now()}`, name, ...command]) } /** * Build ctr command with options */ - private buildCtrCommand(args: string[]): string[] { - const cmd = ['ctr'] + buildCtrCommand(args: string[]): string[] { + // Split ctrCommand and filter out empty strings (supports "lima sudo ctr") + const cmd = this.ctrCommand.split(' ').filter(part => part.length > 0) if (this.socket) { cmd.push('--address', this.socket) @@ -72,6 +78,15 @@ export class ContainerdClient { async createContainer(config: ContainerConfig): Promise { const args = ['container', 'create'] + // Add mounts if specified + if (config.mounts && config.mounts.length > 0) { + for (const mount of config.mounts) { + // ctr uses 'src' and 'dst' instead of 'source' and 'target' + const mountStr = `type=${mount.type},src=${mount.source},dst=${mount.target}${mount.readonly ? ',readonly' : ''}` + args.push('--mount', mountStr) + } + } + // Add image args.push(config.image) @@ -155,7 +170,7 @@ export class ContainerdClient { * Execute command in container */ async execInContainer(name: string, command: string[]): Promise<{ stdout: string; stderr: string; exitCode: number }> { - const args = buildExecCommand(name, command) + const args = this.buildExecCommand(name, command) try { const result = await this.exec(args) diff --git a/packages/container/src/index.ts b/packages/container/src/index.ts index 3805c7e4..35535eb7 100644 --- a/packages/container/src/index.ts +++ b/packages/container/src/index.ts @@ -11,8 +11,9 @@ export { removeAllContainers, } from './container' -// Export client -export { ContainerdClient, buildExecCommand } from './containerd' +// Export clients +export { ContainerdClient } from './containerd' +export { NerdctlClient } from './nerdctl' // Export types export type { diff --git a/packages/container/src/nerdctl.ts b/packages/container/src/nerdctl.ts new file mode 100644 index 00000000..b50e711f --- /dev/null +++ b/packages/container/src/nerdctl.ts @@ -0,0 +1,339 @@ +/** + * Nerdctl client implementation (Docker-compatible CLI for containerd) + */ + +import { execa } from 'execa' +import type { ContainerConfig, ContainerInfo, ContainerStatus, ContainerdOptions } from './types' + +/** + * Nerdctl client for managing containers + * Provides a Docker-like interface for containerd + */ +export class NerdctlClient { + private namespace: string + private socket?: string + private timeout: number + nerdctlCommand: string[] + + constructor(options: ContainerdOptions = {}) { + this.namespace = options.namespace || 'default' + this.socket = options.socket || process.env.CONTAINERD_SOCKET + this.timeout = options.timeout || 30000 + // Support commands like "lima nerdctl" + const rawCommand = options.ctrCommand || process.env.CTR_COMMAND || 'nerdctl' + this.nerdctlCommand = rawCommand.split(' ').filter(part => part.length > 0) + } + + /** + * Build nerdctl command with global options + */ + private buildCommand(args: string[]): string[] { + // Split command to support "lima nerdctl" + const cmd = [...this.nerdctlCommand] + + // Add global options before the subcommand + if (this.socket) { + cmd.push('--address', this.socket) + } + + cmd.push('--namespace', this.namespace) + cmd.push(...args) + + return cmd + } + + /** + * Execute nerdctl command + */ + private async exec(args: string[]): Promise<{ stdout: string; stderr: string }> { + const cmd = this.buildCommand(args) + const [program, ...programArgs] = cmd + + try { + const result = await execa(program, programArgs, { + timeout: this.timeout, + }) + + return { + stdout: result.stdout, + stderr: result.stderr, + } + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error) + throw new Error(`Nerdctl command failed: ${message}`) + } + } + + /** + * Pull container image + */ + async pullImage(image: string): Promise { + await this.exec(['pull', image]) + } + + /** + * Create a new container + */ + async createContainer(config: ContainerConfig): Promise { + const args = ['container', 'create'] + + // Add container name + args.push('--name', config.name) + + // Add environment variables + if (config.env) { + for (const [key, value] of Object.entries(config.env)) { + args.push('--env', `${key}=${value}`) + } + } + + // Add working directory + if (config.workingDir) { + args.push('--workdir', config.workingDir) + } + + // Add labels + if (config.labels) { + for (const [key, value] of Object.entries(config.labels)) { + args.push('--label', `${key}=${value}`) + } + } + + // Add mounts (nerdctl uses Docker-style mount syntax) + if (config.mounts && config.mounts.length > 0) { + for (const mount of config.mounts) { + let mountStr = `type=${mount.type},src=${mount.source},dst=${mount.target}` + if (mount.readonly) { + mountStr += ',readonly' + } + args.push('--mount', mountStr) + } + } + + // Add image + args.push(config.image) + + // Add command if specified + if (config.command && config.command.length > 0) { + args.push(...config.command) + } + + await this.exec(args) + + // Return container info + return this.getContainerInfo(config.name) + } + + /** + * Start a container + */ + async startContainer(name: string): Promise { + // Check container status and handle accordingly + try { + const status = await this.getContainerStatus(name) + + if (status === 'running') { + console.log(`Container ${name} is already running`) + return + } + + if (status === 'paused') { + console.log(`Container ${name} is paused, unpausing first...`) + await this.exec(['unpause', name]) + return + } + + // For 'created' or 'stopped' status, we can start + } catch { + // Container might not exist, let start command handle it + } + + await this.exec(['start', name]) + } + + /** + * Stop a container + */ + async stopContainer(name: string, timeout: number = 10): Promise { + // Check if container is running + try { + const status = await this.getContainerStatus(name) + if (status !== 'running') { + console.log(`Container ${name} is not running (status: ${status})`) + return + } + } catch { + // Container might not exist, let stop command handle it + } + + await this.exec(['stop', '--time', timeout.toString(), name]) + } + + /** + * Pause a container + */ + async pauseContainer(name: string): Promise { + // Check if container is running before pausing + const status = await this.getContainerStatus(name) + if (status !== 'running') { + console.log(`Container ${name} cannot be paused (status: ${status})`) + return + } + + await this.exec(['pause', name]) + } + + /** + * Resume a paused container + */ + async resumeContainer(name: string): Promise { + // Check if container is paused before resuming + const status = await this.getContainerStatus(name) + if (status !== 'paused') { + console.log(`Container ${name} is not paused (status: ${status})`) + return + } + + await this.exec(['unpause', name]) + } + + /** + * Remove a container + */ + async removeContainer(name: string, force: boolean = false): Promise { + const args = ['rm'] + if (force) { + args.push('--force') + } + args.push(name) + await this.exec(args) + } + + /** + * Execute command in container + */ + async execInContainer(name: string, command: string[]): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const args = ['exec', name, ...command] + + try { + const result = await this.exec(args) + return { + stdout: result.stdout, + stderr: result.stderr, + exitCode: 0, + } + } catch (error: unknown) { + const err = error as { stdout?: string; stderr?: string; exitCode?: number; message?: string } + return { + stdout: err.stdout || '', + stderr: err.stderr || err.message || '', + exitCode: err.exitCode || 1, + } + } + } + + /** + * Get container information + */ + async getContainerInfo(name: string): Promise { + const result = await this.exec(['inspect', name]) + + try { + const data = JSON.parse(result.stdout) + const info = Array.isArray(data) ? data[0] : data + + // Parse nerdctl inspect output (similar to Docker) + return { + id: info.Id || name, + name: info.Name?.replace(/^\//, '') || name, + image: info.Config?.Image || info.Image || '', + status: this.parseStatus(info.State), + namespace: this.namespace, + createdAt: info.Created ? new Date(info.Created) : new Date(), + labels: info.Config?.Labels || {}, + } + } catch { + // Fallback if JSON parsing fails + return { + id: name, + name: name, + image: '', + status: 'unknown', + namespace: this.namespace, + createdAt: new Date(), + } + } + } + + /** + * Parse container status from inspect output + */ + private parseStatus(state: unknown): ContainerStatus { + const s = state as { Running?: boolean; Paused?: boolean; Status?: string; Dead?: boolean } + if (!s) return 'unknown' + + if (s.Running) return 'running' + if (s.Paused) return 'paused' + if (s.Status === 'created') return 'created' + if (s.Status === 'exited' || s.Dead) return 'stopped' + + return 'unknown' + } + + /** + * Get container status + */ + async getContainerStatus(name: string): Promise { + try { + const info = await this.getContainerInfo(name) + return info.status + } catch { + return 'unknown' + } + } + + /** + * Get container logs + */ + async getContainerLogs(name: string): Promise { + try { + const result = await this.exec(['logs', name]) + return result.stdout + } catch (error: unknown) { + return error instanceof Error ? error.message : '' + } + } + + /** + * List all containers + */ + async listContainers(): Promise { + const result = await this.exec(['ps', '--all', '--format', '{{.Names}}']) + const containerNames = result.stdout.split('\n').filter(name => name.trim()) + + const containers: ContainerInfo[] = [] + for (const name of containerNames) { + try { + const info = await this.getContainerInfo(name) + containers.push(info) + } catch { + // Skip containers that can't be accessed + } + } + + return containers + } + + /** + * Check if container exists + */ + async containerExists(name: string): Promise { + try { + await this.getContainerInfo(name) + return true + } catch { + return false + } + } +} + diff --git a/packages/container/src/types.ts b/packages/container/src/types.ts index 10fce477..4601013b 100644 --- a/packages/container/src/types.ts +++ b/packages/container/src/types.ts @@ -98,6 +98,8 @@ export interface ContainerStats { * Container operations interface */ export interface ContainerOperations { + /** Build exec command */ + buildExecCommand(command: string[]): string[]; /** Start the container */ start(): Promise; /** Stop the container */ @@ -130,5 +132,7 @@ export interface ContainerdOptions { namespace?: string; /** Timeout for operations (ms) */ timeout?: number; + /** ctr command */ + ctrCommand?: string; } diff --git a/packages/db/src/container-helpers.ts b/packages/db/src/container-helpers.ts new file mode 100644 index 00000000..4659d437 --- /dev/null +++ b/packages/db/src/container-helpers.ts @@ -0,0 +1,203 @@ +import { db } from './index' +import { containers } from './container' +import { eq } from 'drizzle-orm' + +/** + * 容器信息类型 + */ +export type ContainerInfo = { + id: string + userId: string + containerId: string + containerName: string + image: string + status: string + namespace: string + autoStart: boolean + hostPath: string | null + containerPath: string + createdAt: Date + updatedAt: Date + lastStartedAt: Date | null + lastStoppedAt: Date | null +} + +/** + * 创建容器输入类型 + */ +export type CreateContainerInput = { + userId: string + containerId: string + containerName: string + image: string + namespace?: string + autoStart?: boolean + hostPath?: string + containerPath?: string +} + +/** + * 更新容器输入类型 + */ +export type UpdateContainerInput = { + status?: string + autoStart?: boolean + lastStartedAt?: Date + lastStoppedAt?: Date +} + +/** + * 获取所有容器 + */ +export const getAllContainers = async (): Promise => { + const containerList = await db.select().from(containers) + return containerList +} + +/** + * 获取所有自动启动的容器 + */ +export const getAutoStartContainers = async (): Promise => { + const containerList = await db + .select() + .from(containers) + .where(eq(containers.autoStart, true)) + return containerList +} + +/** + * 根据用户ID获取容器 + */ +export const getContainerByUserId = async (userId: string): Promise => { + const [container] = await db + .select() + .from(containers) + .where(eq(containers.userId, userId)) + + return container +} + +/** + * 根据容器名称获取容器 + */ +export const getContainerByName = async (containerName: string): Promise => { + const [container] = await db + .select() + .from(containers) + .where(eq(containers.containerName, containerName)) + + return container +} + +/** + * 根据容器ID获取容器 + */ +export const getContainerById = async (id: string): Promise => { + const [container] = await db + .select() + .from(containers) + .where(eq(containers.id, id)) + + return container +} + +/** + * 创建容器记录 + */ +export const createContainerRecord = async (data: CreateContainerInput): Promise => { + // 检查用户是否已有容器 + const existing = await getContainerByUserId(data.userId) + if (existing) { + throw new Error('User already has a container') + } + + const [newContainer] = await db + .insert(containers) + .values({ + userId: data.userId, + containerId: data.containerId, + containerName: data.containerName, + image: data.image, + namespace: data.namespace || 'default', + autoStart: data.autoStart ?? true, + hostPath: data.hostPath || null, + containerPath: data.containerPath || '/data', + status: 'created', + }) + .returning() + + return newContainer +} + +/** + * 更新容器状态 + */ +export const updateContainerStatus = async ( + containerId: string, + status: string +): Promise => { + const [updated] = await db + .update(containers) + .set({ + status, + updatedAt: new Date(), + ...(status === 'running' ? { lastStartedAt: new Date() } : {}), + ...(status === 'stopped' || status === 'paused' ? { lastStoppedAt: new Date() } : {}), + }) + .where(eq(containers.containerId, containerId)) + .returning() + + return updated || null +} + +/** + * 更新容器信息 + */ +export const updateContainer = async ( + id: string, + data: UpdateContainerInput +): Promise => { + const [updated] = await db + .update(containers) + .set({ + ...data, + updatedAt: new Date(), + }) + .where(eq(containers.id, id)) + .returning() + + return updated || null +} + +/** + * 删除容器记录 + */ +export const deleteContainerRecord = async (id: string): Promise => { + const [deleted] = await db + .delete(containers) + .where(eq(containers.id, id)) + .returning() + + return deleted || null +} + +/** + * 根据用户ID删除容器记录 + */ +export const deleteContainerByUserId = async (userId: string): Promise => { + const [deleted] = await db + .delete(containers) + .where(eq(containers.userId, userId)) + .returning() + + return deleted || null +} + +/** + * 检查用户是否有容器 + */ +export const userHasContainer = async (userId: string): Promise => { + const container = await getContainerByUserId(userId) + return !!container +} + diff --git a/packages/db/src/container.ts b/packages/db/src/container.ts new file mode 100644 index 00000000..2b19e463 --- /dev/null +++ b/packages/db/src/container.ts @@ -0,0 +1,52 @@ +import { pgTable, text, timestamp, uuid, boolean } from 'drizzle-orm/pg-core' +import { users } from './users' + +/** + * 容器表 - 存储用户容器信息 + */ +export const containers = pgTable('containers', { + // 主键ID + id: uuid('id').primaryKey().defaultRandom(), + + // 关联用户ID + userId: uuid('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + + // 容器ID(containerd 中的实际容器ID) + containerId: text('container_id').notNull().unique(), + + // 容器名称 + containerName: text('container_name').notNull().unique(), + + // 容器镜像 + image: text('image').notNull(), + + // 容器状态:created, running, paused, stopped, unknown + status: text('status').notNull().default('created'), + + // 容器命名空间 + namespace: text('namespace').notNull().default('default'), + + // 是否自动启动 + autoStart: boolean('auto_start').notNull().default(true), + + // 宿主机挂载目录 + hostPath: text('host_path'), + + // 容器内挂载目录 + containerPath: text('container_path').notNull().default('/data'), + + // 创建时间 + createdAt: timestamp('created_at').notNull().defaultNow(), + + // 更新时间 + updatedAt: timestamp('updated_at').notNull().defaultNow(), + + // 最后启动时间 + lastStartedAt: timestamp('last_started_at'), + + // 最后停止时间 + lastStoppedAt: timestamp('last_stopped_at'), +}) + diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index dda4888c..71559fea 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -4,3 +4,7 @@ import { config } from 'dotenv' config({ path: '../../' }) export const db = drizzle(process.env.DATABASE_URL!) + +// Export helpers +export * from './user-helpers' +export * from './container-helpers' diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index a7610079..87bd2e93 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -4,4 +4,5 @@ export * from './settings' export * from './schedule' export * from './users' export * from './platform' -export * from './mcp-connection' \ No newline at end of file +export * from './mcp-connection' +export * from './container' \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eb578514..08e1f3a9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -126,6 +126,9 @@ importers: '@memoh/agent': specifier: workspace:* version: link:../agent + '@memoh/container': + specifier: workspace:* + version: link:../container '@memoh/db': specifier: workspace:* version: link:../db