Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/NODE_AGENT_API_OPERATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@ curl --fail --max-time 5 http://127.0.0.1:18770/health

## 停止与重启

VNC 第一阶段改动和测试记录见 [连接预热与查看器复用](NODE_AGENT_VNC_PHASE_ONE.md)。内嵌查看器产物需在 API 重载前构建;保留正在使用的状态目录和密钥。

2026-10-07的网页会话与VNC性能改动已通过现有LaunchAgent重载生效,保留原状态目录、密钥及13个已有会话。更新后刷新网页并重新点击观看;详细结果与尚未达到的预算见[性能验证](NODE_AGENT_API_PERFORMANCE.md)。

先结束或取消当前 API 回合。停止本次后台实例:

```sh
Expand Down
57 changes: 57 additions & 0 deletions docs/NODE_AGENT_API_PERFORMANCE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Node Agent API 性能优化与验证

日期:2026-10-07。本次针对网页会话提交和 VNC 打开过程。没有更换模型、端点或推理强度,没有迁移会话格式,没有重新打包 GrokNode App 或重建容器。

本文保留首轮优化的测量记录。后续查看器预热、复用和打包后的实现及最新结果见 [VNC 第一阶段](NODE_AGENT_VNC_PHASE_ONE.md)。

## 已实施

- 桌面解析:一次 fresh Docker inspect 加一次批量容器执行完成 roster、状态和归属验证;使用 node:http 避免短进程中的 fetch 初始化成本。正常桌面不重复 ensure。
- 会话提交:存活线程与当前容器、启动时间、桌面分配和 Harness 连接身份一致时走快速路径;变化或冷恢复时保留完整验证。
- 查看器:issue/open/upgrade 保留各自授权和实时身份核对;静态内容使用 private/no-cache 与内容 ETag,新 viewer 授权仍可条件复用。动态授权配置不缓存。
- 持久化:状态与通知一次原子提交;普通 delta 最多25毫秒或64条一批,保留每个事件与游标顺序。完成、审批、取消、断线、读取和关停按明确边界 drain。
- 网页:文字与日志按帧绘制,刷新请求合并;旧快照不再覆盖正在输出的文本,切换会话仍清理旧状态。

## 真实环境观察

| 操作 | 之前 | 优化后 | 范围 |
| --- | --- | --- | --- |
| 已运行桌面的环境查询 |3982/4442毫秒|445/437/442毫秒|不同时间的低频只读样本;每次1 inspect+1 exec |
| 点击观看到可见桌面 canvas |16907毫秒|8011/5601/5642毫秒|更新后第一次及两次重复打开;连接成功、图像有变化 |

更新后重复打开约5.6秒,比之前的单次16.9秒观察缩短约三分之二。样本少、时间与桌面选择可能不同,不能解释为严格同条件 p95 或模型推理加速。真实观察采用同一点击至可见不透明 canvas 的计时方式,不等同于准确的首 framebuffer-update 事件;受控图案测试另行验证解码和首帧。

**真实 VNC 尚未达到2–3秒的拟定预算。** 最后一次查看器共83个资源请求,HTTP/1.1下部分 SVG 等资源在浏览器排队约1.3–1.6秒,服务端等待约0.1–0.2秒,iframe加载约2.4秒。资源加载、校验和远端画面初始化仍占用时间;本轮不改画质或绕过身份检查。

## 受控对比

原始归档与候选使用相同测量工具,每组30个冷样本、100个热样本;产品源码哈希在测量前后保持一致并与交付代码匹配。runtime和RFB上游为合成 fixture,HTTP、Harness子进程、文件同步、SSE与Chromium解码/绘制是真实路径,不调用模型。

| 热路径指标 | 原版中位数 | 候选中位数 |
| --- | --- | --- |
| HTTP提交到 Harness turn/start 写出 |55.0毫秒|32.2毫秒 |
| HTTP接受响应 |121.5毫秒|95.8毫秒 |
| 单条通知到 SSE 客户端 |20.4毫秒|47.3毫秒 |
| 已知图案 noVNC 首帧 |94.8毫秒|83.8毫秒 |

单条 SSE 多等待约27毫秒,主要是有意引入的最多25毫秒增量批次;突发输出获得明显写入收益。256个增量保留256个有序事件,仅4次磁盘提交。网页5秒内5000个增量合为500次文字写入,每个项目每帧至多一次,绘制延迟 p95 约16毫秒。

**冷查看器 fixture 没有提速:**中位数210.9→245.5毫秒,p95 233.7→297.2毫秒。浏览器启动和认证发生在计时前,不能用启动时间解释该差异。未计算置信区间。自动化客户端发生过2个原版、4个候选冷组异常,原始尝试保留并以同配置补齐;不能将这些异常隐藏为产品成功。

## 验证

- API回归190/190。
- 仓库检查:1057通过,129项按已有构建条件跳过,0失败;前端与源码类型检查通过。
- 前端构建通过,保留已有动态导入提示。
- 测量工具最终4/4;UI与Chromium17/17;查看器授权/缓存49/49;持久化19/19;事件集成92/92。
- 独立审查通过,包含授权撤销、分配变化、原子提交、配额拒绝、fsync/rename故障、重放顺序和快照竞争。

## 部署与恢复

已通过原有 `com.groknode.node-agent-api` LaunchAgent 完成受控重载。重载前无活动API回合,重载后健康/认证正常,原密钥字节和13个既有会话ID保持一致。用户需要刷新网页并重新签发查看器授权;不需要重新创建会话。

本机源码位于 `grok-node-agent-api-0-3` 工作副本,实际 `--state`、`--runtime-state` 仍引用 `grok-node-codex-lab/.lab`。不要将状态路径改成当前源码目录的默认 `.lab`。

回退启动预先保留的旧API源码入口并沿用原状态目录;无数据格式迁移。不要用 `git reset --hard` 覆盖共享工作区,不要重建容器来更新这个Mac API。

完整机器证据保存在本次私有 `.omo/evidence/node-agent-api-speed/` 下,包括来源哈希、原始尝试、测试日志和操作结果;密钥、实际会话内容和真实桌面图像不进入本文。测量工具说明见 [bench README](../tools/node-agent-api/bench/README.md),后台流程见[运行手册](NODE_AGENT_API_OPERATIONS.md)。
53 changes: 53 additions & 0 deletions docs/NODE_AGENT_VNC_PHASE_ONE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# VNC 第一阶段:连接预热和查看器复用

日期:2026-10-07。范围为当前一个查看器的复用、只读预热、独立 viewer 授权、查看器打包和常驻只读校验通道。第二阶段的近期桌面池、同一连接上的接管/交还未实施。

## 实现

- 全部 viewer 使用独立 lane 和 HttpOnly Cookie,避免完整桌面授权相互覆盖。关闭单个 viewer 不影响其他 viewer。
- 选中已有会话、读取运行环境后,等待 400ms 再预热当前桌面。预热仅观看已有桌面,不启动桌面,不替换已有控制租约。显式观看可复用完成首帧、仍有授权的当前查看器。
- 切换会话、断开认证、过期和连接中断清理旧查看器;旧 iframe 消息和晚到的预热响应不改变新会话。
- noVNC 1.6.0 的核心和内嵌入口打为一个约 191KB 的文件,包含原库许可证注释。保留默认画质 6、压缩 2。构建读取本机暂存的当前容器资源,不下载或安装依赖。
- 常驻低权限 Docker exec 通道只有读取最新桌面快照这一项操作。每次读取最新 gateway 状态、分配和令牌摘要,前后检查容器 ID/StartedAt;不使用分配 TTL 缓存。通道崩溃、重启、超限或超时拒绝请求,runtime.close 清理进程。
- 共享容器的保留连接每秒校验一次归属;变化或校验失败关闭连接。服务器仍执行只读输入过滤、控制租约、密钥撤销与过期检查。

## 已有测试证据

相关 API 回归:239/239 通过,无跳过。涵盖独立 lane、预热不准备桌面/不替换控制、过期/撤销、桌面重新分配、容器重启、进程清理、前端竞态和原会话功能。

实际 Chromium + API + noVNC + 已知颜色 RFB 夹具完成 5 组。每组覆盖真实画面像素、复用无额外授权/连接、选择清理、旧消息隔离、预热保留控制、接管/交还和取消在途预热:

| 场景 | 样本 | p50 | p95 |
| --- | ---: | ---: | ---: |
| 未预热首次打开 | 5 | 310ms | 315ms |
| 预热后显示 | 5 | 1.5ms | 12ms |
| 重复显示 | 5 | 0.3ms | 11ms |

这些数值验证本地 UI/协议行为,夹具不包含真实 Docker 校验及远端桌面等待,不能代替真实首帧预算。

真实只读桌面使用同一个运行中的 `:3`,独立临时 API 状态与密钥,没有模型请求、桌面输入或控制接管。基线和候选使用同一个 ego-browser TaskSpace:

| 场景 | 样本 | p50 | p95 / 最大值 |
| --- | ---: | ---: | ---: |
| 第一阶段前,新连接 | 6 | 2.10s | 14.23s |
| 第一阶段后,显式点击早于预热的新连接 | 10 | 1.84s | 3.10s |

基线第一次 14.23s,其后五次 2.08–2.62s;未删去第一次。候选范围 0.91–3.10s。样本少、测试非随机交错,部分样本与回归测试重叠,不能作为稳定线上 p95 或严格速度提升百分比。

候选另一次首次打开为 1.99s;iframe 资源请求从基线 83 个降为 1 个。隐藏预热约 2.07s 完成真实首帧;点击后同一个 iframe 约 2ms 完成 DOM 显示。另一重复显示测到下一次浏览器动画帧为 1.00s,无额外授权请求。ego 浏览器的 100 次连续动画帧测量超过单次求值的 15s 上限,保留该工具异常并刷新页面终止晚到执行,没有把它记为成功样本。

真实画面计时基于完整真实 framebuffer update(排除伪矩形)、显示队列清空和动画帧;不使用 iframe load 或 WebSocket connected 代替首帧。未保存真实桌面像素。受控夹具另有已知像素断言和截图。

## 全量检查和边界

类型检查通过。全量 npm run check 为 1105 个通过、129 个原有条件跳过、1 个失败。唯一失败在未改动的 marketplace-d17-classnames:读取不到官方 App 中固定名称 `chunk-plugin-detail-view-tZskkHRA.js`。单独运行该测试仍复现,测试与验证脚本均未修改。没有删除、跳过或放宽此检查。

第一阶段实现及相关功能验收已通过,真实首帧的尾部样本仍有 3.10s,尚不能宣布稳定达到 2–3 秒预算。用户已决定不做第二阶段:近期桌面池主要改善切换不同 Bot,同连接接管主要改善权限模式切换,都不能直接解决首次连接的剩余尾延迟。首开尾延迟单独分析 RFB 握手和浏览器绘制调度。

原始证据:`.omo/evidence/vnc-phase-one-20261007/`,受控浏览器证据:`.omo/evidence/viewer-phase-one-sidecar/`。自动化工具失败和早期修正尝试均保留。

## 本机部署验收

已通过现有 LaunchAgent 重载 18770。首次 bootstrap 与旧任务退出发生时序冲突,待旧实例完全退出后相同配置加载成功。重载前无活动回合,重载后健康检查、原密钥和 13 个会话身份一致。

正式网页选择已有会话后,只读隐藏预热收到 ready、connected 和 first-frame,iframe 为独立 lane 且只有一个资源;点击观看复用相同 iframe 并显示画面。没有新增正式会话、模型请求、控制接管、容器重建或 App 重打包。
2 changes: 1 addition & 1 deletion tests/node-agent-api-web-ui-codex-contract.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,7 @@ if (process.env.NODE_AGENT_UI_BROWSER === '1') test('real Chromium UI contract:
capabilityMode = 'unknown'; await fill('#key', 'contract-test-key'); await click('#connect');
await waitFor("document.getElementById('bots').options.length === 3");
await page.locator('#bots').selectOption('bot_1'); await click('#attach');
await waitFor("document.getElementById('thread').textContent === 'session_1'");
await waitFor("document.getElementById('thread').textContent === '当前会话' && document.querySelector('#sessions button[aria-current=\"true\"]')?.title === 'session_1'");
assert.equal(await evaluate("['send','cancel','view','control','clipboard-read','clipboard-write','actions-refresh','diff-refresh','import','export'].every(id=>document.getElementById(id).disabled)"), true);
await capture('unsupported-capabilities');
assert.equal(await evaluate('localStorage.length === 0 && sessionStorage.length === 0'), true);
Expand Down
26 changes: 13 additions & 13 deletions tests/node-agent-api.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -211,13 +211,13 @@ test('desktop authorization is single-use, bot-bound and server enforced', async
assert.equal(opened.status, 303);
const cookie = /nodeviewer=([A-Za-z0-9_-]+)/.exec(opened.headers.get('set-cookie'))[1];
assert.equal((await call('GET', '/desktop/open?ticket=' + ticket, { raw: true, headers: { authorization } })).status, 401);
const mandatory = await call('GET', '/desktop/mandatory.json', { raw: true, headers: { cookie: 'nodeviewer=' + cookie } });
const mandatory = await call('GET', '/desktop/lane/' + cookie + '/mandatory.json', { raw: true, headers: { cookie: 'nodeviewer=' + cookie } });
const settings = await mandatory.json();
const validateSettings = ajv.compile({ $ref: schemaId + '#/components/schemas/ViewerSettings' });
assert.ok(validateSettings(settings), JSON.stringify(validateSettings.errors));
assert.equal(settings.view_only, true);
assert.equal(settings.path, 'desktop/socket');
const html = await call('GET', '/desktop/vnc.html', { raw: true, headers: { cookie: 'nodeviewer=' + cookie } });
assert.equal(settings.path, 'desktop/lane/' + cookie + '/socket');
const html = await call('GET', '/desktop/lane/' + cookie + '/vnc.html', { raw: true, headers: { cookie: 'nodeviewer=' + cookie } });
assert.match(await html.text(), /#noVNC_control_bar/);
const scoped = issued[0];
const crossBot = await call('POST', '/v1/agents/sessions/' + sessions.b.id + '/desktop', { headers: { authorization: 'Bearer ' + scoped.key }, body: { mode: 'view' } });
Expand All @@ -226,7 +226,7 @@ test('desktop authorization is single-use, bot-bound and server enforced', async
});

async function driveViewer(cookie, messages) {
const socket = new WebSocket(service.origin.replace('http:', 'ws:') + '/desktop/socket', { headers: { cookie: 'nodeviewer=' + cookie, origin: service.origin } });
const socket = new WebSocket(service.origin.replace('http:', 'ws:') + '/desktop/lane/' + cookie + '/socket', { headers: { cookie: 'nodeviewer=' + cookie, origin: service.origin } });
const closed = new Promise(resolve => { socket.once('close', resolve); socket.once('error', resolve); });
await new Promise((resolve, reject) => { socket.once('open', resolve); socket.once('error', reject); });
try {
Expand Down Expand Up @@ -264,7 +264,7 @@ test('control viewers can deliver input and hold a single exclusive lease', asyn
const ticket = new URL(control.data.url).searchParams.get('ticket');
const opened = await call('GET', '/desktop/open?ticket=' + ticket, { raw: true, headers: { authorization } });
const cookie = /nodeviewer=([A-Za-z0-9_-]+)/.exec(opened.headers.get('set-cookie'))[1];
const settings = await (await call('GET', '/desktop/mandatory.json', { raw: true, headers: { cookie: 'nodeviewer=' + cookie } })).json();
const settings = await (await call('GET', '/desktop/lane/' + cookie + '/mandatory.json', { raw: true, headers: { cookie: 'nodeviewer=' + cookie } })).json();
assert.equal(settings.view_only, false);
const busy = await call('POST', '/v1/agents/sessions/' + sessions.a.id + '/desktop', { headers: { authorization }, body: { mode: 'control' } });
assert.equal(busy.status, 409);
Expand All @@ -291,12 +291,12 @@ test('explicit own-control replacement recovers a viewer without stealing anothe
const refused = await call('POST', route, { headers: { authorization: 'Bearer ' + peer.data.key }, body: { mode: 'control', replace_own_control: true } });
assert.equal(refused.status, 409);
const current = await open({ mode: 'control', replace_own_control: true });
assert.equal((await call('GET', '/desktop/mandatory.json', { raw: true, headers: { cookie: 'nodeviewer=' + old } })).status, 401);
const settings = await (await call('GET', '/desktop/mandatory.json', { raw: true, headers: { cookie: 'nodeviewer=' + current } })).json();
assert.equal((await call('GET', '/desktop/lane/' + old + '/mandatory.json', { raw: true, headers: { cookie: 'nodeviewer=' + old } })).status, 401);
const settings = await (await call('GET', '/desktop/lane/' + current + '/mandatory.json', { raw: true, headers: { cookie: 'nodeviewer=' + current } })).json();
assert.equal(settings.view_only, false);
const view = await open({ mode: 'view', replace_own_control: true });
assert.equal((await call('GET', '/desktop/mandatory.json', { raw: true, headers: { cookie: 'nodeviewer=' + current } })).status, 401);
assert.equal((await (await call('GET', '/desktop/mandatory.json', { raw: true, headers: { cookie: 'nodeviewer=' + view } })).json()).view_only, true);
assert.equal((await call('GET', '/desktop/lane/' + current + '/mandatory.json', { raw: true, headers: { cookie: 'nodeviewer=' + current } })).status, 401);
assert.equal((await (await call('GET', '/desktop/lane/' + view + '/mandatory.json', { raw: true, headers: { cookie: 'nodeviewer=' + view } })).json()).view_only, true);
await open({ mode: 'control' });
} finally { await call('POST', '/v1/agents/sessions/' + sessions.a.id + '/handback', { headers: { authorization }, body: {} }); }
});
Expand All @@ -322,8 +322,8 @@ test('revoking a credential closes its access and live desktop lease', async ()
const scopedTicket = new URL(scopedView.data.url).searchParams.get('ticket');
const scopedOpened = await call('GET', '/desktop/open?ticket=' + scopedTicket, { raw: true, headers: { authorization: 'Bearer ' + scoped.key } });
const scopedCookie = /nodeviewer=([A-Za-z0-9_-]+)/.exec(scopedOpened.headers.get('set-cookie'))[1];
assert.equal((await call('GET', '/desktop/mandatory.json', { raw: true, headers: { cookie: 'nodeviewer=' + scopedCookie } })).status, 200);
const live = new WebSocket(service.origin.replace('http:', 'ws:') + '/desktop/socket', { headers: { cookie: 'nodeviewer=' + scopedCookie, origin: service.origin } });
assert.equal((await call('GET', '/desktop/lane/' + scopedCookie + '/mandatory.json', { raw: true, headers: { cookie: 'nodeviewer=' + scopedCookie } })).status, 200);
const live = new WebSocket(service.origin.replace('http:', 'ws:') + '/desktop/lane/' + scopedCookie + '/socket', { headers: { cookie: 'nodeviewer=' + scopedCookie, origin: service.origin } });
const liveClosed = new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Revoked viewer stayed connected')), 4000);
live.once('close', () => { clearTimeout(timeout); resolve(); });
Expand All @@ -333,8 +333,8 @@ test('revoking a credential closes its access and live desktop lease', async ()
assert.equal(revoked.data.revoked, true);
await liveClosed;
assert.equal((await call('GET', '/v1/agents', { headers: { authorization: 'Bearer ' + scoped.key } })).status, 401);
assert.equal((await call('GET', '/desktop/mandatory.json', { raw: true, headers: { cookie: 'nodeviewer=' + scopedCookie } })).status, 401);
assert.equal((await call('GET', '/desktop/mandatory.json', { raw: true, headers: { cookie: 'nodeviewer=' + sessions.viewCookie } })).status, 200, 'an owner viewer stays valid');
assert.equal((await call('GET', '/desktop/lane/' + scopedCookie + '/mandatory.json', { raw: true, headers: { cookie: 'nodeviewer=' + scopedCookie } })).status, 401);
assert.equal((await call('GET', '/desktop/lane/' + sessions.viewCookie + '/mandatory.json', { raw: true, headers: { cookie: 'nodeviewer=' + sessions.viewCookie } })).status, 200, 'an owner viewer stays valid');
});

test('credential store and owner key stay private', async () => {
Expand Down
Loading
Loading