Files
openclaw/src/cli/skills-cli.workshop-cache.test.ts

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.",
),
);
});
});