first commit

This commit is contained in:
魏天炜
2026-11-12 15:29:12 +08:00
commit 5dabea54ab
93 changed files with 12170 additions and 0 deletions

136
demo/cloud/server.js Normal file
View File

@@ -0,0 +1,136 @@
// 云端控制面:Session 存储 + 设备通道 + 调度器。
// 它不调模型、不碰代码,只做三件事:
// 1. 把事件追加进 Session(写到 cloud/sessions/<id>.jsonl,相当于数据库)
// 2. 让本地守护进程长轮询领取工具调用、回传结果
// 3. 看到 Session 有活要干、又没有 Worker 在跑时,拉起一个新的 Worker 进程
const http = require('node:http');
const fs = require('node:fs');
const path = require('node:path');
const { spawn } = require('node:child_process');
const { tracer, pendingWork } = require('../shared');
const PORT = Number(process.env.PORT || 7700);
const DATA = process.env.CLOUD_DATA;
const trace = tracer('cloud-server');
fs.mkdirSync(path.join(DATA, 'sessions'), { recursive: true });
// ---------- Session 存储 ----------
const sessFile = (id) => path.join(DATA, 'sessions', `${id}.jsonl`);
function readEvents(id) {
if (!fs.existsSync(sessFile(id))) return [];
return fs.readFileSync(sessFile(id), 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
}
function appendEvent(id, ev) {
const events = readEvents(id);
const rec = { seq: events.length + 1, at: Date.now(), ...ev };
fs.appendFileSync(sessFile(id), JSON.stringify(rec) + '\n');
trace('session_append', { session: id, seq: rec.seq, type: rec.type, bytes: JSON.stringify(rec).length, by: ev.by });
setTimeout(() => schedule(id), 0);
return rec;
}
// ---------- 设备通道 ----------
const devices = {}; // id -> { waiting: res|null, queue: [], pending: Map(reqId -> res), lastSeen }
const dev = (id) => (devices[id] ||= { waiting: null, queue: [], pending: new Map(), lastSeen: 0, online: false });
function isOnline(id) {
const d = devices[id];
return !!(d && d.online && (d.waiting || Date.now() - d.lastSeen < 20000));
}
function flush(d) {
if (d.waiting && d.queue.length) {
const res = d.waiting; d.waiting = null; d.lastSeen = Date.now();
res.end(JSON.stringify(d.queue.shift()));
}
}
// ---------- 调度器 ----------
const running = {}; // session -> pid
const sessionDevice = {}; // session -> device
function schedule(id) {
if (running[id]) return;
const work = pendingWork(readEvents(id));
if (work.kind === 'idle') return;
if (work.kind === 'dispatch' && !isOnline(sessionDevice[id])) return; // 设备不在线:不拉起 Worker,等它回来
const child = spawn(process.execPath, [path.join(__dirname, 'loop-worker.js')], {
cwd: path.join(DATA, 'worker-scratch'),
env: { ...process.env, SESSION_ID: id, CLOUD_URL: `http://127.0.0.1:${PORT}`, HOST_LABEL: 'cloud-worker' },
stdio: ['ignore', 'inherit', 'inherit'],
});
running[id] = child.pid;
trace('worker_spawn', { session: id, worker_pid: child.pid, reason: work.kind });
child.on('exit', (code, signal) => {
trace('worker_exit', { session: id, worker_pid: child.pid, code, signal });
delete running[id];
setTimeout(() => schedule(id), signal ? 800 : 0); // 被杀掉的话稍等一下再看,模拟“故障检测”
});
}
fs.mkdirSync(path.join(DATA, 'worker-scratch'), { recursive: true });
// ---------- HTTP ----------
const body = (req) => new Promise((ok) => { let s = ''; req.on('data', (c) => (s += c)); req.on('end', () => ok(s ? JSON.parse(s) : {})); });
const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); };
http.createServer(async (req, res) => {
const url = new URL(req.url, 'http://x');
const p = url.pathname.split('/').filter(Boolean);
const handled = (req.method === 'POST' && p[0] === 'dispatch') || (req.method === 'GET' && p[0] === 'devices' && p[2] === 'poll');
try {
// 用户 / 外部事件 / Worker 往 Session 里追加事件
if (req.method === 'POST' && p[0] === 'sessions' && p[2] === 'events') {
const ev = await body(req);
if (ev.device) sessionDevice[p[1]] = ev.device;
return json(res, 200, appendEvent(p[1], ev));
}
if (req.method === 'GET' && p[0] === 'sessions' && p[2] === 'events') return json(res, 200, readEvents(p[1]));
if (req.method === 'GET' && p[0] === 'sessions' && p[2] === 'device') return json(res, 200, { device: sessionDevice[p[1]], online: isOnline(sessionDevice[p[1]]) });
// Worker 把一次工具调用交给设备,同步等结果;设备不在线立刻返回 offline
if (req.method === 'POST' && p[0] === 'dispatch') {
const cmd = await body(req);
const d = dev(cmd.device);
if (!isOnline(cmd.device)) {
trace('dispatch_offline', { device: cmd.device, call_id: cmd.call_id, tool: cmd.tool });
return json(res, 200, { offline: true });
}
cmd.req_id = `${cmd.call_id}`;
d.pending.set(cmd.req_id, res);
d.queue.push(cmd);
trace('dispatch', { device: cmd.device, call_id: cmd.call_id, tool: cmd.tool, input: cmd.input });
flush(d);
return void 0;
}
// 守护进程长轮询领取命令(设备在 NAT 后面,只能它主动连云端)
if (req.method === 'GET' && p[0] === 'devices' && p[2] === 'poll') {
const d = dev(p[1]);
const wasOnline = isOnline(p[1]);
d.online = true; d.lastSeen = Date.now(); d.waiting = res;
if (!wasOnline) {
trace('device_online', { device: p[1] });
for (const s of Object.keys(sessionDevice)) if (sessionDevice[s] === p[1]) setTimeout(() => schedule(s), 50);
}
req.on('close', () => { if (d.waiting === res) { d.waiting = null; d.lastSeen = Date.now(); } });
flush(d);
setTimeout(() => { if (d.waiting === res) { d.waiting = null; d.lastSeen = Date.now(); res.end('null'); } }, 15000);
return;
}
if (req.method === 'POST' && p[0] === 'devices' && p[2] === 'result') {
const r = await body(req);
const d = dev(p[1]); d.lastSeen = Date.now();
const waiter = d.pending.get(r.req_id);
d.pending.delete(r.req_id);
if (waiter) waiter.end(JSON.stringify(r));
return json(res, 200, { ok: true });
}
if (req.method === 'POST' && p[0] === 'devices' && p[2] === 'bye') {
const d = dev(p[1]); d.online = false;
trace('device_offline', { device: p[1] });
return json(res, 200, { ok: true });
}
if (req.method === 'GET' && p[0] === 'status') return json(res, 200, { running, devices: Object.fromEntries(Object.keys(devices).map((k) => [k, isOnline(k)])) });
if (!res.headersSent && !res.writableEnded && !handled) json(res, 404, { error: 'not found' });
} catch (e) {
console.error('[cloud-server]', e);
if (!res.headersSent) json(res, 500, { error: String(e) });
}
}).listen(PORT, () => trace('server_start', { port: PORT, data_dir: DATA }));