@co0ontty/wand 4.72.1 → 4.72.2-beta.g52f54af
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/build-info.json +4 -4
- package/dist/config.js +18 -0
- package/dist/message-truncator.js +6 -0
- package/dist/native/darwin-arm64/wand-render +0 -0
- package/dist/native/darwin-arm64/wand-render.sha256 +1 -0
- package/dist/native/darwin-arm64/wand-render.version +1 -0
- package/dist/render-binary.d.ts +61 -0
- package/dist/render-binary.js +204 -0
- package/dist/render-daemon-client.d.ts +153 -0
- package/dist/render-daemon-client.js +902 -0
- package/dist/render-host.d.ts +59 -0
- package/dist/render-host.js +270 -0
- package/dist/render-protocol.d.ts +128 -0
- package/dist/render-protocol.js +99 -0
- package/dist/server-session-routes.js +42 -6
- package/dist/server.js +26 -3
- package/dist/session-transport.js +1 -1
- package/dist/structured-client-protocol.d.ts +3 -1
- package/dist/structured-client-protocol.js +46 -3
- package/dist/structured-content.d.ts +20 -3
- package/dist/structured-content.js +80 -1
- package/dist/structured-pi-adapter.js +18 -2
- package/dist/structured-session-manager.js +2 -2
- package/dist/types.d.ts +12 -0
- package/dist/web-ui/content/scripts.js +59 -59
- package/dist/web-ui/content/tailwind.css +12 -0
- package/dist/web-ui/embedded-assets.js +2 -2
- package/dist/ws-broadcast.js +4 -4
- package/package.json +7 -3
- package/scripts/install-render-binary.js +499 -0
- package/scripts/stage-render-binaries.js +580 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { type RenderDaemonClient } from "./render-daemon-client.js";
|
|
2
|
+
import { TerminalDaemonClient } from "./terminal-daemon-client.js";
|
|
3
|
+
import { type TerminalAttachResult, type TerminalHost, type TerminalSpawnRequest } from "./terminal-host.js";
|
|
4
|
+
import type { RenderEngine } from "./types.js";
|
|
5
|
+
export interface RenderHostOptions {
|
|
6
|
+
/** 配置里显式指定的引擎;环境变量 WAND_RENDER_ENGINE 优先。 */
|
|
7
|
+
engine?: RenderEngine;
|
|
8
|
+
/** 配置里显式指定的二进制路径。 */
|
|
9
|
+
binaryPath?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface UpgradeAwareTerminalHost {
|
|
12
|
+
host: TerminalHost;
|
|
13
|
+
renderHost: TerminalHost | null;
|
|
14
|
+
legacyHost: TerminalDaemonClient | null;
|
|
15
|
+
}
|
|
16
|
+
export interface RoutedTerminalHosts {
|
|
17
|
+
legacy: TerminalHost | null;
|
|
18
|
+
render: TerminalHost;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* 升级期无损路由:旧会话留在 legacy terminald,新会话一律进 Render。
|
|
22
|
+
*
|
|
23
|
+
* 所有权判断只能靠 legacy 自己的 inventory —— Server 无法从 DB 倒推
|
|
24
|
+
* 「这个 PTY 现在归谁」,因为升级前建的会话进程就在旧 daemon 里。
|
|
25
|
+
*/
|
|
26
|
+
export declare class CompositeTerminalHost implements TerminalHost {
|
|
27
|
+
readonly persistent = true;
|
|
28
|
+
private readonly legacy;
|
|
29
|
+
private readonly render;
|
|
30
|
+
constructor(hosts: RoutedTerminalHosts);
|
|
31
|
+
attach(sessionId: string, afterSeq?: number): TerminalAttachResult | null;
|
|
32
|
+
createOrAttach(request: TerminalSpawnRequest, afterSeq?: number): Promise<TerminalAttachResult>;
|
|
33
|
+
forget(sessionId: string): void;
|
|
34
|
+
/**
|
|
35
|
+
* 两侧都只是解绑:legacy daemon 可能还持有升级前的旧 PTY,Render 更是必须
|
|
36
|
+
* 跨 Server 重启继续活着。**这里绝不能 kill 任何东西。**
|
|
37
|
+
*/
|
|
38
|
+
disconnect(): void;
|
|
39
|
+
/**
|
|
40
|
+
* legacy 查询失败(socket 抖动)时按「不属于它」处理:宁可让 Render 侧查一遍,
|
|
41
|
+
* 也不能在会话恢复路径上抛异常 —— 那会让服务器重启后旧会话全部恢复失败。
|
|
42
|
+
*/
|
|
43
|
+
private attachFromLegacy;
|
|
44
|
+
private legacyOwns;
|
|
45
|
+
}
|
|
46
|
+
/** 环境变量优先于 config;非法值忽略并告警。 */
|
|
47
|
+
export declare function resolveRenderEngine(envValue: string | undefined, configEngine: RenderEngine | undefined): RenderEngine;
|
|
48
|
+
export declare function createUpgradeAwareTerminalHost(configPath: string, options?: RenderHostOptions): Promise<UpgradeAwareTerminalHost>;
|
|
49
|
+
/**
|
|
50
|
+
* adopt 已存在的 Render,不存在则 spawn 后等待就绪。
|
|
51
|
+
*
|
|
52
|
+
* 协议(docs/render-protocol.md §6):pid 活着但 socket 不可用 → 等待就绪,
|
|
53
|
+
* 不另起第二个 daemon;协议版本不符 → 明确抛错,**不做降级运行**。
|
|
54
|
+
*/
|
|
55
|
+
export declare function createRenderTerminalHost(configPath: string, options?: {
|
|
56
|
+
binaryPath?: string;
|
|
57
|
+
}): Promise<RenderDaemonClient>;
|
|
58
|
+
/** detached spawn(stdio 忽略、unref),并等 socket + token 就绪。 */
|
|
59
|
+
export declare function spawnDetachedRender(configPath: string, binaryPath: string): Promise<RenderDaemonClient>;
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync, unlinkSync } from "node:fs";
|
|
3
|
+
import net from "node:net";
|
|
4
|
+
import process from "node:process";
|
|
5
|
+
import { getErrorMessage } from "./error-utils.js";
|
|
6
|
+
import { isExecutableFile, readRenderBinaryVersion, resolveRenderBinaryPath } from "./render-binary.js";
|
|
7
|
+
import { connectExistingRenderClient } from "./render-daemon-client.js";
|
|
8
|
+
import { renderPaths } from "./render-protocol.js";
|
|
9
|
+
import { connectExistingTerminalHost, createTerminalHost, TerminalDaemonClient } from "./terminal-daemon-client.js";
|
|
10
|
+
import { InProcessTerminalHost, } from "./terminal-host.js";
|
|
11
|
+
/** Render 的 socket/token 就绪等待窗口,与 legacy daemon 保持一致。 */
|
|
12
|
+
const RENDER_READY_TIMEOUT_MS = 5_000;
|
|
13
|
+
/**
|
|
14
|
+
* 升级期无损路由:旧会话留在 legacy terminald,新会话一律进 Render。
|
|
15
|
+
*
|
|
16
|
+
* 所有权判断只能靠 legacy 自己的 inventory —— Server 无法从 DB 倒推
|
|
17
|
+
* 「这个 PTY 现在归谁」,因为升级前建的会话进程就在旧 daemon 里。
|
|
18
|
+
*/
|
|
19
|
+
export class CompositeTerminalHost {
|
|
20
|
+
persistent = true;
|
|
21
|
+
legacy;
|
|
22
|
+
render;
|
|
23
|
+
constructor(hosts) {
|
|
24
|
+
this.legacy = hosts.legacy;
|
|
25
|
+
this.render = hosts.render;
|
|
26
|
+
}
|
|
27
|
+
attach(sessionId, afterSeq = 0) {
|
|
28
|
+
// legacy 命中就直接用;返回 null 说明这条会话不属于它(新会话在 Render)。
|
|
29
|
+
const fromLegacy = this.attachFromLegacy(sessionId, afterSeq);
|
|
30
|
+
if (fromLegacy)
|
|
31
|
+
return fromLegacy;
|
|
32
|
+
return this.render.attach(sessionId, afterSeq);
|
|
33
|
+
}
|
|
34
|
+
async createOrAttach(request, afterSeq = 0) {
|
|
35
|
+
if (this.legacyOwns(request.sessionId)) {
|
|
36
|
+
return this.legacy.createOrAttach(request, afterSeq);
|
|
37
|
+
}
|
|
38
|
+
return this.render.createOrAttach(request, afterSeq);
|
|
39
|
+
}
|
|
40
|
+
forget(sessionId) {
|
|
41
|
+
this.legacy?.forget(sessionId);
|
|
42
|
+
this.render.forget(sessionId);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* 两侧都只是解绑:legacy daemon 可能还持有升级前的旧 PTY,Render 更是必须
|
|
46
|
+
* 跨 Server 重启继续活着。**这里绝不能 kill 任何东西。**
|
|
47
|
+
*/
|
|
48
|
+
disconnect() {
|
|
49
|
+
this.legacy?.disconnect();
|
|
50
|
+
this.render.disconnect();
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* legacy 查询失败(socket 抖动)时按「不属于它」处理:宁可让 Render 侧查一遍,
|
|
54
|
+
* 也不能在会话恢复路径上抛异常 —— 那会让服务器重启后旧会话全部恢复失败。
|
|
55
|
+
*/
|
|
56
|
+
attachFromLegacy(sessionId, afterSeq) {
|
|
57
|
+
if (!this.legacy)
|
|
58
|
+
return null;
|
|
59
|
+
try {
|
|
60
|
+
return this.legacy.attach(sessionId, afterSeq) ?? null;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
legacyOwns(sessionId) {
|
|
67
|
+
if (!this.legacy)
|
|
68
|
+
return false;
|
|
69
|
+
try {
|
|
70
|
+
return this.legacy.attach(sessionId) !== null;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// 查询失败按「不属于 legacy」处理:宁可让 Render 建新会话,也不要把请求丢进坏掉的 daemon。
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** 环境变量优先于 config;非法值忽略并告警。 */
|
|
79
|
+
export function resolveRenderEngine(envValue, configEngine) {
|
|
80
|
+
const raw = (envValue ?? "").trim().toLowerCase();
|
|
81
|
+
if (raw) {
|
|
82
|
+
if (raw === "rust" || raw === "legacy" || raw === "auto")
|
|
83
|
+
return raw;
|
|
84
|
+
process.stderr.write(`[wand] WAND_RENDER_ENGINE=${envValue} is invalid (expected rust|legacy|auto); ignoring it.\n`);
|
|
85
|
+
}
|
|
86
|
+
return configEngine === "rust" || configEngine === "legacy" ? configEngine : "auto";
|
|
87
|
+
}
|
|
88
|
+
export async function createUpgradeAwareTerminalHost(configPath, options = {}) {
|
|
89
|
+
if (process.env.WAND_TEST_MODE === "1" || process.env.NODE_TEST_CONTEXT) {
|
|
90
|
+
// 与 legacy 缝隙一致:测试进程不拉起任何常驻 daemon。
|
|
91
|
+
return { host: new InProcessTerminalHost(), renderHost: null, legacyHost: null };
|
|
92
|
+
}
|
|
93
|
+
const engine = resolveRenderEngine(process.env.WAND_RENDER_ENGINE, options.engine);
|
|
94
|
+
// engine=legacy 等价于当前行为:adopt 已有 terminald,不存在才 spawn。
|
|
95
|
+
if (engine === "legacy") {
|
|
96
|
+
const legacy = await createTerminalHost(configPath);
|
|
97
|
+
return { host: legacy, renderHost: null, legacyHost: asTerminalDaemonClient(legacy) };
|
|
98
|
+
}
|
|
99
|
+
// 只 adopt,绝不 spawn/杀:它可能正持有升级前的会话 PTY。
|
|
100
|
+
const legacy = await connectExistingTerminalHost(configPath);
|
|
101
|
+
const configuredBinary = options.binaryPath?.trim();
|
|
102
|
+
const binaryPath = configuredBinary || resolveRenderBinaryPath(configPath);
|
|
103
|
+
if (!binaryPath) {
|
|
104
|
+
if (engine === "rust") {
|
|
105
|
+
throw new Error("render.engine=rust but no wand-render binary was found (checked WAND_RENDER_BIN, render/target/{release,debug}, " +
|
|
106
|
+
"<configDir>/bin, dist/native/<platform>-<arch>, PATH). " +
|
|
107
|
+
"Build it with `npm run build:render-native` (cargo build --release in render/), " +
|
|
108
|
+
"or set render.engine=auto/legacy to fall back.");
|
|
109
|
+
}
|
|
110
|
+
process.stderr.write("[wand] WARNING: render.engine=auto but no wand-render binary was found; PTYs stay on the legacy terminald. " +
|
|
111
|
+
"Build it with `npm run build:render-native`, stage a published build with `npm run build:render-bin`, or set WAND_RENDER_BIN.\n");
|
|
112
|
+
const fallback = legacy ?? await createTerminalHost(configPath);
|
|
113
|
+
return { host: fallback, renderHost: null, legacyHost: asTerminalDaemonClient(fallback) };
|
|
114
|
+
}
|
|
115
|
+
if (configuredBinary && !isExecutableFile(configuredBinary)) {
|
|
116
|
+
// 显式配置指向不可执行文件属于配置错误,静默回退 legacy 会让人以为 Rust 引擎在跑。
|
|
117
|
+
throw new Error(`render.binaryPath points at ${configuredBinary}, which is not an executable file.`);
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
const render = await createRenderTerminalHost(configPath, { binaryPath });
|
|
121
|
+
const version = await readRenderBinaryVersion(binaryPath);
|
|
122
|
+
process.stderr.write(`[wand] Render engine active (${version ? `wand-render ${version}` : "version unknown"}, ${binaryPath})` +
|
|
123
|
+
`${legacy ? "; legacy terminald still serving pre-upgrade sessions" : ""}.\n`);
|
|
124
|
+
const host = legacy ? new CompositeTerminalHost({ legacy, render }) : render;
|
|
125
|
+
return { host, renderHost: render, legacyHost: legacy };
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
if (engine === "rust")
|
|
129
|
+
throw error;
|
|
130
|
+
// auto 模式下把失败讲清楚再回退:这种情况用户最需要知道 Rust 引擎没生效。
|
|
131
|
+
process.stderr.write(`[wand] WARNING: render.engine=auto could not bring up Render (${getErrorMessage(error)}); falling back to the legacy terminald.\n`);
|
|
132
|
+
const fallback = legacy ?? await createTerminalHost(configPath);
|
|
133
|
+
return { host: fallback, renderHost: null, legacyHost: asTerminalDaemonClient(fallback) };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* adopt 已存在的 Render,不存在则 spawn 后等待就绪。
|
|
138
|
+
*
|
|
139
|
+
* 协议(docs/render-protocol.md §6):pid 活着但 socket 不可用 → 等待就绪,
|
|
140
|
+
* 不另起第二个 daemon;协议版本不符 → 明确抛错,**不做降级运行**。
|
|
141
|
+
*/
|
|
142
|
+
export async function createRenderTerminalHost(configPath, options = {}) {
|
|
143
|
+
const paths = renderPaths(configPath);
|
|
144
|
+
const endpointExists = process.platform !== "win32" && existsSync(paths.socketPath);
|
|
145
|
+
if (endpointExists) {
|
|
146
|
+
const adopted = await connectExistingRenderClient(configPath);
|
|
147
|
+
if (adopted)
|
|
148
|
+
return adopted;
|
|
149
|
+
if (await isSocketListening(paths.socketPath)) {
|
|
150
|
+
// 有人监听却拒绝 adopt(协议版本不符 / token 不符):这是错误配置,必须报出来而不是绕开。
|
|
151
|
+
const starting = await waitForRender(configPath, RENDER_READY_TIMEOUT_MS);
|
|
152
|
+
if (starting)
|
|
153
|
+
return starting;
|
|
154
|
+
throw new Error(`A Render daemon is listening on ${paths.socketPath} but rejected adoption. Refusing to spawn a competing Render; ` +
|
|
155
|
+
"check the protocol version/token of the running binary, or stop it first.");
|
|
156
|
+
}
|
|
157
|
+
// 没人在监听,且没有活着的 pid = 上一次 renderd 崩溃留下的孤儿 socket 文件。
|
|
158
|
+
// 清掉它再 spawn,否则会永久卡在「等一个永远不会就绪的 socket」。
|
|
159
|
+
if (readLiveRenderPid(paths.pidPath) === null) {
|
|
160
|
+
try {
|
|
161
|
+
unlinkSync(paths.socketPath);
|
|
162
|
+
}
|
|
163
|
+
catch { /* 已被别处清掉即可 */ }
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// 进程活着但 socket 还没就绪(启动竞态 / 正在 drain):等它就绪,
|
|
167
|
+
// 绝不另起第二个 daemon(协议 §6 single instance)。
|
|
168
|
+
const livePid = readLiveRenderPid(paths.pidPath);
|
|
169
|
+
if (livePid !== null) {
|
|
170
|
+
const starting = await waitForRender(configPath, RENDER_READY_TIMEOUT_MS);
|
|
171
|
+
if (starting)
|
|
172
|
+
return starting;
|
|
173
|
+
throw new Error(`Render process ${livePid} is alive but its socket ${paths.socketPath} is unavailable; refusing to spawn a second daemon.`);
|
|
174
|
+
}
|
|
175
|
+
const binaryPath = options.binaryPath ?? resolveRenderBinaryPath(configPath);
|
|
176
|
+
if (!binaryPath)
|
|
177
|
+
throw new Error("wand-render binary is required to start Render");
|
|
178
|
+
return spawnDetachedRender(configPath, binaryPath);
|
|
179
|
+
}
|
|
180
|
+
/** detached spawn(stdio 忽略、unref),并等 socket + token 就绪。 */
|
|
181
|
+
export async function spawnDetachedRender(configPath, binaryPath) {
|
|
182
|
+
const paths = renderPaths(configPath);
|
|
183
|
+
// `-c <configPath>` 是按 config 隔离的既定约定(docs/render-protocol.md §6):
|
|
184
|
+
// socket / token / pid / meta 全部由它派生。
|
|
185
|
+
const child = spawn(binaryPath, ["-c", configPath], {
|
|
186
|
+
detached: true,
|
|
187
|
+
stdio: "ignore",
|
|
188
|
+
cwd: process.cwd(),
|
|
189
|
+
env: process.env,
|
|
190
|
+
});
|
|
191
|
+
child.on("error", (error) => {
|
|
192
|
+
process.stderr.write(`[wand] Failed to spawn wand-render: ${getErrorMessage(error)}\n`);
|
|
193
|
+
});
|
|
194
|
+
child.unref();
|
|
195
|
+
const client = await waitForRender(configPath, RENDER_READY_TIMEOUT_MS);
|
|
196
|
+
if (!client) {
|
|
197
|
+
// 自己生的进程自己收:就位超时(选错了二进制、二进制是 stub、或者它卡在启动)
|
|
198
|
+
// 时如果不回收,就会留下一个永久孤儿 daemon —— 它占着 socket 名字、下次启动会被
|
|
199
|
+
// 当成「活着但不接受领养」而直接阻塞启动。收尾是 best-effort,失败不影响报错。
|
|
200
|
+
reapUnreadyRender(child.pid);
|
|
201
|
+
throw new Error(`Spawned ${binaryPath} but its socket/token (${paths.socketPath}) did not become ready within ${RENDER_READY_TIMEOUT_MS}ms.`);
|
|
202
|
+
}
|
|
203
|
+
return client;
|
|
204
|
+
}
|
|
205
|
+
async function waitForRender(configPath, timeoutMs) {
|
|
206
|
+
const deadline = Date.now() + timeoutMs;
|
|
207
|
+
while (Date.now() < deadline) {
|
|
208
|
+
const client = await connectExistingRenderClient(configPath);
|
|
209
|
+
if (client)
|
|
210
|
+
return client;
|
|
211
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
212
|
+
}
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* 回收一个「已 spawn 但始终没就绪」的 Render 进程。
|
|
217
|
+
*
|
|
218
|
+
* 不回收会留下孤儿 daemon:它占着按 config 派生的 socket 名字,下次启动会被判成
|
|
219
|
+
* 「进程活着但 socket 不可达」,于是启动直接失败而不是重建 —— 一个失败的启动不该
|
|
220
|
+
* 破坏后续所有启动。先 SIGTERM 给它清理 socket/token 的机会,短暂等待后 SIGKILL。
|
|
221
|
+
*/
|
|
222
|
+
function reapUnreadyRender(pid) {
|
|
223
|
+
if (typeof pid !== "number" || pid <= 0)
|
|
224
|
+
return;
|
|
225
|
+
const signal = (name) => {
|
|
226
|
+
try {
|
|
227
|
+
process.kill(pid, name);
|
|
228
|
+
}
|
|
229
|
+
catch { /* 已经退出 */ }
|
|
230
|
+
};
|
|
231
|
+
signal("SIGTERM");
|
|
232
|
+
const timer = setTimeout(() => signal("SIGKILL"), 500);
|
|
233
|
+
timer.unref?.();
|
|
234
|
+
}
|
|
235
|
+
/** 对端是否真的在监听:区分「活着但拒绝 adopt」与「上一轮崩溃留下的孤儿 socket 文件」。 */ function isSocketListening(socketPath) {
|
|
236
|
+
return new Promise((resolve) => {
|
|
237
|
+
const socket = net.createConnection(socketPath);
|
|
238
|
+
const finish = (alive) => {
|
|
239
|
+
socket.removeAllListeners();
|
|
240
|
+
socket.destroy();
|
|
241
|
+
resolve(alive);
|
|
242
|
+
};
|
|
243
|
+
socket.once("connect", () => finish(true));
|
|
244
|
+
socket.once("error", () => finish(false));
|
|
245
|
+
const timer = setTimeout(() => finish(false), 300);
|
|
246
|
+
timer.unref?.();
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
function readLiveRenderPid(pidPath) {
|
|
250
|
+
let pid;
|
|
251
|
+
try {
|
|
252
|
+
pid = Number(readFileSync(pidPath, "utf8").trim());
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
258
|
+
return null;
|
|
259
|
+
try {
|
|
260
|
+
process.kill(pid, 0);
|
|
261
|
+
return pid;
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
/** structuredExecHost 只认 daemon 客户端;测试注入的 in-process host 不算。 */
|
|
268
|
+
function asTerminalDaemonClient(host) {
|
|
269
|
+
return host instanceof TerminalDaemonClient ? host : null;
|
|
270
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render 协议 v1 的 TS 镜像。Rust 单一真源在
|
|
3
|
+
* `render/crates/wand-render-protocol/src/lib.rs`,契约文档在
|
|
4
|
+
* `render/docs/render-protocol.md`(本仓库 `docs/render-protocol.md` 只是指针)。
|
|
5
|
+
*
|
|
6
|
+
* 两侧常量与字段名必须逐一对齐;任一侧改动都要同时提升版本号并更新文档。
|
|
7
|
+
*/
|
|
8
|
+
export declare const RENDER_PROTOCOL_VERSION = 1;
|
|
9
|
+
/** 单帧上限:`u32` 大端长度前缀 + UTF-8 JSON。 */
|
|
10
|
+
export declare const MAX_FRAME_BYTES: number;
|
|
11
|
+
/** 与 `PTY_OUTPUT_MAX_SIZE` 对齐(chunk 窗口 / output 有界)。 */
|
|
12
|
+
export declare const RENDER_PTY_OUTPUT_MAX_CHARS = 200000;
|
|
13
|
+
export type RenderErrorCode = "unauthorized" | "badRequest" | "notFound" | "conflict" | "unsupportedMethod" | "protocolMismatch" | "internal";
|
|
14
|
+
export interface RenderRequest {
|
|
15
|
+
id: number;
|
|
16
|
+
token: string;
|
|
17
|
+
protocolVersion: number;
|
|
18
|
+
method: RenderMethod;
|
|
19
|
+
params?: Record<string, unknown>;
|
|
20
|
+
}
|
|
21
|
+
export type RenderMethod = "hello" | "ping" | "list" | "attach" | "createOrAttach" | "write" | "resize" | "kill" | "forget" | "stats" | "shutdown";
|
|
22
|
+
export interface RenderResponse {
|
|
23
|
+
id: number;
|
|
24
|
+
ok: boolean;
|
|
25
|
+
result?: unknown;
|
|
26
|
+
error?: {
|
|
27
|
+
code: RenderErrorCode;
|
|
28
|
+
message: string;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export type RenderEvent = {
|
|
32
|
+
event: "data";
|
|
33
|
+
sessionId: string;
|
|
34
|
+
incarnationId: string;
|
|
35
|
+
data: string;
|
|
36
|
+
seq: number;
|
|
37
|
+
} | {
|
|
38
|
+
event: "exit";
|
|
39
|
+
sessionId: string;
|
|
40
|
+
incarnationId: string;
|
|
41
|
+
exitCode: number | null;
|
|
42
|
+
signal: number | null;
|
|
43
|
+
} | {
|
|
44
|
+
event: "reconcile";
|
|
45
|
+
sessionIds: string[];
|
|
46
|
+
};
|
|
47
|
+
export interface RenderHelloResult {
|
|
48
|
+
version: string;
|
|
49
|
+
protocolVersion: number;
|
|
50
|
+
pid: number;
|
|
51
|
+
startedAt: string;
|
|
52
|
+
sessions: number;
|
|
53
|
+
}
|
|
54
|
+
export interface RenderSessionState {
|
|
55
|
+
sessionId: string;
|
|
56
|
+
incarnationId: string;
|
|
57
|
+
pid: number;
|
|
58
|
+
status: "running" | "exited";
|
|
59
|
+
exitCode: number | null;
|
|
60
|
+
cols: number;
|
|
61
|
+
rows: number;
|
|
62
|
+
seq: number;
|
|
63
|
+
output: string;
|
|
64
|
+
chunks: {
|
|
65
|
+
data: string;
|
|
66
|
+
seq: number;
|
|
67
|
+
}[];
|
|
68
|
+
terminalSnapshot: {
|
|
69
|
+
version: number;
|
|
70
|
+
data: string;
|
|
71
|
+
cols: number;
|
|
72
|
+
rows: number;
|
|
73
|
+
pending: ({
|
|
74
|
+
type: "data";
|
|
75
|
+
data: string;
|
|
76
|
+
} | {
|
|
77
|
+
type: "resize";
|
|
78
|
+
cols: number;
|
|
79
|
+
rows: number;
|
|
80
|
+
})[];
|
|
81
|
+
} | null;
|
|
82
|
+
launchMarkerToken: string | null;
|
|
83
|
+
}
|
|
84
|
+
export interface RenderCreateOrAttachParams {
|
|
85
|
+
sessionId: string;
|
|
86
|
+
file: string;
|
|
87
|
+
args: string[];
|
|
88
|
+
cwd: string;
|
|
89
|
+
env: Record<string, string>;
|
|
90
|
+
name: string;
|
|
91
|
+
cols: number;
|
|
92
|
+
rows: number;
|
|
93
|
+
launchMarkerToken?: string;
|
|
94
|
+
afterSeq?: number;
|
|
95
|
+
}
|
|
96
|
+
export interface RenderStatsResult {
|
|
97
|
+
uptimeMs: number;
|
|
98
|
+
sessions: number;
|
|
99
|
+
liveBytes: number;
|
|
100
|
+
rssBytes: number;
|
|
101
|
+
}
|
|
102
|
+
export interface RenderPaths {
|
|
103
|
+
socketPath: string;
|
|
104
|
+
tokenPath: string;
|
|
105
|
+
pidPath: string;
|
|
106
|
+
metaPath: string;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Render 的按 config 隔离寻址。
|
|
110
|
+
*
|
|
111
|
+
* 与 legacy `terminald`(`wand-terminald-<uid>-<suffix>.sock` /
|
|
112
|
+
* `.terminald-<suffix>.token`)**刻意使用不同文件名**:升级期两套并存,
|
|
113
|
+
* 但永不互相领养。
|
|
114
|
+
*/
|
|
115
|
+
export declare function renderPaths(configPath: string): RenderPaths;
|
|
116
|
+
/** 编码一帧:`u32` 大端长度 + UTF-8 JSON。 */
|
|
117
|
+
export declare function encodeRenderFrame(value: unknown): Buffer;
|
|
118
|
+
/**
|
|
119
|
+
* 从累积缓冲区解出所有完整帧。
|
|
120
|
+
*
|
|
121
|
+
* 返回剩余(不完整)字节。长度越界时抛错,调用方应关闭连接。
|
|
122
|
+
*/
|
|
123
|
+
export declare function decodeRenderFrames<T>(buffer: Buffer): {
|
|
124
|
+
frames: T[];
|
|
125
|
+
rest: Buffer;
|
|
126
|
+
};
|
|
127
|
+
/** 是否为 protocol v1 事件帧(无 `id`,带 `event`)。 */
|
|
128
|
+
export declare function isRenderEvent(value: unknown): value is RenderEvent;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
/**
|
|
6
|
+
* Render 协议 v1 的 TS 镜像。Rust 单一真源在
|
|
7
|
+
* `render/crates/wand-render-protocol/src/lib.rs`,契约文档在
|
|
8
|
+
* `render/docs/render-protocol.md`(本仓库 `docs/render-protocol.md` 只是指针)。
|
|
9
|
+
*
|
|
10
|
+
* 两侧常量与字段名必须逐一对齐;任一侧改动都要同时提升版本号并更新文档。
|
|
11
|
+
*/
|
|
12
|
+
export const RENDER_PROTOCOL_VERSION = 1;
|
|
13
|
+
/** 单帧上限:`u32` 大端长度前缀 + UTF-8 JSON。 */
|
|
14
|
+
export const MAX_FRAME_BYTES = 64 * 1024 * 1024;
|
|
15
|
+
/** 与 `PTY_OUTPUT_MAX_SIZE` 对齐(chunk 窗口 / output 有界)。 */
|
|
16
|
+
export const RENDER_PTY_OUTPUT_MAX_CHARS = 200_000;
|
|
17
|
+
/**
|
|
18
|
+
* config 路径的 canonical 形式:`realpathSync` 解析符号链接,失败(文件尚未创建)
|
|
19
|
+
* 才回退纯词法的 `path.resolve`。
|
|
20
|
+
*
|
|
21
|
+
* 两侧必须对同一 config 派生出**同一套**路径,而「同一份 config」在用户那里常常是
|
|
22
|
+
* 经过符号链接的路径(`~/.wand` → 别的卷、`/tmp` → `/private/tmp`、容器挂载点)。
|
|
23
|
+
* 一侧做 realpath、另一侧只做 resolve,就会算出两个 suffix:同一个 config 出现两个
|
|
24
|
+
* Render、两套 PTY 所有权 —— 这正是要靠寻址归一化防住的故障。
|
|
25
|
+
*/
|
|
26
|
+
function canonicalConfigPath(configPath) {
|
|
27
|
+
const resolved = path.resolve(configPath);
|
|
28
|
+
try {
|
|
29
|
+
return realpathSync(resolved);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return resolved;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Render 的按 config 隔离寻址。
|
|
37
|
+
*
|
|
38
|
+
* 与 legacy `terminald`(`wand-terminald-<uid>-<suffix>.sock` /
|
|
39
|
+
* `.terminald-<suffix>.token`)**刻意使用不同文件名**:升级期两套并存,
|
|
40
|
+
* 但永不互相领养。
|
|
41
|
+
*/
|
|
42
|
+
export function renderPaths(configPath) {
|
|
43
|
+
const resolved = canonicalConfigPath(configPath);
|
|
44
|
+
const suffix = createHash("sha256").update(resolved).digest("hex").slice(0, 12);
|
|
45
|
+
const dir = path.dirname(resolved);
|
|
46
|
+
const uid = (() => {
|
|
47
|
+
try {
|
|
48
|
+
return os.userInfo().uid;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return 0;
|
|
52
|
+
}
|
|
53
|
+
})();
|
|
54
|
+
return {
|
|
55
|
+
// macOS 的 Unix socket 路径上限约 100 字节,所以 socket 放 /tmp 保持短。
|
|
56
|
+
socketPath: path.join("/tmp", `wand-render-${uid}-${suffix}.sock`),
|
|
57
|
+
tokenPath: path.join(dir, `.render-${suffix}.token`),
|
|
58
|
+
pidPath: path.join(dir, `.render-${suffix}.pid`),
|
|
59
|
+
metaPath: path.join(dir, `.render-${suffix}.json`),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/** 编码一帧:`u32` 大端长度 + UTF-8 JSON。 */
|
|
63
|
+
export function encodeRenderFrame(value) {
|
|
64
|
+
const body = Buffer.from(JSON.stringify(value), "utf8");
|
|
65
|
+
if (body.length > MAX_FRAME_BYTES) {
|
|
66
|
+
throw new Error(`Render frame of ${body.length} bytes exceeds the ${MAX_FRAME_BYTES} byte limit`);
|
|
67
|
+
}
|
|
68
|
+
const header = Buffer.allocUnsafe(4);
|
|
69
|
+
header.writeUInt32BE(body.length, 0);
|
|
70
|
+
return Buffer.concat([header, body]);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* 从累积缓冲区解出所有完整帧。
|
|
74
|
+
*
|
|
75
|
+
* 返回剩余(不完整)字节。长度越界时抛错,调用方应关闭连接。
|
|
76
|
+
*/
|
|
77
|
+
export function decodeRenderFrames(buffer) {
|
|
78
|
+
const frames = [];
|
|
79
|
+
let offset = 0;
|
|
80
|
+
while (buffer.length - offset >= 4) {
|
|
81
|
+
const length = buffer.readUInt32BE(offset);
|
|
82
|
+
if (length > MAX_FRAME_BYTES) {
|
|
83
|
+
throw new Error(`Render frame length ${length} exceeds the ${MAX_FRAME_BYTES} byte limit`);
|
|
84
|
+
}
|
|
85
|
+
if (buffer.length - offset - 4 < length)
|
|
86
|
+
break;
|
|
87
|
+
const body = buffer.subarray(offset + 4, offset + 4 + length);
|
|
88
|
+
frames.push(JSON.parse(body.toString("utf8")));
|
|
89
|
+
offset += 4 + length;
|
|
90
|
+
}
|
|
91
|
+
return { frames, rest: offset === 0 ? buffer : buffer.subarray(offset) };
|
|
92
|
+
}
|
|
93
|
+
/** 是否为 protocol v1 事件帧(无 `id`,带 `event`)。 */
|
|
94
|
+
export function isRenderEvent(value) {
|
|
95
|
+
if (!value || typeof value !== "object")
|
|
96
|
+
return false;
|
|
97
|
+
const record = value;
|
|
98
|
+
return typeof record.event === "string" && record.id === undefined;
|
|
99
|
+
}
|
|
@@ -17,6 +17,7 @@ import { sendRouteError } from "./server-request.js";
|
|
|
17
17
|
import { registerStructuredResumeRoutes } from "./server-resume-routes.js";
|
|
18
18
|
import { addHiddenSessionIds, removeHiddenSessionIds } from "./session-registry.js";
|
|
19
19
|
import { enrichStructuredMessages, WAND_PROTOCOL_VERSION } from "./structured-client-protocol.js";
|
|
20
|
+
import { asRecord, isStructuredImagePart } from "./structured-content.js";
|
|
20
21
|
import { syncWorkspaceTaskToBoard } from "./wand-task-sync.js";
|
|
21
22
|
import { buildDirectoryTree, normalizeSessionDirectory, } from "./session-directory-tree.js";
|
|
22
23
|
import { projectCwdForSession, renameWorkspaceDirectory, resolveWorkspaceIdForNewSession, } from "./workspace-binding.js";
|
|
@@ -351,7 +352,7 @@ function isMergeActionAllowed(snapshot) {
|
|
|
351
352
|
}
|
|
352
353
|
export function registerSessionRoutes(app, processes, structured, storage, defaultMode, config, sessions, onSessionCreated) {
|
|
353
354
|
const sessionResponseDTO = (snapshot) => {
|
|
354
|
-
const windowed = windowMessagesForTransport(enrichStructuredMessages(snapshot.messages ?? []), config.cardDefaults ?? {});
|
|
355
|
+
const windowed = windowMessagesForTransport(enrichStructuredMessages(snapshot.messages ?? [], snapshot.id), config.cardDefaults ?? {});
|
|
355
356
|
return toSessionDetailDTO(snapshot, {
|
|
356
357
|
messages: windowed.messages,
|
|
357
358
|
messageOffset: windowed.messageOffset,
|
|
@@ -688,6 +689,41 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
|
|
|
688
689
|
}
|
|
689
690
|
res.status(404).json({ error: "未找到该工具结果。" });
|
|
690
691
|
});
|
|
692
|
+
// ── Inline tool image endpoint ──
|
|
693
|
+
// tool_result 里内联的 base64 图片在传输层被改写为这个 URL(避免把整张图的 base64
|
|
694
|
+
// 塞进每次快照 / WS 帧)。各端用同一套带鉴权的取图通道按需加载。
|
|
695
|
+
app.get("/api/sessions/:id/tool-images/:toolUseId/:index", (req, res) => {
|
|
696
|
+
const snapshot = sessions.get(req.params.id);
|
|
697
|
+
if (!snapshot) {
|
|
698
|
+
res.status(404).json({ error: "未找到该会话。" });
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
const toolUseId = req.params.toolUseId;
|
|
702
|
+
const index = Number.parseInt(req.params.index, 10);
|
|
703
|
+
if (!Number.isFinite(index) || index < 0) {
|
|
704
|
+
res.status(400).json({ error: "无效的图片序号。" });
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
for (const turn of snapshot.messages ?? []) {
|
|
708
|
+
for (const block of turn.content) {
|
|
709
|
+
if (block.type !== "tool_result" || block.tool_use_id !== toolUseId || !Array.isArray(block.content))
|
|
710
|
+
continue;
|
|
711
|
+
const part = block.content.filter(isStructuredImagePart)[index];
|
|
712
|
+
const source = asRecord(part?.source);
|
|
713
|
+
const data = typeof source?.data === "string" ? source.data : undefined;
|
|
714
|
+
if (source?.type !== "base64" || !data) {
|
|
715
|
+
res.status(404).json({ error: "未找到该图片。" });
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
res.setHeader("Content-Type", typeof source.media_type === "string" ? source.media_type : "image/png");
|
|
719
|
+
res.setHeader("Cache-Control", "private, max-age=3600");
|
|
720
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
721
|
+
res.send(Buffer.from(data, "base64"));
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
res.status(404).json({ error: "未找到该工具结果。" });
|
|
726
|
+
});
|
|
691
727
|
app.post("/api/sessions/:id/worktree/merge/check", asyncRoute(async (req, res) => {
|
|
692
728
|
try {
|
|
693
729
|
const current = requireWorktreeSession(sessions.getLatest(req.params.id));
|
|
@@ -1016,7 +1052,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
|
|
|
1016
1052
|
const rawBudget = req.query.blockBudget;
|
|
1017
1053
|
if (typeof rawBudget === "string" && /^\d+$/.test(rawBudget) && Number(rawBudget) > 0) {
|
|
1018
1054
|
const blockBudget = parseBoundedInteger(rawBudget, 1, 1, 2_000);
|
|
1019
|
-
const windowed = blockWindowMessagesForTransport(enrichStructuredMessages(snapshot.messages ?? []), config.cardDefaults ?? {}, blockBudget);
|
|
1055
|
+
const windowed = blockWindowMessagesForTransport(enrichStructuredMessages(snapshot.messages ?? [], snapshot.id), config.cardDefaults ?? {}, blockBudget);
|
|
1020
1056
|
res.json(toSessionDetailDTO(snapshot, {
|
|
1021
1057
|
output: transcriptOutput,
|
|
1022
1058
|
messages: windowed.messages,
|
|
@@ -1028,7 +1064,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
|
|
|
1028
1064
|
return;
|
|
1029
1065
|
}
|
|
1030
1066
|
// 与 WS init 对齐:只回最近一窗 turn + offset/total,更早的走 /messages 翻页。
|
|
1031
|
-
const windowed = windowMessagesForTransport(enrichStructuredMessages(snapshot.messages ?? []), config.cardDefaults ?? {});
|
|
1067
|
+
const windowed = windowMessagesForTransport(enrichStructuredMessages(snapshot.messages ?? [], snapshot.id), config.cardDefaults ?? {});
|
|
1032
1068
|
res.json(toSessionDetailDTO(snapshot, {
|
|
1033
1069
|
output: transcriptOutput,
|
|
1034
1070
|
messages: windowed.messages,
|
|
@@ -1048,7 +1084,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
|
|
|
1048
1084
|
res.status(404).json({ error: "未找到该会话,可能已被删除。" });
|
|
1049
1085
|
return;
|
|
1050
1086
|
}
|
|
1051
|
-
const all = enrichStructuredMessages(snapshot.messages ?? []);
|
|
1087
|
+
const all = enrichStructuredMessages(snapshot.messages ?? [], snapshot.id);
|
|
1052
1088
|
const total = all.length;
|
|
1053
1089
|
// 块级翻页(iOS):?turn=<i>&blockOffset=<当前 leading 偏移>&blockLimit=<N>
|
|
1054
1090
|
// 取该 turn 的 [start, blockOffset) 段(start = max(0, blockOffset - blockLimit))。
|
|
@@ -1069,7 +1105,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
|
|
|
1069
1105
|
const blocks = enrichStructuredMessages([{
|
|
1070
1106
|
...turn,
|
|
1071
1107
|
content: sliceTurnBlocksForTransport(turn, blockStart, blockEnd, config.cardDefaults ?? {}),
|
|
1072
|
-
}])[0].content;
|
|
1108
|
+
}], snapshot.id)[0].content;
|
|
1073
1109
|
res.json({ wandProtocolVersion: WAND_PROTOCOL_VERSION, turnIndex, blocks, blockOffset: blockStart, blockTotal });
|
|
1074
1110
|
return;
|
|
1075
1111
|
}
|
|
@@ -1078,7 +1114,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
|
|
|
1078
1114
|
const limit = Math.min(Math.max(Number.isFinite(rawLimit) ? rawLimit : 40, 1), 200);
|
|
1079
1115
|
const offset = Math.min(Math.max(Number.isFinite(rawOffset) ? rawOffset : 0, 0), total);
|
|
1080
1116
|
const end = Math.min(offset + limit, total);
|
|
1081
|
-
const slice = enrichStructuredMessages(truncateMessagesForTransport(all.slice(offset, end), config.cardDefaults ?? {}));
|
|
1117
|
+
const slice = enrichStructuredMessages(truncateMessagesForTransport(all.slice(offset, end), config.cardDefaults ?? {}), snapshot.id);
|
|
1082
1118
|
res.json({ wandProtocolVersion: WAND_PROTOCOL_VERSION, messages: slice, offset, total });
|
|
1083
1119
|
});
|
|
1084
1120
|
app.post("/api/sessions/:id/resume", asyncRoute(async (req, res) => {
|