fix: store skill workshop proposals in state

This commit is contained in:
Shakker
2026-05-30 03:51:11 +01:00
committed by Shakker
parent 9da7498d31
commit 91ba5fd4fe
11 changed files with 154 additions and 124 deletions
+3 -3
View File
@@ -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:
+3 -2
View 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>
+3 -1
View File
@@ -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
+12 -3
View File
@@ -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
View File
@@ -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")
+8 -3
View File
@@ -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();
});
});
+2 -2
View File
@@ -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,
+20 -23
View File
@@ -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");
});
});
+16 -21
View File
@@ -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}`);
}
+61 -41
View File
@@ -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");
}