mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix: store skill workshop proposals in state
This commit is contained in:
+3
-3
@@ -126,9 +126,9 @@ Notes:
|
||||
## Skill Workshop proposals
|
||||
|
||||
`openclaw skills workshop` manages pending skill proposals in the selected
|
||||
workspace. Proposals are durable drafts under
|
||||
`<workspace>/.openclaw/skill-workshop/proposals/`; they are not active skills
|
||||
until applied.
|
||||
workspace. Proposals are durable OpenClaw state under
|
||||
`<OPENCLAW_STATE_DIR>/skill-workshop/proposals/`; they are not active skills
|
||||
until applied. The default state directory is `~/.openclaw`.
|
||||
|
||||
Create a proposal from a draft markdown file:
|
||||
|
||||
|
||||
@@ -107,8 +107,9 @@ openclaw skills workshop propose-create \
|
||||
```
|
||||
|
||||
The draft is stored under
|
||||
`<workspace>/.openclaw/skill-workshop/proposals/<proposal-id>/PROPOSAL.md` and
|
||||
stays inactive until an operator reviews and applies it:
|
||||
`<OPENCLAW_STATE_DIR>/skill-workshop/proposals/<proposal-id>/PROPOSAL.md` and
|
||||
stays inactive until an operator reviews and applies it. The default state
|
||||
directory is `~/.openclaw`:
|
||||
|
||||
```bash
|
||||
openclaw skills workshop inspect <proposal-id>
|
||||
|
||||
@@ -125,7 +125,7 @@ skills without silently mutating active `SKILL.md` files. OpenClaw stores them
|
||||
under:
|
||||
|
||||
```text
|
||||
<workspace>/.openclaw/skill-workshop/
|
||||
<OPENCLAW_STATE_DIR>/skill-workshop/
|
||||
proposals.json
|
||||
proposals/<proposal-id>/
|
||||
proposal.json
|
||||
@@ -133,6 +133,8 @@ under:
|
||||
rollback.json
|
||||
```
|
||||
|
||||
The default state directory is `~/.openclaw`.
|
||||
|
||||
`proposal.json` is the canonical proposal record. `proposals.json` is the fast
|
||||
listing manifest and can be rebuilt from proposal folders when missing or stale.
|
||||
`PROPOSAL.md` marks draft content explicitly with `status: proposal` and
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { captureEnv } from "../../test-utils/env.js";
|
||||
import { createTrackedTempDirs } from "../../test-utils/tracked-temp-dirs.js";
|
||||
import { createOpenClawTools } from "../openclaw-tools.js";
|
||||
import { createSkillResearchTool } from "./skill-research-tool.js";
|
||||
|
||||
const tempDirs = createTrackedTempDirs();
|
||||
let envSnapshot: ReturnType<typeof captureEnv>;
|
||||
let stateDir = "";
|
||||
|
||||
beforeEach(async () => {
|
||||
envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]);
|
||||
stateDir = await tempDirs.make("openclaw-skill-research-state-");
|
||||
process.env.OPENCLAW_STATE_DIR = stateDir;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
envSnapshot.restore();
|
||||
await tempDirs.cleanup();
|
||||
});
|
||||
|
||||
@@ -43,8 +53,7 @@ describe("skill_research tool", () => {
|
||||
await expect(
|
||||
fs.readFile(
|
||||
path.join(
|
||||
workspaceDir,
|
||||
".openclaw",
|
||||
stateDir,
|
||||
"skill-workshop",
|
||||
"proposals",
|
||||
(result.details as { id: string }).id,
|
||||
|
||||
+18
-22
@@ -469,10 +469,9 @@ export function registerSkillsCli(program: Command) {
|
||||
.command("list")
|
||||
.description("List pending and completed skill proposals")
|
||||
.option("--json", "Output as JSON", false)
|
||||
.action(async (opts: { json?: boolean; agent?: string }, command: Command) => {
|
||||
.action(async (opts: { json?: boolean; agent?: string }) => {
|
||||
try {
|
||||
const { workspaceDir } = resolveSkillsWorkspaceForCommand(command.parent, opts);
|
||||
const manifest = await listSkillProposals(workspaceDir);
|
||||
const manifest = await listSkillProposals();
|
||||
if (opts.json) {
|
||||
defaultRuntime.writeJson(manifest);
|
||||
return;
|
||||
@@ -489,27 +488,24 @@ export function registerSkillsCli(program: Command) {
|
||||
.description("Inspect a skill proposal")
|
||||
.argument("<proposal-id>", "Skill proposal id")
|
||||
.option("--json", "Output as JSON", false)
|
||||
.action(
|
||||
async (proposalId: string, opts: { json?: boolean; agent?: string }, command: Command) => {
|
||||
try {
|
||||
const { workspaceDir } = resolveSkillsWorkspaceForCommand(command.parent, opts);
|
||||
const proposal = await inspectSkillProposal(workspaceDir, proposalId);
|
||||
if (!proposal) {
|
||||
defaultRuntime.error(`Skill proposal not found: ${proposalId}`);
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
if (opts.json) {
|
||||
defaultRuntime.writeJson(proposal);
|
||||
return;
|
||||
}
|
||||
defaultRuntime.writeStdout(formatSkillProposalInspect(proposal));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
.action(async (proposalId: string, opts: { json?: boolean; agent?: string }) => {
|
||||
try {
|
||||
const proposal = await inspectSkillProposal(proposalId);
|
||||
if (!proposal) {
|
||||
defaultRuntime.error(`Skill proposal not found: ${proposalId}`);
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
},
|
||||
);
|
||||
if (opts.json) {
|
||||
defaultRuntime.writeJson(proposal);
|
||||
return;
|
||||
}
|
||||
defaultRuntime.writeStdout(formatSkillProposalInspect(proposal));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
workshop
|
||||
.command("propose-create")
|
||||
|
||||
@@ -2,10 +2,13 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { Command } from "commander";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { captureEnv } from "../test-utils/env.js";
|
||||
import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js";
|
||||
import { registerSkillsCli } from "./skills-cli.js";
|
||||
|
||||
const tempDirs = createTrackedTempDirs();
|
||||
let envSnapshot: ReturnType<typeof captureEnv>;
|
||||
let stateDir = "";
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const runtimeStdout: string[] = [];
|
||||
@@ -69,7 +72,10 @@ describe("skills workshop cli", () => {
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]);
|
||||
mocks.workspaceDir = await tempDirs.make("openclaw-skills-cli-workshop-");
|
||||
stateDir = await tempDirs.make("openclaw-skills-cli-workshop-state-");
|
||||
process.env.OPENCLAW_STATE_DIR = stateDir;
|
||||
mocks.runtimeStdout.length = 0;
|
||||
mocks.runtimeErrors.length = 0;
|
||||
mocks.defaultRuntime.log.mockClear();
|
||||
@@ -80,6 +86,7 @@ describe("skills workshop cli", () => {
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
envSnapshot.restore();
|
||||
await tempDirs.cleanup();
|
||||
});
|
||||
|
||||
@@ -135,8 +142,6 @@ describe("skills workshop cli", () => {
|
||||
).rejects.toThrow("__exit__:1");
|
||||
|
||||
expect(mocks.runtimeErrors[0]).toContain("file not found");
|
||||
await expect(
|
||||
fs.access(path.join(mocks.workspaceDir, ".openclaw", "skill-workshop")),
|
||||
).rejects.toThrow();
|
||||
await expect(fs.access(path.join(stateDir, "skill-workshop"))).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { captureEnv } from "../../test-utils/env.js";
|
||||
import { createTrackedTempDirs } from "../../test-utils/tracked-temp-dirs.js";
|
||||
|
||||
const tempDirs = createTrackedTempDirs();
|
||||
let envSnapshot: ReturnType<typeof captureEnv>;
|
||||
let stateDir = "";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
workspaceDir: "",
|
||||
@@ -74,10 +77,14 @@ async function callHandler(method: string, params: Record<string, unknown>) {
|
||||
|
||||
describe("skills proposal gateway handlers", () => {
|
||||
beforeEach(async () => {
|
||||
envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]);
|
||||
mocks.workspaceDir = await tempDirs.make("openclaw-skills-proposals-gateway-");
|
||||
stateDir = await tempDirs.make("openclaw-skills-proposals-gateway-state-");
|
||||
process.env.OPENCLAW_STATE_DIR = stateDir;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
envSnapshot.restore();
|
||||
await tempDirs.cleanup();
|
||||
});
|
||||
|
||||
@@ -119,8 +126,6 @@ describe("skills proposal gateway handlers", () => {
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect((result.error as { code?: string }).code).toBe("INVALID_REQUEST");
|
||||
await expect(
|
||||
fs.access(path.join(mocks.workspaceDir, ".openclaw", "skill-workshop")),
|
||||
).rejects.toThrow();
|
||||
await expect(fs.access(path.join(stateDir, "skill-workshop"))).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -246,7 +246,7 @@ export const skillsHandlers: GatewayRequestHandlers = {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
respond(true, await listSkillProposals(resolved.workspaceDir), undefined);
|
||||
respond(true, await listSkillProposals(), undefined);
|
||||
} catch (err) {
|
||||
respondSkillWorkshopError(respond, err);
|
||||
}
|
||||
@@ -268,7 +268,7 @@ export const skillsHandlers: GatewayRequestHandlers = {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const proposal = await inspectSkillProposal(resolved.workspaceDir, params.proposalId);
|
||||
const proposal = await inspectSkillProposal(params.proposalId);
|
||||
if (!proposal) {
|
||||
respond(
|
||||
false,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { captureEnv } from "../../test-utils/env.js";
|
||||
import { createTrackedTempDirs } from "../../test-utils/tracked-temp-dirs.js";
|
||||
import { buildWorkspaceSkillStatus } from "../discovery/status.js";
|
||||
import { writeSkill } from "../test-support/e2e-test-helpers.js";
|
||||
@@ -16,8 +17,17 @@ import {
|
||||
import { readSkillProposalManifest, resolveProposalDraftPath } from "./store.js";
|
||||
|
||||
const tempDirs = createTrackedTempDirs();
|
||||
let envSnapshot: ReturnType<typeof captureEnv>;
|
||||
let stateDir = "";
|
||||
|
||||
beforeEach(async () => {
|
||||
envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]);
|
||||
stateDir = await tempDirs.make("openclaw-skill-workshop-state-");
|
||||
process.env.OPENCLAW_STATE_DIR = stateDir;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
envSnapshot.restore();
|
||||
await tempDirs.cleanup();
|
||||
});
|
||||
|
||||
@@ -43,10 +53,10 @@ describe("skill workshop proposals", () => {
|
||||
path.join(workspaceDir, "skills", "weather-helper", "SKILL.md"),
|
||||
);
|
||||
await expect(
|
||||
fs.readFile(resolveProposalDraftPath(workspaceDir, proposal.record.id), "utf8"),
|
||||
fs.readFile(resolveProposalDraftPath(proposal.record.id), "utf8"),
|
||||
).resolves.toContain("status: proposal");
|
||||
|
||||
const listed = await listSkillProposals(workspaceDir);
|
||||
const listed = await listSkillProposals();
|
||||
expect(listed.proposals).toHaveLength(1);
|
||||
expect(listed.proposals[0]).toMatchObject({
|
||||
id: proposal.record.id,
|
||||
@@ -70,9 +80,7 @@ describe("skill workshop proposals", () => {
|
||||
source: "openclaw-workspace",
|
||||
filePath: applied.targetSkillFile,
|
||||
});
|
||||
expect((await inspectSkillProposal(workspaceDir, proposal.record.id))?.record.status).toBe(
|
||||
"applied",
|
||||
);
|
||||
expect((await inspectSkillProposal(proposal.record.id))?.record.status).toBe("applied");
|
||||
});
|
||||
|
||||
it("updates only writable workspace skills and marks stale proposals when the target changes", async () => {
|
||||
@@ -100,9 +108,7 @@ describe("skill workshop proposals", () => {
|
||||
await expect(
|
||||
applySkillProposal({ workspaceDir, proposalId: proposal.record.id }),
|
||||
).rejects.toThrow("proposal marked stale");
|
||||
expect((await inspectSkillProposal(workspaceDir, proposal.record.id))?.record.status).toBe(
|
||||
"stale",
|
||||
);
|
||||
expect((await inspectSkillProposal(proposal.record.id))?.record.status).toBe("stale");
|
||||
});
|
||||
|
||||
it("applies update proposals with rollback metadata", async () => {
|
||||
@@ -127,14 +133,7 @@ describe("skill workshop proposals", () => {
|
||||
);
|
||||
const rollback = JSON.parse(
|
||||
await fs.readFile(
|
||||
path.join(
|
||||
workspaceDir,
|
||||
".openclaw",
|
||||
"skill-workshop",
|
||||
"proposals",
|
||||
proposal.record.id,
|
||||
"rollback.json",
|
||||
),
|
||||
path.join(stateDir, "skill-workshop", "proposals", proposal.record.id, "rollback.json"),
|
||||
"utf8",
|
||||
),
|
||||
) as { previousContent?: string };
|
||||
@@ -167,7 +166,7 @@ describe("skill workshop proposals", () => {
|
||||
reason: "needs review",
|
||||
});
|
||||
|
||||
const manifest = await readSkillProposalManifest(workspaceDir);
|
||||
const manifest = await readSkillProposalManifest();
|
||||
expect(manifest.proposals.map((entry) => [entry.skillKey, entry.status])).toEqual([
|
||||
["draft-two", "quarantined"],
|
||||
["draft-one", "rejected"],
|
||||
@@ -189,12 +188,12 @@ describe("skill workshop proposals", () => {
|
||||
content: "# Manifest Repair\n",
|
||||
});
|
||||
await fs.writeFile(
|
||||
path.join(workspaceDir, ".openclaw", "skill-workshop", "proposals.json"),
|
||||
path.join(stateDir, "skill-workshop", "proposals.json"),
|
||||
"{not-json",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const manifest = await listSkillProposals(workspaceDir);
|
||||
const manifest = await listSkillProposals();
|
||||
|
||||
expect(manifest.proposals).toHaveLength(1);
|
||||
expect(manifest.proposals[0]?.id).toBe(proposal.record.id);
|
||||
@@ -213,8 +212,6 @@ describe("skill workshop proposals", () => {
|
||||
await expect(
|
||||
applySkillProposal({ workspaceDir, proposalId: proposal.record.id }),
|
||||
).rejects.toThrow("Proposal scan failed");
|
||||
expect((await inspectSkillProposal(workspaceDir, proposal.record.id))?.record.status).toBe(
|
||||
"quarantined",
|
||||
);
|
||||
expect((await inspectSkillProposal(proposal.record.id))?.record.status).toBe("quarantined");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,8 +48,8 @@ type SkillWorkshopWorkspaceOptions = {
|
||||
const WRITABLE_WORKSPACE_SOURCES = new Set(["openclaw-workspace", "agents-skills-project"]);
|
||||
const MAX_PROPOSAL_DRAFT_BYTES = 1024 * 1024;
|
||||
|
||||
export async function listSkillProposals(workspaceDir: string): Promise<SkillProposalManifest> {
|
||||
return await readSkillProposalManifest(workspaceDir);
|
||||
export async function listSkillProposals(): Promise<SkillProposalManifest> {
|
||||
return await readSkillProposalManifest();
|
||||
}
|
||||
|
||||
export async function readSkillProposalDraftFile(filePath: string): Promise<string> {
|
||||
@@ -61,10 +61,9 @@ export async function readSkillProposalDraftFile(filePath: string): Promise<stri
|
||||
}
|
||||
|
||||
export async function inspectSkillProposal(
|
||||
workspaceDir: string,
|
||||
proposalId: string,
|
||||
): Promise<SkillProposalReadResult | null> {
|
||||
return await readSkillProposal(workspaceDir, proposalId);
|
||||
return await readSkillProposal(proposalId);
|
||||
}
|
||||
|
||||
export async function proposeCreateSkill(
|
||||
@@ -110,7 +109,7 @@ export async function proposeCreateSkill(
|
||||
...(goal ? { goal } : {}),
|
||||
...(evidence ? { evidence } : {}),
|
||||
};
|
||||
await writeSkillProposal({ workspaceDir: input.workspaceDir, record, content: proposalContent });
|
||||
await writeSkillProposal({ record, content: proposalContent });
|
||||
return { record, content: proposalContent };
|
||||
}
|
||||
|
||||
@@ -166,7 +165,7 @@ export async function proposeUpdateSkill(
|
||||
...(goal ? { goal } : {}),
|
||||
...(evidence ? { evidence } : {}),
|
||||
};
|
||||
await writeSkillProposal({ workspaceDir: input.workspaceDir, record, content: proposalContent });
|
||||
await writeSkillProposal({ record, content: proposalContent });
|
||||
return { record, content: proposalContent };
|
||||
}
|
||||
|
||||
@@ -179,7 +178,7 @@ export async function rejectSkillProposal(
|
||||
export async function quarantineSkillProposal(
|
||||
input: SkillProposalActionInput,
|
||||
): Promise<SkillProposalRecord> {
|
||||
const read = await readRequiredProposal(input.workspaceDir, input.proposalId);
|
||||
const read = await readRequiredProposal(input.proposalId);
|
||||
const now = new Date().toISOString();
|
||||
const record: SkillProposalRecord = {
|
||||
...read.record,
|
||||
@@ -192,14 +191,14 @@ export async function quarantineSkillProposal(
|
||||
state: "quarantined",
|
||||
},
|
||||
};
|
||||
await updateSkillProposalRecord({ workspaceDir: input.workspaceDir, record });
|
||||
await updateSkillProposalRecord({ record });
|
||||
return record;
|
||||
}
|
||||
|
||||
export async function applySkillProposal(
|
||||
input: SkillProposalActionInput,
|
||||
): Promise<SkillProposalApplyResult> {
|
||||
const read = await readRequiredProposal(input.workspaceDir, input.proposalId);
|
||||
const read = await readRequiredProposal(input.proposalId);
|
||||
const { record, content } = read;
|
||||
if (record.status !== "pending") {
|
||||
throw new Error(`Only pending proposals can be applied. Current status: ${record.status}.`);
|
||||
@@ -222,7 +221,7 @@ export async function applySkillProposal(
|
||||
scan: { ...scan, state: "quarantined" as const },
|
||||
statusReason: "Proposal scan failed.",
|
||||
};
|
||||
await updateSkillProposalRecord({ workspaceDir: input.workspaceDir, record: updated });
|
||||
await updateSkillProposalRecord({ record: updated });
|
||||
throw new Error("Proposal scan failed; proposal was quarantined.");
|
||||
}
|
||||
|
||||
@@ -247,7 +246,7 @@ export async function applySkillProposal(
|
||||
staleAt: new Date().toISOString(),
|
||||
statusReason: "Target skill changed after proposal creation.",
|
||||
};
|
||||
await updateSkillProposalRecord({ workspaceDir: input.workspaceDir, record: stale });
|
||||
await updateSkillProposalRecord({ record: stale });
|
||||
throw new Error("Target skill changed after proposal creation; proposal marked stale.");
|
||||
}
|
||||
}
|
||||
@@ -259,7 +258,6 @@ export async function applySkillProposal(
|
||||
...(previousContent !== null ? { previousContent } : {}),
|
||||
});
|
||||
await writeSkillProposalRollback({
|
||||
workspaceDir: input.workspaceDir,
|
||||
proposalId: record.id,
|
||||
rollback,
|
||||
});
|
||||
@@ -278,8 +276,8 @@ export async function applySkillProposal(
|
||||
appliedAt: now,
|
||||
scan,
|
||||
};
|
||||
await updateSkillProposalRecord({ workspaceDir: input.workspaceDir, record: applied });
|
||||
await refreshSkillProposalManifest(input.workspaceDir);
|
||||
await updateSkillProposalRecord({ record: applied });
|
||||
await refreshSkillProposalManifest();
|
||||
return { record: applied, targetSkillFile: record.target.skillFile };
|
||||
}
|
||||
|
||||
@@ -303,7 +301,7 @@ async function markProposal(
|
||||
input: SkillProposalActionInput,
|
||||
status: "rejected",
|
||||
): Promise<SkillProposalRecord> {
|
||||
const read = await readRequiredProposal(input.workspaceDir, input.proposalId);
|
||||
const read = await readRequiredProposal(input.proposalId);
|
||||
const now = new Date().toISOString();
|
||||
const record: SkillProposalRecord = {
|
||||
...read.record,
|
||||
@@ -312,15 +310,12 @@ async function markProposal(
|
||||
rejectedAt: now,
|
||||
statusReason: normalizeOptionalString(input.reason),
|
||||
};
|
||||
await updateSkillProposalRecord({ workspaceDir: input.workspaceDir, record });
|
||||
await updateSkillProposalRecord({ record });
|
||||
return record;
|
||||
}
|
||||
|
||||
async function readRequiredProposal(
|
||||
workspaceDir: string,
|
||||
proposalId: string,
|
||||
): Promise<SkillProposalReadResult> {
|
||||
const read = await readSkillProposal(workspaceDir, proposalId);
|
||||
async function readRequiredProposal(proposalId: string): Promise<SkillProposalReadResult> {
|
||||
const read = await readSkillProposal(proposalId);
|
||||
if (!read) {
|
||||
throw new Error(`Skill proposal not found: ${proposalId}`);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import crypto from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { pathExists, readFileWithinRoot, root } from "../../infra/fs-safe.js";
|
||||
import { resolveStateDir } from "../../config/paths.js";
|
||||
import { pathExists, root } from "../../infra/fs-safe.js";
|
||||
import { tryReadJson } from "../../infra/json-files.js";
|
||||
import { isPathInside } from "../../infra/path-safety.js";
|
||||
import { normalizeOptionalString } from "../../shared/string-coerce.js";
|
||||
@@ -16,7 +17,7 @@ import {
|
||||
type SkillProposalRollback,
|
||||
} from "./types.js";
|
||||
|
||||
const WORKSHOP_REL_DIR = path.join(".openclaw", "skill-workshop");
|
||||
const WORKSHOP_REL_DIR = "skill-workshop";
|
||||
const PROPOSALS_REL_DIR = path.join(WORKSHOP_REL_DIR, "proposals");
|
||||
const MANIFEST_REL_PATH = path.join(WORKSHOP_REL_DIR, "proposals.json");
|
||||
const PROPOSAL_RECORD_FILE = "proposal.json";
|
||||
@@ -25,6 +26,11 @@ const PROPOSAL_ROLLBACK_FILE = "rollback.json";
|
||||
const MAX_PROPOSAL_BYTES = 1024 * 1024;
|
||||
const PROPOSAL_ID_PATTERN = /^[a-z0-9][a-z0-9-]{5,120}$/;
|
||||
|
||||
type SkillWorkshopStoreOptions = {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
stateDir?: string;
|
||||
};
|
||||
|
||||
export function createSkillProposalId(name: string, now = new Date()): string {
|
||||
const normalized = normalizeSkillIndexName(name) || "skill";
|
||||
const date = now.toISOString().slice(0, 10).replaceAll("-", "");
|
||||
@@ -36,21 +42,34 @@ export function hashSkillProposalContent(content: string): string {
|
||||
return crypto.createHash("sha256").update(content).digest("hex");
|
||||
}
|
||||
|
||||
export function resolveWorkshopPath(workspaceDir: string): string {
|
||||
return path.resolve(workspaceDir, WORKSHOP_REL_DIR);
|
||||
function resolveSkillWorkshopStateDir(options: SkillWorkshopStoreOptions = {}): string {
|
||||
return path.resolve(options.stateDir ?? resolveStateDir(options.env));
|
||||
}
|
||||
|
||||
export function resolveProposalDir(workspaceDir: string, proposalId: string): string {
|
||||
export function resolveWorkshopPath(options: SkillWorkshopStoreOptions = {}): string {
|
||||
return path.join(resolveSkillWorkshopStateDir(options), WORKSHOP_REL_DIR);
|
||||
}
|
||||
|
||||
export function resolveProposalDir(
|
||||
proposalId: string,
|
||||
options: SkillWorkshopStoreOptions = {},
|
||||
): string {
|
||||
assertProposalId(proposalId);
|
||||
return path.resolve(workspaceDir, proposalRelativeDir(proposalId));
|
||||
return path.join(resolveSkillWorkshopStateDir(options), proposalRelativeDir(proposalId));
|
||||
}
|
||||
|
||||
export function resolveProposalRecordPath(workspaceDir: string, proposalId: string): string {
|
||||
return path.join(resolveProposalDir(workspaceDir, proposalId), PROPOSAL_RECORD_FILE);
|
||||
export function resolveProposalRecordPath(
|
||||
proposalId: string,
|
||||
options: SkillWorkshopStoreOptions = {},
|
||||
): string {
|
||||
return path.join(resolveProposalDir(proposalId, options), PROPOSAL_RECORD_FILE);
|
||||
}
|
||||
|
||||
export function resolveProposalDraftPath(workspaceDir: string, proposalId: string): string {
|
||||
return path.join(resolveProposalDir(workspaceDir, proposalId), PROPOSAL_DRAFT_FILE);
|
||||
export function resolveProposalDraftPath(
|
||||
proposalId: string,
|
||||
options: SkillWorkshopStoreOptions = {},
|
||||
): string {
|
||||
return path.join(resolveProposalDir(proposalId, options), PROPOSAL_DRAFT_FILE);
|
||||
}
|
||||
|
||||
export function resolveSkillProposalTarget(params: { workspaceDir: string; skillName: string }): {
|
||||
@@ -70,15 +89,15 @@ export function resolveSkillProposalTarget(params: { workspaceDir: string; skill
|
||||
}
|
||||
|
||||
export async function readSkillProposal(
|
||||
workspaceDir: string,
|
||||
proposalId: string,
|
||||
options: SkillWorkshopStoreOptions = {},
|
||||
): Promise<SkillProposalReadResult | null> {
|
||||
const record = await readSkillProposalRecord(workspaceDir, proposalId);
|
||||
const record = await readSkillProposalRecord(proposalId, options);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
const workspaceRoot = await root(workspaceDir);
|
||||
const draft = await workspaceRoot.read(
|
||||
const stateRoot = await root(resolveSkillWorkshopStateDir(options));
|
||||
const draft = await stateRoot.read(
|
||||
path.join(proposalRelativeDir(proposalId), PROPOSAL_DRAFT_FILE),
|
||||
{
|
||||
hardlinks: "reject",
|
||||
@@ -90,52 +109,52 @@ export async function readSkillProposal(
|
||||
}
|
||||
|
||||
export async function readSkillProposalRecord(
|
||||
workspaceDir: string,
|
||||
proposalId: string,
|
||||
options: SkillWorkshopStoreOptions = {},
|
||||
): Promise<SkillProposalRecord | null> {
|
||||
const raw = await tryReadJson<unknown>(resolveProposalRecordPath(workspaceDir, proposalId));
|
||||
const raw = await tryReadJson<unknown>(resolveProposalRecordPath(proposalId, options));
|
||||
return parseSkillProposalRecord(raw);
|
||||
}
|
||||
|
||||
export async function writeSkillProposal(params: {
|
||||
workspaceDir: string;
|
||||
record: SkillProposalRecord;
|
||||
content: string;
|
||||
store?: SkillWorkshopStoreOptions;
|
||||
}): Promise<void> {
|
||||
assertProposalId(params.record.id);
|
||||
const workspaceRoot = await root(params.workspaceDir);
|
||||
const stateRoot = await root(resolveSkillWorkshopStateDir(params.store));
|
||||
const relativeDir = proposalRelativeDir(params.record.id);
|
||||
await workspaceRoot.mkdir(relativeDir);
|
||||
await workspaceRoot.write(path.join(relativeDir, PROPOSAL_DRAFT_FILE), params.content, {
|
||||
await stateRoot.mkdir(relativeDir);
|
||||
await stateRoot.write(path.join(relativeDir, PROPOSAL_DRAFT_FILE), params.content, {
|
||||
encoding: "utf8",
|
||||
});
|
||||
await workspaceRoot.writeJson(path.join(relativeDir, PROPOSAL_RECORD_FILE), params.record, {
|
||||
await stateRoot.writeJson(path.join(relativeDir, PROPOSAL_RECORD_FILE), params.record, {
|
||||
trailingNewline: true,
|
||||
});
|
||||
await refreshSkillProposalManifest(params.workspaceDir);
|
||||
await refreshSkillProposalManifest(params.store);
|
||||
}
|
||||
|
||||
export async function updateSkillProposalRecord(params: {
|
||||
workspaceDir: string;
|
||||
record: SkillProposalRecord;
|
||||
store?: SkillWorkshopStoreOptions;
|
||||
}): Promise<void> {
|
||||
assertProposalId(params.record.id);
|
||||
const workspaceRoot = await root(params.workspaceDir);
|
||||
await workspaceRoot.writeJson(
|
||||
const stateRoot = await root(resolveSkillWorkshopStateDir(params.store));
|
||||
await stateRoot.writeJson(
|
||||
path.join(proposalRelativeDir(params.record.id), PROPOSAL_RECORD_FILE),
|
||||
params.record,
|
||||
{ trailingNewline: true },
|
||||
);
|
||||
await refreshSkillProposalManifest(params.workspaceDir);
|
||||
await refreshSkillProposalManifest(params.store);
|
||||
}
|
||||
|
||||
export async function writeSkillProposalRollback(params: {
|
||||
workspaceDir: string;
|
||||
proposalId: string;
|
||||
rollback: SkillProposalRollback;
|
||||
store?: SkillWorkshopStoreOptions;
|
||||
}): Promise<void> {
|
||||
const workspaceRoot = await root(params.workspaceDir);
|
||||
await workspaceRoot.writeJson(
|
||||
const stateRoot = await root(resolveSkillWorkshopStateDir(params.store));
|
||||
await stateRoot.writeJson(
|
||||
path.join(proposalRelativeDir(params.proposalId), PROPOSAL_ROLLBACK_FILE),
|
||||
params.rollback,
|
||||
{ trailingNewline: true },
|
||||
@@ -143,29 +162,29 @@ export async function writeSkillProposalRollback(params: {
|
||||
}
|
||||
|
||||
export async function readSkillProposalManifest(
|
||||
workspaceDir: string,
|
||||
options: SkillWorkshopStoreOptions = {},
|
||||
): Promise<SkillProposalManifest> {
|
||||
const manifestPath = path.resolve(workspaceDir, MANIFEST_REL_PATH);
|
||||
const manifestPath = path.join(resolveSkillWorkshopStateDir(options), MANIFEST_REL_PATH);
|
||||
const parsed = parseSkillProposalManifest(await tryReadJson<unknown>(manifestPath));
|
||||
if (parsed) {
|
||||
return parsed;
|
||||
}
|
||||
return await refreshSkillProposalManifest(workspaceDir);
|
||||
return await refreshSkillProposalManifest(options);
|
||||
}
|
||||
|
||||
export async function refreshSkillProposalManifest(
|
||||
workspaceDir: string,
|
||||
options: SkillWorkshopStoreOptions = {},
|
||||
): Promise<SkillProposalManifest> {
|
||||
const workspaceRoot = await root(workspaceDir);
|
||||
await workspaceRoot.mkdir(PROPOSALS_REL_DIR);
|
||||
const entries = await workspaceRoot.list(PROPOSALS_REL_DIR, { withFileTypes: true });
|
||||
const stateRoot = await root(resolveSkillWorkshopStateDir(options));
|
||||
await stateRoot.mkdir(PROPOSALS_REL_DIR);
|
||||
const entries = await stateRoot.list(PROPOSALS_REL_DIR, { withFileTypes: true });
|
||||
const proposals: SkillProposalManifestEntry[] = [];
|
||||
|
||||
for (const entry of entries.toSorted((a, b) => a.name.localeCompare(b.name))) {
|
||||
if (!entry.isDirectory || !PROPOSAL_ID_PATTERN.test(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
const record = await readSkillProposalRecord(workspaceDir, entry.name);
|
||||
const record = await readSkillProposalRecord(entry.name, options);
|
||||
if (!record) {
|
||||
continue;
|
||||
}
|
||||
@@ -177,7 +196,7 @@ export async function refreshSkillProposalManifest(
|
||||
updatedAt: new Date().toISOString(),
|
||||
proposals: proposals.toSorted((a, b) => b.updatedAt.localeCompare(a.updatedAt)),
|
||||
};
|
||||
await workspaceRoot.writeJson(MANIFEST_REL_PATH, manifest, {
|
||||
await stateRoot.writeJson(MANIFEST_REL_PATH, manifest, {
|
||||
mkdir: true,
|
||||
trailingNewline: true,
|
||||
});
|
||||
@@ -188,10 +207,11 @@ export async function readWorkspaceSkillFile(filePath: string): Promise<string |
|
||||
if (!(await pathExists(filePath))) {
|
||||
return null;
|
||||
}
|
||||
const read = await readFileWithinRoot({
|
||||
rootDir: path.dirname(filePath),
|
||||
relativePath: path.basename(filePath),
|
||||
const skillRoot = await root(path.dirname(filePath));
|
||||
const read = await skillRoot.read(path.basename(filePath), {
|
||||
hardlinks: "reject",
|
||||
maxBytes: MAX_PROPOSAL_BYTES,
|
||||
symlinks: "reject",
|
||||
});
|
||||
return read.buffer.toString("utf8");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user