mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 19:35:28 -06:00
fix(cli): emit JSON for trajectory export failures (#128351)
* fix(cli): render trajectory export JSON failures Co-authored-by: zhang-guiping <zhang.guiping@xydigit.com> * fix(cli): route invalid trajectory stores through root errors Co-authored-by: zhang-guiping <zhang.guiping@xydigit.com> --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
// Export trajectory tests cover trajectory export command output and file selection.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ExpectedCliError } from "../cli/failure-output.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { exportTrajectoryCommand } from "./export-trajectory.js";
|
||||
|
||||
@@ -9,6 +10,7 @@ const mocks = vi.hoisted(() => ({
|
||||
getRuntimeConfig: vi.fn(),
|
||||
loadSessionEntryReadOnly: vi.fn(),
|
||||
resolveExplicitStorePath: vi.fn(),
|
||||
resolveSessionTranscriptReadTarget: vi.fn(),
|
||||
resolveStorePath: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -18,9 +20,13 @@ vi.mock("../config/config.js", () => ({
|
||||
|
||||
vi.mock("../config/sessions/session-accessor.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/sessions/session-accessor.js")>();
|
||||
mocks.resolveSessionTranscriptReadTarget.mockImplementation(
|
||||
actual.resolveSessionTranscriptReadTarget,
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
loadSessionEntryReadOnly: mocks.loadSessionEntryReadOnly,
|
||||
resolveSessionTranscriptReadTarget: mocks.resolveSessionTranscriptReadTarget,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -38,7 +44,7 @@ vi.mock("../config/sessions/paths.js", async (importOriginal) => {
|
||||
});
|
||||
|
||||
vi.mock("./session-store-targets.js", () => ({
|
||||
resolveExplicitSessionStorePathOrExit: mocks.resolveExplicitStorePath,
|
||||
resolveExplicitSessionStorePath: mocks.resolveExplicitStorePath,
|
||||
}));
|
||||
|
||||
function createRuntime(): RuntimeEnv {
|
||||
@@ -49,6 +55,22 @@ function createRuntime(): RuntimeEnv {
|
||||
} as unknown as RuntimeEnv;
|
||||
}
|
||||
|
||||
async function expectTrajectoryFailure(
|
||||
execution: Promise<void>,
|
||||
runtime: RuntimeEnv,
|
||||
message: string,
|
||||
) {
|
||||
await expect(execution).rejects.toBeInstanceOf(ExpectedCliError);
|
||||
await expect(execution).rejects.toMatchObject({
|
||||
message,
|
||||
humanOutput: message,
|
||||
machineOutput: message,
|
||||
});
|
||||
expect(runtime.error).not.toHaveBeenCalled();
|
||||
expect(runtime.exit).not.toHaveBeenCalled();
|
||||
expect(runtime.log).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
describe("exportTrajectoryCommand", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -57,7 +79,7 @@ describe("exportTrajectoryCommand", () => {
|
||||
mocks.resolveExplicitStorePath.mockImplementation(
|
||||
(params: { storePath: string }) => params.storePath,
|
||||
);
|
||||
mocks.loadSessionEntryReadOnly.mockReturnValue(undefined);
|
||||
mocks.loadSessionEntryReadOnly.mockReturnValue({ sessionId: "session-1", updatedAt: 1 });
|
||||
mocks.exportTrajectoryForCommand.mockResolvedValue({
|
||||
outputDir: "/tmp/workspace/.openclaw/trajectory-exports/export",
|
||||
displayPath: ".openclaw/trajectory-exports/export",
|
||||
@@ -73,44 +95,57 @@ describe("exportTrajectoryCommand", () => {
|
||||
it("points missing session key users at the sessions command", async () => {
|
||||
const runtime = createRuntime();
|
||||
|
||||
await exportTrajectoryCommand({}, runtime);
|
||||
|
||||
expect(runtime.error).toHaveBeenCalledWith(
|
||||
await expectTrajectoryFailure(
|
||||
exportTrajectoryCommand({}, runtime),
|
||||
runtime,
|
||||
"--session-key is required. Run openclaw sessions to choose a session.",
|
||||
);
|
||||
expect(runtime.exit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("reports malformed encoded request JSON without leaking parser output", async () => {
|
||||
const runtime = createRuntime();
|
||||
const requestJsonBase64 = Buffer.from("not json", "utf8").toString("base64url");
|
||||
|
||||
await exportTrajectoryCommand({ requestJsonBase64 }, runtime);
|
||||
|
||||
expect(runtime.error).toHaveBeenCalledWith(
|
||||
"Failed to decode trajectory export request: Encoded trajectory export request is invalid JSON",
|
||||
);
|
||||
expect(runtime.exit).toHaveBeenCalledWith(1);
|
||||
expect(mocks.resolveStorePath).not.toHaveBeenCalled();
|
||||
expect(mocks.loadSessionEntryReadOnly).not.toHaveBeenCalled();
|
||||
expect(mocks.exportTrajectoryForCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a discarded suffix", (encoded: string) => `${encoded}A`, "x"],
|
||||
["nonzero padding bits", (encoded: string) => `${encoded.slice(0, -1)}R`, "xy"],
|
||||
["surrounding whitespace", (encoded: string) => ` ${encoded} `, "xyz"],
|
||||
{
|
||||
name: "malformed JSON",
|
||||
encoded: Buffer.from("not json", "utf8").toString("base64url"),
|
||||
detail: "Encoded trajectory export request is invalid JSON",
|
||||
},
|
||||
{
|
||||
name: "a non-object JSON value",
|
||||
encoded: Buffer.from("[]", "utf8").toString("base64url"),
|
||||
detail: "Encoded trajectory export request must be a JSON object",
|
||||
},
|
||||
{
|
||||
name: "a discarded suffix",
|
||||
encoded: `${Buffer.from(JSON.stringify({ sessionKey: "x" }), "utf8").toString("base64url")}A`,
|
||||
detail: "Encoded trajectory export request is invalid",
|
||||
},
|
||||
{
|
||||
name: "nonzero padding bits",
|
||||
encoded: `${Buffer.from(JSON.stringify({ sessionKey: "xy" }), "utf8")
|
||||
.toString("base64url")
|
||||
.slice(0, -1)}R`,
|
||||
detail: "Encoded trajectory export request is invalid",
|
||||
},
|
||||
{
|
||||
name: "surrounding whitespace",
|
||||
encoded: ` ${Buffer.from(JSON.stringify({ sessionKey: "xyz" }), "utf8").toString("base64url")} `,
|
||||
detail: "Encoded trajectory export request is invalid",
|
||||
},
|
||||
])(
|
||||
"rejects a non-canonical base64url request with %s before looking up its session",
|
||||
async (_case, makeNonCanonical, sessionKey) => {
|
||||
"rejects an encoded request containing $name before looking up its session",
|
||||
async ({ encoded, detail }) => {
|
||||
const runtime = createRuntime();
|
||||
const canonical = Buffer.from(JSON.stringify({ sessionKey }), "utf8").toString("base64url");
|
||||
const requestJsonBase64 = makeNonCanonical(canonical);
|
||||
|
||||
await exportTrajectoryCommand({ requestJsonBase64 }, runtime);
|
||||
|
||||
expect(runtime.error).toHaveBeenCalledWith(
|
||||
"Failed to decode trajectory export request: Encoded trajectory export request is invalid",
|
||||
await expectTrajectoryFailure(
|
||||
exportTrajectoryCommand({ requestJsonBase64: encoded }, runtime),
|
||||
runtime,
|
||||
`Failed to decode trajectory export request: ${detail}`,
|
||||
);
|
||||
expect(mocks.resolveStorePath).not.toHaveBeenCalled();
|
||||
expect(mocks.loadSessionEntryReadOnly).not.toHaveBeenCalled();
|
||||
expect(runtime.exit).toHaveBeenCalledWith(1);
|
||||
expect(mocks.exportTrajectoryForCommand).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -140,10 +175,9 @@ describe("exportTrajectoryCommand", () => {
|
||||
sessionKey: "agent:main:telegram:direct:123",
|
||||
storePath: "/tmp/direct-store.json",
|
||||
});
|
||||
expect(runtime.error).toHaveBeenCalledWith(
|
||||
"Session not found: agent:main:telegram:direct:123. Run openclaw sessions to see available sessions.",
|
||||
expect(mocks.exportTrajectoryForCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ outputPath: "/tmp/export.json" }),
|
||||
);
|
||||
expect(runtime.exit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -158,12 +192,38 @@ describe("exportTrajectoryCommand", () => {
|
||||
const runtime = createRuntime();
|
||||
mocks.getRuntimeConfig.mockReturnValue({ agents: { list: [{ id: "main" }] } });
|
||||
|
||||
await exportTrajectoryCommand({ sessionKey: "agent:main:telegram:direct:123", agent }, runtime);
|
||||
|
||||
expect(runtime.error).toHaveBeenCalledWith(message);
|
||||
expect(runtime.exit).toHaveBeenCalledWith(1);
|
||||
await expectTrajectoryFailure(
|
||||
exportTrajectoryCommand({ sessionKey: "agent:main:telegram:direct:123", agent }, runtime),
|
||||
runtime,
|
||||
message,
|
||||
);
|
||||
expect(mocks.resolveStorePath).not.toHaveBeenCalled();
|
||||
expect(mocks.loadSessionEntryReadOnly).not.toHaveBeenCalled();
|
||||
expect(mocks.exportTrajectoryForCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes invalid explicit stores through the command failure owner", async () => {
|
||||
const runtime = createRuntime();
|
||||
mocks.resolveStorePath.mockReturnValue("/tmp/missing.sqlite");
|
||||
mocks.resolveExplicitStorePath.mockImplementationOnce(() => {
|
||||
throw new Error("Session store target does not exist: /tmp/missing.sqlite");
|
||||
});
|
||||
|
||||
await expectTrajectoryFailure(
|
||||
exportTrajectoryCommand(
|
||||
{
|
||||
sessionKey: "agent:main:telegram:direct:123",
|
||||
store: "/tmp/missing.sqlite",
|
||||
json: true,
|
||||
},
|
||||
runtime,
|
||||
),
|
||||
runtime,
|
||||
"Session store target does not exist: /tmp/missing.sqlite",
|
||||
);
|
||||
|
||||
expect(mocks.loadSessionEntryReadOnly).not.toHaveBeenCalled();
|
||||
expect(mocks.exportTrajectoryForCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps a configured explicit agent as the session store owner", async () => {
|
||||
@@ -214,18 +274,12 @@ describe("exportTrajectoryCommand", () => {
|
||||
storePath: resolvedStore,
|
||||
inputStorePath: store,
|
||||
agentId: "work",
|
||||
runtime,
|
||||
json: undefined,
|
||||
});
|
||||
expect(mocks.loadSessionEntryReadOnly).toHaveBeenCalledWith({
|
||||
agentId: "work",
|
||||
sessionKey: "agent:work:telegram:direct:123",
|
||||
storePath: resolvedStore,
|
||||
});
|
||||
expect(runtime.error).toHaveBeenCalledWith(
|
||||
"Session not found: agent:work:telegram:direct:123. Run openclaw sessions to see available sessions.",
|
||||
);
|
||||
expect(runtime.exit).toHaveBeenCalledWith(1);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -247,10 +301,6 @@ describe("exportTrajectoryCommand", () => {
|
||||
sessionKey: "agent:work:telegram:direct:123",
|
||||
storePath: "/tmp/openclaw/agents/work/sessions/sessions.json",
|
||||
});
|
||||
expect(runtime.error).toHaveBeenCalledWith(
|
||||
"Session not found: agent:work:telegram:direct:123. Run openclaw sessions to see available sessions.",
|
||||
);
|
||||
expect(runtime.exit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("falls back through resolveStorePath when no session.store is configured", async () => {
|
||||
@@ -264,10 +314,6 @@ describe("exportTrajectoryCommand", () => {
|
||||
sessionKey: "agent:main:telegram:direct:123",
|
||||
storePath: "/tmp/openclaw/sessions.json",
|
||||
});
|
||||
expect(runtime.error).toHaveBeenCalledWith(
|
||||
"Session not found: agent:main:telegram:direct:123. Run openclaw sessions to see available sessions.",
|
||||
);
|
||||
expect(runtime.exit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("passes blank configured session.store through the default-store resolver", async () => {
|
||||
@@ -282,18 +328,52 @@ describe("exportTrajectoryCommand", () => {
|
||||
sessionKey: "agent:main:telegram:direct:123",
|
||||
storePath: "/tmp/openclaw/sessions.json",
|
||||
});
|
||||
expect(runtime.error).toHaveBeenCalledWith(
|
||||
});
|
||||
|
||||
it("reports a missing session without resolving its transcript or exporting", async () => {
|
||||
const runtime = createRuntime();
|
||||
mocks.loadSessionEntryReadOnly.mockReturnValue(undefined);
|
||||
|
||||
await expectTrajectoryFailure(
|
||||
exportTrajectoryCommand({ sessionKey: "agent:main:telegram:direct:123" }, runtime),
|
||||
runtime,
|
||||
"Session not found: agent:main:telegram:direct:123. Run openclaw sessions to see available sessions.",
|
||||
);
|
||||
expect(runtime.exit).toHaveBeenCalledWith(1);
|
||||
|
||||
expect(mocks.resolveSessionTranscriptReadTarget).not.toHaveBeenCalled();
|
||||
expect(mocks.exportTrajectoryForCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports transcript target resolution failures without invoking the exporter", async () => {
|
||||
const runtime = createRuntime();
|
||||
mocks.resolveSessionTranscriptReadTarget.mockImplementationOnce(() => {
|
||||
throw new Error("transcript target is unavailable");
|
||||
});
|
||||
|
||||
await expectTrajectoryFailure(
|
||||
exportTrajectoryCommand({ sessionKey: "agent:main:telegram:direct:123" }, runtime),
|
||||
runtime,
|
||||
"Failed to resolve session file: transcript target is unavailable",
|
||||
);
|
||||
|
||||
expect(mocks.exportTrajectoryForCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports exporter failures without formatting a successful result", async () => {
|
||||
const runtime = createRuntime();
|
||||
mocks.exportTrajectoryForCommand.mockRejectedValueOnce(new Error("workspace is unavailable"));
|
||||
|
||||
await expectTrajectoryFailure(
|
||||
exportTrajectoryCommand({ sessionKey: "agent:main:telegram:direct:123" }, runtime),
|
||||
runtime,
|
||||
"Failed to export trajectory: workspace is unavailable",
|
||||
);
|
||||
|
||||
expect(mocks.formatTrajectoryCommandExportSummary).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("exports SQLite sessions without probing a transcript JSONL file", async () => {
|
||||
const runtime = createRuntime();
|
||||
mocks.loadSessionEntryReadOnly.mockReturnValue({
|
||||
sessionId: "session-1",
|
||||
updatedAt: 1,
|
||||
});
|
||||
|
||||
await exportTrajectoryCommand(
|
||||
{
|
||||
@@ -319,4 +399,20 @@ describe("exportTrajectoryCommand", () => {
|
||||
expect(runtime.exit).not.toHaveBeenCalled();
|
||||
expect(runtime.log).toHaveBeenCalledWith("trajectory exported");
|
||||
});
|
||||
|
||||
it("preserves successful JSON output", async () => {
|
||||
const runtime = createRuntime();
|
||||
|
||||
await exportTrajectoryCommand(
|
||||
{ sessionKey: "agent:main:telegram:direct:123", json: true },
|
||||
runtime,
|
||||
);
|
||||
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
JSON.stringify(await mocks.exportTrajectoryForCommand.mock.results[0]?.value, null, 2),
|
||||
);
|
||||
expect(mocks.formatTrajectoryCommandExportSummary).not.toHaveBeenCalled();
|
||||
expect(runtime.error).not.toHaveBeenCalled();
|
||||
expect(runtime.exit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import path from "node:path";
|
||||
import { readNonBlankString } from "@openclaw/normalization-core/string-coerce";
|
||||
import { resolveConfiguredAgentId } from "../agents/agent-scope-config.js";
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import { ExpectedCliError } from "../cli/failure-output.js";
|
||||
import { getRuntimeConfig } from "../config/config.js";
|
||||
import { resolveSessionStorePathCore } from "../config/sessions/paths.js";
|
||||
import {
|
||||
@@ -17,7 +18,7 @@ import {
|
||||
formatTrajectoryCommandExportSummary,
|
||||
type TrajectoryCommandExportSummary,
|
||||
} from "../trajectory/command-export.js";
|
||||
import { resolveExplicitSessionStorePathOrExit } from "./session-store-targets.js";
|
||||
import { resolveExplicitSessionStorePath } from "./session-store-targets.js";
|
||||
|
||||
type ExportTrajectoryCommandOptions = {
|
||||
sessionKey?: string;
|
||||
@@ -94,6 +95,10 @@ function resolveExportTrajectoryOptions(
|
||||
};
|
||||
}
|
||||
|
||||
function throwTrajectoryExportError(message: string): never {
|
||||
throw new ExpectedCliError({ message, humanOutput: message, machineOutput: message });
|
||||
}
|
||||
|
||||
/** Resolves the requested session and exports its trajectory summary or JSON result. */
|
||||
export async function exportTrajectoryCommand(
|
||||
opts: ExportTrajectoryCommandOptions,
|
||||
@@ -103,49 +108,41 @@ export async function exportTrajectoryCommand(
|
||||
try {
|
||||
resolvedOpts = resolveExportTrajectoryOptions(opts);
|
||||
} catch (error) {
|
||||
runtime.error(`Failed to decode trajectory export request: ${formatErrorMessage(error)}`);
|
||||
runtime.exit(1);
|
||||
return;
|
||||
throwTrajectoryExportError(
|
||||
`Failed to decode trajectory export request: ${formatErrorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
const sessionKey = resolvedOpts.sessionKey?.trim();
|
||||
if (!sessionKey) {
|
||||
runtime.error(
|
||||
throwTrajectoryExportError(
|
||||
`--session-key is required. Run ${formatCliCommand("openclaw sessions")} to choose a session.`,
|
||||
);
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
const requestedAgent = resolvedOpts.agent?.trim();
|
||||
if (resolvedOpts.agent !== undefined && !requestedAgent) {
|
||||
runtime.error("--agent must not be blank");
|
||||
runtime.exit(1);
|
||||
return;
|
||||
throwTrajectoryExportError("--agent must not be blank");
|
||||
}
|
||||
let targetAgentId = resolveAgentIdFromSessionKey(sessionKey);
|
||||
if (requestedAgent) {
|
||||
try {
|
||||
targetAgentId = resolveConfiguredAgentId(getRuntimeConfig(), requestedAgent);
|
||||
} catch (error) {
|
||||
runtime.error(formatErrorMessage(error));
|
||||
runtime.exit(1);
|
||||
return;
|
||||
throwTrajectoryExportError(formatErrorMessage(error));
|
||||
}
|
||||
}
|
||||
let storePath = resolvedOpts.store
|
||||
? resolveSessionStorePathCore(resolvedOpts.store, { agentId: targetAgentId })
|
||||
: resolveSessionStorePathCore(getRuntimeConfig().session?.store, { agentId: targetAgentId });
|
||||
if (resolvedOpts.store) {
|
||||
const explicitStorePath = resolveExplicitSessionStorePathOrExit({
|
||||
storePath,
|
||||
inputStorePath: resolvedOpts.store,
|
||||
agentId: targetAgentId ?? "main",
|
||||
runtime,
|
||||
json: resolvedOpts.json,
|
||||
});
|
||||
if (!explicitStorePath) {
|
||||
return;
|
||||
try {
|
||||
storePath = resolveExplicitSessionStorePath({
|
||||
storePath,
|
||||
inputStorePath: resolvedOpts.store,
|
||||
agentId: targetAgentId ?? "main",
|
||||
});
|
||||
} catch (error) {
|
||||
throwTrajectoryExportError(formatErrorMessage(error));
|
||||
}
|
||||
storePath = explicitStorePath;
|
||||
}
|
||||
// CLI reads must not join the Gateway's writable SQLite lifecycle (#101290).
|
||||
const entry = loadSessionEntryReadOnly({
|
||||
@@ -154,11 +151,9 @@ export async function exportTrajectoryCommand(
|
||||
storePath,
|
||||
});
|
||||
if (!entry?.sessionId) {
|
||||
runtime.error(
|
||||
throwTrajectoryExportError(
|
||||
`Session not found: ${sessionKey}. Run ${formatCliCommand("openclaw sessions")} to see available sessions.`,
|
||||
);
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
let sessionTarget: ReturnType<typeof resolveSessionTranscriptReadTarget>;
|
||||
@@ -171,9 +166,7 @@ export async function exportTrajectoryCommand(
|
||||
storePath,
|
||||
});
|
||||
} catch (error) {
|
||||
runtime.error(`Failed to resolve session file: ${formatErrorMessage(error)}`);
|
||||
runtime.exit(1);
|
||||
return;
|
||||
throwTrajectoryExportError(`Failed to resolve session file: ${formatErrorMessage(error)}`);
|
||||
}
|
||||
let summary: TrajectoryCommandExportSummary;
|
||||
try {
|
||||
@@ -190,9 +183,7 @@ export async function exportTrajectoryCommand(
|
||||
workspaceDir: path.resolve(resolvedOpts.workspace ?? process.cwd()),
|
||||
});
|
||||
} catch (error) {
|
||||
runtime.error(`Failed to export trajectory: ${formatErrorMessage(error)}`);
|
||||
runtime.exit(1);
|
||||
return;
|
||||
throwTrajectoryExportError(`Failed to export trajectory: ${formatErrorMessage(error)}`);
|
||||
}
|
||||
|
||||
if (resolvedOpts.json) {
|
||||
|
||||
@@ -33,7 +33,7 @@ function formatResolvedStoreTarget(params: {
|
||||
: `${params.resolvedPath} (resolved from --store ${JSON.stringify(params.inputStorePath)})`;
|
||||
}
|
||||
|
||||
function validateExplicitSessionStorePath(params: {
|
||||
export function resolveExplicitSessionStorePath(params: {
|
||||
agentId: string;
|
||||
inputStorePath: string;
|
||||
storePath: string;
|
||||
@@ -100,7 +100,7 @@ function validateExplicitSessionStorePath(params: {
|
||||
}
|
||||
|
||||
/** Resolves and validates an operator-supplied legacy selector without changing its semantics. */
|
||||
export function resolveExplicitSessionStorePathOrExit(params: {
|
||||
function resolveExplicitSessionStorePathOrExit(params: {
|
||||
storePath: string;
|
||||
inputStorePath?: string;
|
||||
agentId: string;
|
||||
@@ -108,7 +108,7 @@ export function resolveExplicitSessionStorePathOrExit(params: {
|
||||
json?: boolean;
|
||||
}): string | null {
|
||||
try {
|
||||
return validateExplicitSessionStorePath({
|
||||
return resolveExplicitSessionStorePath({
|
||||
agentId: params.agentId,
|
||||
inputStorePath: params.inputStorePath ?? params.storePath,
|
||||
storePath: params.storePath,
|
||||
|
||||
@@ -28,6 +28,29 @@ function runBuiltCli(tempHome: string, args: string[], envOverrides: NodeJS.Proc
|
||||
});
|
||||
}
|
||||
|
||||
async function seedTrajectorySession(tempHome: string, sessionKey: string) {
|
||||
const stateDir = path.join(tempHome, "isolated-state");
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
HOME: tempHome,
|
||||
USERPROFILE: tempHome,
|
||||
OPENCLAW_CONFIG_PATH: path.join(tempHome, "missing-openclaw.json"),
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
};
|
||||
delete env.OPENCLAW_HOME;
|
||||
const [{ upsertSessionEntryCore }, { closeOpenClawAgentDatabaseByPath }] = await Promise.all([
|
||||
import("../src/config/sessions/session-accessor.js"),
|
||||
import("../src/state/openclaw-agent-db.js"),
|
||||
]);
|
||||
await upsertSessionEntryCore(
|
||||
{ agentId: "main", env, sessionKey },
|
||||
{ sessionId: "trajectory-process-session", updatedAt: 1 },
|
||||
);
|
||||
closeOpenClawAgentDatabaseByPath(
|
||||
path.join(stateDir, "agents", "main", "agent", "openclaw-agent.sqlite"),
|
||||
);
|
||||
}
|
||||
|
||||
describe("cli json stdout contract", () => {
|
||||
it.each([
|
||||
{
|
||||
@@ -427,6 +450,109 @@ describe("cli json stdout contract", () => {
|
||||
message:
|
||||
"`sessions export-trajectory` does not support the parent `sessions` option --all-agents; trajectory export targets one session and cannot apply session-list filters.",
|
||||
},
|
||||
{
|
||||
name: "trajectory export missing session key in human mode",
|
||||
args: ["sessions", "export-trajectory"],
|
||||
message: "--session-key is required. Run openclaw sessions to choose a session.",
|
||||
human: true,
|
||||
},
|
||||
{
|
||||
name: "trajectory export missing session key with leaf JSON",
|
||||
args: ["sessions", "export-trajectory", "--json"],
|
||||
message: "--session-key is required. Run openclaw sessions to choose a session.",
|
||||
},
|
||||
{
|
||||
name: "trajectory export missing session key with parent JSON through forced Commander",
|
||||
args: ["sessions", "--json", "export-trajectory"],
|
||||
message: "--session-key is required. Run openclaw sessions to choose a session.",
|
||||
commander: true,
|
||||
},
|
||||
{
|
||||
name: "trajectory export malformed encoded request",
|
||||
args: [
|
||||
"sessions",
|
||||
"export-trajectory",
|
||||
"--request-json-base64",
|
||||
Buffer.from("not json", "utf8").toString("base64url"),
|
||||
"--json",
|
||||
],
|
||||
message:
|
||||
"Failed to decode trajectory export request: Encoded trajectory export request is invalid JSON",
|
||||
},
|
||||
{
|
||||
name: "trajectory export noncanonical encoded request with parent JSON",
|
||||
args: [
|
||||
"sessions",
|
||||
"--json",
|
||||
"export-trajectory",
|
||||
"--request-json-base64",
|
||||
` ${Buffer.from(JSON.stringify({ sessionKey: "agent:main:test" })).toString("base64url")} `,
|
||||
],
|
||||
message:
|
||||
"Failed to decode trajectory export request: Encoded trajectory export request is invalid",
|
||||
},
|
||||
{
|
||||
name: "trajectory export blank explicit agent",
|
||||
args: [
|
||||
"sessions",
|
||||
"export-trajectory",
|
||||
"--session-key",
|
||||
"agent:main:test",
|
||||
"--agent",
|
||||
"",
|
||||
"--json",
|
||||
],
|
||||
message: "--agent must not be blank",
|
||||
},
|
||||
{
|
||||
name: "trajectory export unconfigured explicit agent",
|
||||
args: [
|
||||
"sessions",
|
||||
"export-trajectory",
|
||||
"--session-key",
|
||||
"agent:main:test",
|
||||
"--agent",
|
||||
"unknown-agent",
|
||||
"--json",
|
||||
],
|
||||
message:
|
||||
'Unknown agent id "unknown-agent". Run openclaw agents list to see configured agents.',
|
||||
},
|
||||
{
|
||||
name: "trajectory export missing session through dual-TTY finalization",
|
||||
args: ["sessions", "export-trajectory", "--session-key", "agent:main:missing", "--json"],
|
||||
message:
|
||||
"Session not found: agent:main:missing. Run openclaw sessions to see available sessions.",
|
||||
tty: true,
|
||||
},
|
||||
{
|
||||
name: "trajectory export invalid explicit store",
|
||||
args: [
|
||||
"sessions",
|
||||
"export-trajectory",
|
||||
"--session-key",
|
||||
"agent:main:trajectory-process",
|
||||
"--store",
|
||||
"$MISSING_STORE",
|
||||
"--json",
|
||||
],
|
||||
message:
|
||||
"Session store target does not exist: $MISSING_STORE. Pass a selector whose resolved SQLite target exists.",
|
||||
},
|
||||
{
|
||||
name: "trajectory exporter operational failure",
|
||||
args: [
|
||||
"sessions",
|
||||
"export-trajectory",
|
||||
"--session-key",
|
||||
"agent:main:trajectory-process",
|
||||
"--workspace",
|
||||
"$TRAJECTORY_WORKSPACE",
|
||||
"--json",
|
||||
],
|
||||
message: "Failed to export trajectory: injected trajectory exporter failure",
|
||||
exporterFailure: true,
|
||||
},
|
||||
{
|
||||
name: "archive inherited store with leaf JSON",
|
||||
args: ["sessions", "--store", "/tmp/other.sqlite", "archive", "agent:main:test", "--json"],
|
||||
@@ -487,11 +613,21 @@ describe("cli json stdout contract", () => {
|
||||
])("renders sessions list and registration validation failures for $name", async (testCase) => {
|
||||
await withTempHome(
|
||||
async (tempHome) => {
|
||||
if ("exporterFailure" in testCase) {
|
||||
await seedTrajectorySession(tempHome, "agent:main:trajectory-process");
|
||||
}
|
||||
const preload = Buffer.from(
|
||||
[
|
||||
'import net from "node:net";',
|
||||
'net.Socket.prototype.connect = function () { throw new Error("AUTOQA_NETWORK_FORBIDDEN"); };',
|
||||
'globalThis.fetch = async () => { throw new Error("AUTOQA_NETWORK_FORBIDDEN"); };',
|
||||
...("exporterFailure" in testCase
|
||||
? [
|
||||
'import fs from "node:fs/promises";',
|
||||
"const originalRealpath = fs.realpath;",
|
||||
`fs.realpath = async (target, ...args) => { if (target === ${JSON.stringify(tempHome)}) { throw new Error("injected trajectory exporter failure"); } return originalRealpath(target, ...args); };`,
|
||||
]
|
||||
: []),
|
||||
...("tty" in testCase
|
||||
? [
|
||||
'Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true });',
|
||||
@@ -500,7 +636,16 @@ describe("cli json stdout contract", () => {
|
||||
: []),
|
||||
].join("\n"),
|
||||
).toString("base64");
|
||||
const result = runBuiltCli(tempHome, testCase.args, {
|
||||
const missingStore = path.join(tempHome, "missing-store.sqlite");
|
||||
const args = testCase.args.map((arg) =>
|
||||
arg === "$TRAJECTORY_WORKSPACE"
|
||||
? tempHome
|
||||
: arg === "$MISSING_STORE"
|
||||
? missingStore
|
||||
: arg,
|
||||
);
|
||||
const message = testCase.message.replace("$MISSING_STORE", missingStore);
|
||||
const result = runBuiltCli(tempHome, args, {
|
||||
NODE_OPTIONS: `--import=data:text/javascript;base64,${preload}`,
|
||||
OPENCLAW_CONFIG_PATH: path.join(tempHome, "missing-openclaw.json"),
|
||||
OPENCLAW_GATEWAY_PORT: "29791",
|
||||
@@ -517,11 +662,11 @@ describe("cli json stdout contract", () => {
|
||||
} else {
|
||||
expect(JSON.parse(result.stdout)).toEqual({
|
||||
ok: false,
|
||||
error: { type: "cli_error", message: testCase.message },
|
||||
error: { type: "cli_error", message },
|
||||
});
|
||||
}
|
||||
expect(result.stderr).toContain(testCase.message);
|
||||
expect(result.stderr.split(testCase.message)).toHaveLength(2);
|
||||
expect(result.stderr).toContain(message);
|
||||
expect(result.stderr.split(message)).toHaveLength(2);
|
||||
expect(result.stderr).not.toContain("AUTOQA_NETWORK_FORBIDDEN");
|
||||
if ("tty" in testCase) {
|
||||
expect(result.stderr).toContain("\u001B[?25h");
|
||||
@@ -531,6 +676,62 @@ describe("cli json stdout contract", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "direct JSON export", encoded: false, json: true },
|
||||
{ name: "encoded request precedence with plain output", encoded: true, json: false },
|
||||
])("preserves successful trajectory $name", async (testCase) => {
|
||||
await withTempHome(
|
||||
async (tempHome) => {
|
||||
const sessionKey = "agent:main:trajectory-process";
|
||||
await seedTrajectorySession(tempHome, sessionKey);
|
||||
const output = testCase.encoded ? "encoded-export" : "direct-export";
|
||||
const args = [
|
||||
"sessions",
|
||||
"export-trajectory",
|
||||
"--session-key",
|
||||
testCase.encoded ? "agent:main:missing" : sessionKey,
|
||||
"--output",
|
||||
"direct-export",
|
||||
"--workspace",
|
||||
tempHome,
|
||||
];
|
||||
if (testCase.encoded) {
|
||||
args.push(
|
||||
"--request-json-base64",
|
||||
Buffer.from(JSON.stringify({ sessionKey, output }), "utf8").toString("base64url"),
|
||||
);
|
||||
}
|
||||
if (testCase.json) {
|
||||
args.push("--json");
|
||||
}
|
||||
|
||||
const result = runBuiltCli(tempHome, args, {
|
||||
OPENCLAW_CONFIG_PATH: path.join(tempHome, "missing-openclaw.json"),
|
||||
OPENCLAW_GATEWAY_PORT: "29791",
|
||||
OPENCLAW_STATE_DIR: path.join(tempHome, "isolated-state"),
|
||||
});
|
||||
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
if (testCase.json) {
|
||||
expect(JSON.parse(result.stdout)).toMatchObject({
|
||||
displayPath: `.openclaw/trajectory-exports/${output}`,
|
||||
sessionId: "trajectory-process-session",
|
||||
});
|
||||
} else {
|
||||
expect(result.stdout).toContain("✅ Trajectory exported!");
|
||||
expect(result.stdout).toContain(`.openclaw/trajectory-exports/${output}`);
|
||||
expect(result.stdout).toContain("trajectory-process-session");
|
||||
}
|
||||
await expect(
|
||||
fs.access(
|
||||
path.join(tempHome, ".openclaw", "trajectory-exports", output, "manifest.json"),
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
},
|
||||
{ prefix: "openclaw-trajectory-success-e2e-" },
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "account validation in human mode",
|
||||
|
||||
Reference in New Issue
Block a user