From cca158da1eff0f19b43969a8643cd9e327dd30c2 Mon Sep 17 00:00:00 2001 From: kang Date: Sun, 19 Apr 2026 22:34:43 +0800 Subject: [PATCH] auto-save 2026-04-19 22:34 (+2, ~2) --- .memory/worklog.json | 7 ++ orchestrator/src/admin.ts | 162 +++++++++++++++++++++++++++++++++++++ orchestrator/src/env.ts | 1 + orchestrator/src/events.ts | 72 +++++++++++++++++ 4 files changed, 242 insertions(+) create mode 100644 orchestrator/src/admin.ts create mode 100644 orchestrator/src/events.ts diff --git a/.memory/worklog.json b/.memory/worklog.json index c4ebbbd..c3d4f32 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -279,6 +279,13 @@ "message": "auto-save 2026-04-19 22:23 (~1)", "hash": "76e1682", "files_changed": 1 + }, + { + "ts": "2026-04-19T22:29:14+08:00", + "type": "commit", + "message": "auto-save 2026-04-19 22:29 (~1)", + "hash": "119afa3", + "files_changed": 1 } ] } diff --git a/orchestrator/src/admin.ts b/orchestrator/src/admin.ts new file mode 100644 index 0000000..cf43e7d --- /dev/null +++ b/orchestrator/src/admin.ts @@ -0,0 +1,162 @@ +import { Hono } from 'hono'; +import { streamSSE } from 'hono/streaming'; +import { env } from './env.ts'; +import { getRecentEvents, onEvent } from './events.ts'; +import { containerName, incus } from './incus.ts'; + +export const admin = new Hono(); + +// 鉴权中间件 — token 走 query(首屏 iframe/跳转友好)或 header +admin.use('*', async (c, next) => { + if (!env.adminToken) return c.text('Admin UI disabled: set ADMIN_TOKEN env', 503); + const token = c.req.query('token') ?? c.req.header('Admin-Token'); + if (token !== env.adminToken) return c.text('Unauthorized', 401); + await next(); +}); + +// 全局用户列表 +admin.get('/api/users', async (c) => { + const r = await Bun.spawn( + ['incus', 'list', '--project', env.incus.project, '--format', 'json'], + { stdout: 'pipe' }, + ); + const raw = await new Response(r.stdout).text(); + await r.exited; + const instances = JSON.parse(raw || '[]') as any[]; + const users = instances.map((inst) => ({ + name: inst.name as string, + userId: inst.name.replace(new RegExp(`^${env.incus.prefix}`), ''), + state: inst.state?.status as string, + ipv4: + inst.state?.network?.eth0?.addresses?.find((a: any) => a.family === 'inet')?.address ?? '', + memory: inst.state?.memory?.usage ?? 0, + disk: inst.state?.disk?.root?.usage ?? 0, + cpuSeconds: inst.state?.cpu?.usage ?? 0, + lastUsedAt: inst.last_used_at as string, + })); + return c.json({ users }); +}); + +// 单用户 detail +admin.get('/api/user/:userId', async (c) => { + const userId = c.req.param('userId'); + const name = containerName(userId); + const s = await incus.state(name); + return c.json({ userId, containerName: name, state: s }); +}); + +// 文件树(递归平铺) +admin.get('/api/files', async (c) => { + const userId = c.req.query('userId'); + if (!userId) return c.json({ error: 'userId required' }, 400); + const name = containerName(userId); + const s = await incus.state(name); + if (s === 'MISSING') return c.json({ files: [], error: 'no sandbox' }); + if (s === 'STOPPED') return c.json({ files: [], note: 'sandbox stopped — start to view files' }); + // 递归 + 安全限制:最多 500 个文件,排除 venv 里的包 + const r = await incus.exec( + name, + [ + 'bash', + '-c', + `find /workspace -type f -not -path '*/.venv/lib/*' -not -path '*/node_modules/*' -printf '%T@\\t%s\\t%p\\n' 2>/dev/null | head -500`, + ], + { user: 1000 }, + ); + const files = r.stdout + .split('\n') + .filter(Boolean) + .map((line) => { + const [ts, size, path] = line.split('\t'); + return { + path, + size: Number(size), + mtime: Math.floor(Number(ts) * 1000), + }; + }); + return c.json({ files }); +}); + +// 文件内容(小文件 text / 大文件截断) +admin.get('/api/file', async (c) => { + const userId = c.req.query('userId'); + const path = c.req.query('path'); + if (!userId || !path) return c.json({ error: 'userId + path required' }, 400); + const name = containerName(userId); + // 安全:限制 /workspace 下,最大 2MB + if (!path.startsWith('/workspace')) return c.json({ error: 'path must be under /workspace' }, 403); + try { + const bytes = await incus.pull(name, path); + if (bytes.byteLength > 2 * 1024 * 1024) { + return c.json({ error: 'file too large (>2MB), download only' }, 413); + } + const ext = path.slice(path.lastIndexOf('.')).toLowerCase(); + const isImage = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'].includes(ext); + if (isImage) { + return new Response(bytes, { + headers: { 'Content-Type': mimeFor(ext) }, + }); + } + // 文本返回 — 让前端自己渲染 + return new Response(bytes, { + headers: { 'Content-Type': 'text/plain; charset=utf-8' }, + }); + } catch (e) { + return c.json({ error: (e as Error).message }, 500); + } +}); + +// 手动 start/stop +admin.post('/api/user/:userId/start', async (c) => { + try { + await incus.start(containerName(c.req.param('userId'))); + return c.json({ ok: true }); + } catch (e) { + return c.json({ error: (e as Error).message }, 500); + } +}); +admin.post('/api/user/:userId/stop', async (c) => { + try { + await incus.stop(containerName(c.req.param('userId'))); + return c.json({ ok: true }); + } catch (e) { + return c.json({ error: (e as Error).message }, 500); + } +}); + +// SSE 实时事件流 +admin.get('/api/events', (c) => { + const userId = c.req.query('userId') ?? undefined; + return streamSSE(c, async (stream) => { + // 回放最近事件 + for (const e of getRecentEvents(userId)) { + await stream.writeSSE({ data: JSON.stringify(e), event: 'event' }); + } + let writing: Promise = Promise.resolve(); + const off = onEvent((e) => { + if (userId && e.userId !== userId) return; + writing = writing.then(() => + stream.writeSSE({ data: JSON.stringify(e), event: 'event' }).catch(() => {}), + ); + }); + try { + // keepalive + while (!stream.aborted) { + await stream.sleep(30_000); + await stream.writeSSE({ data: String(Date.now()), event: 'ping' }).catch(() => {}); + } + } finally { + off(); + } + }); +}); + +const mimeFor = (ext: string): string => + ({ + '.gif': 'image/gif', + '.jpeg': 'image/jpeg', + '.jpg': 'image/jpeg', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.webp': 'image/webp', + })[ext] ?? 'application/octet-stream'; diff --git a/orchestrator/src/env.ts b/orchestrator/src/env.ts index dc28465..cb16960 100644 --- a/orchestrator/src/env.ts +++ b/orchestrator/src/env.ts @@ -14,6 +14,7 @@ export const env = { host: process.env.HOST ?? '127.0.0.1', orchSecret: required('SANDBOX_ORCH_SECRET'), + adminToken: process.env.ADMIN_TOKEN ?? '', incus: { project: process.env.INCUS_PROJECT ?? 'lobe-sandbox', diff --git a/orchestrator/src/events.ts b/orchestrator/src/events.ts new file mode 100644 index 0000000..7312b69 --- /dev/null +++ b/orchestrator/src/events.ts @@ -0,0 +1,72 @@ +// Sandbox 活动事件总线 — 供 Manus-style 视图 SSE 订阅 + +export interface SandboxEvent { + id: string; + ts: number; + userId: string; + toolName: string; + params: unknown; + success: boolean; + durationMs: number; + summary?: string; +} + +const listeners = new Set<(e: SandboxEvent) => void>(); +const recent: SandboxEvent[] = []; +const MAX_RECENT = 500; + +export const emitEvent = (e: Omit): SandboxEvent => { + const event: SandboxEvent = { ...e, id: `${e.ts}-${Math.random().toString(36).slice(2, 8)}` }; + recent.push(event); + if (recent.length > MAX_RECENT) recent.shift(); + for (const fn of listeners) { + try { + fn(event); + } catch {} + } + return event; +}; + +export const onEvent = (fn: (e: SandboxEvent) => void): (() => void) => { + listeners.add(fn); + return () => listeners.delete(fn); +}; + +export const getRecentEvents = (userId?: string, limit = 100): SandboxEvent[] => { + const filtered = userId ? recent.filter((e) => e.userId === userId) : recent; + return filtered.slice(-limit); +}; + +// 把工具结果压缩成一句话摘要(UI 好读) +export const summarizeToolResult = (toolName: string, params: any, result: any): string => { + if (!result.success) return `❌ ${result.error?.message ?? 'failed'}`; + const r = result.result as any; + switch (toolName) { + case 'executeCode': + return r?.exitCode === 0 + ? `✓ ${params?.language} ${(r?.stdout ?? '').slice(0, 80)}` + : `exit=${r?.exitCode} ${(r?.stderr ?? '').slice(0, 80)}`; + case 'runCommand': + return `$ ${String(params?.command ?? '').slice(0, 60)}`; + case 'writeLocalFile': + return `${r?.bytes ?? 0}B → ${params?.filePath ?? '?'}`; + case 'readLocalFile': + return `${params?.filePath ?? '?'} (${(r?.content ?? '').length}B)`; + case 'listLocalFiles': + return `ls ${params?.directoryPath ?? '/'}`; + case 'editLocalFile': + return `edit ${params?.filePath ?? '?'}`; + case 'moveLocalFiles': + return `mv ${(params?.sources ?? []).length} → ${params?.destination ?? '?'}`; + case 'renameLocalFile': + return `mv ${params?.oldPath} → ${params?.newPath}`; + case 'searchLocalFiles': + return `find ${params?.pattern}`; + case 'grepContent': + return `rg ${params?.pattern}`; + case 'globLocalFiles': + return `glob ${params?.pattern}`; + default: + return toolName; + } +};