mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:40:44 +00:00
Merge branch 'main' into meow/control-chat-responsive
This commit is contained in:
@@ -63,6 +63,8 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Plugins/loader: do not retry native-loaded JavaScript plugin modules through the source transformer after native evaluation has already reached a missing dependency, avoiding duplicate top-level side effects. Thanks @vincentkoc.
|
||||
- Plugins/packages: reject blank `openclaw.runtimeExtensions` entries instead of silently ignoring them and falling back to inferred TypeScript runtime entries. Thanks @vincentkoc.
|
||||
- Doctor/plugins: remove stale managed npm plugin shadow entries from the managed package lock as well as `package.json` and `node_modules`, so future npm operations do not keep referencing repaired bundled-plugin shadows. Thanks @vincentkoc.
|
||||
- Plugins/runtime state: keep the key being registered when namespace eviction runs in the same millisecond as existing entries, so `register` and `registerIfAbsent` do not report success while evicting their own fresh value. Thanks @vincentkoc.
|
||||
- Control UI/Talk: make failed Talk startup errors dismissable and clear the stale Talk error state when dismissed, so missing realtime voice provider configuration does not leave a permanent chat banner. Fixes #77071. Thanks @ijoshdavis.
|
||||
@@ -86,6 +88,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Google Meet: fork the caller's current agent transcript into agent-mode meeting consultant sessions, so Meet replies inherit the context from the tool call that joined the meeting.
|
||||
- iOS/mobile pairing: reject non-loopback `ws://` setup URLs before QR/setup-code issuance and let the iOS Gateway settings screen scan QR codes or paste full setup-code messages. Thanks @BunsDev.
|
||||
- Control UI: keep Gateway Access inputs and locale picker contained inside the card at narrow and tablet widths.
|
||||
- Agents/trajectory: bound runtime trajectory capture and yield queued sidecar writes so oversized traces stop recording instead of monopolizing Gateway cleanup. Fixes #77124. Thanks @loyur.
|
||||
- Telegram/streaming: sanitize tool-progress draft preview backticks before shared compaction, so long backtick-heavy progress text still renders inside the safe code-formatted preview instead of collapsing to an ellipsis.
|
||||
- UI/chat: remove the unsupported `line-clamp` declaration from the chat queue text rule to eliminate Firefox console noise without changing visible truncation behavior. Thanks @ZanderH-code.
|
||||
- Agents/Pi: suppress persistence for synthetic mid-turn overflow continuation prompts, so transcript-retry recovery does not write the "continue from transcript" prompt as a new user turn. Thanks @vincentkoc.
|
||||
@@ -111,6 +114,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Web search: keep first-class assistant `web_search` auto-detect and configured runtime providers visible when active runtime metadata or the active plugin registry is incomplete. Fixes #77073. Thanks @joeykrug.
|
||||
- Plugins/tools: mark manifest-optional sibling tools as optional even when they come from a shared non-optional factory, so cached/status/MCP metadata keeps opt-in tool policy accurate. Thanks @vincentkoc.
|
||||
- Matrix: keep `streaming.progress.toolProgress` scoped to progress draft mode, so partial and quiet Matrix previews do not lose tool progress unless `streaming.preview.toolProgress` is disabled. Thanks @vincentkoc.
|
||||
- Gateway/validation: isolate gateway server validation files, ignore unrelated startup logs in request-trace coverage, and fail fast on stuck shared-auth sockets, reducing false main-branch CI failures for contributors. Thanks @amknight.
|
||||
- Channels/streaming: keep `streaming.progress.toolProgress` scoped to progress draft mode, so disabling compact progress lines does not silence partial/block preview tool updates. Thanks @vincentkoc.
|
||||
- Plugins/update: treat OpenClaw stable correction versions like `2026.5.3-1` as stable releases for npm installs, plugin updates, and bundled-version comparisons, so `latest` can advance official plugins without prerelease opt-in. Thanks @vincentkoc.
|
||||
- Control UI: point the Appearance tweakcn browse action and docs at the live tweakcn editor route instead of the removed `/themes` page. Fixes #77048.
|
||||
|
||||
@@ -181,7 +181,7 @@ OpenClaw redacts sensitive values before writing export files:
|
||||
|
||||
The exporter also bounds input size:
|
||||
|
||||
- runtime sidecar files: 50 MiB
|
||||
- runtime sidecar files: live capture stops at 10 MiB and records a truncation event when space remains; export accepts existing runtime sidecars up to 50 MiB
|
||||
- session files: 50 MiB
|
||||
- runtime events: 200,000
|
||||
- total exported events: 250,000
|
||||
|
||||
@@ -5,18 +5,20 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { clearConfigCache } from "../../dist/config/config.js";
|
||||
import type { OpenClawConfig } from "../../dist/config/types.openclaw.js";
|
||||
import { runCrestodian } from "../../dist/crestodian/crestodian.js";
|
||||
import type { RuntimeEnv } from "../../dist/runtime.js";
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
function createRuntime(): { runtime: RuntimeEnv; lines: string[] } {
|
||||
const lines: string[] = [];
|
||||
function assertOutputIncludes(output, expected, message) {
|
||||
assert(output.includes(expected), `${message}\n\nCaptured Crestodian output:\n${output}`);
|
||||
}
|
||||
|
||||
function createRuntime() {
|
||||
const lines = [];
|
||||
return {
|
||||
lines,
|
||||
runtime: {
|
||||
@@ -29,7 +31,7 @@ function createRuntime(): { runtime: RuntimeEnv; lines: string[] } {
|
||||
};
|
||||
}
|
||||
|
||||
async function installFakeClaudeCli(fakeBinDir: string, promptLogPath: string): Promise<void> {
|
||||
async function installFakeClaudeCli(fakeBinDir, promptLogPath) {
|
||||
await fs.mkdir(fakeBinDir, { recursive: true });
|
||||
const scriptPath = path.join(fakeBinDir, "claude");
|
||||
await fs.writeFile(
|
||||
@@ -75,20 +77,24 @@ async function main() {
|
||||
runtime.runtime,
|
||||
);
|
||||
const output = runtime.lines.join("\n");
|
||||
assert(
|
||||
output.includes("[crestodian] planner: claude-cli/claude-opus-4-7"),
|
||||
assertOutputIncludes(
|
||||
output,
|
||||
"[crestodian] planner: claude-cli/claude-opus-4-7",
|
||||
"configless planner did not use Claude CLI fallback",
|
||||
);
|
||||
assert(
|
||||
output.includes("Fake Claude planner selected a typed model update."),
|
||||
assertOutputIncludes(
|
||||
output,
|
||||
"Fake Claude planner selected a typed model update.",
|
||||
"planner reply was not surfaced",
|
||||
);
|
||||
assert(
|
||||
output.includes("[crestodian] interpreted: set default model openai/gpt-5.2"),
|
||||
assertOutputIncludes(
|
||||
output,
|
||||
"[crestodian] interpreted: set default model openai/gpt-5.2",
|
||||
"planner command was not interpreted",
|
||||
);
|
||||
assert(
|
||||
output.includes("[crestodian] done: config.setDefaultModel"),
|
||||
assertOutputIncludes(
|
||||
output,
|
||||
"[crestodian] done: config.setDefaultModel",
|
||||
"planned model update did not apply",
|
||||
);
|
||||
|
||||
@@ -99,7 +105,7 @@ async function main() {
|
||||
"planner prompt did not include docs reference context",
|
||||
);
|
||||
|
||||
const config = JSON.parse(await fs.readFile(configPath, "utf8")) as OpenClawConfig;
|
||||
const config = JSON.parse(await fs.readFile(configPath, "utf8"));
|
||||
assert(
|
||||
config.agents?.defaults?.model &&
|
||||
typeof config.agents.defaults.model === "object" &&
|
||||
@@ -28,7 +28,7 @@ docker_e2e_run_with_harness \
|
||||
bash -lc "set -euo pipefail
|
||||
source scripts/lib/openclaw-e2e-instance.sh
|
||||
openclaw_e2e_eval_test_state_from_b64 \"\${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}\"
|
||||
tsx scripts/e2e/crestodian-planner-docker-client.ts
|
||||
node scripts/e2e/crestodian-planner-docker-client.mjs
|
||||
" >"$RUN_LOG" 2>&1
|
||||
status=${PIPESTATUS[0]}
|
||||
set -e
|
||||
|
||||
@@ -46,7 +46,7 @@ describe("extractMessagingToolSend", () => {
|
||||
|
||||
expect(result?.tool).toBe("message");
|
||||
expect(result?.provider).toBe("slack");
|
||||
expect(result?.to).toBe("channel:C1");
|
||||
expect(result?.to).toBe("channel:c1");
|
||||
});
|
||||
|
||||
it("accepts target alias when to is omitted", () => {
|
||||
|
||||
@@ -80,4 +80,16 @@ describe("getQueuedFileWriter", () => {
|
||||
|
||||
expect(fs.readFileSync(filePath, "utf8")).toBe("12345\n");
|
||||
});
|
||||
|
||||
it("drops writes that would exceed the pending queue cap", async () => {
|
||||
const tmpDir = makeTempDir();
|
||||
const filePath = path.join(tmpDir, "trace.jsonl");
|
||||
const writer = getQueuedFileWriter(new Map(), filePath, { maxQueuedBytes: 6 });
|
||||
|
||||
expect(writer.write("12345\n")).toBe("queued");
|
||||
expect(writer.write("after\n")).toBe("dropped");
|
||||
await writer.flush();
|
||||
|
||||
expect(fs.readFileSync(filePath, "utf8")).toBe("12345\n");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,14 +2,18 @@ import nodeFs from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
export type QueuedFileWriteResult = "queued" | "dropped";
|
||||
|
||||
export type QueuedFileWriter = {
|
||||
filePath: string;
|
||||
write: (line: string) => void;
|
||||
write: (line: string) => unknown;
|
||||
flush: () => Promise<void>;
|
||||
};
|
||||
|
||||
type QueuedFileWriterOptions = {
|
||||
maxFileBytes?: number;
|
||||
maxQueuedBytes?: number;
|
||||
yieldBeforeWrite?: boolean;
|
||||
};
|
||||
|
||||
type QueuedFileAppendFlagConstants = Pick<
|
||||
@@ -111,6 +115,12 @@ async function safeAppendFile(
|
||||
}
|
||||
}
|
||||
|
||||
function waitForImmediate(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
export function getQueuedFileWriter(
|
||||
writers: Map<string, QueuedFileWriter>,
|
||||
filePath: string,
|
||||
@@ -123,15 +133,29 @@ export function getQueuedFileWriter(
|
||||
|
||||
const dir = path.dirname(filePath);
|
||||
const ready = fs.mkdir(dir, { recursive: true, mode: 0o700 }).catch(() => undefined);
|
||||
let queue = Promise.resolve();
|
||||
let queue: Promise<unknown> = Promise.resolve();
|
||||
let queuedBytes = 0;
|
||||
|
||||
const writer: QueuedFileWriter = {
|
||||
filePath,
|
||||
write: (line: string) => {
|
||||
const lineBytes = Buffer.byteLength(line, "utf8");
|
||||
if (
|
||||
options.maxQueuedBytes !== undefined &&
|
||||
queuedBytes + lineBytes > options.maxQueuedBytes
|
||||
) {
|
||||
return "dropped";
|
||||
}
|
||||
queuedBytes += lineBytes;
|
||||
queue = queue
|
||||
.then(() => ready)
|
||||
.then(() => (options.yieldBeforeWrite ? waitForImmediate() : undefined))
|
||||
.then(() => safeAppendFile(filePath, line, options))
|
||||
.catch(() => undefined);
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
queuedBytes = Math.max(0, queuedBytes - lineBytes);
|
||||
});
|
||||
return "queued";
|
||||
},
|
||||
flush: async () => {
|
||||
await queue;
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { resolveFollowupDeliveryPayloads } from "./followup-delivery.js";
|
||||
|
||||
vi.mock("../../channels/plugins/index.js", () => ({
|
||||
getChannelPlugin: () => undefined,
|
||||
}));
|
||||
|
||||
const baseConfig = {} as OpenClawConfig;
|
||||
|
||||
describe("resolveFollowupDeliveryPayloads", () => {
|
||||
|
||||
@@ -82,7 +82,7 @@ describe("channel plugin module loader helpers", () => {
|
||||
expect(createJiti).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("loads TypeScript channel plugin modules through Jiti when no native hook exists", async () => {
|
||||
it("loads TypeScript channel plugin modules through Jiti when native loading is unavailable", async () => {
|
||||
const loadWithJiti = vi.fn((target: string) => ({
|
||||
loadedBy: "jiti",
|
||||
target,
|
||||
@@ -103,7 +103,7 @@ describe("channel plugin module loader helpers", () => {
|
||||
const rootDir = createTempDir();
|
||||
const modulePath = path.join(rootDir, "extensions", "demo", "index.ts");
|
||||
fs.mkdirSync(path.dirname(modulePath), { recursive: true });
|
||||
fs.writeFileSync(modulePath, "export const ok = true;\n", "utf8");
|
||||
fs.writeFileSync(modulePath, 'throw new Error("native source load failed");\n', "utf8");
|
||||
|
||||
try {
|
||||
expect(
|
||||
|
||||
@@ -88,9 +88,13 @@ describe("gateway HTTP request trace scope", () => {
|
||||
expect(activeTraceInHandler?.spanId).toMatch(/^[0-9a-f]{16}$/);
|
||||
expect(events).toEqual([{ trace: activeTraceInHandler, type: "message.queued" }]);
|
||||
|
||||
const [line] = fs.readFileSync(logPath, "utf8").trim().split("\n");
|
||||
const record = JSON.parse(line ?? "{}") as Record<string, unknown>;
|
||||
expect(record).toMatchObject({
|
||||
const traceRecord = fs
|
||||
.readFileSync(logPath, "utf8")
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line) as Record<string, unknown>)
|
||||
.find((record) => record.message === "handled request trace");
|
||||
expect(traceRecord).toMatchObject({
|
||||
traceId: activeTraceInHandler?.traceId,
|
||||
spanId: activeTraceInHandler?.spanId,
|
||||
});
|
||||
|
||||
@@ -38,6 +38,14 @@ function isEnvHttpProxyDispatcher(dispatcher: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
async function closeTestDispatcher(dispatcher: unknown): Promise<void> {
|
||||
const close = (dispatcher as { close?: () => Promise<void> | void } | undefined)?.close;
|
||||
if (typeof close !== "function") {
|
||||
return;
|
||||
}
|
||||
await close.call(dispatcher);
|
||||
}
|
||||
|
||||
describe("gateway network runtime", () => {
|
||||
beforeEach(() => {
|
||||
clearRuntimeConfigSnapshot();
|
||||
@@ -64,7 +72,8 @@ describe("gateway network runtime", () => {
|
||||
let server: Awaited<ReturnType<typeof startGatewayServer>> | undefined;
|
||||
|
||||
try {
|
||||
setGlobalDispatcher(new Agent());
|
||||
const testDispatcher = new Agent();
|
||||
setGlobalDispatcher(testDispatcher);
|
||||
for (const key of NETWORK_GATEWAY_ENV_KEYS) {
|
||||
delete process.env[key];
|
||||
}
|
||||
@@ -101,7 +110,11 @@ describe("gateway network runtime", () => {
|
||||
expect(isEnvHttpProxyDispatcher(getGlobalDispatcher())).toBe(true);
|
||||
} finally {
|
||||
await server?.close({ reason: "gateway proxy bootstrap test complete" });
|
||||
const dispatcherToClose = getGlobalDispatcher();
|
||||
setGlobalDispatcher(originalDispatcher);
|
||||
if (dispatcherToClose !== originalDispatcher) {
|
||||
await closeTestDispatcher(dispatcherToClose);
|
||||
}
|
||||
await fs.rm(tempHome, { recursive: true, force: true });
|
||||
envSnapshot.restore();
|
||||
}
|
||||
|
||||
@@ -2,10 +2,42 @@ import { expect } from "vitest";
|
||||
import { WebSocket } from "ws";
|
||||
import { connectOk, rpcReq, trackConnectChallengeNonce } from "./test-helpers.js";
|
||||
|
||||
export async function openAuthenticatedGatewayWs(port: number, token: string): Promise<WebSocket> {
|
||||
export async function openAuthenticatedGatewayWs(
|
||||
port: number,
|
||||
token: string,
|
||||
timeoutMs = 10_000,
|
||||
): Promise<WebSocket> {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||
trackConnectChallengeNonce(ws);
|
||||
await new Promise<void>((resolve) => ws.once("open", resolve));
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer);
|
||||
ws.off("open", onOpen);
|
||||
ws.off("error", onError);
|
||||
ws.off("close", onClose);
|
||||
};
|
||||
const onOpen = () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const onError = (error: unknown) => {
|
||||
cleanup();
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
};
|
||||
const onClose = (code: number, reason: Buffer) => {
|
||||
cleanup();
|
||||
reject(new Error(`gateway websocket closed before open (${code}: ${reason.toString()})`));
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
cleanup();
|
||||
ws.close();
|
||||
reject(new Error(`gateway websocket did not open within ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
timer.unref?.();
|
||||
ws.once("open", onOpen);
|
||||
ws.once("error", onError);
|
||||
ws.once("close", onClose);
|
||||
});
|
||||
await connectOk(ws, { token });
|
||||
return ws;
|
||||
}
|
||||
@@ -17,8 +49,11 @@ export async function waitForGatewayWsClose(
|
||||
return await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
ws.off("close", onClose);
|
||||
reject(new Error(`gateway websocket did not close within ${timeoutMs}ms`));
|
||||
reject(
|
||||
new Error(`gateway websocket did not close within ${timeoutMs}ms (state=${ws.readyState})`),
|
||||
);
|
||||
}, timeoutMs);
|
||||
timer.unref?.();
|
||||
const onClose = (code: number, reason: Buffer) => {
|
||||
clearTimeout(timer);
|
||||
resolve({ code, reason: reason.toString() });
|
||||
|
||||
@@ -285,7 +285,7 @@ describe("loadBundledEntryExportSync", () => {
|
||||
});
|
||||
|
||||
it("keeps Windows dist sidecar loads off source-transform loading", async () => {
|
||||
const createJiti = vi.fn(() => vi.fn(() => ({ load: 42 })));
|
||||
const createJiti = vi.fn(() => vi.fn(() => ({ load: 0 })));
|
||||
stubPluginModuleLoaderJitiFactory(createJiti as unknown as PluginModuleLoaderFactory);
|
||||
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
|
||||
|
||||
@@ -300,22 +300,17 @@ describe("loadBundledEntryExportSync", () => {
|
||||
fs.mkdirSync(pluginRoot, { recursive: true });
|
||||
|
||||
const importerPath = path.join(pluginRoot, "index.js");
|
||||
const helperPath = path.join(pluginRoot, "helper.ts");
|
||||
const helperPath = path.join(pluginRoot, "helper.cjs");
|
||||
fs.writeFileSync(importerPath, "export default {};\n", "utf8");
|
||||
fs.writeFileSync(helperPath, "export const load = 42;\n", "utf8");
|
||||
fs.writeFileSync(helperPath, "module.exports = { load: 42 };\n", "utf8");
|
||||
|
||||
expect(
|
||||
channelEntryContract.loadBundledEntryExportSync<number>(pathToFileURL(importerPath).href, {
|
||||
specifier: "./helper.ts",
|
||||
specifier: "./helper.cjs",
|
||||
exportName: "load",
|
||||
}),
|
||||
).toBe(42);
|
||||
expect(createJiti).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
tryNative: false,
|
||||
}),
|
||||
);
|
||||
expect(createJiti).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
platformSpy.mockRestore();
|
||||
}
|
||||
|
||||
@@ -928,6 +928,34 @@ describe("discoverOpenClawPlugins", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects blank package runtimeExtensions before falling back to inferred entries", async () => {
|
||||
const stateDir = makeTempDir();
|
||||
const pluginDir = path.join(stateDir, "extensions", "runtime-blank-pack");
|
||||
mkdirSafe(path.join(pluginDir, "src"));
|
||||
mkdirSafe(path.join(pluginDir, "dist"));
|
||||
|
||||
writePluginPackageManifest({
|
||||
packageDir: pluginDir,
|
||||
packageName: "@openclaw/runtime-blank-pack",
|
||||
extensions: ["./src/index.ts"],
|
||||
runtimeExtensions: [" "],
|
||||
});
|
||||
writePluginEntry(path.join(pluginDir, "src", "index.ts"));
|
||||
writePluginEntry(path.join(pluginDir, "dist", "index.js"));
|
||||
|
||||
const result = await discoverWithStateDir(stateDir, {});
|
||||
|
||||
expectCandidatePresence(result, { absent: ["runtime-blank-pack"] });
|
||||
expect(
|
||||
result.diagnostics.some(
|
||||
(entry) =>
|
||||
entry.level === "error" &&
|
||||
entry.message.includes("openclaw.runtimeExtensions[0]") &&
|
||||
entry.message.includes("non-empty string"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("infers built dist entries for installed TypeScript package plugins", async () => {
|
||||
const stateDir = makeTempDir();
|
||||
const pluginDir = path.join(stateDir, "extensions", "built-peer-pack");
|
||||
|
||||
@@ -985,6 +985,37 @@ describe("installPluginFromArchive", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects package installs when runtimeExtensions contains a blank entry", async () => {
|
||||
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
|
||||
fs.mkdirSync(path.join(pluginDir, "src"), { recursive: true });
|
||||
fs.mkdirSync(path.join(pluginDir, "dist"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "runtime-blank-plugin",
|
||||
version: "1.0.0",
|
||||
openclaw: {
|
||||
extensions: ["./src/index.ts"],
|
||||
runtimeExtensions: [" "],
|
||||
},
|
||||
}),
|
||||
);
|
||||
fs.writeFileSync(path.join(pluginDir, "src", "index.ts"), "export {};\n");
|
||||
fs.writeFileSync(path.join(pluginDir, "dist", "index.js"), "export {};\n");
|
||||
|
||||
const result = await installPluginFromDir({
|
||||
dirPath: pluginDir,
|
||||
extensionsDir,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.INVALID_OPENCLAW_EXTENSIONS);
|
||||
expect(result.error).toContain("openclaw.runtimeExtensions[0]");
|
||||
expect(result.error).toContain("non-empty string");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects package installs when runtimeSetupEntry is missing", async () => {
|
||||
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
|
||||
fs.mkdirSync(path.join(pluginDir, "src"), { recursive: true });
|
||||
|
||||
@@ -1037,7 +1037,8 @@ describe("loadOpenClawPlugins", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(registry.plugins.find((entry) => entry.id === "discord")?.status).toBe("loaded");
|
||||
const record = registry.plugins.find((entry) => entry.id === "discord");
|
||||
expect(record?.status, record?.error).toBe("loaded");
|
||||
});
|
||||
it("registers standalone text transforms", () => {
|
||||
useNoBundledPlugins();
|
||||
@@ -6591,7 +6592,7 @@ module.exports = {
|
||||
}),
|
||||
);
|
||||
const record = registry.plugins.find((entry) => entry.id === "legacy-root-import");
|
||||
expect(record?.status).toBe("loaded");
|
||||
expect(record?.status, record?.error).toBe("loaded");
|
||||
});
|
||||
|
||||
it("supports legacy plugins subscribing to diagnostic events from the root sdk", async () => {
|
||||
@@ -6639,7 +6640,7 @@ module.exports = {
|
||||
const record = registry.plugins.find(
|
||||
(entry) => entry.id === "legacy-root-diagnostic-listener",
|
||||
);
|
||||
expect(record?.status).toBe("loaded");
|
||||
expect(record?.status, record?.error).toBe("loaded");
|
||||
|
||||
emitDiagnosticEvent({
|
||||
type: "model.usage",
|
||||
|
||||
@@ -64,6 +64,19 @@ describe("tryNativeRequireJavaScriptModule", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("declines missing dependency errors when source-transform fallback is available", () => {
|
||||
const dir = makeTempDir();
|
||||
const modulePath = path.join(dir, "plugin.cjs");
|
||||
fs.writeFileSync(modulePath, 'require("openclaw/plugin-sdk");\n', "utf8");
|
||||
|
||||
expect(
|
||||
tryNativeRequireJavaScriptModule(modulePath, {
|
||||
allowWindows: true,
|
||||
fallbackOnMissingDependency: true,
|
||||
}),
|
||||
).toEqual({ ok: false });
|
||||
});
|
||||
|
||||
it("propagates real module evaluation errors instead of falling back", () => {
|
||||
const dir = makeTempDir();
|
||||
const modulePath = path.join(dir, "plugin.cjs");
|
||||
|
||||
@@ -33,7 +33,7 @@ function isSourceTransformFallbackError(error: unknown, modulePath: string): boo
|
||||
|
||||
export function tryNativeRequireJavaScriptModule(
|
||||
modulePath: string,
|
||||
options: { allowWindows?: boolean } = {},
|
||||
options: { allowWindows?: boolean; fallbackOnMissingDependency?: boolean } = {},
|
||||
): { ok: true; moduleExport: unknown } | { ok: false } {
|
||||
if (process.platform === "win32" && options.allowWindows !== true) {
|
||||
return { ok: false };
|
||||
@@ -44,7 +44,15 @@ export function tryNativeRequireJavaScriptModule(
|
||||
try {
|
||||
return { ok: true, moduleExport: nodeRequire(modulePath) };
|
||||
} catch (error) {
|
||||
if (!isSourceTransformFallbackError(error, modulePath)) {
|
||||
const code =
|
||||
error && typeof error === "object" ? (error as { code?: unknown }).code : undefined;
|
||||
if (
|
||||
!isSourceTransformFallbackError(error, modulePath) &&
|
||||
!(
|
||||
options.fallbackOnMissingDependency === true &&
|
||||
(code === "MODULE_NOT_FOUND" || code === "ERR_MODULE_NOT_FOUND")
|
||||
)
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
return { ok: false };
|
||||
|
||||
@@ -21,6 +21,8 @@ type RuntimeExtensionsResolution =
|
||||
| { ok: true; runtimeExtensions: string[] }
|
||||
| { ok: false; error: string };
|
||||
|
||||
type PackageManifestStringList = { ok: true; entries: string[] } | { ok: false; error: string };
|
||||
|
||||
function runtimeExtensionsLengthMismatchMessage(params: {
|
||||
runtimeExtensionsLength: number;
|
||||
extensionsLength: number;
|
||||
@@ -31,11 +33,25 @@ function runtimeExtensionsLengthMismatchMessage(params: {
|
||||
);
|
||||
}
|
||||
|
||||
function normalizePackageManifestStringList(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
function readPackageManifestStringList(params: {
|
||||
fieldName: string;
|
||||
value: unknown;
|
||||
}): PackageManifestStringList {
|
||||
if (!Array.isArray(params.value)) {
|
||||
return { ok: true, entries: [] };
|
||||
}
|
||||
return value.map((entry) => normalizeOptionalString(entry) ?? "").filter(Boolean);
|
||||
const entries: string[] = [];
|
||||
for (const [index, entry] of params.value.entries()) {
|
||||
const normalized = normalizeOptionalString(entry);
|
||||
if (!normalized) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `package.json ${params.fieldName}[${index}] must be a non-empty string`,
|
||||
};
|
||||
}
|
||||
entries.push(normalized);
|
||||
}
|
||||
return { ok: true, entries };
|
||||
}
|
||||
|
||||
function resolvePackageRuntimeExtensionEntries(params: {
|
||||
@@ -43,7 +59,14 @@ function resolvePackageRuntimeExtensionEntries(params: {
|
||||
extensions: readonly string[];
|
||||
}): RuntimeExtensionsResolution {
|
||||
const packageManifest = getPackageManifestMetadata(params.manifest ?? undefined);
|
||||
const runtimeExtensions = normalizePackageManifestStringList(packageManifest?.runtimeExtensions);
|
||||
const runtimeExtensionsResult = readPackageManifestStringList({
|
||||
fieldName: "openclaw.runtimeExtensions",
|
||||
value: packageManifest?.runtimeExtensions,
|
||||
});
|
||||
if (!runtimeExtensionsResult.ok) {
|
||||
return runtimeExtensionsResult;
|
||||
}
|
||||
const runtimeExtensions = runtimeExtensionsResult.entries;
|
||||
if (runtimeExtensions.length === 0) {
|
||||
return { ok: true, runtimeExtensions: [] };
|
||||
}
|
||||
|
||||
@@ -416,6 +416,48 @@ describe("getCachedPluginModuleLoader", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not source-transform fallback after native loading reaches a missing dependency", async () => {
|
||||
const fromSourceTransformer = vi.fn();
|
||||
const createJiti = vi.fn(() => fromSourceTransformer);
|
||||
vi.doMock("jiti", () => ({ createJiti }));
|
||||
const missingDependency = Object.assign(new Error("Cannot find module 'missing-dep'"), {
|
||||
code: "MODULE_NOT_FOUND",
|
||||
});
|
||||
const nativeStub = vi.fn(() => {
|
||||
throw missingDependency;
|
||||
});
|
||||
vi.doMock("./native-module-require.js", () => ({
|
||||
isJavaScriptModulePath: () => true,
|
||||
tryNativeRequireJavaScriptModule: nativeStub,
|
||||
}));
|
||||
const { getCachedPluginModuleLoader, getPluginModuleLoaderStats } = await importFreshModule<
|
||||
typeof import("./plugin-module-loader-cache.js")
|
||||
>(import.meta.url, "./plugin-module-loader-cache.js?scope=native-missing-dependency");
|
||||
|
||||
const cache = new Map();
|
||||
const loader = getCachedPluginModuleLoader({
|
||||
cache,
|
||||
modulePath: "/repo/dist/extensions/demo/api.js",
|
||||
importerUrl: "file:///repo/src/plugins/public-surface-loader.ts",
|
||||
loaderFilename: "file:///repo/src/plugins/public-surface-loader.ts",
|
||||
createLoader: asPluginModuleLoaderFactory(createJiti),
|
||||
});
|
||||
|
||||
expect(() => loader("/repo/dist/extensions/demo/api.js")).toThrow("missing-dep");
|
||||
expect(createJiti).not.toHaveBeenCalled();
|
||||
expect(fromSourceTransformer).not.toHaveBeenCalled();
|
||||
expect(nativeStub).toHaveBeenCalledWith("/repo/dist/extensions/demo/api.js", {
|
||||
allowWindows: true,
|
||||
});
|
||||
expect(getPluginModuleLoaderStats()).toMatchObject({
|
||||
calls: 1,
|
||||
nativeHits: 0,
|
||||
nativeMisses: 0,
|
||||
sourceTransformFallbacks: 0,
|
||||
sourceTransformForced: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to source transform when the native-require helper declines", async () => {
|
||||
const fromSourceTransformer = vi.fn(() => ({ fromSourceTransform: true }));
|
||||
const createJiti = vi.fn(() => fromSourceTransformer);
|
||||
|
||||
@@ -282,7 +282,9 @@ function createPluginModuleLoader(params: {
|
||||
...rest,
|
||||
);
|
||||
}
|
||||
const native = tryNativeRequireJavaScriptModule(target, { allowWindows: true });
|
||||
const native = tryNativeRequireJavaScriptModule(target, {
|
||||
allowWindows: true,
|
||||
});
|
||||
if (native.ok) {
|
||||
pluginModuleLoaderStats.nativeHits += 1;
|
||||
return native.moduleExport;
|
||||
|
||||
@@ -8,12 +8,7 @@ function writeRuntimeJsonFile(targetPath: string, value: unknown): void {
|
||||
|
||||
function writeRuntimeModuleWrapper(sourcePath: string, targetPath: string): void {
|
||||
const relative = `./${path.relative(path.dirname(targetPath), sourcePath).split(path.sep).join("/")}`;
|
||||
const content = [
|
||||
`export * from ${JSON.stringify(relative)};`,
|
||||
`import * as moduleExports from ${JSON.stringify(relative)};`,
|
||||
`export default moduleExports.default ?? moduleExports;`,
|
||||
"",
|
||||
].join("\n");
|
||||
const content = [`export * from ${JSON.stringify(relative)};`, ""].join("\n");
|
||||
try {
|
||||
if (fs.readFileSync(targetPath, "utf8") === content) {
|
||||
return;
|
||||
|
||||
@@ -4,7 +4,7 @@ import path from "node:path";
|
||||
import type { Message, Usage } from "@mariozechner/pi-ai";
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
import { exportTrajectoryBundle, resolveDefaultTrajectoryExportDir } from "./export.js";
|
||||
import { resolveTrajectoryPointerFilePath } from "./paths.js";
|
||||
import { TRAJECTORY_RUNTIME_FILE_MAX_BYTES, resolveTrajectoryPointerFilePath } from "./paths.js";
|
||||
import type { TrajectoryEvent } from "./types.js";
|
||||
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-trajectory-"));
|
||||
@@ -272,7 +272,7 @@ describe("exportTrajectoryBundle", () => {
|
||||
const outputDir = path.join(tmpDir, "bundle");
|
||||
writeSimpleSessionFile(sessionFile);
|
||||
fs.closeSync(fs.openSync(runtimeFile, "w"));
|
||||
fs.truncateSync(runtimeFile, 50 * 1024 * 1024 + 1);
|
||||
fs.truncateSync(runtimeFile, TRAJECTORY_RUNTIME_FILE_MAX_BYTES + 1);
|
||||
|
||||
await expect(
|
||||
exportTrajectoryBundle({
|
||||
|
||||
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { resolveHomeRelativePath } from "../infra/home-dir.js";
|
||||
|
||||
export const TRAJECTORY_RUNTIME_CAPTURE_MAX_BYTES = 10 * 1024 * 1024;
|
||||
export const TRAJECTORY_RUNTIME_FILE_MAX_BYTES = 50 * 1024 * 1024;
|
||||
export const TRAJECTORY_RUNTIME_EVENT_MAX_BYTES = 256 * 1024;
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ describe("trajectory runtime", () => {
|
||||
expect(JSON.stringify(parsed.data)).not.toContain("sk-other-secret-token");
|
||||
});
|
||||
|
||||
it("truncates events that exceed the runtime event byte limit", () => {
|
||||
it("bounds large runtime event fields before serialization", () => {
|
||||
const writes: string[] = [];
|
||||
const recorder = createTrajectoryRuntimeRecorder({
|
||||
sessionId: "session-1",
|
||||
@@ -108,15 +108,53 @@ describe("trajectory runtime", () => {
|
||||
|
||||
expect(writes).toHaveLength(1);
|
||||
const parsed = JSON.parse(writes[0]);
|
||||
expect(parsed.data).toMatchObject({
|
||||
expect(parsed.data.prompt).toMatchObject({
|
||||
truncated: true,
|
||||
reason: "trajectory-event-size-limit",
|
||||
reason: "trajectory-field-size-limit",
|
||||
});
|
||||
expect(Buffer.byteLength(writes[0], "utf8")).toBeLessThanOrEqual(
|
||||
TRAJECTORY_RUNTIME_EVENT_MAX_BYTES + 1,
|
||||
);
|
||||
});
|
||||
|
||||
it("stops runtime capture at the file budget and records a truncation event", async () => {
|
||||
const writes: string[] = [];
|
||||
const recorder = createTrajectoryRuntimeRecorder({
|
||||
sessionId: "session-1",
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
maxRuntimeFileBytes: 900,
|
||||
writer: {
|
||||
filePath: "/tmp/session.trajectory.jsonl",
|
||||
write: (line) => {
|
||||
writes.push(line);
|
||||
},
|
||||
flush: async () => undefined,
|
||||
},
|
||||
});
|
||||
|
||||
recorder?.recordEvent("context.compiled", {
|
||||
prompt: "x".repeat(180),
|
||||
});
|
||||
recorder?.recordEvent("prompt.submitted", {
|
||||
prompt: "y".repeat(180),
|
||||
});
|
||||
recorder?.recordEvent("model.completed", {
|
||||
get prompt() {
|
||||
throw new Error("stopped recorder should not read dropped payloads");
|
||||
},
|
||||
});
|
||||
await recorder?.flush();
|
||||
|
||||
const parsed = writes.map((line) => JSON.parse(line));
|
||||
expect(parsed.map((event) => event.type)).toContain("trace.truncated");
|
||||
const truncated = parsed.find((event) => event.type === "trace.truncated");
|
||||
expect(truncated?.data).toMatchObject({
|
||||
reason: "trajectory-runtime-file-size-limit",
|
||||
limitBytes: 900,
|
||||
});
|
||||
expect(truncated?.data.droppedEvents).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("writes a session-adjacent pointer when using an override directory", () => {
|
||||
const tmpDir = makeTempDir();
|
||||
const sessionFile = path.join(tmpDir, "session.jsonl");
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { parseBooleanValue } from "../utils/boolean.js";
|
||||
import { safeJsonStringify } from "../utils/safe-json.js";
|
||||
import {
|
||||
TRAJECTORY_RUNTIME_CAPTURE_MAX_BYTES,
|
||||
TRAJECTORY_RUNTIME_EVENT_MAX_BYTES,
|
||||
TRAJECTORY_RUNTIME_FILE_MAX_BYTES,
|
||||
resolveTrajectoryFilePath,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
import type { TrajectoryEvent, TrajectoryToolDefinition } from "./types.js";
|
||||
|
||||
export {
|
||||
TRAJECTORY_RUNTIME_CAPTURE_MAX_BYTES,
|
||||
TRAJECTORY_RUNTIME_EVENT_MAX_BYTES,
|
||||
TRAJECTORY_RUNTIME_FILE_MAX_BYTES,
|
||||
resolveTrajectoryFilePath,
|
||||
@@ -26,6 +28,7 @@ export {
|
||||
type TrajectoryRuntimeInit = {
|
||||
cfg?: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
maxRuntimeFileBytes?: number;
|
||||
runId?: string;
|
||||
sessionId: string;
|
||||
sessionKey?: string;
|
||||
@@ -46,6 +49,11 @@ type TrajectoryRuntimeRecorder = {
|
||||
|
||||
const writers = new Map<string, QueuedFileWriter>();
|
||||
const MAX_TRAJECTORY_WRITERS = 100;
|
||||
const TRAJECTORY_RUNTIME_TRUNCATION_SENTINEL_RESERVE_BYTES = 2048;
|
||||
const TRAJECTORY_RUNTIME_DATA_STRING_MAX_CHARS = 32_768;
|
||||
const TRAJECTORY_RUNTIME_DATA_ARRAY_MAX_ITEMS = 64;
|
||||
const TRAJECTORY_RUNTIME_DATA_OBJECT_MAX_KEYS = 64;
|
||||
const TRAJECTORY_RUNTIME_DATA_MAX_DEPTH = 6;
|
||||
|
||||
function writeTrajectoryPointerBestEffort(params: {
|
||||
filePath: string;
|
||||
@@ -128,6 +136,75 @@ function truncateOversizedTrajectoryEvent(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function truncatedTrajectoryValue(reason: string, details: Record<string, unknown> = {}): unknown {
|
||||
return {
|
||||
truncated: true,
|
||||
reason,
|
||||
...details,
|
||||
};
|
||||
}
|
||||
|
||||
function limitTrajectoryPayloadValue(
|
||||
value: unknown,
|
||||
depth = 0,
|
||||
seen: WeakSet<object> = new WeakSet(),
|
||||
): unknown {
|
||||
if (typeof value === "string") {
|
||||
if (value.length > TRAJECTORY_RUNTIME_DATA_STRING_MAX_CHARS) {
|
||||
return truncatedTrajectoryValue("trajectory-field-size-limit", {
|
||||
originalChars: value.length,
|
||||
limitChars: TRAJECTORY_RUNTIME_DATA_STRING_MAX_CHARS,
|
||||
});
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return value;
|
||||
}
|
||||
if (seen.has(value)) {
|
||||
return truncatedTrajectoryValue("trajectory-circular-reference");
|
||||
}
|
||||
if (depth >= TRAJECTORY_RUNTIME_DATA_MAX_DEPTH) {
|
||||
return truncatedTrajectoryValue("trajectory-depth-limit", {
|
||||
limitDepth: TRAJECTORY_RUNTIME_DATA_MAX_DEPTH,
|
||||
});
|
||||
}
|
||||
seen.add(value);
|
||||
if (Array.isArray(value)) {
|
||||
const limited = value
|
||||
.slice(0, TRAJECTORY_RUNTIME_DATA_ARRAY_MAX_ITEMS)
|
||||
.map((item) => limitTrajectoryPayloadValue(item, depth + 1, seen));
|
||||
if (value.length > TRAJECTORY_RUNTIME_DATA_ARRAY_MAX_ITEMS) {
|
||||
limited.push(
|
||||
truncatedTrajectoryValue("trajectory-array-size-limit", {
|
||||
originalLength: value.length,
|
||||
limitItems: TRAJECTORY_RUNTIME_DATA_ARRAY_MAX_ITEMS,
|
||||
}),
|
||||
);
|
||||
}
|
||||
seen.delete(value);
|
||||
return limited;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
const keys = Object.keys(record);
|
||||
const limited: Record<string, unknown> = {};
|
||||
for (const key of keys.slice(0, TRAJECTORY_RUNTIME_DATA_OBJECT_MAX_KEYS)) {
|
||||
limited[key] = limitTrajectoryPayloadValue(record[key], depth + 1, seen);
|
||||
}
|
||||
if (keys.length > TRAJECTORY_RUNTIME_DATA_OBJECT_MAX_KEYS) {
|
||||
limited._truncated = truncatedTrajectoryValue("trajectory-object-size-limit", {
|
||||
originalKeys: keys.length,
|
||||
limitKeys: TRAJECTORY_RUNTIME_DATA_OBJECT_MAX_KEYS,
|
||||
});
|
||||
}
|
||||
seen.delete(value);
|
||||
return limited;
|
||||
}
|
||||
|
||||
function sanitizeTrajectoryPayload(data: Record<string, unknown>): Record<string, unknown> {
|
||||
return sanitizeDiagnosticPayload(limitTrajectoryPayloadValue(data)) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function toTrajectoryToolDefinitions(
|
||||
tools: ReadonlyArray<{ name?: string; description?: string; parameters?: unknown }>,
|
||||
): TrajectoryToolDefinition[] {
|
||||
@@ -141,7 +218,7 @@ export function toTrajectoryToolDefinitions(
|
||||
{
|
||||
name,
|
||||
description: tool.description,
|
||||
parameters: sanitizeDiagnosticPayload(tool.parameters),
|
||||
parameters: sanitizeDiagnosticPayload(limitTrajectoryPayloadValue(tool.parameters)),
|
||||
},
|
||||
];
|
||||
})
|
||||
@@ -167,10 +244,16 @@ export function createTrajectoryRuntimeRecorder(
|
||||
if (!params.writer) {
|
||||
trimTrajectoryWriterCache();
|
||||
}
|
||||
const maxRuntimeFileBytes = Math.max(
|
||||
1,
|
||||
Math.floor(params.maxRuntimeFileBytes ?? TRAJECTORY_RUNTIME_CAPTURE_MAX_BYTES),
|
||||
);
|
||||
const writer =
|
||||
params.writer ??
|
||||
getQueuedFileWriter(writers, filePath, {
|
||||
maxFileBytes: TRAJECTORY_RUNTIME_FILE_MAX_BYTES,
|
||||
maxFileBytes: maxRuntimeFileBytes,
|
||||
maxQueuedBytes: maxRuntimeFileBytes,
|
||||
yieldBeforeWrite: true,
|
||||
});
|
||||
writeTrajectoryPointerBestEffort({
|
||||
filePath,
|
||||
@@ -179,40 +262,97 @@ export function createTrajectoryRuntimeRecorder(
|
||||
});
|
||||
let seq = 0;
|
||||
const traceId = params.sessionId;
|
||||
const sentinelReserveBytes = Math.min(
|
||||
TRAJECTORY_RUNTIME_TRUNCATION_SENTINEL_RESERVE_BYTES,
|
||||
Math.floor(maxRuntimeFileBytes / 2),
|
||||
);
|
||||
const normalEventLimitBytes = Math.max(1, maxRuntimeFileBytes - sentinelReserveBytes);
|
||||
let acceptedRuntimeBytes = 0;
|
||||
let droppedEvents = 0;
|
||||
let droppedEventBytes = 0;
|
||||
let captureStopped = false;
|
||||
|
||||
const writeBoundedLine = (line: string, options: { reserveSentinel: boolean }): boolean => {
|
||||
const jsonlLine = `${line}\n`;
|
||||
const lineBytes = Buffer.byteLength(jsonlLine, "utf8");
|
||||
const limitBytes = options.reserveSentinel ? normalEventLimitBytes : maxRuntimeFileBytes;
|
||||
if (acceptedRuntimeBytes + lineBytes > limitBytes) {
|
||||
captureStopped = true;
|
||||
droppedEvents += 1;
|
||||
droppedEventBytes += lineBytes;
|
||||
return false;
|
||||
}
|
||||
const result = writer.write(jsonlLine);
|
||||
if (result === "dropped") {
|
||||
captureStopped = true;
|
||||
droppedEvents += 1;
|
||||
droppedEventBytes += lineBytes;
|
||||
return false;
|
||||
}
|
||||
acceptedRuntimeBytes += lineBytes;
|
||||
return true;
|
||||
};
|
||||
|
||||
const buildEventLine = (type: string, data?: Record<string, unknown>): string | undefined => {
|
||||
const nextSeq = seq + 1;
|
||||
const event: TrajectoryEvent = {
|
||||
traceSchema: "openclaw-trajectory",
|
||||
schemaVersion: 1,
|
||||
traceId,
|
||||
source: "runtime",
|
||||
type,
|
||||
ts: new Date().toISOString(),
|
||||
seq: nextSeq,
|
||||
sourceSeq: nextSeq,
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
runId: params.runId,
|
||||
workspaceDir: params.workspaceDir,
|
||||
provider: params.provider,
|
||||
modelId: params.modelId,
|
||||
modelApi: params.modelApi,
|
||||
data: data ? sanitizeTrajectoryPayload(data) : undefined,
|
||||
};
|
||||
const line = safeJsonStringify(event);
|
||||
if (!line) {
|
||||
return undefined;
|
||||
}
|
||||
const boundedLine = truncateOversizedTrajectoryEvent(event, line);
|
||||
if (!boundedLine) {
|
||||
return undefined;
|
||||
}
|
||||
seq = nextSeq;
|
||||
return boundedLine;
|
||||
};
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
filePath,
|
||||
recordEvent: (type, data) => {
|
||||
const event: TrajectoryEvent = {
|
||||
traceSchema: "openclaw-trajectory",
|
||||
schemaVersion: 1,
|
||||
traceId,
|
||||
source: "runtime",
|
||||
type,
|
||||
ts: new Date().toISOString(),
|
||||
seq: (seq += 1),
|
||||
sourceSeq: seq,
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
runId: params.runId,
|
||||
workspaceDir: params.workspaceDir,
|
||||
provider: params.provider,
|
||||
modelId: params.modelId,
|
||||
modelApi: params.modelApi,
|
||||
data: data ? (sanitizeDiagnosticPayload(data) as Record<string, unknown>) : undefined,
|
||||
};
|
||||
const line = safeJsonStringify(event);
|
||||
if (captureStopped) {
|
||||
droppedEvents += 1;
|
||||
return;
|
||||
}
|
||||
const line = buildEventLine(type, data);
|
||||
if (!line) {
|
||||
return;
|
||||
}
|
||||
const boundedLine = truncateOversizedTrajectoryEvent(event, line);
|
||||
if (!boundedLine) {
|
||||
return;
|
||||
}
|
||||
writer.write(`${boundedLine}\n`);
|
||||
writeBoundedLine(line, { reserveSentinel: true });
|
||||
},
|
||||
flush: async () => {
|
||||
if (droppedEvents > 0) {
|
||||
const line = buildEventLine("trace.truncated", {
|
||||
reason: "trajectory-runtime-file-size-limit",
|
||||
droppedEvents,
|
||||
droppedEventBytes,
|
||||
limitBytes: maxRuntimeFileBytes,
|
||||
});
|
||||
if (line) {
|
||||
writeBoundedLine(line, { reserveSentinel: false });
|
||||
}
|
||||
droppedEvents = 0;
|
||||
droppedEventBytes = 0;
|
||||
}
|
||||
await writer.flush();
|
||||
if (!params.writer) {
|
||||
writers.delete(filePath);
|
||||
|
||||
@@ -20,6 +20,9 @@ export function createGatewayServerVitestConfig(env?: Record<string, string | un
|
||||
"src/gateway/server.startup-matrix-migration.integration.test.ts",
|
||||
"src/gateway/sessions-history-http.test.ts",
|
||||
],
|
||||
// Gateway server suites share process-level env, logger, and server helper state.
|
||||
// Isolate files so parallel shards cannot cross-wire suite-scoped servers.
|
||||
isolate: true,
|
||||
name: "gateway-server",
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user