mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-05 11:31:35 +00:00
312 lines
11 KiB
TypeScript
312 lines
11 KiB
TypeScript
import { Command } from "commander";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
createOpenClawTestState,
|
|
type OpenClawTestState,
|
|
} from "../test-utils/openclaw-test-state.js";
|
|
import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js";
|
|
|
|
const tempDirs = createTrackedTempDirs();
|
|
let testState: OpenClawTestState;
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
callGateway: vi.fn(),
|
|
config: {} as { gateway?: { mode: "local" | "remote" } },
|
|
gatewayApply: undefined as
|
|
| ((request: { method: string; params?: { proposalId?: string } }) => Promise<unknown>)
|
|
| undefined,
|
|
acquireGatewayLock: vi.fn(),
|
|
releaseGatewayLock: vi.fn(),
|
|
workspaceDir: "",
|
|
defaultRuntime: {
|
|
log: vi.fn(),
|
|
error: vi.fn(),
|
|
writeStdout: vi.fn(),
|
|
writeJson: vi.fn(),
|
|
exit: vi.fn((code: number) => {
|
|
throw new Error(`__exit__:${code}`);
|
|
}),
|
|
},
|
|
}));
|
|
|
|
vi.mock("../runtime.js", () => ({ defaultRuntime: mocks.defaultRuntime }));
|
|
vi.mock("../gateway/call.js", () => ({
|
|
callGateway: mocks.callGateway,
|
|
isGatewayCredentialsRequiredError: (error: unknown) =>
|
|
error instanceof Error && error.name === "GatewayCredentialsRequiredError",
|
|
isGatewayTransportError: () => false,
|
|
}));
|
|
vi.mock("../infra/gateway-lock.js", () => ({
|
|
acquireGatewayLock: mocks.acquireGatewayLock,
|
|
}));
|
|
vi.mock("../config/config.js", () => ({
|
|
getRuntimeConfig: () => mocks.config,
|
|
resetConfigRuntimeState: () => undefined,
|
|
}));
|
|
vi.mock("../agents/agent-scope.js", () => ({
|
|
resolveAgentIdByWorkspacePath: () => undefined,
|
|
resolveDefaultAgentId: () => "main",
|
|
resolveAgentWorkspaceDir: () => mocks.workspaceDir,
|
|
}));
|
|
|
|
describe("skills workshop CLI gateway snapshot invalidation", () => {
|
|
beforeEach(async () => {
|
|
testState = await createOpenClawTestState({
|
|
layout: "state-only",
|
|
prefix: "openclaw-skills-cli-workshop-cache-",
|
|
});
|
|
mocks.workspaceDir = await tempDirs.make("openclaw-skills-cli-workshop-cache-");
|
|
delete mocks.config.gateway;
|
|
mocks.gatewayApply = undefined;
|
|
mocks.releaseGatewayLock.mockReset();
|
|
mocks.acquireGatewayLock.mockReset().mockResolvedValue({ release: mocks.releaseGatewayLock });
|
|
mocks.defaultRuntime.error.mockClear();
|
|
mocks.defaultRuntime.exit.mockClear();
|
|
mocks.callGateway.mockReset().mockImplementation(async (request) => {
|
|
if (request.method === "health") {
|
|
return {};
|
|
}
|
|
if (!mocks.gatewayApply) {
|
|
throw new Error("gateway unavailable");
|
|
}
|
|
return await mocks.gatewayApply(request);
|
|
});
|
|
vi.resetModules();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await testState.cleanup();
|
|
await tempDirs.cleanup();
|
|
vi.resetModules();
|
|
});
|
|
|
|
it("applies through the gateway process that owns the cached session skill index", async () => {
|
|
// This first module graph stands in for the long-running Gateway process.
|
|
const gatewaySnapshots = await import("../skills/runtime/session-snapshot.js");
|
|
const gatewayRefreshState = await import("../skills/runtime/refresh-state.js");
|
|
const gatewayWorkshop = await import("../skills/workshop/service.js");
|
|
const proposal = await gatewayWorkshop.proposeCreateSkill({
|
|
workspaceDir: mocks.workspaceDir,
|
|
name: "Gateway Visible",
|
|
description: "Visible in sessions without restarting the gateway",
|
|
content: "# Gateway Visible\n\nUse the newly applied workflow.\n",
|
|
});
|
|
const beforeApply = gatewaySnapshots.resolveReusableWorkspaceSkillSnapshot({
|
|
workspaceDir: mocks.workspaceDir,
|
|
config: mocks.config,
|
|
watch: false,
|
|
}).snapshot;
|
|
expect(beforeApply.skills.map((skill) => skill.name)).not.toContain("gateway-visible");
|
|
|
|
// Session persistence strips resolvedSkills; the Gateway rehydrates that field
|
|
// from its process cache when the snapshot version has not advanced.
|
|
const { resolvedSkills: _runtimeOnly, ...persistedSnapshot } = beforeApply;
|
|
const beforeVersion = gatewayRefreshState.getSkillsSnapshotVersion(mocks.workspaceDir);
|
|
mocks.gatewayApply = async (request) => {
|
|
expect(request.method).toBe("skills.proposals.apply");
|
|
return await gatewayWorkshop.applySkillProposal({
|
|
workspaceDir: mocks.workspaceDir,
|
|
config: mocks.config,
|
|
proposalId: request.params?.proposalId ?? "",
|
|
});
|
|
};
|
|
|
|
// A fresh module graph models the short-lived CLI process. Direct application
|
|
// here would bump only the CLI's refresh-state map, leaving the Gateway stale.
|
|
vi.resetModules();
|
|
const { registerSkillsCli } = await import("./skills-cli.js");
|
|
const program = new Command();
|
|
program.exitOverride();
|
|
registerSkillsCli(program);
|
|
await program.parseAsync(["skills", "workshop", "apply", proposal.record.id], {
|
|
from: "user",
|
|
});
|
|
|
|
expect(mocks.callGateway).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
method: "skills.proposals.apply",
|
|
params: { agentId: "main", proposalId: proposal.record.id },
|
|
timeoutMs: 1_850_000,
|
|
}),
|
|
);
|
|
expect(gatewayRefreshState.getSkillsSnapshotVersion(mocks.workspaceDir)).toBeGreaterThan(
|
|
beforeVersion,
|
|
);
|
|
const newSession = gatewaySnapshots.resolveReusableWorkspaceSkillSnapshot({
|
|
workspaceDir: mocks.workspaceDir,
|
|
config: mocks.config,
|
|
existingSnapshot: persistedSnapshot,
|
|
watch: false,
|
|
}).snapshot;
|
|
expect(newSession.skills.map((skill) => skill.name)).toContain("gateway-visible");
|
|
});
|
|
|
|
it("does not replay a dispatched gateway apply failure in the CLI process", async () => {
|
|
const workshop = await import("../skills/workshop/service.js");
|
|
const proposal = await workshop.proposeCreateSkill({
|
|
workspaceDir: mocks.workspaceDir,
|
|
name: "Single Dispatch",
|
|
description: "Apply only in the process that owns snapshot state",
|
|
content: "# Single Dispatch\n\nDo not replay this mutation.\n",
|
|
});
|
|
mocks.gatewayApply = async () => {
|
|
throw new Error("gateway apply failed");
|
|
};
|
|
|
|
vi.resetModules();
|
|
const { registerSkillsCli } = await import("./skills-cli.js");
|
|
const program = new Command();
|
|
program.exitOverride();
|
|
registerSkillsCli(program);
|
|
await expect(
|
|
program.parseAsync(["skills", "workshop", "apply", proposal.record.id], { from: "user" }),
|
|
).rejects.toThrow("__exit__:1");
|
|
|
|
expect(mocks.callGateway.mock.calls.map(([request]) => request.method)).toEqual([
|
|
"health",
|
|
"skills.proposals.apply",
|
|
]);
|
|
await expect(workshop.inspectSkillProposal(proposal.record.id)).resolves.toMatchObject({
|
|
record: { status: "pending" },
|
|
});
|
|
});
|
|
|
|
it("preserves configless offline apply when no local gateway is listening", async () => {
|
|
const workshop = await import("../skills/workshop/service.js");
|
|
const proposal = await workshop.proposeCreateSkill({
|
|
workspaceDir: mocks.workspaceDir,
|
|
name: "Offline Upgrade",
|
|
description: "Keep shipped configless Workshop apply behavior",
|
|
content: "# Offline Upgrade\n\nApply without a running gateway.\n",
|
|
});
|
|
const authError = Object.assign(new Error("gateway health requires credentials"), {
|
|
name: "GatewayCredentialsRequiredError",
|
|
method: "health",
|
|
configPath: "/tmp/openclaw.json",
|
|
});
|
|
mocks.callGateway.mockRejectedValueOnce(authError);
|
|
|
|
vi.resetModules();
|
|
const { registerSkillsCli } = await import("./skills-cli.js");
|
|
const program = new Command();
|
|
program.exitOverride();
|
|
registerSkillsCli(program);
|
|
await program.parseAsync(["skills", "workshop", "apply", proposal.record.id], {
|
|
from: "user",
|
|
});
|
|
|
|
expect(mocks.callGateway).toHaveBeenCalledTimes(1);
|
|
expect(mocks.acquireGatewayLock).toHaveBeenCalledWith({
|
|
allowInTests: true,
|
|
port: 18789,
|
|
role: "skill-workshop-apply",
|
|
timeoutMs: 250,
|
|
});
|
|
expect(mocks.releaseGatewayLock).toHaveBeenCalledTimes(1);
|
|
await expect(workshop.inspectSkillProposal(proposal.record.id)).resolves.toMatchObject({
|
|
record: { status: "applied" },
|
|
});
|
|
});
|
|
|
|
it("does not bypass Gateway ownership when CLI credentials are missing", async () => {
|
|
const workshop = await import("../skills/workshop/service.js");
|
|
const proposal = await workshop.proposeCreateSkill({
|
|
workspaceDir: mocks.workspaceDir,
|
|
name: "Gateway Owned Upgrade",
|
|
description: "Keep snapshot invalidation in the running gateway",
|
|
content: "# Gateway Owned Upgrade\n\nDo not apply in the CLI process.\n",
|
|
});
|
|
const authError = Object.assign(new Error("gateway health requires credentials"), {
|
|
name: "GatewayCredentialsRequiredError",
|
|
method: "health",
|
|
configPath: "/tmp/openclaw.json",
|
|
});
|
|
mocks.callGateway.mockRejectedValueOnce(authError);
|
|
mocks.acquireGatewayLock.mockRejectedValueOnce(new Error("gateway lock is owned"));
|
|
|
|
vi.resetModules();
|
|
const { registerSkillsCli } = await import("./skills-cli.js");
|
|
const program = new Command();
|
|
program.exitOverride();
|
|
registerSkillsCli(program);
|
|
await expect(
|
|
program.parseAsync(["skills", "workshop", "apply", proposal.record.id], { from: "user" }),
|
|
).rejects.toThrow("__exit__:1");
|
|
|
|
expect(mocks.callGateway).toHaveBeenCalledTimes(1);
|
|
expect(mocks.acquireGatewayLock).toHaveBeenCalledTimes(1);
|
|
expect(mocks.releaseGatewayLock).not.toHaveBeenCalled();
|
|
await expect(workshop.inspectSkillProposal(proposal.record.id)).resolves.toMatchObject({
|
|
record: { status: "pending" },
|
|
});
|
|
});
|
|
|
|
it("evaluates the exact inspected draft through the gateway plugin registry", async () => {
|
|
mocks.gatewayApply = async (request) => {
|
|
if (request.method === "skills.proposals.inspect") {
|
|
return {
|
|
record: {
|
|
id: "proposal-evaluate",
|
|
draftHash: "a".repeat(64),
|
|
},
|
|
revisionHash: "b".repeat(64),
|
|
content: "# Evaluate\n",
|
|
};
|
|
}
|
|
expect(request).toMatchObject({
|
|
method: "skills.proposals.evaluate",
|
|
params: {
|
|
agentId: "main",
|
|
proposalId: "proposal-evaluate",
|
|
expectedRevisionHash: "b".repeat(64),
|
|
correlationId: "optimizer-run-7",
|
|
},
|
|
timeoutMs: 650_000,
|
|
});
|
|
return {
|
|
record: { id: "proposal-evaluate" },
|
|
evaluation: {
|
|
proposedVersion: "0.2.0",
|
|
revisionHash: "b".repeat(64),
|
|
outcomes: [
|
|
{
|
|
evaluatorId: "skill-spector",
|
|
pluginId: "nvidia-evals",
|
|
pluginVersion: "1.2.3",
|
|
status: "completed",
|
|
result: { decision: "revise", summary: "Tighten the trigger." },
|
|
},
|
|
],
|
|
},
|
|
};
|
|
};
|
|
|
|
vi.resetModules();
|
|
const { registerSkillsCli } = await import("./skills-cli.js");
|
|
const program = new Command();
|
|
program.exitOverride();
|
|
registerSkillsCli(program);
|
|
await program.parseAsync(
|
|
[
|
|
"skills",
|
|
"workshop",
|
|
"evaluate",
|
|
"proposal-evaluate",
|
|
"--correlation-id",
|
|
"optimizer-run-7",
|
|
],
|
|
{ from: "user" },
|
|
);
|
|
|
|
expect(mocks.callGateway.mock.calls.map(([request]) => request.method)).toEqual([
|
|
"skills.proposals.inspect",
|
|
"skills.proposals.evaluate",
|
|
]);
|
|
expect(mocks.defaultRuntime.writeStdout).toHaveBeenCalledWith(
|
|
expect.stringContaining(
|
|
"skill-spector (nvidia-evals@1.2.3) completed revise: Tighten the trigger.",
|
|
),
|
|
);
|
|
});
|
|
});
|