@co0ontty/wand 4.72.2 → 4.73.0
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 +3 -3
- package/dist/config.js +20 -0
- package/dist/daemon-connection.d.ts +9 -0
- package/dist/daemon-connection.js +86 -0
- package/dist/distribution-manager.d.ts +5 -0
- package/dist/distribution-manager.js +79 -1
- package/dist/git-quick-commit.js +12 -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/native/darwin-x64/wand-render +0 -0
- package/dist/native/darwin-x64/wand-render.sha256 +1 -0
- package/dist/native/darwin-x64/wand-render.version +1 -0
- package/dist/native/linux-arm64/wand-render +0 -0
- package/dist/native/linux-arm64/wand-render.sha256 +1 -0
- package/dist/native/linux-arm64/wand-render.version +1 -0
- package/dist/native/linux-x64/wand-render +0 -0
- package/dist/native/linux-x64/wand-render.sha256 +1 -0
- package/dist/native/linux-x64/wand-render.version +1 -0
- package/dist/process-manager.d.ts +10 -0
- package/dist/process-manager.js +73 -4
- package/dist/render-binary.d.ts +61 -0
- package/dist/render-binary.js +204 -0
- package/dist/render-daemon-client.d.ts +171 -0
- package/dist/render-daemon-client.js +1114 -0
- package/dist/render-host.d.ts +64 -0
- package/dist/render-host.js +301 -0
- package/dist/render-protocol.d.ts +128 -0
- package/dist/render-protocol.js +99 -0
- package/dist/render-structured-client.d.ts +80 -0
- package/dist/render-structured-client.js +513 -0
- package/dist/render-structured-host.d.ts +31 -0
- package/dist/render-structured-host.js +218 -0
- package/dist/render-structured-protocol.d.ts +101 -0
- package/dist/render-structured-protocol.js +35 -0
- package/dist/server-session-routes.js +72 -9
- package/dist/server-task-routes.js +52 -3
- package/dist/server-update-routes.d.ts +2 -0
- package/dist/server-update-routes.js +44 -0
- package/dist/server.js +31 -9
- package/dist/session-transport.js +1 -1
- package/dist/storage.d.ts +2 -1
- package/dist/storage.js +14 -5
- package/dist/structured-client-protocol.d.ts +3 -1
- package/dist/structured-client-protocol.js +85 -25
- package/dist/structured-content.d.ts +20 -3
- package/dist/structured-content.js +80 -1
- package/dist/structured-exec-host.js +7 -1
- package/dist/structured-exec-pump.js +6 -2
- package/dist/structured-pi-adapter.js +18 -2
- package/dist/structured-session-manager.d.ts +2 -0
- package/dist/structured-session-manager.js +27 -3
- package/dist/task-types.d.ts +2 -0
- package/dist/terminal-daemon-client.d.ts +9 -2
- package/dist/terminal-daemon-client.js +119 -46
- package/dist/terminal-daemon-server.js +20 -4
- package/dist/terminal-host.d.ts +6 -0
- package/dist/terminal-host.js +3 -0
- package/dist/tui/ipc-server.js +12 -0
- package/dist/types.d.ts +19 -1
- package/dist/unix-socket-keepalive.d.ts +3 -0
- package/dist/unix-socket-keepalive.js +94 -0
- package/dist/web-ui/content/scripts.js +68 -68
- package/dist/web-ui/content/styles.css +1 -1
- package/dist/web-ui/content/tailwind.css +12 -0
- package/dist/web-ui/embedded-assets.js +2 -2
- package/dist/ws-broadcast.d.ts +1 -1
- package/dist/ws-broadcast.js +17 -5
- package/package.json +8 -3
- package/scripts/install-render-binary.js +499 -0
- package/scripts/stage-render-binaries.js +626 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync, lstatSync, readFileSync, unlinkSync } from "node:fs";
|
|
3
|
+
import net from "node:net";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
6
|
+
import { getErrorMessage } from "./error-utils.js";
|
|
7
|
+
import { currentRenderTriple, isExecutableFile } from "./render-binary.js";
|
|
8
|
+
import { RenderStructuredClient } from "./render-structured-client.js";
|
|
9
|
+
import { structuredRenderPaths } from "./render-structured-protocol.js";
|
|
10
|
+
const READY_TIMEOUT_MS = 5_000;
|
|
11
|
+
/** Resolve the separate v2 executable; do not confuse it with PTY v1 wand-render. */
|
|
12
|
+
export function resolveStructuredRenderBinary(configPath) {
|
|
13
|
+
const explicit = process.env.WAND_STRUCTURED_RENDER_BIN?.trim();
|
|
14
|
+
if (explicit) {
|
|
15
|
+
if (!isExecutableFile(explicit))
|
|
16
|
+
throw new Error("WAND_STRUCTURED_RENDER_BIN is not executable");
|
|
17
|
+
return explicit;
|
|
18
|
+
}
|
|
19
|
+
const binary = process.platform === "win32" ? "wand-structured-renderd.exe" : "wand-structured-renderd";
|
|
20
|
+
const root = path.resolve(import.meta.dirname, "..");
|
|
21
|
+
const choices = [
|
|
22
|
+
path.join(root, "render", "target", "release", binary),
|
|
23
|
+
path.join(root, "render", "target", "debug", binary),
|
|
24
|
+
path.join(path.dirname(configPath), "bin", binary),
|
|
25
|
+
path.join(root, "dist", "native", currentRenderTriple(), binary),
|
|
26
|
+
...(process.env.PATH ?? "").split(path.delimiter).filter(Boolean).map((dir) => path.join(dir, binary)),
|
|
27
|
+
];
|
|
28
|
+
return choices.find(isExecutableFile) ?? null;
|
|
29
|
+
}
|
|
30
|
+
function livePid(file) {
|
|
31
|
+
try {
|
|
32
|
+
const pid = Number(readFileSync(file, "utf8").trim());
|
|
33
|
+
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
34
|
+
return null;
|
|
35
|
+
process.kill(pid, 0);
|
|
36
|
+
return pid;
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
return error.code === "EPERM" ? -1 : null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
async function socketIsListening(socketPath) {
|
|
43
|
+
return new Promise((resolve) => {
|
|
44
|
+
const socket = net.createConnection(socketPath);
|
|
45
|
+
let done = false;
|
|
46
|
+
const finish = (listening) => {
|
|
47
|
+
if (done)
|
|
48
|
+
return;
|
|
49
|
+
done = true;
|
|
50
|
+
clearTimeout(timer);
|
|
51
|
+
socket.destroy();
|
|
52
|
+
resolve(listening);
|
|
53
|
+
};
|
|
54
|
+
const timer = setTimeout(() => finish(false), 250);
|
|
55
|
+
socket.once("connect", () => finish(true));
|
|
56
|
+
socket.once("error", () => finish(false));
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
async function connectExisting(configPath) {
|
|
60
|
+
const paths = structuredRenderPaths(configPath);
|
|
61
|
+
if (!existsSync(paths.socketPath))
|
|
62
|
+
return null;
|
|
63
|
+
const client = new RenderStructuredClient(configPath);
|
|
64
|
+
try {
|
|
65
|
+
await client.connect();
|
|
66
|
+
return client;
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
client.disconnect();
|
|
70
|
+
if (await socketIsListening(paths.socketPath) || livePid(paths.pidPath) !== null) {
|
|
71
|
+
throw new Error(`Structured Render endpoint exists but refused adoption: ${getErrorMessage(error)}`);
|
|
72
|
+
}
|
|
73
|
+
// Only remove our own private, unlistened stale socket. Never unlink a
|
|
74
|
+
// foreign path or race a live daemon's startup/credential rotation.
|
|
75
|
+
const stat = lstatSync(paths.socketPath);
|
|
76
|
+
const ownUid = typeof process.getuid !== "function" || stat.uid === process.getuid();
|
|
77
|
+
if (!stat.isSocket() || !ownUid || (stat.mode & 0o777) !== 0o600) {
|
|
78
|
+
throw new Error("Structured Render endpoint is not a private socket; refusing cleanup");
|
|
79
|
+
}
|
|
80
|
+
unlinkSync(paths.socketPath);
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async function startV2(configPath) {
|
|
85
|
+
const existing = await connectExisting(configPath);
|
|
86
|
+
if (existing)
|
|
87
|
+
return existing;
|
|
88
|
+
const paths = structuredRenderPaths(configPath);
|
|
89
|
+
if (livePid(paths.pidPath) !== null) {
|
|
90
|
+
const deadline = Date.now() + READY_TIMEOUT_MS;
|
|
91
|
+
while (Date.now() < deadline) {
|
|
92
|
+
const starting = await connectExisting(configPath);
|
|
93
|
+
if (starting)
|
|
94
|
+
return starting;
|
|
95
|
+
await delay(50);
|
|
96
|
+
}
|
|
97
|
+
throw new Error("Structured Render PID is alive but its socket is unavailable; refusing a competing daemon");
|
|
98
|
+
}
|
|
99
|
+
const binary = resolveStructuredRenderBinary(configPath);
|
|
100
|
+
if (!binary)
|
|
101
|
+
throw new Error("structured.processHost=rust requires a v2 binary; build wand-structured-renderd or set WAND_STRUCTURED_RENDER_BIN");
|
|
102
|
+
const child = spawn(binary, ["--config", configPath], {
|
|
103
|
+
detached: true, stdio: "ignore", cwd: process.cwd(), env: process.env,
|
|
104
|
+
});
|
|
105
|
+
child.on("error", (error) => process.stderr.write(`[wand] Structured Render launch failed: ${getErrorMessage(error)}\n`));
|
|
106
|
+
child.unref();
|
|
107
|
+
const deadline = Date.now() + READY_TIMEOUT_MS;
|
|
108
|
+
while (Date.now() < deadline) {
|
|
109
|
+
try {
|
|
110
|
+
const client = await connectExisting(configPath);
|
|
111
|
+
if (client)
|
|
112
|
+
return client;
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
// A live but incompatible owner must not be replaced or silently downgraded.
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
await delay(50);
|
|
119
|
+
}
|
|
120
|
+
if (child.pid) {
|
|
121
|
+
try {
|
|
122
|
+
process.kill(child.pid, "SIGTERM");
|
|
123
|
+
}
|
|
124
|
+
catch { /* child already exited */ }
|
|
125
|
+
}
|
|
126
|
+
throw new Error("Structured Render v2 did not become ready; refusing legacy fallback");
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* A stable runId can belong to exactly one persistent daemon. Configuration
|
|
130
|
+
* chooses only new runs; existing owners are always resolved by inventories.
|
|
131
|
+
*/
|
|
132
|
+
export class CompositeStructuredExecHost {
|
|
133
|
+
legacy;
|
|
134
|
+
rust;
|
|
135
|
+
newOwner;
|
|
136
|
+
persistent = true;
|
|
137
|
+
owners = new Map();
|
|
138
|
+
starting = new Map();
|
|
139
|
+
constructor(legacy, rust, newOwner) {
|
|
140
|
+
this.legacy = legacy;
|
|
141
|
+
this.rust = rust;
|
|
142
|
+
this.newOwner = newOwner;
|
|
143
|
+
if (!legacy && !rust)
|
|
144
|
+
throw new Error("at least one structured daemon is required");
|
|
145
|
+
}
|
|
146
|
+
getHost(owner) {
|
|
147
|
+
return owner === "legacy" ? this.legacy : this.rust;
|
|
148
|
+
}
|
|
149
|
+
async listRuns() {
|
|
150
|
+
const [legacyRuns, rustRuns] = await Promise.all([
|
|
151
|
+
this.legacy?.listRuns() ?? Promise.resolve([]),
|
|
152
|
+
this.rust?.listRuns() ?? Promise.resolve([]),
|
|
153
|
+
]);
|
|
154
|
+
const legacyIds = new Set(legacyRuns.map((run) => run.runId));
|
|
155
|
+
for (const run of rustRuns) {
|
|
156
|
+
if (legacyIds.has(run.runId))
|
|
157
|
+
throw new Error(`structured run ${run.runId} has competing owners; refusing adoption`);
|
|
158
|
+
}
|
|
159
|
+
for (const run of legacyRuns)
|
|
160
|
+
this.owners.set(run.runId, "legacy");
|
|
161
|
+
for (const run of rustRuns)
|
|
162
|
+
this.owners.set(run.runId, "rust");
|
|
163
|
+
return [...legacyRuns, ...rustRuns];
|
|
164
|
+
}
|
|
165
|
+
async ownerOf(runId) {
|
|
166
|
+
const cached = this.owners.get(runId);
|
|
167
|
+
if (cached)
|
|
168
|
+
return cached;
|
|
169
|
+
await this.listRuns();
|
|
170
|
+
return this.owners.get(runId) ?? null;
|
|
171
|
+
}
|
|
172
|
+
async spawnStructured(request) {
|
|
173
|
+
const pending = this.starting.get(request.runId);
|
|
174
|
+
if (pending)
|
|
175
|
+
return pending;
|
|
176
|
+
const attempt = (async () => {
|
|
177
|
+
const owner = (await this.ownerOf(request.runId)) ?? this.newOwner;
|
|
178
|
+
const host = this.getHost(owner);
|
|
179
|
+
if (!host)
|
|
180
|
+
throw new Error(`structured owner ${owner} is unavailable; no fallback across owners`);
|
|
181
|
+
const handle = await host.spawnStructured(request);
|
|
182
|
+
this.owners.set(request.runId, owner);
|
|
183
|
+
return handle;
|
|
184
|
+
})();
|
|
185
|
+
this.starting.set(request.runId, attempt);
|
|
186
|
+
try {
|
|
187
|
+
return await attempt;
|
|
188
|
+
}
|
|
189
|
+
finally {
|
|
190
|
+
if (this.starting.get(request.runId) === attempt)
|
|
191
|
+
this.starting.delete(request.runId);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
async attachRun(runId) {
|
|
195
|
+
const owner = await this.ownerOf(runId);
|
|
196
|
+
return owner ? this.getHost(owner)?.attachRun(runId) ?? null : null;
|
|
197
|
+
}
|
|
198
|
+
async adoptRun(runId) {
|
|
199
|
+
const owner = await this.ownerOf(runId);
|
|
200
|
+
return owner ? this.getHost(owner)?.adoptRun(runId) ?? null : null;
|
|
201
|
+
}
|
|
202
|
+
forgetRun(runId) {
|
|
203
|
+
const owner = this.owners.get(runId);
|
|
204
|
+
this.owners.delete(runId);
|
|
205
|
+
if (owner)
|
|
206
|
+
this.getHost(owner)?.forgetRun(runId);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/** Default legacy. v2 is adopted even on rollback when it still owns runs. */
|
|
210
|
+
export async function createUpgradeAwareStructuredHost(configPath, legacy, processHost = "legacy") {
|
|
211
|
+
if (process.env.WAND_TEST_MODE === "1" || process.env.NODE_TEST_CONTEXT) {
|
|
212
|
+
return { host: legacy ?? undefined, rustClient: null };
|
|
213
|
+
}
|
|
214
|
+
const rustClient = processHost === "rust" ? await startV2(configPath) : await connectExisting(configPath);
|
|
215
|
+
if (!rustClient && !legacy)
|
|
216
|
+
return { host: undefined, rustClient: null };
|
|
217
|
+
return { host: new CompositeStructuredExecHost(legacy, rustClient, processHost), rustClient };
|
|
218
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/** Separate structured process daemon. v1 PTY socket and wire format are unchanged. */
|
|
2
|
+
export declare const STRUCTURED_RENDER_PROTOCOL_VERSION = 2;
|
|
3
|
+
export declare const STRUCTURED_RENDER_RUN_LOG_MAX_BYTES: number;
|
|
4
|
+
export declare const STRUCTURED_RENDER_REPLAY_PAGE_MAX_BYTES: number;
|
|
5
|
+
/** Envelope and length-prefixed JSON framing are identical to Render v1. */
|
|
6
|
+
export { encodeRenderFrame, decodeRenderFrames } from "./render-protocol.js";
|
|
7
|
+
export type { RenderResponse, RenderErrorCode } from "./render-protocol.js";
|
|
8
|
+
export interface StructuredRenderRunState {
|
|
9
|
+
runId: string;
|
|
10
|
+
incarnationId: string;
|
|
11
|
+
pid: number;
|
|
12
|
+
status: "running" | "exited";
|
|
13
|
+
exitCode: number | null;
|
|
14
|
+
signal: number | null;
|
|
15
|
+
stdoutSeq: number;
|
|
16
|
+
stderrSeq: number;
|
|
17
|
+
stdoutTruncated: boolean;
|
|
18
|
+
stderrTruncated: boolean;
|
|
19
|
+
}
|
|
20
|
+
export interface StructuredRenderSpawnParams {
|
|
21
|
+
runId: string;
|
|
22
|
+
file: string;
|
|
23
|
+
args: string[];
|
|
24
|
+
cwd: string;
|
|
25
|
+
env: Record<string, string>;
|
|
26
|
+
stdinData?: string;
|
|
27
|
+
}
|
|
28
|
+
export interface StructuredRenderSpawnResult {
|
|
29
|
+
state: StructuredRenderRunState;
|
|
30
|
+
isNew: boolean;
|
|
31
|
+
}
|
|
32
|
+
/** Inventory deliberately carries no raw bytes, prompt, argv, cwd or env. */
|
|
33
|
+
export interface StructuredRenderListResult {
|
|
34
|
+
runs: StructuredRenderRunState[];
|
|
35
|
+
}
|
|
36
|
+
export interface StructuredRenderAttachParams {
|
|
37
|
+
runId: string;
|
|
38
|
+
afterStdoutSeq?: number;
|
|
39
|
+
afterStderrSeq?: number;
|
|
40
|
+
maxBytes?: number;
|
|
41
|
+
}
|
|
42
|
+
export interface StructuredRenderReplayStream {
|
|
43
|
+
chunks: {
|
|
44
|
+
seq: number;
|
|
45
|
+
data: string;
|
|
46
|
+
}[];
|
|
47
|
+
nextSeq: number;
|
|
48
|
+
complete: boolean;
|
|
49
|
+
resetRequired: boolean;
|
|
50
|
+
}
|
|
51
|
+
export interface StructuredRenderAttachResult {
|
|
52
|
+
state: StructuredRenderRunState;
|
|
53
|
+
stdout: StructuredRenderReplayStream;
|
|
54
|
+
stderr: StructuredRenderReplayStream;
|
|
55
|
+
}
|
|
56
|
+
export interface StructuredRenderHelloResult {
|
|
57
|
+
version: string;
|
|
58
|
+
protocolVersion: number;
|
|
59
|
+
pid: number;
|
|
60
|
+
startedAt: string;
|
|
61
|
+
runs: number;
|
|
62
|
+
}
|
|
63
|
+
export interface StructuredRenderStatsResult {
|
|
64
|
+
runs: number;
|
|
65
|
+
runningRuns: number;
|
|
66
|
+
retainedLogBytes: number;
|
|
67
|
+
rssBytes: number;
|
|
68
|
+
}
|
|
69
|
+
export type StructuredRenderEvent = {
|
|
70
|
+
event: "stream";
|
|
71
|
+
runId: string;
|
|
72
|
+
incarnationId: string;
|
|
73
|
+
stream: "stdout" | "stderr";
|
|
74
|
+
seq: number;
|
|
75
|
+
data: string;
|
|
76
|
+
} | {
|
|
77
|
+
event: "exit";
|
|
78
|
+
runId: string;
|
|
79
|
+
incarnationId: string;
|
|
80
|
+
exitCode: number | null;
|
|
81
|
+
signal: number | null;
|
|
82
|
+
} | {
|
|
83
|
+
event: "reconcile";
|
|
84
|
+
runIds: string[];
|
|
85
|
+
};
|
|
86
|
+
export type StructuredRenderMethod = "hello" | "ping" | "list" | "spawn" | "attach" | "interrupt" | "forget" | "stats" | "shutdown";
|
|
87
|
+
export interface StructuredRenderRequest {
|
|
88
|
+
id: number;
|
|
89
|
+
token: string;
|
|
90
|
+
protocolVersion: number;
|
|
91
|
+
method: StructuredRenderMethod;
|
|
92
|
+
params?: Record<string, unknown>;
|
|
93
|
+
}
|
|
94
|
+
export interface StructuredRenderPaths {
|
|
95
|
+
socketPath: string;
|
|
96
|
+
tokenPath: string;
|
|
97
|
+
pidPath: string;
|
|
98
|
+
metaPath: string;
|
|
99
|
+
}
|
|
100
|
+
/** Mirrors Rust normalized_absolute() and config_suffix(), in an independent namespace. */
|
|
101
|
+
export declare function structuredRenderPaths(configPath: string): StructuredRenderPaths;
|
|
@@ -0,0 +1,35 @@
|
|
|
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
|
+
/** Separate structured process daemon. v1 PTY socket and wire format are unchanged. */
|
|
6
|
+
export const STRUCTURED_RENDER_PROTOCOL_VERSION = 2;
|
|
7
|
+
export const STRUCTURED_RENDER_RUN_LOG_MAX_BYTES = 8 * 1024 * 1024;
|
|
8
|
+
export const STRUCTURED_RENDER_REPLAY_PAGE_MAX_BYTES = 512 * 1024;
|
|
9
|
+
/** Envelope and length-prefixed JSON framing are identical to Render v1. */
|
|
10
|
+
export { encodeRenderFrame, decodeRenderFrames } from "./render-protocol.js";
|
|
11
|
+
/** Mirrors Rust normalized_absolute() and config_suffix(), in an independent namespace. */
|
|
12
|
+
export function structuredRenderPaths(configPath) {
|
|
13
|
+
const absolute = path.resolve(configPath);
|
|
14
|
+
let resolved = absolute;
|
|
15
|
+
try {
|
|
16
|
+
resolved = realpathSync(absolute);
|
|
17
|
+
}
|
|
18
|
+
catch { /* config may not exist yet */ }
|
|
19
|
+
const suffix = createHash("sha256").update(resolved).digest("hex").slice(0, 12);
|
|
20
|
+
const dir = path.dirname(resolved);
|
|
21
|
+
const uid = (() => {
|
|
22
|
+
try {
|
|
23
|
+
return os.userInfo().uid;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return 0;
|
|
27
|
+
}
|
|
28
|
+
})();
|
|
29
|
+
return {
|
|
30
|
+
socketPath: path.join("/tmp", `wand-structured-render-${uid}-${suffix}.sock`),
|
|
31
|
+
tokenPath: path.join(dir, `.structured-render-${suffix}.token`),
|
|
32
|
+
pidPath: path.join(dir, `.structured-render-${suffix}.pid`),
|
|
33
|
+
metaPath: path.join(dir, `.structured-render-${suffix}.json`),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { SessionInputError } from "./process-manager.js";
|
|
2
|
+
import { PtyInputDeliveryError, SessionInputError } from "./process-manager.js";
|
|
3
3
|
import { getDefaultModelForProvider, isExecutionMode } from "./config.js";
|
|
4
4
|
import { blockWindowMessagesForTransport, sliceTurnBlocksForTransport, truncateMessagesForTransport, windowMessagesForTransport } from "./message-truncator.js";
|
|
5
5
|
import { toSessionDetailDTO, toSessionListItemDTO } from "./session-transport.js";
|
|
@@ -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";
|
|
@@ -64,6 +65,17 @@ export function parseSessionCreationOrigin(body) {
|
|
|
64
65
|
};
|
|
65
66
|
}
|
|
66
67
|
function getInputErrorResponse(error, sessionId) {
|
|
68
|
+
if (error instanceof PtyInputDeliveryError) {
|
|
69
|
+
return {
|
|
70
|
+
statusCode: 503,
|
|
71
|
+
payload: {
|
|
72
|
+
error: "PTY 输入结果未获确认;请先检查终端输出,不要直接重复提交。",
|
|
73
|
+
errorCode: "INPUT_DELIVERY_UNCONFIRMED",
|
|
74
|
+
sessionId,
|
|
75
|
+
sessionStatus: null,
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
67
79
|
if (error instanceof SessionInputError) {
|
|
68
80
|
const statusCode = error.code === "SESSION_NOT_FOUND" ? 404 : 409;
|
|
69
81
|
return {
|
|
@@ -351,7 +363,7 @@ function isMergeActionAllowed(snapshot) {
|
|
|
351
363
|
}
|
|
352
364
|
export function registerSessionRoutes(app, processes, structured, storage, defaultMode, config, sessions, onSessionCreated) {
|
|
353
365
|
const sessionResponseDTO = (snapshot) => {
|
|
354
|
-
const windowed = windowMessagesForTransport(enrichStructuredMessages(snapshot.messages ?? []), config.cardDefaults ?? {});
|
|
366
|
+
const windowed = windowMessagesForTransport(enrichStructuredMessages(snapshot.messages ?? [], snapshot.id), config.cardDefaults ?? {});
|
|
355
367
|
return toSessionDetailDTO(snapshot, {
|
|
356
368
|
messages: windowed.messages,
|
|
357
369
|
messageOffset: windowed.messageOffset,
|
|
@@ -620,6 +632,19 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
|
|
|
620
632
|
sendRouteError(res, error, "无法调整排队顺序。");
|
|
621
633
|
}
|
|
622
634
|
});
|
|
635
|
+
app.patch("/api/structured-sessions/:id/queued/:index", (req, res) => {
|
|
636
|
+
const { expectedText, text } = req.body ?? {};
|
|
637
|
+
if (typeof expectedText !== "string" || typeof text !== "string" || !text.trim()) {
|
|
638
|
+
res.status(400).json({ error: "排队消息不能为空,且必须提供原文。" });
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
try {
|
|
642
|
+
res.json(sessionResponseDTO(structured.editQueuedMessage(req.params.id, Number(req.params.index), expectedText, text)));
|
|
643
|
+
}
|
|
644
|
+
catch (error) {
|
|
645
|
+
sendRouteError(res, error, "无法编辑排队消息。");
|
|
646
|
+
}
|
|
647
|
+
});
|
|
623
648
|
app.delete("/api/structured-sessions/:id/queued/:index", (req, res) => {
|
|
624
649
|
const index = Number(req.params.index);
|
|
625
650
|
if (!Number.isFinite(index)) {
|
|
@@ -688,6 +713,41 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
|
|
|
688
713
|
}
|
|
689
714
|
res.status(404).json({ error: "未找到该工具结果。" });
|
|
690
715
|
});
|
|
716
|
+
// ── Inline tool image endpoint ──
|
|
717
|
+
// tool_result 里内联的 base64 图片在传输层被改写为这个 URL(避免把整张图的 base64
|
|
718
|
+
// 塞进每次快照 / WS 帧)。各端用同一套带鉴权的取图通道按需加载。
|
|
719
|
+
app.get("/api/sessions/:id/tool-images/:toolUseId/:index", (req, res) => {
|
|
720
|
+
const snapshot = sessions.get(req.params.id);
|
|
721
|
+
if (!snapshot) {
|
|
722
|
+
res.status(404).json({ error: "未找到该会话。" });
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
const toolUseId = req.params.toolUseId;
|
|
726
|
+
const index = Number.parseInt(req.params.index, 10);
|
|
727
|
+
if (!Number.isFinite(index) || index < 0) {
|
|
728
|
+
res.status(400).json({ error: "无效的图片序号。" });
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
for (const turn of snapshot.messages ?? []) {
|
|
732
|
+
for (const block of turn.content) {
|
|
733
|
+
if (block.type !== "tool_result" || block.tool_use_id !== toolUseId || !Array.isArray(block.content))
|
|
734
|
+
continue;
|
|
735
|
+
const part = block.content.filter(isStructuredImagePart)[index];
|
|
736
|
+
const source = asRecord(part?.source);
|
|
737
|
+
const data = typeof source?.data === "string" ? source.data : undefined;
|
|
738
|
+
if (source?.type !== "base64" || !data) {
|
|
739
|
+
res.status(404).json({ error: "未找到该图片。" });
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
res.setHeader("Content-Type", typeof source.media_type === "string" ? source.media_type : "image/png");
|
|
743
|
+
res.setHeader("Cache-Control", "private, max-age=3600");
|
|
744
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
745
|
+
res.send(Buffer.from(data, "base64"));
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
res.status(404).json({ error: "未找到该工具结果。" });
|
|
750
|
+
});
|
|
691
751
|
app.post("/api/sessions/:id/worktree/merge/check", asyncRoute(async (req, res) => {
|
|
692
752
|
try {
|
|
693
753
|
const current = requireWorktreeSession(sessions.getLatest(req.params.id));
|
|
@@ -1016,7 +1076,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
|
|
|
1016
1076
|
const rawBudget = req.query.blockBudget;
|
|
1017
1077
|
if (typeof rawBudget === "string" && /^\d+$/.test(rawBudget) && Number(rawBudget) > 0) {
|
|
1018
1078
|
const blockBudget = parseBoundedInteger(rawBudget, 1, 1, 2_000);
|
|
1019
|
-
const windowed = blockWindowMessagesForTransport(enrichStructuredMessages(snapshot.messages ?? []), config.cardDefaults ?? {}, blockBudget);
|
|
1079
|
+
const windowed = blockWindowMessagesForTransport(enrichStructuredMessages(snapshot.messages ?? [], snapshot.id), config.cardDefaults ?? {}, blockBudget);
|
|
1020
1080
|
res.json(toSessionDetailDTO(snapshot, {
|
|
1021
1081
|
output: transcriptOutput,
|
|
1022
1082
|
messages: windowed.messages,
|
|
@@ -1028,7 +1088,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
|
|
|
1028
1088
|
return;
|
|
1029
1089
|
}
|
|
1030
1090
|
// 与 WS init 对齐:只回最近一窗 turn + offset/total,更早的走 /messages 翻页。
|
|
1031
|
-
const windowed = windowMessagesForTransport(enrichStructuredMessages(snapshot.messages ?? []), config.cardDefaults ?? {});
|
|
1091
|
+
const windowed = windowMessagesForTransport(enrichStructuredMessages(snapshot.messages ?? [], snapshot.id), config.cardDefaults ?? {});
|
|
1032
1092
|
res.json(toSessionDetailDTO(snapshot, {
|
|
1033
1093
|
output: transcriptOutput,
|
|
1034
1094
|
messages: windowed.messages,
|
|
@@ -1048,7 +1108,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
|
|
|
1048
1108
|
res.status(404).json({ error: "未找到该会话,可能已被删除。" });
|
|
1049
1109
|
return;
|
|
1050
1110
|
}
|
|
1051
|
-
const all = enrichStructuredMessages(snapshot.messages ?? []);
|
|
1111
|
+
const all = enrichStructuredMessages(snapshot.messages ?? [], snapshot.id);
|
|
1052
1112
|
const total = all.length;
|
|
1053
1113
|
// 块级翻页(iOS):?turn=<i>&blockOffset=<当前 leading 偏移>&blockLimit=<N>
|
|
1054
1114
|
// 取该 turn 的 [start, blockOffset) 段(start = max(0, blockOffset - blockLimit))。
|
|
@@ -1069,7 +1129,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
|
|
|
1069
1129
|
const blocks = enrichStructuredMessages([{
|
|
1070
1130
|
...turn,
|
|
1071
1131
|
content: sliceTurnBlocksForTransport(turn, blockStart, blockEnd, config.cardDefaults ?? {}),
|
|
1072
|
-
}])[0].content;
|
|
1132
|
+
}], snapshot.id)[0].content;
|
|
1073
1133
|
res.json({ wandProtocolVersion: WAND_PROTOCOL_VERSION, turnIndex, blocks, blockOffset: blockStart, blockTotal });
|
|
1074
1134
|
return;
|
|
1075
1135
|
}
|
|
@@ -1078,7 +1138,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
|
|
|
1078
1138
|
const limit = Math.min(Math.max(Number.isFinite(rawLimit) ? rawLimit : 40, 1), 200);
|
|
1079
1139
|
const offset = Math.min(Math.max(Number.isFinite(rawOffset) ? rawOffset : 0, 0), total);
|
|
1080
1140
|
const end = Math.min(offset + limit, total);
|
|
1081
|
-
const slice = enrichStructuredMessages(truncateMessagesForTransport(all.slice(offset, end), config.cardDefaults ?? {}));
|
|
1141
|
+
const slice = enrichStructuredMessages(truncateMessagesForTransport(all.slice(offset, end), config.cardDefaults ?? {}), snapshot.id);
|
|
1082
1142
|
res.json({ wandProtocolVersion: WAND_PROTOCOL_VERSION, messages: slice, offset, total });
|
|
1083
1143
|
});
|
|
1084
1144
|
app.post("/api/sessions/:id/resume", asyncRoute(async (req, res) => {
|
|
@@ -1207,14 +1267,17 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
|
|
|
1207
1267
|
const autoResumeInput = getAutoResumeInitialInput(existingSession, input, view, shortcutKey);
|
|
1208
1268
|
if (autoResumeInput !== null && canAutoResumePtyForInput(existingSession, autoResumeInput)) {
|
|
1209
1269
|
const snapshot = await startResumedPtySession(processes, storage, existingSession, sessionId, defaultMode, {}, autoResumeInput);
|
|
1270
|
+
// Resume input waits for the provider prompt and is deliberately queued after start.
|
|
1271
|
+
// This branch reports a pending delivery, unlike an already-running PTY write.
|
|
1272
|
+
res.setHeader("X-Wand-Input-Delivery", "pending");
|
|
1210
1273
|
if (body.responseMode === "accepted") {
|
|
1211
|
-
res.status(202).json({ accepted: true });
|
|
1274
|
+
res.status(202).json({ accepted: true, deliveryConfirmed: false });
|
|
1212
1275
|
return;
|
|
1213
1276
|
}
|
|
1214
1277
|
res.json(sessionResponseDTO(snapshot));
|
|
1215
1278
|
return;
|
|
1216
1279
|
}
|
|
1217
|
-
const snapshot = processes.
|
|
1280
|
+
const snapshot = await processes.sendInputConfirmed(sessionId, input, view, shortcutKey);
|
|
1218
1281
|
if (body.responseMode === "accepted") {
|
|
1219
1282
|
res.status(202).json({ accepted: true });
|
|
1220
1283
|
return;
|
|
@@ -53,6 +53,37 @@ function labelsFrom(value) {
|
|
|
53
53
|
return [];
|
|
54
54
|
return value.filter((item) => typeof item === "string").map((item) => item.trim()).filter(Boolean).slice(0, 20);
|
|
55
55
|
}
|
|
56
|
+
/** 新认领只能挂到同项目且正在处理的任务;历史归属在父任务完成后仍保留。 */
|
|
57
|
+
function parentTaskIdFrom(storage, value, workspaceId, childId = null, existingParentId = null) {
|
|
58
|
+
if (value === null || value === "")
|
|
59
|
+
return null;
|
|
60
|
+
if (typeof value !== "string" || !value.trim())
|
|
61
|
+
throw new Error("父任务无效。");
|
|
62
|
+
const parentId = value.trim();
|
|
63
|
+
const parent = storage.getWandTask(parentId);
|
|
64
|
+
if (!parent)
|
|
65
|
+
throw new Error("父任务不存在。");
|
|
66
|
+
if (parent.workspaceId !== workspaceId)
|
|
67
|
+
throw new Error("父任务必须与子任务属于同一项目目录。");
|
|
68
|
+
if (parent.status !== "doing" && parentId !== existingParentId) {
|
|
69
|
+
throw new Error("只能认领到正在处理的父任务。");
|
|
70
|
+
}
|
|
71
|
+
// 不允许自己做父任务,也不允许把祖先接到后代下。
|
|
72
|
+
const visited = new Set();
|
|
73
|
+
let ancestorId = parentId;
|
|
74
|
+
while (ancestorId) {
|
|
75
|
+
if (ancestorId === childId || visited.has(ancestorId))
|
|
76
|
+
throw new Error("父任务不能形成循环。");
|
|
77
|
+
visited.add(ancestorId);
|
|
78
|
+
ancestorId = storage.getWandTask(ancestorId)?.parentTaskId ?? null;
|
|
79
|
+
}
|
|
80
|
+
return parentId;
|
|
81
|
+
}
|
|
82
|
+
function assertTaskWorkspaceMove(storage, taskId, workspaceId) {
|
|
83
|
+
if (storage.listWandTasks().some((task) => task.parentTaskId === taskId && task.workspaceId !== workspaceId)) {
|
|
84
|
+
throw new Error("请先将子任务解除归属,再移动父任务的项目目录。");
|
|
85
|
+
}
|
|
86
|
+
}
|
|
56
87
|
/** 里程碑名:必填、去首尾空白、限长。 */
|
|
57
88
|
function milestoneNameFrom(value) {
|
|
58
89
|
const name = text(value).slice(0, WAND_MILESTONE_NAME_MAX_LENGTH);
|
|
@@ -350,9 +381,13 @@ export function registerTaskRoutes(app, deps) {
|
|
|
350
381
|
app.post("/api/wand-tasks", (req, res) => {
|
|
351
382
|
try {
|
|
352
383
|
const body = bodyObject(req.body);
|
|
353
|
-
const
|
|
384
|
+
const requestedParent = body.parentTaskId == null ? null : storage.getWandTask(text(body.parentTaskId));
|
|
385
|
+
const workspaceId = body.workspaceId === undefined && requestedParent
|
|
386
|
+
? requestedParent.workspaceId
|
|
387
|
+
: body.workspaceId === null ? null : text(body.workspaceId) || null;
|
|
354
388
|
if (workspaceId && !storage.getWorkspace(workspaceId))
|
|
355
389
|
throw new Error("项目不存在。");
|
|
390
|
+
const parentTaskId = parentTaskIdFrom(storage, body.parentTaskId ?? null, workspaceId);
|
|
356
391
|
const description = text(body.description);
|
|
357
392
|
// 标题是可选字段:用户不写就先用描述首行占位,再由模型在后台覆盖。
|
|
358
393
|
const providedTitle = text(body.title);
|
|
@@ -374,6 +409,7 @@ export function registerTaskRoutes(app, deps) {
|
|
|
374
409
|
const task = storage.transaction(() => {
|
|
375
410
|
const card = storage.createWandTask({
|
|
376
411
|
workspaceId,
|
|
412
|
+
parentTaskId,
|
|
377
413
|
title,
|
|
378
414
|
titleSource,
|
|
379
415
|
description,
|
|
@@ -403,6 +439,11 @@ export function registerTaskRoutes(app, deps) {
|
|
|
403
439
|
app.patch("/api/wand-tasks/:id", (req, res) => {
|
|
404
440
|
try {
|
|
405
441
|
const body = bodyObject(req.body);
|
|
442
|
+
const currentTask = storage.getWandTask(req.params.id);
|
|
443
|
+
if (!currentTask) {
|
|
444
|
+
res.status(404).json({ error: "未找到该任务。" });
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
406
447
|
const patch = {};
|
|
407
448
|
if (body.title !== undefined) {
|
|
408
449
|
// 显式改标题(包括原生端的可编辑标题框)都算用户自己写的。
|
|
@@ -444,10 +485,15 @@ export function registerTaskRoutes(app, deps) {
|
|
|
444
485
|
patch.milestoneId = scopedMilestoneId(storage, milestoneId, patch.workspaceId)
|
|
445
486
|
?? defaultMilestoneIdForWrite(storage);
|
|
446
487
|
}
|
|
488
|
+
if (body.parentTaskId !== undefined || body.workspaceId !== undefined) {
|
|
489
|
+
const workspaceId = patch.workspaceId === undefined ? currentTask.workspaceId : patch.workspaceId;
|
|
490
|
+
patch.parentTaskId = parentTaskIdFrom(storage, body.parentTaskId === undefined ? currentTask.parentTaskId : body.parentTaskId, workspaceId, currentTask.id, currentTask.parentTaskId);
|
|
491
|
+
if (workspaceId !== currentTask.workspaceId)
|
|
492
|
+
assertTaskWorkspaceMove(storage, currentTask.id, workspaceId);
|
|
493
|
+
}
|
|
447
494
|
if (body.agent !== undefined) {
|
|
448
495
|
// 老客户端不传 mode / kind:沿用任务当前值,而不是复位成标准 / 结构化。
|
|
449
|
-
|
|
450
|
-
patch.agent = parseTaskAgent(body.agent, current?.agent?.mode, current?.agent?.kind);
|
|
496
|
+
patch.agent = parseTaskAgent(body.agent, currentTask.agent?.mode, currentTask.agent?.kind);
|
|
451
497
|
if (patch.agent)
|
|
452
498
|
writeTaskBoardLastAgent(storage, patch.agent);
|
|
453
499
|
}
|
|
@@ -537,6 +583,9 @@ export function registerTaskRoutes(app, deps) {
|
|
|
537
583
|
const workspaceId = body.workspaceId === null ? null : text(body.workspaceId) || null;
|
|
538
584
|
if (workspaceId && !storage.getWorkspace(workspaceId))
|
|
539
585
|
throw new Error("项目不存在。");
|
|
586
|
+
parentTaskIdFrom(storage, task.parentTaskId, workspaceId, task.id, task.parentTaskId);
|
|
587
|
+
if (workspaceId !== task.workspaceId)
|
|
588
|
+
assertTaskWorkspaceMove(storage, task.id, workspaceId);
|
|
540
589
|
task = storage.updateWandTask(task.id, { workspaceId }) ?? task;
|
|
541
590
|
}
|
|
542
591
|
const workspace = task.workspaceId ? storage.getWorkspace(task.workspaceId) : null;
|
|
@@ -34,6 +34,8 @@ export interface PublicUpdateRoutesDependencies {
|
|
|
34
34
|
computeAssetSha256(asset: DownloadAsset): Promise<string | null>;
|
|
35
35
|
resolveLatestDmg(): Promise<ResolvedUpdateAsset | null>;
|
|
36
36
|
resolveMacosDownload(): Promise<DownloadAsset | null>;
|
|
37
|
+
resolveLatestMacosBeta(): Promise<ResolvedUpdateAsset | null>;
|
|
38
|
+
resolveMacosBetaDownload(fileName: string): Promise<DownloadAsset | null>;
|
|
37
39
|
resolveLatestIpa(): Promise<ResolvedUpdateAsset | null>;
|
|
38
40
|
resolveIosDownload(): Promise<DownloadAsset | null>;
|
|
39
41
|
}
|