fix: preserve skill proposals across apply crashes and workspace moves (#114535)

* fix: move skill workshop proposals to sqlite

* fix(doctor): make workshop manifest cleanup idempotent

* fix(skills): resolve post-rebase static checks

* ci: retrigger exact-head checks
This commit is contained in:
Peter Steinberger
2026-07-28 10:42:32 -04:00
committed by GitHub
parent 92f6289cce
commit 206f989069
27 changed files with 1958 additions and 491 deletions
+17 -15
View File
@@ -350,12 +350,10 @@ proposals.
## Storage
```text
<OPENCLAW_STATE_DIR>/skill-workshop/
proposals.json
proposals/<proposal-id>/
proposal.json
<OPENCLAW_STATE_DIR>/
state/openclaw.sqlite
skill-workshop/proposals/<proposal-id>/
PROPOSAL.md
rollback.json
assets/
examples/
references/
@@ -365,20 +363,24 @@ proposals.
Default state directory: `~/.openclaw`.
- `proposal.json`: canonical proposal record.
- `proposals.json`: fast listing index, rebuildable from proposal folders.
- `state/openclaw.sqlite`: canonical proposal records, lifecycle status, origin attribution, and apply rollback metadata.
- `PROPOSAL.md`: pending skill proposal.
- `rollback.json`: recovery metadata written before apply changes live files.
- Support files remain beside `PROPOSAL.md` so operators can review the proposed skill as a normal directory.
`openclaw doctor --fix` imports the previous `proposals.json`, `proposal.json`, and
`rollback.json` metadata into SQLite after verifying each proposal, then removes
the migrated JSON files. If an agent's configured workspace changes, its earlier
proposals remain listed with a previous-workspace marker instead of disappearing.
## Limits
| Limit | Value |
| ------------------------------- | -------------------------------------------------------------------- |
| Description | 160 bytes |
| Proposal body | `skills.workshop.maxSkillBytes` (default 40,000; hard ceiling 1 MiB) |
| Support files | 64 per proposal |
| Support file size | 256 KiB each, 2 MiB total |
| Pending + quarantined proposals | `skills.workshop.maxPending` per workspace (default 50) |
| Limit | Value |
| ------------------------------- | ----------------------------------------------------------------- |
| Description | 160 bytes |
| Proposal body | `skills.workshop.maxSkillBytes` (default 40,000; maximum 200,000) |
| Support files | 64 per proposal |
| Support file size | 256 KiB each, 2 MiB total |
| Pending + quarantined proposals | `skills.workshop.maxPending` per workspace (default 50) |
## Troubleshooting
@@ -126,10 +126,11 @@ export async function readProposalForInspect(
params: Record<string, unknown>,
workspaceDir: string,
env?: NodeJS.ProcessEnv,
agentId?: string,
): Promise<SkillProposalReadResult> {
const proposalId = readStringParam(params, "proposal_id", { label: "proposal_id" });
if (proposalId) {
const proposal = await inspectSkillProposal(proposalId, { workspaceDir, env });
const proposal = await inspectSkillProposal(proposalId, { agentId, workspaceDir, env });
if (!proposal) {
throw new ToolInputError(`Skill proposal not found: ${proposalId}`);
}
@@ -139,8 +140,13 @@ export async function readProposalForInspect(
name: readStringParam(params, "name", { required: true }),
workspaceDir,
env,
agentId,
});
const proposal = await inspectSkillProposal(resolved.record.id, {
agentId,
workspaceDir,
env,
});
const proposal = await inspectSkillProposal(resolved.record.id, { workspaceDir, env });
if (!proposal) {
throw new ToolInputError(`Skill proposal not found: ${resolved.record.id}`);
}
@@ -63,7 +63,7 @@ export function formatProposalList(proposals: readonly SkillProposalManifestEntr
return proposals
.map(
(proposal) =>
`- ${proposal.id} [${proposal.status}, ${proposal.kind}, ${proposal.scanState}] ${proposal.skillKey}: ${proposal.title}`,
`- ${proposal.id} [${proposal.status}, ${proposal.kind}, ${proposal.scanState}${proposal.workspaceMismatch ? ", previous workspace" : ""}] ${proposal.skillKey}: ${proposal.title}`,
)
.join("\n");
}
+20 -35
View File
@@ -3,6 +3,7 @@
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { readSkillProposalRecord } from "../../skills/workshop/store.js";
import type { SkillWorkshopProposalMutationBudget } from "../../skills/workshop/types.js";
import {
createOpenClawTestState,
@@ -439,22 +440,13 @@ describe("skill_workshop tool", () => {
.then((buffer) => buffer.at(-1)),
).resolves.toBe(0x0a);
await expect(
fs
.readFile(
path.join(
stateDir,
"skill-workshop",
"proposals",
(result.details as { id: string }).id,
"proposal.json",
),
"utf8",
)
.then((raw) => JSON.parse(raw).origin),
).resolves.toEqual({
agentId: "main",
sessionKey: "agent:main:dashboard:workshop-test",
runId: "run-workshop-test",
readSkillProposalRecord((result.details as { id: string }).id),
).resolves.toMatchObject({
origin: {
agentId: "main",
sessionKey: "agent:main:dashboard:workshop-test",
runId: "run-workshop-test",
},
});
await expect(
fs.readFile(
@@ -521,19 +513,8 @@ describe("skill_workshop tool", () => {
),
).resolves.toContain('version: "v2"');
await expect(
fs
.readFile(
path.join(
stateDir,
"skill-workshop",
"proposals",
(result.details as { id: string }).id,
"proposal.json",
),
"utf8",
)
.then((raw) => JSON.parse(raw).origin),
).resolves.toEqual(reviewerOrigin);
readSkillProposalRecord((result.details as { id: string }).id),
).resolves.toMatchObject({ origin: reviewerOrigin });
const listed = await tool.execute("call-3", {
action: "list",
@@ -779,7 +760,7 @@ describe("skill_workshop tool", () => {
).rejects.toThrow();
});
it("scopes proposal discovery to the tool workspace", async () => {
it("keeps proposal discovery scoped to the tool agent across workspace changes", async () => {
const firstWorkspaceDir = await tempDirs.make("openclaw-skill-workshop-tool-first-");
const secondWorkspaceDir = await tempDirs.make("openclaw-skill-workshop-tool-second-");
const firstTool = createSkillWorkshopTool({
@@ -811,15 +792,19 @@ describe("skill_workshop tool", () => {
status: "pending",
});
expect(
(listed.details as { proposals: Array<{ id: string }> }).proposals.map(
(proposal) => proposal.id,
),
).toEqual([(first.details as { id: string }).id]);
(listed.details as { proposals: Array<{ id: string; workspaceMismatch?: true }> }).proposals,
).toEqual([
expect.objectContaining({
id: (second.details as { id: string }).id,
workspaceMismatch: true,
}),
expect.objectContaining({ id: (first.details as { id: string }).id }),
]);
await expect(
firstTool.execute("call-4", {
action: "inspect",
proposal_id: (second.details as { id: string }).id,
}),
).rejects.toThrow(`Skill proposal not found: ${(second.details as { id: string }).id}`);
).resolves.toMatchObject({ details: { id: (second.details as { id: string }).id } });
});
});
+18 -3
View File
@@ -224,7 +224,11 @@ export function createSkillWorkshopTool(options: SkillWorkshopToolOptions): AnyA
const limit = readListLimitParam(params);
const proposals = listProposalEntries({
proposals: (
await listSkillProposals({ workspaceDir: options.workspaceDir, env: options.env })
await listSkillProposals({
agentId: options.agentId,
workspaceDir: options.workspaceDir,
env: options.env,
})
).proposals,
status,
query,
@@ -239,7 +243,12 @@ export function createSkillWorkshopTool(options: SkillWorkshopToolOptions): AnyA
}
if (action === "inspect") {
const proposal = await readProposalForInspect(params, options.workspaceDir, options.env);
const proposal = await readProposalForInspect(
params,
options.workspaceDir,
options.env,
options.agentId,
);
return proposalResult(proposal, {
contentText: formatProposalInspect(proposal),
includeContent: true,
@@ -249,6 +258,7 @@ export function createSkillWorkshopTool(options: SkillWorkshopToolOptions): AnyA
if (action === "apply") {
const applied = await applySkillProposal({
workspaceDir: options.workspaceDir,
agentId: options.agentId,
config: options.config,
env: options.env,
proposalId: readLifecycleProposalIdParam(params),
@@ -263,6 +273,7 @@ export function createSkillWorkshopTool(options: SkillWorkshopToolOptions): AnyA
if (action === "reject") {
const rejected = await rejectSkillProposal({
workspaceDir: options.workspaceDir,
agentId: options.agentId,
env: options.env,
proposalId: readLifecycleProposalIdParam(params),
reason: readStringParam(params, "reason"),
@@ -275,6 +286,7 @@ export function createSkillWorkshopTool(options: SkillWorkshopToolOptions): AnyA
if (action === "quarantine") {
const quarantined = await quarantineSkillProposal({
workspaceDir: options.workspaceDir,
agentId: options.agentId,
env: options.env,
proposalId: readLifecycleProposalIdParam(params),
reason: readStringParam(params, "reason"),
@@ -319,6 +331,7 @@ export function createSkillWorkshopTool(options: SkillWorkshopToolOptions): AnyA
if (action === "create") {
proposal = await proposeCreateSkill({
workspaceDir: options.workspaceDir,
agentId: options.agentId,
config: options.config,
env: options.env,
name: readStringParam(params, "name", { required: true }),
@@ -334,9 +347,9 @@ export function createSkillWorkshopTool(options: SkillWorkshopToolOptions): AnyA
} else if (action === "update") {
proposal = await proposeUpdateSkill({
workspaceDir: options.workspaceDir,
agentId: options.agentId,
config: options.config,
env: options.env,
agentId: options.agentId,
skillName: readStringParam(params, "skill_name", {
required: true,
label: "skill_name",
@@ -357,10 +370,12 @@ export function createSkillWorkshopTool(options: SkillWorkshopToolOptions): AnyA
}),
name: readStringParam(params, "name"),
workspaceDir: options.workspaceDir,
agentId: options.agentId,
env: options.env,
});
proposal = await reviseSkillProposal({
workspaceDir: options.workspaceDir,
agentId: options.agentId,
config: options.config,
env: options.env,
proposalId: pendingProposal.record.id,
+21 -9
View File
@@ -281,7 +281,8 @@ function formatSkillProposalList(manifest: SkillProposalManifest): string {
}
return `${manifest.proposals
.map(
(entry) => `${entry.id} ${entry.status} ${entry.kind} ${entry.skillKey} ${entry.title}`,
(entry) =>
`${entry.id} ${entry.status} ${entry.kind} ${entry.skillKey} ${entry.title}${entry.workspaceMismatch ? " [previous workspace]" : ""}`,
)
.join("\n")}\n`;
}
@@ -424,6 +425,7 @@ async function runSkillProposalApply(
throw err;
}
return await applySkillProposal({
agentId: resolved.agentId,
workspaceDir: resolved.workspaceDir,
config: resolved.config,
proposalId,
@@ -866,8 +868,8 @@ export function registerSkillsCli(program: Command) {
.option("--json", "Output as JSON", false)
.action(async (opts: { json?: boolean; agent?: string }) => {
try {
const { workspaceDir } = resolveSkillsWorkspaceForCommand(workshop, opts);
const manifest = await listSkillProposals({ workspaceDir });
const { agentId, workspaceDir } = resolveSkillsWorkspaceForCommand(workshop, opts);
const manifest = await listSkillProposals({ agentId, workspaceDir });
if (opts.json) {
defaultRuntime.writeJson(manifest);
return;
@@ -886,8 +888,8 @@ export function registerSkillsCli(program: Command) {
.option("--json", "Output as JSON", false)
.action(async (proposalId: string, opts: { json?: boolean; agent?: string }) => {
try {
const { workspaceDir } = resolveSkillsWorkspaceForCommand(workshop, opts);
const proposal = await inspectSkillProposal(proposalId, { workspaceDir });
const { agentId, workspaceDir } = resolveSkillsWorkspaceForCommand(workshop, opts);
const proposal = await inspectSkillProposal(proposalId, { agentId, workspaceDir });
if (!proposal) {
defaultRuntime.error(`Skill proposal not found: ${proposalId}`);
defaultRuntime.exit(1);
@@ -932,10 +934,14 @@ export function registerSkillsCli(program: Command) {
command: Command,
) => {
try {
const { config, workspaceDir } = resolveSkillsWorkspaceForCommand(command.parent, opts);
const { config, workspaceDir, agentId } = resolveSkillsWorkspaceForCommand(
command.parent,
opts,
);
const draft = await readSkillProposalInput(opts);
const proposal = await proposeCreateSkill({
workspaceDir,
agentId,
config,
name: opts.name,
description: opts.description,
@@ -1042,10 +1048,14 @@ export function registerSkillsCli(program: Command) {
command: Command,
) => {
try {
const { config, workspaceDir } = resolveSkillsWorkspaceForCommand(command.parent, opts);
const { config, workspaceDir, agentId } = resolveSkillsWorkspaceForCommand(
command.parent,
opts,
);
const draft = await readSkillProposalInput(opts);
const proposal = await reviseSkillProposal({
workspaceDir,
agentId,
config,
proposalId,
content: draft.content,
@@ -1105,8 +1115,9 @@ export function registerSkillsCli(program: Command) {
command: Command,
) => {
try {
const { workspaceDir } = resolveSkillsWorkspaceForCommand(command.parent, opts);
const { agentId, workspaceDir } = resolveSkillsWorkspaceForCommand(command.parent, opts);
const record = await rejectSkillProposal({
agentId,
workspaceDir,
proposalId,
reason: opts.reason,
@@ -1136,8 +1147,9 @@ export function registerSkillsCli(program: Command) {
command: Command,
) => {
try {
const { workspaceDir } = resolveSkillsWorkspaceForCommand(command.parent, opts);
const { agentId, workspaceDir } = resolveSkillsWorkspaceForCommand(command.parent, opts);
const record = await quarantineSkillProposal({
agentId,
workspaceDir,
proposalId,
reason: opts.reason,
+5 -6
View File
@@ -187,7 +187,7 @@ describe("skills workshop cli", () => {
).resolves.toContain("Use current conditions");
});
it("scopes list and inspect to the selected workspace", async () => {
it("lists and inspects an agent proposal after its workspace changes", async () => {
const firstWorkspaceDir = mocks.workspaceDir;
const draftPath = path.join(firstWorkspaceDir, "proposal-draft.md");
await fs.writeFile(draftPath, "# First CLI Skill\n", "utf8");
@@ -208,11 +208,10 @@ describe("skills workshop cli", () => {
mocks.workspaceDir = await tempDirs.make("openclaw-skills-cli-workshop-second-");
await runCommand(["skills", "workshop", "list"]);
expect(mocks.runtimeStdout.at(-1)).toBe("No skill proposals.");
await expect(runCommand(["skills", "workshop", "inspect", proposalId!])).rejects.toThrow(
"__exit__:1",
);
expect(mocks.runtimeErrors).toContain(`Skill proposal not found: ${proposalId}`);
expect(mocks.runtimeStdout.at(-1)).toContain(`${proposalId} pending create`);
expect(mocks.runtimeStdout.at(-1)).toContain("[previous workspace]");
await runCommand(["skills", "workshop", "inspect", proposalId!]);
expect(mocks.runtimeStdout.at(-1)).toContain("status: proposal");
mocks.workspaceDir = firstWorkspaceDir;
await runCommand(["skills", "workshop", "inspect", proposalId!]);
@@ -0,0 +1,186 @@
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { renderProposalMarkdown } from "../skills/workshop/frontmatter.js";
import { inspectSkillProposal, listSkillProposals } from "../skills/workshop/service.js";
import { hashSkillProposalContent, readSkillProposalRollback } from "../skills/workshop/store.js";
import {
SKILL_WORKSHOP_ROLLBACK_SCHEMA,
SKILL_WORKSHOP_SCHEMA,
type SkillProposalRecord,
type SkillProposalRollback,
} from "../skills/workshop/types.js";
import {
OPENCLAW_STATE_SCHEMA_VERSION,
openOpenClawStateDatabase,
} from "../state/openclaw-state-db.js";
import {
createOpenClawTestState,
type OpenClawTestState,
} from "../test-utils/openclaw-test-state.js";
import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js";
import { migrateLegacySkillWorkshopProposals } from "./doctor-skill-workshop-sqlite.js";
const tempDirs = createTrackedTempDirs();
let testState: OpenClawTestState;
beforeEach(async () => {
testState = await createOpenClawTestState({
layout: "state-only",
prefix: "openclaw-doctor-workshop-sqlite-",
});
});
afterEach(async () => {
await testState.cleanup();
await tempDirs.cleanup();
});
describe("doctor Skill Workshop SQLite migration", () => {
it("imports verified sidecars, preserves review artifacts, and removes legacy JSON", async () => {
const oldWorkspace = await tempDirs.make("openclaw-workshop-old-workspace-");
const currentWorkspace = await tempDirs.make("openclaw-workshop-current-workspace-");
const proposalId = "legacy-workshop-20260727-1234567890";
const proposalDir = path.join(testState.stateDir, "skill-workshop", "proposals", proposalId);
const targetDir = path.join(oldWorkspace, "skills", "legacy-workshop");
const now = "2026-07-27T00:00:00.000Z";
const content = renderProposalMarkdown({
name: "legacy-workshop",
description: "Migrate the legacy proposal store",
content: "# Legacy Workshop\n\nKeep this review artifact.\n",
date: now,
});
const record: SkillProposalRecord = {
schema: SKILL_WORKSHOP_SCHEMA,
id: proposalId,
kind: "create",
status: "pending",
title: "Create Legacy Workshop",
description: "Migrate the legacy proposal store",
createdAt: now,
updatedAt: now,
createdBy: "cli",
origin: {
sessionKey: "agent:main:legacy-workshop",
runId: "legacy-run",
messageId: "legacy-message",
},
originRunIds: ["legacy-run", "revision-run"],
originRunMutationCounts: { "legacy-run": 1, "revision-run": 2 },
proposedVersion: "v1",
draftFile: "PROPOSAL.md",
draftHash: hashSkillProposalContent(content),
target: {
skillName: "Legacy Workshop",
skillKey: "legacy-workshop",
skillDir: targetDir,
skillFile: path.join(targetDir, "SKILL.md"),
source: "openclaw-workspace",
},
scan: {
state: "clean",
scannedAt: now,
critical: 0,
warn: 0,
info: 0,
findings: [],
},
};
const previousSupportContent = "\n".repeat(256 * 1024);
const rollback: SkillProposalRollback = {
schema: SKILL_WORKSHOP_ROLLBACK_SCHEMA,
proposalId,
writtenAt: now,
targetSkillFile: record.target.skillFile,
action: "create",
supportFiles: Array.from({ length: 64 }, (_, index) => ({
path: `references/large-${index}.md`,
existed: true,
previousContent: previousSupportContent,
previousContentHash: hashSkillProposalContent(previousSupportContent),
})),
};
await fs.mkdir(path.join(proposalDir, "references"), { recursive: true });
await fs.writeFile(path.join(proposalDir, "proposal.json"), JSON.stringify(record), "utf8");
await fs.writeFile(path.join(proposalDir, "PROPOSAL.md"), content, "utf8");
await fs.writeFile(path.join(proposalDir, "rollback.json"), JSON.stringify(rollback), "utf8");
await fs.writeFile(
path.join(proposalDir, "references", "proof.md"),
"# Preserved support file\n",
"utf8",
);
await fs.writeFile(
path.join(testState.stateDir, "skill-workshop", "proposals.json"),
"{}",
"utf8",
);
await expect(listSkillProposals()).resolves.toMatchObject({ proposals: [] });
const result = await migrateLegacySkillWorkshopProposals({ config: {} });
expect(result).toMatchObject({ detected: 1, migrated: 1, warnings: [] });
const listed = await listSkillProposals({ agentId: "main", workspaceDir: currentWorkspace });
expect(listed.proposals).toEqual([
expect.objectContaining({ id: proposalId, workspaceMismatch: true }),
]);
await expect(
inspectSkillProposal(proposalId, { agentId: "main", workspaceDir: currentWorkspace }),
).resolves.toMatchObject({
record: {
originRunIds: ["legacy-run", "revision-run"],
originRunMutationCounts: { "legacy-run": 1, "revision-run": 2 },
},
});
await expect(readSkillProposalRollback(proposalId)).resolves.toMatchObject(rollback);
await expect(fs.readFile(path.join(proposalDir, "PROPOSAL.md"), "utf8")).resolves.toBe(content);
await expect(
fs.readFile(path.join(proposalDir, "references", "proof.md"), "utf8"),
).resolves.toContain("Preserved support file");
await expect(fs.access(path.join(proposalDir, "proposal.json"))).rejects.toThrow();
await expect(fs.access(path.join(proposalDir, "rollback.json"))).rejects.toThrow();
await expect(
fs.access(path.join(testState.stateDir, "skill-workshop", "proposals.json")),
).rejects.toThrow();
expect(openOpenClawStateDatabase().db.prepare("PRAGMA user_version").get()).toEqual({
user_version: OPENCLAW_STATE_SCHEMA_VERSION,
});
const ambiguousId = "ambiguous-workshop-20260727-1234567890";
const ambiguousDir = path.join(testState.stateDir, "skill-workshop", "proposals", ambiguousId);
const ambiguousRecord: SkillProposalRecord = {
...record,
id: ambiguousId,
origin: { runId: "ambiguous-run" },
originRunIds: ["ambiguous-run"],
originRunMutationCounts: { "ambiguous-run": 1 },
target: {
...record.target,
skillDir: path.join(oldWorkspace, "skills", "ambiguous-workshop"),
skillFile: path.join(oldWorkspace, "skills", "ambiguous-workshop", "SKILL.md"),
},
};
await fs.mkdir(ambiguousDir, { recursive: true });
await fs.writeFile(
path.join(ambiguousDir, "proposal.json"),
JSON.stringify(ambiguousRecord),
"utf8",
);
await fs.writeFile(path.join(ambiguousDir, "PROPOSAL.md"), content, "utf8");
const secondWorkspace = await tempDirs.make("openclaw-workshop-second-agent-");
const ambiguous = await migrateLegacySkillWorkshopProposals({
config: {
agents: {
entries: {
main: { default: true, workspace: currentWorkspace },
other: { workspace: secondWorkspace },
},
},
},
});
expect(ambiguous).toMatchObject({ migrated: 0 });
expect(ambiguous.warnings).toEqual([
expect.stringContaining("owning agent could not be inferred"),
]);
await expect(fs.access(path.join(ambiguousDir, "proposal.json"))).resolves.toBeUndefined();
});
});
@@ -0,0 +1,261 @@
/** Doctor-owned migration of Skill Workshop proposal metadata into shared SQLite. */
import path from "node:path";
import {
listAgentIds,
resolveAgentWorkspaceDir,
resolveDefaultAgentId,
} from "../agents/agent-scope.js";
import { resolveStateDir } from "../config/paths.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { removePathWithinRoot } from "../infra/fs-safe-remove.js";
import { pathExists, root, type Root } from "../infra/fs-safe.js";
import { normalizeAgentId, resolveAgentIdFromSessionKey } from "../routing/session-key.js";
import {
hashSkillProposalContent,
importLegacySkillProposal,
parseSkillProposalRecord,
parseSkillProposalRollback,
readSkillProposalRecord,
readSkillProposalRollback,
} from "../skills/workshop/store.js";
import type { SkillProposalRecord, SkillProposalRollback } from "../skills/workshop/types.js";
const WORKSHOP_DIR = "skill-workshop";
const PROPOSALS_DIR = `${WORKSHOP_DIR}/proposals`;
const MANIFEST_PATH = `${WORKSHOP_DIR}/proposals.json`;
const MAX_RECORD_BYTES = 1024 * 1024;
// Legacy rollback JSON can expand control characters sixfold across 1 MiB of
// SKILL.md plus 64 existing 256 KiB support targets.
const MAX_ROLLBACK_BYTES = 128 * 1024 * 1024;
const PROPOSAL_ID_PATTERN = /^[a-z0-9][a-z0-9-]{5,120}$/;
type MigrationResult = {
changes: string[];
warnings: string[];
detected: number;
migrated: number;
};
function isNotFoundError(error: unknown): boolean {
const code = (error as NodeJS.ErrnoException).code;
return code === "not-found" || code === "ENOENT";
}
async function readJson(rootDir: Root, relativePath: string, maxBytes: number): Promise<unknown> {
const read = await rootDir.read(relativePath, {
hardlinks: "reject",
maxBytes,
symlinks: "reject",
});
return JSON.parse(read.buffer.toString("utf8")) as unknown;
}
function proposalWorkspace(record: SkillProposalRecord): string {
return path.dirname(path.dirname(path.resolve(record.target.skillDir)));
}
function configuredAgentIds(config: OpenClawConfig): string[] {
return [
...new Set([resolveDefaultAgentId(config), ...listAgentIds(config)].map(normalizeAgentId)),
];
}
function inferOwnerAgentId(params: {
config: OpenClawConfig;
env: NodeJS.ProcessEnv;
record: SkillProposalRecord;
workspaceDir: string;
}): string | undefined {
if (params.record.origin?.agentId) {
return normalizeAgentId(params.record.origin.agentId);
}
if (params.record.origin?.sessionKey) {
try {
return resolveAgentIdFromSessionKey(
params.record.origin.sessionKey,
resolveDefaultAgentId(params.config),
);
} catch {
// Fall through to the workspace and single-agent evidence below.
}
}
const agentIds = configuredAgentIds(params.config);
const workspaceMatches = agentIds.filter(
(agentId) =>
path.resolve(resolveAgentWorkspaceDir(params.config, agentId, params.env)) ===
path.resolve(params.workspaceDir),
);
if (workspaceMatches.length === 1) {
return workspaceMatches[0];
}
return agentIds.length === 1 ? agentIds[0] : undefined;
}
async function readLegacyRollback(
stateRoot: Root,
proposalId: string,
): Promise<SkillProposalRollback | undefined> {
try {
const rollback = parseSkillProposalRollback(
await readJson(stateRoot, `${PROPOSALS_DIR}/${proposalId}/rollback.json`, MAX_ROLLBACK_BYTES),
);
if (!rollback || rollback.proposalId !== proposalId) {
throw new Error("invalid rollback metadata");
}
return rollback;
} catch (error) {
if (isNotFoundError(error)) {
return undefined;
}
throw error;
}
}
async function verifyImportedProposal(params: {
env: NodeJS.ProcessEnv;
record: SkillProposalRecord;
rollback?: SkillProposalRollback;
}): Promise<void> {
const imported = await readSkillProposalRecord(params.record.id, { env: params.env });
if (
!imported ||
imported.draftHash !== params.record.draftHash ||
imported.target.skillFile !== params.record.target.skillFile
) {
throw new Error("SQLite verification failed");
}
if (
params.rollback &&
!(await readSkillProposalRollback(params.record.id, { env: params.env }))
) {
throw new Error("SQLite rollback verification failed");
}
}
async function migrateProposal(params: {
config: OpenClawConfig;
env: NodeJS.ProcessEnv;
proposalId: string;
stateRoot: Root;
}): Promise<"imported" | "already-imported"> {
const proposalDir = `${PROPOSALS_DIR}/${params.proposalId}`;
const record = parseSkillProposalRecord(
await readJson(params.stateRoot, `${proposalDir}/proposal.json`, MAX_RECORD_BYTES),
);
if (!record || record.id !== params.proposalId) {
throw new Error("invalid proposal metadata");
}
const draft = await params.stateRoot.read(`${proposalDir}/PROPOSAL.md`, {
hardlinks: "reject",
maxBytes: MAX_RECORD_BYTES,
symlinks: "reject",
});
if (hashSkillProposalContent(draft.buffer.toString("utf8")) !== record.draftHash) {
throw new Error("proposal draft hash does not match proposal metadata");
}
const rollback = await readLegacyRollback(params.stateRoot, params.proposalId);
const workspaceDir = proposalWorkspace(record);
const ownerAgentId = inferOwnerAgentId({
config: params.config,
env: params.env,
record,
workspaceDir,
});
if (!ownerAgentId) {
throw new Error(
"owning agent could not be inferred; legacy metadata was retained for manual recovery",
);
}
const result = importLegacySkillProposal({
record,
rollback,
ownerAgentId,
workspaceDir,
store: { env: params.env },
});
await verifyImportedProposal({ env: params.env, record, rollback });
if (rollback) {
await params.stateRoot.remove(`${proposalDir}/rollback.json`);
}
await params.stateRoot.remove(`${proposalDir}/proposal.json`);
return result;
}
/** Import verified legacy proposal sidecars, then remove only the imported JSON metadata. */
export async function migrateLegacySkillWorkshopProposals(params: {
config: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}): Promise<MigrationResult> {
const env = params.env ?? process.env;
const stateDir = resolveStateDir(env);
if (!(await pathExists(path.join(stateDir, PROPOSALS_DIR)))) {
if (!(await pathExists(path.join(stateDir, MANIFEST_PATH)))) {
return { changes: [], warnings: [], detected: 0, migrated: 0 };
}
await removePathWithinRoot({ rootDir: stateDir, relativePath: MANIFEST_PATH });
return {
changes: ["Removed the empty legacy Skill Workshop proposal index."],
warnings: [],
detected: 0,
migrated: 0,
};
}
const stateRoot = await root(stateDir);
let entries;
try {
entries = await stateRoot.list(PROPOSALS_DIR, { withFileTypes: true });
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "not-found") {
return { changes: [], warnings: [], detected: 0, migrated: 0 };
}
return {
changes: [],
warnings: [`Failed to inspect legacy Skill Workshop proposals: ${String(error)}`],
detected: 0,
migrated: 0,
};
}
const proposalIds = entries
.filter((entry) => entry.isDirectory && PROPOSAL_ID_PATTERN.test(entry.name))
.map((entry) => entry.name)
.toSorted((left, right) => left.localeCompare(right));
const warnings: string[] = [];
let migrated = 0;
for (const proposalId of proposalIds) {
try {
await migrateProposal({
config: params.config,
env,
proposalId,
stateRoot,
});
migrated += 1;
} catch (error) {
if (isNotFoundError(error)) {
if (await readSkillProposalRecord(proposalId, { env })) {
continue;
}
}
warnings.push(`Failed to migrate Skill Workshop proposal ${proposalId}: ${String(error)}`);
}
}
await removePathWithinRoot({ rootDir: stateDir, relativePath: MANIFEST_PATH }).catch(
(error: unknown) => {
if (!isNotFoundError(error)) {
warnings.push(`Failed to remove legacy Skill Workshop proposal index: ${String(error)}`);
}
},
);
return {
changes:
migrated > 0
? [
`Migrated ${migrated} Skill Workshop proposal${migrated === 1 ? "" : "s"} into shared SQLite.`,
]
: [],
warnings,
detected: proposalIds.length,
migrated,
};
}
+2
View File
@@ -16,6 +16,7 @@ import {
maybeRepairManagedNpmOpenClawPeerLinks,
maybeRepairStaleManagedNpmBundledPlugins,
} from "../doctor-plugin-registry.js";
import { migrateLegacySkillWorkshopProposals } from "../doctor-skill-workshop-sqlite.js";
import { maybeRepairGroupAllowFromFallback } from "./shared/allowfrom-fallback-migration.js";
import { maybeRepairAllowlistPolicyAllowFrom } from "./shared/allowlist-policy-repair.js";
import { maybeRepairBundledPluginLoadPaths } from "./shared/bundled-plugin-load-paths.js";
@@ -219,6 +220,7 @@ export async function runDoctorRepairSequence(params: {
appendNotes(warningNotes, emptyAllowlistWarnings);
await applyRepairStages([maybeRepairLegacyToolsBySenderKeys, maybeRepairExecSafeBinProfiles]);
appendRepairNotes(await migrateLegacySkillWorkshopProposals({ config: state.candidate, env }));
appendRepairNotes(await cleanupLegacyPluginDependencyState({ env }));
appendRepairNotes(
migrateLegacyOnboardingRecommendationsScope({
@@ -158,7 +158,7 @@ describe("skills proposal gateway handlers", () => {
).resolves.toContain("Use current weather");
});
it("scopes list and inspect to the resolved agent workspace", async () => {
it("keeps list and inspect bound to the agent after its workspace changes", async () => {
const firstWorkspaceDir = mocks.workspaceDir;
const first = await callHandler("skills.proposals.create", {
name: "First Gateway Skill",
@@ -180,24 +180,26 @@ describe("skills proposal gateway handlers", () => {
const secondList = await callHandler("skills.proposals.list", {});
expect(secondList.ok).toBe(true);
expect((secondList.response as { proposals: Array<{ id: string }> }).proposals).toEqual([
expect(
(secondList.response as { proposals: Array<{ id: string; workspaceMismatch?: true }> })
.proposals,
).toEqual([
expect.objectContaining({ id: secondCreated.record.id }),
expect.objectContaining({ id: firstCreated.record.id, workspaceMismatch: true }),
]);
const hiddenInspect = await callHandler("skills.proposals.inspect", {
const oldWorkspaceInspect = await callHandler("skills.proposals.inspect", {
proposalId: firstCreated.record.id,
});
expect(hiddenInspect.ok).toBe(false);
expect((hiddenInspect.error as { message?: string }).message).toContain(
`Skill proposal not found: ${firstCreated.record.id}`,
expect(oldWorkspaceInspect.ok).toBe(true);
expect((oldWorkspaceInspect.response as { record: { id: string } }).record.id).toBe(
firstCreated.record.id,
);
mocks.workspaceDir = firstWorkspaceDir;
const firstList = await callHandler("skills.proposals.list", {});
expect(firstList.ok).toBe(true);
expect((firstList.response as { proposals: Array<{ id: string }> }).proposals).toEqual([
expect.objectContaining({ id: firstCreated.record.id }),
]);
expect((firstList.response as { proposals: Array<{ id: string }> }).proposals).toHaveLength(2);
});
it("rejects invalid params before touching workshop state", async () => {
+9 -1
View File
@@ -369,7 +369,8 @@ export const skillsHandlers: GatewayRequestHandlers = {
respond,
context,
validate: validateSkillsProposalsListParams,
run: (_parsedParams, resolved) => listSkillProposals({ workspaceDir: resolved.workspaceDir }),
run: (_parsedParams, resolved) =>
listSkillProposals({ agentId: resolved.agentId, workspaceDir: resolved.workspaceDir }),
});
},
"skills.proposals.inspect": async ({ params, respond, context }) => {
@@ -381,6 +382,7 @@ export const skillsHandlers: GatewayRequestHandlers = {
validate: validateSkillsProposalInspectParams,
run: async (parsedParams, resolved) => {
const proposal = await inspectSkillProposal(parsedParams.proposalId, {
agentId: resolved.agentId,
workspaceDir: resolved.workspaceDir,
});
if (!proposal) {
@@ -408,6 +410,7 @@ export const skillsHandlers: GatewayRequestHandlers = {
run: (parsedParams, resolved) =>
proposeCreateSkill({
workspaceDir: resolved.workspaceDir,
agentId: resolved.agentId,
config: resolved.cfg,
name: parsedParams.name,
description: parsedParams.description,
@@ -451,6 +454,7 @@ export const skillsHandlers: GatewayRequestHandlers = {
run: (parsedParams, resolved) =>
reviseSkillProposal({
workspaceDir: resolved.workspaceDir,
agentId: resolved.agentId,
config: resolved.cfg,
proposalId: parsedParams.proposalId,
content: parsedParams.content,
@@ -471,6 +475,7 @@ export const skillsHandlers: GatewayRequestHandlers = {
validate: validateSkillsProposalRequestRevisionParams,
run: async (parsedParams, resolved) => {
const proposal = await inspectSkillProposal(parsedParams.proposalId, {
agentId: resolved.agentId,
workspaceDir: resolved.workspaceDir,
});
if (!proposal) {
@@ -520,6 +525,7 @@ export const skillsHandlers: GatewayRequestHandlers = {
run: (parsedParams, resolved) =>
applySkillProposal({
workspaceDir: resolved.workspaceDir,
agentId: resolved.agentId,
config: resolved.cfg,
proposalId: parsedParams.proposalId,
reason: parsedParams.reason,
@@ -536,6 +542,7 @@ export const skillsHandlers: GatewayRequestHandlers = {
run: (parsedParams, resolved) =>
rejectSkillProposal({
workspaceDir: resolved.workspaceDir,
agentId: resolved.agentId,
proposalId: parsedParams.proposalId,
reason: parsedParams.reason,
}),
@@ -551,6 +558,7 @@ export const skillsHandlers: GatewayRequestHandlers = {
run: (parsedParams, resolved) =>
quarantineSkillProposal({
workspaceDir: resolved.workspaceDir,
agentId: resolved.agentId,
proposalId: parsedParams.proposalId,
reason: parsedParams.reason,
}),
+1 -1
View File
@@ -3,7 +3,7 @@ import { asNullableRecord } from "@openclaw/normalization-core/record-coerce";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
/** Runtime configuration for the skill workshop proposal flow. */
export type SkillWorkshopConfig = {
type SkillWorkshopConfig = {
autonomous: {
enabled: boolean;
};
+30 -43
View File
@@ -1,21 +1,16 @@
import { expectDefined } from "@openclaw/normalization-core";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { normalizeSkillIndexName } from "../discovery/skill-index.js";
import { assertInsideWorkspace } from "../lifecycle/workspace-skill-write.js";
import {
readProposalSupportFiles,
readSkillProposal,
readSkillProposalManifest,
readSkillProposalRecord,
refreshSkillProposalManifest,
} from "./store.js";
import type {
SkillProposalManifest,
SkillProposalReadResult,
SkillProposalRecord,
} from "./types.js";
import type { SkillProposalManifest, SkillProposalReadResult } from "./types.js";
type SkillProposalScopeOptions = {
agentId?: string;
env?: NodeJS.ProcessEnv;
workspaceDir?: string;
};
@@ -24,45 +19,25 @@ function storeOptions(env?: NodeJS.ProcessEnv) {
return env ? { env } : {};
}
export function isProposalInWorkspace(record: SkillProposalRecord, workspaceDir: string): boolean {
try {
assertInsideWorkspace(workspaceDir, record.target.skillFile, "skill file");
assertInsideWorkspace(workspaceDir, record.target.skillDir, "skill directory");
return true;
} catch {
return false;
}
}
export async function listSkillProposals(
options: SkillProposalScopeOptions = {},
): Promise<SkillProposalManifest> {
const store = storeOptions(options.env);
const manifest = await readSkillProposalManifest(store);
if (!options.workspaceDir) {
return manifest;
}
const proposals: SkillProposalManifest["proposals"] = [];
for (const proposal of manifest.proposals) {
const record = await readSkillProposalRecord(proposal.id, store);
if (record && isProposalInWorkspace(record, options.workspaceDir)) {
proposals.push(proposal);
}
}
return { ...manifest, proposals };
return await readSkillProposalManifest(storeOptions(options.env), {
...(options.agentId ? { agentId: options.agentId } : {}),
...(options.workspaceDir ? { workspaceDir: options.workspaceDir } : {}),
});
}
export async function getSkillProposalRunProgress(
options: SkillProposalScopeOptions & { runId: string },
): Promise<{ mutationCount: number; proposalIds: string[] }> {
const store = storeOptions(options.env);
// Records land before the derived manifest, so rebuild before crash recovery reads them.
const manifest = await refreshSkillProposalManifest(store);
const manifest = await readSkillProposalManifest(store, options);
const ids: string[] = [];
let mutationCount = 0;
for (const proposal of manifest.proposals) {
const record = await readSkillProposalRecord(proposal.id, store);
if (!record || (options.workspaceDir && !isProposalInWorkspace(record, options.workspaceDir))) {
const record = await readSkillProposalRecord(proposal.id, store, options);
if (!record) {
continue;
}
if (record.origin?.runId === options.runId || record.originRunIds?.includes(options.runId)) {
@@ -77,17 +52,15 @@ export async function inspectSkillProposal(
proposalId: string,
options: SkillProposalScopeOptions = {},
): Promise<SkillProposalReadResult | null> {
const read = await readSkillProposal(proposalId, storeOptions(options.env));
if (
!read ||
(options.workspaceDir && !isProposalInWorkspace(read.record, options.workspaceDir))
) {
const read = await readSkillProposal(proposalId, storeOptions(options.env), options);
if (!read) {
return null;
}
return await hydrateProposalSupportFiles(read, options.env);
}
export async function resolvePendingSkillProposal(input: {
agentId?: string;
env?: NodeJS.ProcessEnv;
proposalId?: string;
name?: string;
@@ -95,7 +68,12 @@ export async function resolvePendingSkillProposal(input: {
}): Promise<SkillProposalReadResult> {
const proposalId = normalizeOptionalString(input.proposalId);
if (proposalId) {
const direct = await readRequiredProposal(proposalId, input.workspaceDir, input.env);
const direct = await readRequiredProposal(
proposalId,
input.workspaceDir,
input.env,
input.agentId,
);
if (direct.record.status !== "pending") {
throw new Error(
`Only pending proposals can be revised. Current status: ${direct.record.status}.`,
@@ -107,7 +85,11 @@ export async function resolvePendingSkillProposal(input: {
if (!name) {
throw new Error("proposal_id or name required.");
}
const manifest = await listSkillProposals({ workspaceDir: input.workspaceDir, env: input.env });
const manifest = await listSkillProposals({
agentId: input.agentId,
workspaceDir: input.workspaceDir,
env: input.env,
});
const matches = manifest.proposals.filter(
(proposal) => proposal.status === "pending" && proposalMatchesName(proposal, name),
);
@@ -125,6 +107,7 @@ export async function resolvePendingSkillProposal(input: {
expectDefined(matches[0], "matches capture group 0").id,
input.workspaceDir,
input.env,
input.agentId,
);
if (matched.record.status !== "pending") {
throw new Error(
@@ -138,9 +121,13 @@ export async function readRequiredProposal(
proposalId: string,
workspaceDir?: string,
env?: NodeJS.ProcessEnv,
agentId?: string,
): Promise<SkillProposalReadResult> {
const read = await readSkillProposal(proposalId, storeOptions(env));
if (!read || (workspaceDir && !isProposalInWorkspace(read.record, workspaceDir))) {
const read = await readSkillProposal(proposalId, storeOptions(env), {
...(agentId ? { agentId } : {}),
...(workspaceDir ? { workspaceDir } : {}),
});
if (!read) {
throw new Error(`Skill proposal not found: ${proposalId}`);
}
return read;
+277 -47
View File
@@ -2,6 +2,7 @@
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { closeOpenClawStateDatabaseForTest } from "../../state/openclaw-state-db.js";
import {
createOpenClawTestState,
type OpenClawTestState,
@@ -13,7 +14,7 @@ import {
resetSkillsRefreshStateForTest,
} from "../runtime/refresh-state.js";
import { writeSkill } from "../test-support/e2e-test-helpers.js";
import { renderProposalMarkdown } from "./frontmatter.js";
import { renderProposalMarkdown, stripProposalFrontmatterForSkill } from "./frontmatter.js";
import {
applySkillProposal,
getSkillProposalRunProgress,
@@ -27,7 +28,13 @@ import {
resolvePendingSkillProposal,
reviseSkillProposal,
} from "./service.js";
import { readSkillProposalManifest, updateSkillProposalRecord } from "./store.js";
import {
createSkillProposalRollback,
readSkillProposalManifest,
readSkillProposalRollback,
updateSkillProposalRecord,
writeSkillProposalRollback,
} from "./store.js";
const tempDirs = createTrackedTempDirs();
let testState: OpenClawTestState;
@@ -116,6 +123,7 @@ describe("skill workshop proposals", () => {
skillKey: "weather-helper",
scanState: "clean",
});
expect((await listSkillProposals()).updatedAt).toBe(listed.updatedAt);
const beforeVersion = getSkillsSnapshotVersion(workspaceDir);
const applied = await applySkillProposal({
@@ -392,6 +400,14 @@ describe("skill workshop proposals", () => {
goal: "Original goal",
evidence: "Original evidence",
});
await writeSkillProposalRollback({
proposalId: proposal.record.id,
rollback: createSkillProposalRollback({
proposalId: proposal.record.id,
targetSkillFile: proposal.record.target.skillFile,
action: "create",
}),
});
const revised = await reviseSkillProposal({
workspaceDir,
@@ -412,6 +428,7 @@ describe("skill workshop proposals", () => {
expect(revised.record.supportFiles?.map((file) => file.path)).toEqual([
"references/original.md",
]);
await expect(readSkillProposalRollback(proposal.record.id)).resolves.toBeNull();
expect(revised.content).toContain('version: "v2"');
expect(revised.content).toContain("date: ");
@@ -466,7 +483,7 @@ describe("skill workshop proposals", () => {
);
});
it("rebuilds a stale manifest before recovering run progress", async () => {
it("recovers run progress from canonical proposal records", async () => {
const workspaceDir = await makeWorkspace();
const proposal = await proposeCreateSkill({
workspaceDir,
@@ -475,16 +492,6 @@ describe("skill workshop proposals", () => {
content: "# Recovered Proposal\n",
origin: { runId: "interrupted-run" },
});
await fs.writeFile(
path.join(stateDir, "skill-workshop", "proposals.json"),
`${JSON.stringify({
schema: "openclaw.skill-workshop.proposals-manifest.v1",
updatedAt: new Date().toISOString(),
proposals: [],
})}\n`,
"utf8",
);
await expect(
getSkillProposalRunProgress({ workspaceDir, runId: "interrupted-run" }),
).resolves.toEqual({ mutationCount: 1, proposalIds: [proposal.record.id] });
@@ -531,34 +538,93 @@ describe("skill workshop proposals", () => {
);
});
it("scopes proposal reads and lifecycle actions to the selected workspace", async () => {
it("keeps an agent's proposals visible after its workspace changes", async () => {
const firstWorkspaceDir = await makeWorkspace();
const secondWorkspaceDir = await makeWorkspace();
const first = await proposeCreateSkill({
agentId: "main",
workspaceDir: firstWorkspaceDir,
name: "First Workspace Skill",
description: "Created before the workspace changed",
content: "# First\n",
});
const quarantined = await proposeCreateSkill({
agentId: "main",
workspaceDir: firstWorkspaceDir,
name: "First Workspace Review",
description: "Also created before the workspace changed",
content: "# First Review\n",
});
const second = await proposeCreateSkill({
agentId: "main",
workspaceDir: secondWorkspaceDir,
name: "Second Workspace Skill",
description: "Created after the workspace changed",
content: "# Second\n",
});
await proposeCreateSkill({
agentId: "other",
workspaceDir: secondWorkspaceDir,
name: "Other Agent Skill",
description: "Owned by a different agent",
content: "# Other\n",
});
const listed = await listSkillProposals({ agentId: "main", workspaceDir: secondWorkspaceDir });
expect(listed.proposals).toEqual([
expect.objectContaining({ id: second.record.id }),
expect.objectContaining({ id: quarantined.record.id, workspaceMismatch: true }),
expect.objectContaining({ id: first.record.id, workspaceMismatch: true }),
]);
expect(listed.proposals[0]).not.toHaveProperty("workspaceMismatch");
await expect(
inspectSkillProposal(first.record.id, {
agentId: "main",
workspaceDir: secondWorkspaceDir,
}),
).resolves.toMatchObject({ record: { id: first.record.id } });
await expect(
resolvePendingSkillProposal({
agentId: "main",
name: "first-workspace-skill",
workspaceDir: secondWorkspaceDir,
}),
).resolves.toMatchObject({ record: { id: first.record.id } });
await expect(
rejectSkillProposal({
agentId: "main",
workspaceDir: secondWorkspaceDir,
proposalId: first.record.id,
}),
).resolves.toMatchObject({ status: "rejected" });
await expect(
quarantineSkillProposal({
agentId: "main",
workspaceDir: secondWorkspaceDir,
proposalId: quarantined.record.id,
}),
).resolves.toMatchObject({ status: "quarantined" });
});
it("preserves workspace scoping when no agent identity is supplied", async () => {
const firstWorkspaceDir = await makeWorkspace();
const secondWorkspaceDir = await makeWorkspace();
const first = await proposeCreateSkill({
workspaceDir: firstWorkspaceDir,
name: "First Workspace Skill",
description: "Only visible in the first workspace",
name: "First Unowned Skill",
description: "Bound to the first workspace",
content: "# First\n",
});
const second = await proposeCreateSkill({
workspaceDir: secondWorkspaceDir,
name: "Second Workspace Skill",
description: "Only visible in the second workspace",
name: "Second Unowned Skill",
description: "Bound to the second workspace",
content: "# Second\n",
});
await expect(listSkillProposals({ workspaceDir: firstWorkspaceDir })).resolves.toMatchObject({
proposals: [expect.objectContaining({ id: first.record.id })],
});
await expect(
inspectSkillProposal(second.record.id, { workspaceDir: firstWorkspaceDir }),
).resolves.toBeNull();
await expect(
resolvePendingSkillProposal({
name: "second-workspace-skill",
workspaceDir: firstWorkspaceDir,
}),
).rejects.toThrow("No pending skill proposal matched");
await expect(
rejectSkillProposal({
workspaceDir: firstWorkspaceDir,
@@ -566,11 +632,8 @@ describe("skill workshop proposals", () => {
}),
).rejects.toThrow(`Skill proposal not found: ${second.record.id}`);
await expect(
quarantineSkillProposal({
workspaceDir: firstWorkspaceDir,
proposalId: second.record.id,
}),
).rejects.toThrow(`Skill proposal not found: ${second.record.id}`);
inspectSkillProposal(first.record.id, { workspaceDir: firstWorkspaceDir }),
).resolves.toMatchObject({ record: { id: first.record.id } });
});
it("updates only writable workspace skills and marks stale proposals when the target changes", async () => {
@@ -629,12 +692,10 @@ describe("skill workshop proposals", () => {
await expect(fs.readFile(path.join(skillDir, "SKILL.md"), "utf8")).resolves.toContain(
"New checklist.",
);
const rollback = JSON.parse(
await fs.readFile(
path.join(stateDir, "skill-workshop", "proposals", proposal.record.id, "rollback.json"),
"utf8",
),
) as { previousContent?: string; supportFiles?: Array<{ previousContent?: string }> };
const rollback = await readSkillProposalRollback(proposal.record.id);
if (!rollback) {
throw new Error("expected rollback metadata");
}
expect(rollback.previousContent).toContain("Old checklist.");
expect(rollback.supportFiles?.[0]?.previousContent).toContain("Old support file.");
await expect(fs.readFile(path.join(skillDir, "references", "qa.md"), "utf8")).resolves.toBe(
@@ -786,24 +847,193 @@ describe("skill workshop proposals", () => {
).rejects.toThrow("Only pending proposals can be rejected");
});
it("rebuilds the listing manifest when the fast manifest is corrupt", async () => {
it("reconciles a create apply interrupted after the live skill write", async () => {
const workspaceDir = await makeWorkspace();
const proposal = await proposeCreateSkill({
workspaceDir,
name: "Manifest Repair",
description: "Repair corrupt manifests",
content: "# Manifest Repair\n",
name: "Interrupted Apply",
description: "Recover an interrupted create apply",
content: "# Interrupted Apply\n\nInstalled before the status commit.\n",
supportFiles: [{ path: "references/proof.md", content: "Applied support.\n" }],
});
const sibling = await proposeCreateSkill({
workspaceDir,
name: "Interrupted Apply",
description: "Recover an interrupted create apply",
content: "# Interrupted Apply\n\nInstalled before the status commit.\n",
supportFiles: [{ path: "references/proof.md", content: "Different support.\n" }],
});
await writeSkillProposalRollback({
proposalId: sibling.record.id,
rollback: createSkillProposalRollback({
proposalId: sibling.record.id,
targetSkillFile: sibling.record.target.skillFile,
action: "create",
}),
});
await writeSkillProposalRollback({
proposalId: proposal.record.id,
rollback: createSkillProposalRollback({
proposalId: proposal.record.id,
targetSkillFile: proposal.record.target.skillFile,
action: "create",
}),
});
await expect(readSkillProposalRollback(sibling.record.id)).resolves.toBeNull();
await fs.mkdir(proposal.record.target.skillDir, { recursive: true });
await fs.mkdir(path.join(proposal.record.target.skillDir, "references"), { recursive: true });
await fs.writeFile(
path.join(stateDir, "skill-workshop", "proposals.json"),
"{not-json",
path.join(proposal.record.target.skillDir, "references", "proof.md"),
"Applied support.\n",
"utf8",
);
await fs.writeFile(
proposal.record.target.skillFile,
stripProposalFrontmatterForSkill(proposal.content),
"utf8",
);
const manifest = await listSkillProposals();
closeOpenClawStateDatabaseForTest();
const manifest = await listSkillProposals({ workspaceDir });
expect(manifest.proposals).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: proposal.record.id, status: "applied" }),
expect.objectContaining({ id: sibling.record.id, status: "pending" }),
]),
);
await expect(
applySkillProposal({ workspaceDir, proposalId: proposal.record.id }),
).rejects.toThrow("Only pending proposals can be applied. Current status: applied.");
});
expect(manifest.proposals).toHaveLength(1);
expect(manifest.proposals[0]?.id).toBe(proposal.record.id);
it("does not reconcile an interrupted apply from a tampered proposal draft", async () => {
const workspaceDir = await makeWorkspace();
const proposal = await proposeCreateSkill({
workspaceDir,
name: "Tampered Reconcile",
description: "Reject altered proposal metadata during recovery",
content: "# Tampered Reconcile\n\nCanonical body.\n",
});
await writeSkillProposalRollback({
proposalId: proposal.record.id,
rollback: createSkillProposalRollback({
proposalId: proposal.record.id,
targetSkillFile: proposal.record.target.skillFile,
action: "create",
}),
});
await fs.mkdir(proposal.record.target.skillDir, { recursive: true });
await fs.writeFile(
proposal.record.target.skillFile,
stripProposalFrontmatterForSkill(proposal.content),
"utf8",
);
await fs.writeFile(
path.join(stateDir, "skill-workshop", "proposals", proposal.record.id, "PROPOSAL.md"),
proposal.content.replace(/date: .+/, 'date: "2099-01-01T00:00:00.000Z"'),
"utf8",
);
closeOpenClawStateDatabaseForTest();
await expect(listSkillProposals({ workspaceDir })).resolves.toMatchObject({
proposals: [expect.objectContaining({ id: proposal.record.id, status: "pending" })],
});
});
it("reconciles an update apply interrupted after the live skill write", async () => {
const workspaceDir = await makeWorkspace();
const skillDir = path.join(workspaceDir, "skills", "interrupted-update");
await writeSkill({
dir: skillDir,
name: "interrupted-update",
description: "Recover interrupted updates",
body: "# Interrupted Update\n\nOld body.\n",
});
const skillFile = path.join(skillDir, "SKILL.md");
const previousContent = await fs.readFile(skillFile, "utf8");
const proposal = await proposeUpdateSkill({
workspaceDir,
skillName: "interrupted-update",
content: "# Interrupted Update\n\nNew body.\n",
supportFiles: [{ path: "references/proof.md", content: "New support.\n" }],
});
await writeSkillProposalRollback({
proposalId: proposal.record.id,
rollback: createSkillProposalRollback({
proposalId: proposal.record.id,
targetSkillFile: proposal.record.target.skillFile,
action: "update",
previousContent,
}),
});
await fs.mkdir(path.join(skillDir, "references"), { recursive: true });
await fs.writeFile(path.join(skillDir, "references", "proof.md"), "New support.\n", "utf8");
await fs.writeFile(skillFile, stripProposalFrontmatterForSkill(proposal.content), "utf8");
closeOpenClawStateDatabaseForTest();
await expect(listSkillProposals({ workspaceDir })).resolves.toMatchObject({
proposals: [expect.objectContaining({ id: proposal.record.id, status: "applied" })],
});
});
it("does not reconcile another agent's interrupted apply before authorization", async () => {
const workspaceDir = await makeWorkspace();
const proposal = await proposeCreateSkill({
agentId: "owner",
workspaceDir,
name: "Authorized Reconcile",
description: "Require proposal ownership before recovery",
content: "# Authorized Reconcile\n\nOwner-only recovery.\n",
});
await writeSkillProposalRollback({
proposalId: proposal.record.id,
rollback: createSkillProposalRollback({
proposalId: proposal.record.id,
targetSkillFile: proposal.record.target.skillFile,
action: "create",
}),
});
await fs.mkdir(proposal.record.target.skillDir, { recursive: true });
await fs.writeFile(
proposal.record.target.skillFile,
stripProposalFrontmatterForSkill(proposal.content),
"utf8",
);
closeOpenClawStateDatabaseForTest();
await expect(
inspectSkillProposal(proposal.record.id, { agentId: "other", workspaceDir }),
).resolves.toBeNull();
await fs.rm(proposal.record.target.skillFile);
await expect(listSkillProposals({ agentId: "owner", workspaceDir })).resolves.toMatchObject({
proposals: [expect.objectContaining({ id: proposal.record.id, status: "pending" })],
});
});
it("keeps proposal management available when a reconciliation target cannot be read", async () => {
const workspaceDir = await makeWorkspace();
const proposal = await proposeCreateSkill({
workspaceDir,
name: "Unreadable Reconcile",
description: "Keep lifecycle actions available after target failures",
content: "# Unreadable Reconcile\n",
});
await writeSkillProposalRollback({
proposalId: proposal.record.id,
rollback: createSkillProposalRollback({
proposalId: proposal.record.id,
targetSkillFile: proposal.record.target.skillFile,
action: "create",
}),
});
await fs.mkdir(proposal.record.target.skillFile, { recursive: true });
await expect(listSkillProposals({ workspaceDir })).resolves.toMatchObject({
proposals: [expect.objectContaining({ id: proposal.record.id, status: "pending" })],
});
await expect(
rejectSkillProposal({ workspaceDir, proposalId: proposal.record.id }),
).resolves.toMatchObject({ status: "rejected" });
});
it("enforces configured proposal limits before writing proposal state", async () => {
+29 -59
View File
@@ -19,18 +19,14 @@ import {
} from "../lifecycle/workspace-skill-write.js";
import { resolveAllowedSkillSymlinkTargetRealPaths } from "../loading/symlink-targets.js";
import { bumpSkillsSnapshotVersion } from "../runtime/refresh-state.js";
import { resolveSkillWorkshopConfig, type SkillWorkshopConfig } from "./config.js";
import { resolveSkillWorkshopConfig } from "./config.js";
import {
readProposalFrontmatter,
renderProposalMarkdown,
stripProposalFrontmatterForSkill,
} from "./frontmatter.js";
import { assertProposalContainsNoLiteralSecrets, scanProposalBundle } from "./proposal-scan.js";
import {
isProposalInWorkspace,
listSkillProposals,
readRequiredProposal,
} from "./service-query.js";
import { readRequiredProposal } from "./service-query.js";
import {
createSkillProposalId,
createSkillProposalRollback,
@@ -38,9 +34,7 @@ import {
MAX_PROPOSAL_SUPPORT_FILES,
prepareSkillProposalSupportFiles,
readProposalSupportFiles,
readSkillProposalRecord,
replaceSkillProposalDraft,
refreshSkillProposalManifest,
resolveSkillProposalTarget,
updateSkillProposalRecord,
writeSkillProposal,
@@ -61,7 +55,6 @@ import {
type SkillProposalApplyResult,
type SkillProposalCreateInput,
type SkillProposalOrigin,
type SkillProposalManifest,
type SkillProposalReadResult,
type SkillProposalRecord,
type SkillProposalReviseInput,
@@ -231,7 +224,10 @@ export async function proposeCreateSkill(
{ file: "evidence", content: evidence },
]);
assertProposalContainsNoLiteralSecrets(scan);
const origin = normalizeProposalOrigin(input.origin);
const origin = normalizeProposalOrigin({
...input.origin,
agentId: input.origin?.agentId ?? input.agentId,
});
const originRunProvenance = mergeProposalOriginRunProvenance(undefined, origin);
const record: SkillProposalRecord = {
schema: SKILL_WORKSHOP_SCHEMA,
@@ -266,10 +262,10 @@ export async function proposeCreateSkill(
record,
content: proposalContent,
supportFiles,
workspaceDir: input.workspaceDir,
ownerAgentId: input.agentId,
maxPending: config.maxPending,
store: proposalStoreOptions(input.env),
beforeWrite: async (manifest) => {
await assertCanCreatePendingProposal(input.workspaceDir, config, manifest, input.env);
},
});
return { record, content: proposalContent };
}
@@ -347,7 +343,10 @@ export async function proposeUpdateSkill(
{ file: "evidence", content: evidence },
]);
assertProposalContainsNoLiteralSecrets(scan);
const origin = normalizeProposalOrigin(input.origin);
const origin = normalizeProposalOrigin({
...input.origin,
agentId: input.origin?.agentId ?? input.agentId,
});
const originRunProvenance = mergeProposalOriginRunProvenance(undefined, origin);
const record: SkillProposalRecord = {
schema: SKILL_WORKSHOP_SCHEMA,
@@ -383,10 +382,10 @@ export async function proposeUpdateSkill(
record,
content: proposalContent,
supportFiles,
workspaceDir: input.workspaceDir,
ownerAgentId: input.agentId ?? origin?.agentId,
maxPending: config.maxPending,
store: proposalStoreOptions(input.env),
beforeWrite: async (manifest) => {
await assertCanCreatePendingProposal(input.workspaceDir, config, manifest, input.env);
},
});
return { record, content: proposalContent };
}
@@ -619,7 +618,6 @@ export async function applySkillProposal(
record: applied,
store: proposalStoreOptions(input.env),
});
await refreshSkillProposalManifest(proposalStoreOptions(input.env));
return { record: applied, targetSkillFile: record.target.skillFile };
});
}
@@ -692,44 +690,6 @@ async function readApplyTargetState(
return { previousContent, previousSupportFiles };
}
async function assertCanCreatePendingProposal(
workspaceDir: string,
config: SkillWorkshopConfig,
manifest?: SkillProposalManifest,
env?: NodeJS.ProcessEnv,
): Promise<void> {
if (!manifest) {
const proposals = (await listSkillProposals({ workspaceDir, env })).proposals;
assertPendingProposalCountWithinLimit(
proposals.filter((entry) => entry.status === "pending" || entry.status === "quarantined")
.length,
config,
);
return;
}
let activeProposalCount = 0;
for (const entry of manifest.proposals) {
if (entry.status !== "pending" && entry.status !== "quarantined") {
continue;
}
const record = await readSkillProposalRecord(entry.id, proposalStoreOptions(env));
if (record && isProposalInWorkspace(record, workspaceDir)) {
activeProposalCount += 1;
}
}
assertPendingProposalCountWithinLimit(activeProposalCount, config);
}
function assertPendingProposalCountWithinLimit(
activeProposalCount: number,
config: SkillWorkshopConfig,
): void {
if (activeProposalCount >= config.maxPending) {
throw new Error(`Skill Workshop pending proposal limit reached (${config.maxPending}).`);
}
}
function assertProposalDescriptionWithinLimit(description: string): void {
const sizeBytes = Buffer.byteLength(description, "utf8");
if (sizeBytes > MAX_SKILL_PROPOSAL_DESCRIPTION_BYTES) {
@@ -828,15 +788,25 @@ async function markProposal(
}
async function withPendingSkillProposalMutation<T>(
input: Pick<SkillProposalActionInput, "env" | "proposalId" | "workspaceDir">,
input: Pick<SkillProposalActionInput, "agentId" | "env" | "proposalId" | "workspaceDir">,
action: "applied" | "quarantined" | "rejected" | "revised",
fn: (read: SkillProposalReadResult) => Promise<T>,
): Promise<T> {
const initial = await readRequiredProposal(input.proposalId, input.workspaceDir, input.env);
const initial = await readRequiredProposal(
input.proposalId,
input.workspaceDir,
input.env,
input.agentId,
);
return await withSkillProposalTargetLock(
initial.record,
async () => {
const read = await readRequiredProposal(input.proposalId, input.workspaceDir, input.env);
const read = await readRequiredProposal(
input.proposalId,
input.workspaceDir,
input.env,
input.agentId,
);
if (read.record.status !== "pending") {
throw new Error(
`Only pending proposals can be ${action}. Current status: ${read.record.status}.`,
+118
View File
@@ -0,0 +1,118 @@
import {
MAX_WORKSPACE_SKILL_SUPPORT_FILE_BYTES,
normalizeWorkspaceSkillSupportPath,
} from "../lifecycle/workspace-skill-write.js";
import { hasValidProposalOriginProvenance } from "./proposal-origin-validation.js";
import {
SKILL_WORKSHOP_ROLLBACK_SCHEMA,
SKILL_WORKSHOP_SCHEMA,
type SkillProposalRecord,
type SkillProposalRollback,
type SkillProposalSupportFile,
} from "./types.js";
export const PROPOSAL_DRAFT_FILE = "PROPOSAL.md";
export const MAX_PROPOSAL_SUPPORT_FILES = 64;
const PROPOSAL_ID_PATTERN = /^[a-z0-9][a-z0-9-]{5,120}$/;
export function assertProposalId(proposalId: string): void {
if (!PROPOSAL_ID_PATTERN.test(proposalId)) {
throw new Error("Invalid skill proposal id.");
}
}
export function parseSkillProposalRecord(raw: unknown): SkillProposalRecord | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return null;
}
const record = raw as SkillProposalRecord;
if (
record.schema !== SKILL_WORKSHOP_SCHEMA ||
!PROPOSAL_ID_PATTERN.test(record.id) ||
(record.kind !== "create" && record.kind !== "update") ||
!["pending", "applied", "rejected", "quarantined", "stale"].includes(record.status) ||
typeof record.title !== "string" ||
typeof record.description !== "string" ||
typeof record.createdAt !== "string" ||
typeof record.updatedAt !== "string" ||
typeof record.draftHash !== "string" ||
record.draftFile !== PROPOSAL_DRAFT_FILE ||
!hasValidProposalOriginProvenance(record) ||
!isValidSupportFileList(record.supportFiles) ||
!record.target ||
typeof record.target !== "object" ||
typeof record.target.skillName !== "string" ||
typeof record.target.skillKey !== "string" ||
typeof record.target.skillDir !== "string" ||
typeof record.target.skillFile !== "string" ||
!record.scan ||
typeof record.scan !== "object"
) {
return null;
}
return record;
}
function isValidSupportFileList(value: unknown): boolean {
if (value === undefined) {
return true;
}
if (!Array.isArray(value) || value.length > MAX_PROPOSAL_SUPPORT_FILES) {
return false;
}
const seen = new Set<string>();
for (const item of value) {
if (!item || typeof item !== "object" || Array.isArray(item)) {
return false;
}
const file = item as SkillProposalSupportFile;
if (
typeof file.path !== "string" ||
typeof file.hash !== "string" ||
!/^[a-f0-9]{64}$/i.test(file.hash) ||
typeof file.sizeBytes !== "number" ||
!Number.isSafeInteger(file.sizeBytes) ||
file.sizeBytes < 0 ||
file.sizeBytes > MAX_WORKSPACE_SKILL_SUPPORT_FILE_BYTES ||
(file.targetExisted !== undefined && typeof file.targetExisted !== "boolean") ||
(file.targetContentHash !== undefined &&
(typeof file.targetContentHash !== "string" ||
!/^[a-f0-9]{64}$/i.test(file.targetContentHash)))
) {
return false;
}
let normalized: string;
try {
normalized = normalizeWorkspaceSkillSupportPath(file.path);
} catch {
return false;
}
if (seen.has(normalized)) {
return false;
}
seen.add(normalized);
}
return true;
}
export function parseSkillProposalRollback(raw: unknown): SkillProposalRollback | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return null;
}
const rollback = raw as SkillProposalRollback;
if (
rollback.schema !== SKILL_WORKSHOP_ROLLBACK_SCHEMA ||
!PROPOSAL_ID_PATTERN.test(rollback.proposalId) ||
typeof rollback.writtenAt !== "string" ||
typeof rollback.targetSkillFile !== "string" ||
(rollback.action !== "create" && rollback.action !== "update") ||
(rollback.previousContentHash !== undefined &&
(typeof rollback.previousContentHash !== "string" ||
!/^[a-f0-9]{64}$/i.test(rollback.previousContentHash))) ||
(rollback.previousContent !== undefined && typeof rollback.previousContent !== "string") ||
(rollback.supportFiles !== undefined && !Array.isArray(rollback.supportFiles))
) {
return null;
}
return rollback;
}
+143
View File
@@ -0,0 +1,143 @@
import path from "node:path";
import type { DatabaseSync } from "node:sqlite";
import type { Insertable } from "kysely";
import {
executeSqliteQuerySync,
executeSqliteQueryTakeFirstSync,
getNodeSqliteKysely,
} from "../../infra/kysely-sync.js";
import { parseSkillProposalRecord } from "./store-record.js";
import {
openSkillWorkshopStore,
type SkillProposalRow,
type SkillWorkshopDatabase,
type SkillWorkshopStoreOptions,
} from "./store-sqlite-schema.js";
import type { SkillProposalRecord } from "./types.js";
export function parseJson(value: string | null): unknown {
if (value === null) {
return undefined;
}
try {
return JSON.parse(value) as unknown;
} catch {
return undefined;
}
}
export function parseSkillProposalRow(row: SkillProposalRow): SkillProposalRecord | null {
const record = parseSkillProposalRecord(parseJson(row.record_json));
if (
!record ||
record.id !== row.proposal_id ||
record.kind !== row.kind ||
record.status !== row.status ||
record.createdAt !== row.created_at ||
record.updatedAt !== row.updated_at ||
record.draftHash !== row.draft_hash ||
record.origin?.agentId !== (row.origin_agent_id ?? undefined) ||
record.origin?.sessionKey !== (row.origin_session_key ?? undefined) ||
record.origin?.runId !== (row.origin_run_id ?? undefined) ||
record.origin?.messageId !== (row.origin_message_id ?? undefined)
) {
return null;
}
return record;
}
export function readStoredProposal(
proposalId: string,
options: SkillWorkshopStoreOptions = {},
): { record: SkillProposalRecord; row: SkillProposalRow } | null {
const { database, kysely } = openSkillWorkshopStore(options);
const row = executeSqliteQueryTakeFirstSync(
database.db,
kysely.selectFrom("skill_workshop_proposals").selectAll().where("proposal_id", "=", proposalId),
);
if (!row) {
return null;
}
const record = parseSkillProposalRow(row);
return record ? { record, row } : null;
}
function proposalRowValues(params: {
record: SkillProposalRecord;
ownerAgentId: string | null;
workspaceDir: string;
}): Insertable<SkillWorkshopDatabase["skill_workshop_proposals"]> {
const { record } = params;
return {
proposal_id: record.id,
record_json: JSON.stringify(record),
owner_agent_id: params.ownerAgentId,
workspace_dir: path.resolve(params.workspaceDir),
kind: record.kind,
status: record.status,
created_at: record.createdAt,
updated_at: record.updatedAt,
draft_hash: record.draftHash,
origin_agent_id: record.origin?.agentId ?? null,
origin_session_key: record.origin?.sessionKey ?? null,
origin_run_id: record.origin?.runId ?? null,
origin_message_id: record.origin?.messageId ?? null,
applied_at: record.appliedAt ?? null,
rejected_at: record.rejectedAt ?? null,
quarantined_at: record.quarantinedAt ?? null,
stale_at: record.staleAt ?? null,
status_reason: record.statusReason ?? null,
};
}
function replaceOriginRuns(
database: DatabaseSync,
record: SkillProposalRecord,
kysely = getNodeSqliteKysely<SkillWorkshopDatabase>(database),
): void {
executeSqliteQuerySync(
database,
kysely.deleteFrom("skill_workshop_proposal_origin_runs").where("proposal_id", "=", record.id),
);
record.originRunIds?.forEach((runId, position) => {
executeSqliteQuerySync(
database,
kysely.insertInto("skill_workshop_proposal_origin_runs").values({
proposal_id: record.id,
run_id: runId,
position,
mutation_count: record.originRunMutationCounts?.[runId] ?? 1,
}),
);
});
}
export function insertProposal(
database: DatabaseSync,
params: { record: SkillProposalRecord; ownerAgentId: string | null; workspaceDir: string },
): void {
const kysely = getNodeSqliteKysely<SkillWorkshopDatabase>(database);
executeSqliteQuerySync(
database,
kysely.insertInto("skill_workshop_proposals").values(proposalRowValues(params)),
);
replaceOriginRuns(database, params.record, kysely);
}
export function updateProposal(
database: DatabaseSync,
current: SkillProposalRow,
record: SkillProposalRecord,
): void {
const kysely = getNodeSqliteKysely<SkillWorkshopDatabase>(database);
const { proposal_id: _proposalId, ...values } = proposalRowValues({
record,
ownerAgentId: current.owner_agent_id,
workspaceDir: current.workspace_dir,
});
executeSqliteQuerySync(
database,
kysely.updateTable("skill_workshop_proposals").set(values).where("proposal_id", "=", record.id),
);
replaceOriginRuns(database, record, kysely);
}
@@ -0,0 +1,137 @@
import type { DatabaseSync } from "node:sqlite";
import {
executeSqliteQuerySync,
executeSqliteQueryTakeFirstSync,
getNodeSqliteKysely,
} from "../../infra/kysely-sync.js";
import { runOpenClawStateWriteTransaction } from "../../state/openclaw-state-db.js";
import { assertProposalId, parseSkillProposalRollback } from "./store-record.js";
import { parseJson } from "./store-sqlite-record.js";
import {
databaseOptions,
ensureSkillWorkshopSchema,
openSkillWorkshopStore,
type SkillWorkshopDatabase,
type SkillWorkshopStoreOptions,
} from "./store-sqlite-schema.js";
import { SKILL_WORKSHOP_ROLLBACK_SCHEMA, type SkillProposalRollback } from "./types.js";
function removeOtherPendingTargetRollbacks(
database: DatabaseSync,
params: { proposalId: string; targetSkillFile: string },
): void {
const kysely = getNodeSqliteKysely<SkillWorkshopDatabase>(database);
const rows = executeSqliteQuerySync(
database,
kysely
.selectFrom("skill_workshop_proposal_rollbacks")
.innerJoin(
"skill_workshop_proposals",
"skill_workshop_proposals.proposal_id",
"skill_workshop_proposal_rollbacks.proposal_id",
)
.select("skill_workshop_proposal_rollbacks.proposal_id as proposalId")
.where("skill_workshop_proposal_rollbacks.target_skill_file", "=", params.targetSkillFile)
.where("skill_workshop_proposals.status", "=", "pending")
.where("skill_workshop_proposals.proposal_id", "!=", params.proposalId),
).rows;
for (const row of rows) {
executeSqliteQuerySync(
database,
kysely
.deleteFrom("skill_workshop_proposal_rollbacks")
.where("proposal_id", "=", row.proposalId),
);
}
}
export async function writeSkillProposalRollback(params: {
proposalId: string;
rollback: SkillProposalRollback;
store?: SkillWorkshopStoreOptions;
}): Promise<void> {
assertProposalId(params.proposalId);
ensureSkillWorkshopSchema(params.store);
runOpenClawStateWriteTransaction(
({ db }) => {
const kysely = getNodeSqliteKysely<SkillWorkshopDatabase>(db);
const proposal = executeSqliteQueryTakeFirstSync(
db,
kysely
.selectFrom("skill_workshop_proposals")
.select(["proposal_id", "kind", "status"])
.where("proposal_id", "=", params.proposalId),
);
if (!proposal) {
throw new Error(`Skill proposal not found: ${params.proposalId}`);
}
if (proposal.status !== "pending") {
throw new Error(
`Only pending proposals can be applied. Current status: ${proposal.status}.`,
);
}
removeOtherPendingTargetRollbacks(db, {
proposalId: params.proposalId,
targetSkillFile: params.rollback.targetSkillFile,
});
executeSqliteQuerySync(
db,
kysely
.insertInto("skill_workshop_proposal_rollbacks")
.values({
proposal_id: params.proposalId,
written_at: params.rollback.writtenAt,
target_skill_file: params.rollback.targetSkillFile,
action: params.rollback.action,
previous_content_hash: params.rollback.previousContentHash ?? null,
previous_content: params.rollback.previousContent ?? null,
support_files_json: params.rollback.supportFiles
? JSON.stringify(params.rollback.supportFiles)
: null,
})
.onConflict((conflict) =>
conflict.column("proposal_id").doUpdateSet({
written_at: params.rollback.writtenAt,
target_skill_file: params.rollback.targetSkillFile,
action: params.rollback.action,
previous_content_hash: params.rollback.previousContentHash ?? null,
previous_content: params.rollback.previousContent ?? null,
support_files_json: params.rollback.supportFiles
? JSON.stringify(params.rollback.supportFiles)
: null,
}),
),
);
},
databaseOptions(params.store),
{ operationLabel: "skill-workshop.rollback.write" },
);
}
export async function readSkillProposalRollback(
proposalId: string,
options: SkillWorkshopStoreOptions = {},
): Promise<SkillProposalRollback | null> {
assertProposalId(proposalId);
const { database, kysely } = openSkillWorkshopStore(options);
const row = executeSqliteQueryTakeFirstSync(
database.db,
kysely
.selectFrom("skill_workshop_proposal_rollbacks")
.selectAll()
.where("proposal_id", "=", proposalId),
);
if (!row) {
return null;
}
return parseSkillProposalRollback({
schema: SKILL_WORKSHOP_ROLLBACK_SCHEMA,
proposalId: row.proposal_id,
writtenAt: row.written_at,
targetSkillFile: row.target_skill_file,
action: row.action,
...(row.previous_content_hash ? { previousContentHash: row.previous_content_hash } : {}),
...(row.previous_content !== null ? { previousContent: row.previous_content } : {}),
...(row.support_files_json ? { supportFiles: parseJson(row.support_files_json) } : {}),
});
}
+104
View File
@@ -0,0 +1,104 @@
import path from "node:path";
import type { DatabaseSync } from "node:sqlite";
import type { Selectable } from "kysely";
import { getNodeSqliteKysely } from "../../infra/kysely-sync.js";
import type { DB as OpenClawStateDatabase } from "../../state/openclaw-state-db.generated.js";
import {
openOpenClawStateDatabase,
runOpenClawStateWriteTransaction,
type OpenClawStateDatabaseOptions,
} from "../../state/openclaw-state-db.js";
export type SkillWorkshopDatabase = Pick<
OpenClawStateDatabase,
| "skill_workshop_proposal_origin_runs"
| "skill_workshop_proposal_rollbacks"
| "skill_workshop_proposals"
>;
export type SkillProposalRow = Selectable<SkillWorkshopDatabase["skill_workshop_proposals"]>;
export type SkillWorkshopStoreOptions = {
env?: NodeJS.ProcessEnv;
stateDir?: string;
};
const SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS skill_workshop_proposals (
proposal_id TEXT NOT NULL PRIMARY KEY,
record_json TEXT NOT NULL,
owner_agent_id TEXT,
workspace_dir TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('create', 'update')),
status TEXT NOT NULL CHECK (status IN ('pending', 'applied', 'rejected', 'quarantined', 'stale')),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
draft_hash TEXT NOT NULL,
origin_agent_id TEXT,
origin_session_key TEXT,
origin_run_id TEXT,
origin_message_id TEXT,
applied_at TEXT,
rejected_at TEXT,
quarantined_at TEXT,
stale_at TEXT,
status_reason TEXT
) STRICT;
CREATE TABLE IF NOT EXISTS skill_workshop_proposal_origin_runs (
proposal_id TEXT NOT NULL,
run_id TEXT NOT NULL,
position INTEGER NOT NULL,
mutation_count INTEGER NOT NULL CHECK (mutation_count > 0),
PRIMARY KEY (proposal_id, run_id),
FOREIGN KEY (proposal_id) REFERENCES skill_workshop_proposals(proposal_id) ON DELETE CASCADE
) STRICT;
CREATE TABLE IF NOT EXISTS skill_workshop_proposal_rollbacks (
proposal_id TEXT NOT NULL PRIMARY KEY,
written_at TEXT NOT NULL,
target_skill_file TEXT NOT NULL,
action TEXT NOT NULL CHECK (action IN ('create', 'update')),
previous_content_hash TEXT,
previous_content TEXT,
support_files_json TEXT,
FOREIGN KEY (proposal_id) REFERENCES skill_workshop_proposals(proposal_id) ON DELETE CASCADE
) STRICT;
`;
const ensuredDatabases = new WeakSet<DatabaseSync>();
export function databaseOptions(
options: SkillWorkshopStoreOptions = {},
): OpenClawStateDatabaseOptions {
if (options.stateDir) {
return {
...(options.env ? { env: options.env } : {}),
path: path.join(path.resolve(options.stateDir), "state", "openclaw.sqlite"),
};
}
return options.env ? { env: options.env } : {};
}
export function ensureSkillWorkshopSchema(options: SkillWorkshopStoreOptions = {}): void {
const dbOptions = databaseOptions(options);
const database = openOpenClawStateDatabase(dbOptions);
if (ensuredDatabases.has(database.db)) {
return;
}
runOpenClawStateWriteTransaction(
({ db }) => {
// sqlite-allow-raw -- Feature-local additive schema DDL; proposal rows use Kysely.
db.exec(SCHEMA_SQL);
},
dbOptions,
{ operationLabel: "skill-workshop.schema.ensure" },
);
ensuredDatabases.add(database.db);
}
export function openSkillWorkshopStore(options: SkillWorkshopStoreOptions = {}) {
ensureSkillWorkshopSchema(options);
const database = openOpenClawStateDatabase(databaseOptions(options));
return {
database,
kysely: getNodeSqliteKysely<SkillWorkshopDatabase>(database.db),
};
}
+56
View File
@@ -0,0 +1,56 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { requireNodeSqlite } from "../../infra/node-sqlite.js";
import {
closeOpenClawStateDatabaseForTest,
OPENCLAW_STATE_SCHEMA_VERSION,
openOpenClawStateDatabase,
} from "../../state/openclaw-state-db.js";
import {
createOpenClawTestState,
type OpenClawTestState,
} from "../../test-utils/openclaw-test-state.js";
import { listSkillProposals } from "./service.js";
let testState: OpenClawTestState;
beforeEach(async () => {
testState = await createOpenClawTestState({
layout: "state-only",
prefix: "openclaw-workshop-store-",
});
});
afterEach(async () => {
await testState.cleanup();
});
describe("Skill Workshop SQLite store", () => {
it("lazily ensures additive tables without changing the schema version", async () => {
const databasePath = openOpenClawStateDatabase().path;
closeOpenClawStateDatabaseForTest();
const { DatabaseSync } = requireNodeSqlite();
const existing = new DatabaseSync(databasePath);
existing.exec(`
DROP TABLE skill_workshop_proposal_origin_runs;
DROP TABLE skill_workshop_proposal_rollbacks;
DROP TABLE skill_workshop_proposals;
`);
existing.close();
const reopened = openOpenClawStateDatabase();
expect(
reopened.db
.prepare("SELECT name FROM sqlite_schema WHERE type = 'table' AND name = ?")
.get("skill_workshop_proposals"),
).toBeUndefined();
await expect(listSkillProposals()).resolves.toMatchObject({ proposals: [] });
expect(
reopened.db
.prepare("SELECT name FROM sqlite_schema WHERE type = 'table' AND name = ?")
.get("skill_workshop_proposals"),
).toEqual({ name: "skill_workshop_proposals" });
expect(reopened.db.prepare("PRAGMA user_version").get()).toEqual({
user_version: OPENCLAW_STATE_SCHEMA_VERSION,
});
});
});
+368 -259
View File
@@ -1,25 +1,49 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { resolveStateDir } from "../../config/paths.js";
import { sha256Hex } from "../../infra/crypto-digest.js";
import { type FileLockOptions, withFileLock } from "../../infra/file-lock.js";
import { removePathWithinRoot } from "../../infra/fs-safe-remove.js";
import { root } from "../../infra/fs-safe.js";
import { tryReadJson } from "../../infra/json-files.js";
import { KeyedAsyncQueue } from "../../plugin-sdk/keyed-async-queue.js";
import {
executeSqliteQuerySync,
executeSqliteQueryTakeFirstSync,
getNodeSqliteKysely,
} from "../../infra/kysely-sync.js";
import { runOpenClawStateWriteTransaction } from "../../state/openclaw-state-db.js";
import { withOpenClawStateLease } from "../../state/openclaw-state-lease.js";
import { normalizeSkillIndexName } from "../discovery/skill-index.js";
import {
assertInsideWorkspace,
assertWorkspaceSkillSupportPathSetIsFileOnly,
MAX_WORKSPACE_SKILL_SUPPORT_FILE_BYTES,
normalizeWorkspaceSkillSupportPath,
readWorkspaceSkillFile,
readWorkspaceSupportFile,
} from "../lifecycle/workspace-skill-write.js";
import { hasValidProposalOriginProvenance } from "./proposal-origin-validation.js";
import { stripProposalFrontmatterForSkill } from "./frontmatter.js";
import {
assertProposalId,
MAX_PROPOSAL_SUPPORT_FILES,
PROPOSAL_DRAFT_FILE,
} from "./store-record.js";
import {
insertProposal,
parseSkillProposalRow,
readStoredProposal,
updateProposal,
} from "./store-sqlite-record.js";
import { readSkillProposalRollback } from "./store-sqlite-rollback.js";
import {
databaseOptions,
ensureSkillWorkshopSchema,
openSkillWorkshopStore,
type SkillProposalRow,
type SkillWorkshopDatabase,
type SkillWorkshopStoreOptions,
} from "./store-sqlite-schema.js";
import {
SKILL_WORKSHOP_MANIFEST_SCHEMA,
SKILL_WORKSHOP_ROLLBACK_SCHEMA,
SKILL_WORKSHOP_SCHEMA,
type SkillProposalManifest,
type SkillProposalManifestEntry,
type SkillProposalReadResult,
@@ -31,37 +55,25 @@ import {
const WORKSHOP_REL_DIR = "skill-workshop";
const PROPOSALS_REL_DIR = path.join(WORKSHOP_REL_DIR, "proposals");
const TARGET_LOCKS_REL_DIR = path.join(WORKSHOP_REL_DIR, "locks");
const MANIFEST_REL_PATH = path.join(WORKSHOP_REL_DIR, "proposals.json");
const MANIFEST_LOCK_REL_PATH = path.join(TARGET_LOCKS_REL_DIR, "proposals-manifest");
const PROPOSAL_RECORD_FILE = "proposal.json";
const PROPOSAL_DRAFT_FILE = "PROPOSAL.md";
const PROPOSAL_ROLLBACK_FILE = "rollback.json";
const MAX_PROPOSAL_BYTES = 1024 * 1024;
export const MAX_PROPOSAL_SUPPORT_FILES = 64;
const MAX_PROPOSAL_SUPPORT_FILES_TOTAL_BYTES = 2 * 1024 * 1024;
const PROPOSAL_ID_PATTERN = /^[a-z0-9][a-z0-9-]{5,120}$/;
const SKILL_WORKSHOP_LOCK_OPTIONS: FileLockOptions = {
retries: {
retries: 8,
factor: 1.35,
minTimeout: 10,
maxTimeout: 250,
randomize: true,
},
stale: 60_000,
};
const skillWorkshopProcessLocks = new KeyedAsyncQueue();
const TARGET_LEASE_MS = 60_000;
const TARGET_LEASE_WAIT_MS = 5_000;
export {
MAX_PROPOSAL_SUPPORT_FILES,
parseSkillProposalRecord,
parseSkillProposalRollback,
} from "./store-record.js";
export { readSkillProposalRollback, writeSkillProposalRollback } from "./store-sqlite-rollback.js";
type SkillWorkshopStoreOptions = {
env?: NodeJS.ProcessEnv;
stateDir?: string;
type SkillProposalLookupScope = {
agentId?: string;
workspaceDir?: string;
};
export type PreparedSkillProposalSupportFile = SkillProposalSupportFile & {
content: string;
};
type SkillProposalWriteGuard = (manifest: SkillProposalManifest) => Promise<void> | void;
/** Creates a stable proposal id from skill name, date, and random suffix. */
export function createSkillProposalId(name: string, now = new Date()): string {
@@ -89,16 +101,9 @@ function resolveSkillWorkshopStateDir(options: SkillWorkshopStoreOptions = {}):
return path.resolve(options.stateDir ?? resolveStateDir(options.env));
}
function resolveProposalDir(proposalId: string, options: SkillWorkshopStoreOptions = {}): string {
function proposalRelativeDir(proposalId: string): string {
assertProposalId(proposalId);
return path.join(resolveSkillWorkshopStateDir(options), proposalRelativeDir(proposalId));
}
function resolveProposalRecordPath(
proposalId: string,
options: SkillWorkshopStoreOptions = {},
): string {
return path.join(resolveProposalDir(proposalId, options), PROPOSAL_RECORD_FILE);
return path.join(PROPOSALS_REL_DIR, proposalId);
}
export function prepareSkillProposalSupportFiles(
@@ -157,12 +162,34 @@ export function resolveSkillProposalTarget(params: { workspaceDir: string; skill
return { skillKey, skillDir, skillFile };
}
function isStoredProposalVisible(row: SkillProposalRow, scope: SkillProposalLookupScope): boolean {
if (!scope.agentId) {
return scope.workspaceDir
? path.resolve(row.workspace_dir) === path.resolve(scope.workspaceDir)
: true;
}
if (row.owner_agent_id === scope.agentId) {
return true;
}
return (
row.owner_agent_id === null &&
scope.workspaceDir !== undefined &&
path.resolve(row.workspace_dir) === path.resolve(scope.workspaceDir)
);
}
export async function readSkillProposal(
proposalId: string,
options: SkillWorkshopStoreOptions = {},
scope: SkillProposalLookupScope = {},
): Promise<SkillProposalReadResult | null> {
const record = await readSkillProposalRecord(proposalId, options);
if (!record) {
let stored = readStoredProposal(proposalId, options);
if (!stored || !isStoredProposalVisible(stored.row, scope)) {
return null;
}
await reconcileInterruptedApply(proposalId, options);
stored = readStoredProposal(proposalId, options);
if (!stored || !isStoredProposalVisible(stored.row, scope)) {
return null;
}
const stateRoot = await root(resolveSkillWorkshopStateDir(options));
@@ -174,40 +201,35 @@ export async function readSkillProposal(
symlinks: "reject",
},
);
return { record, content: draft.buffer.toString("utf8") };
return { record: stored.record, content: draft.buffer.toString("utf8") };
}
export async function readSkillProposalRecord(
proposalId: string,
options: SkillWorkshopStoreOptions = {},
scope: SkillProposalLookupScope = {},
): Promise<SkillProposalRecord | null> {
const raw = await tryReadJson<unknown>(resolveProposalRecordPath(proposalId, options));
return parseSkillProposalRecord(raw);
let stored = readStoredProposal(proposalId, options);
if (!stored || !isStoredProposalVisible(stored.row, scope)) {
return null;
}
await reconcileInterruptedApply(proposalId, options);
stored = readStoredProposal(proposalId, options);
return stored && isStoredProposalVisible(stored.row, scope) ? stored.record : null;
}
export async function writeSkillProposal(params: {
record: SkillProposalRecord;
content: string;
supportFiles?: readonly PreparedSkillProposalSupportFile[];
beforeWrite?: SkillProposalWriteGuard;
workspaceDir: string;
ownerAgentId?: string;
maxPending: number;
store?: SkillWorkshopStoreOptions;
}): Promise<void> {
assertProposalId(params.record.id);
assertSkillProposalContentSize(params.content);
await withSkillProposalManifestLock(params.store ?? {}, async () => {
const manifest = await readSkillProposalManifestUnlocked(params.store);
await params.beforeWrite?.(manifest);
await writeSkillProposalFiles(params);
await refreshSkillProposalManifestUnlocked(params.store);
});
}
async function writeSkillProposalFiles(params: {
record: SkillProposalRecord;
content: string;
supportFiles?: readonly PreparedSkillProposalSupportFile[];
store?: SkillWorkshopStoreOptions;
}): Promise<void> {
ensureSkillWorkshopSchema(params.store);
const stateRoot = await root(resolveSkillWorkshopStateDir(params.store));
const relativeDir = proposalRelativeDir(params.record.id);
await stateRoot.mkdir(relativeDir);
@@ -220,9 +242,49 @@ async function writeSkillProposalFiles(params: {
mkdir: true,
});
}
await stateRoot.writeJson(path.join(relativeDir, PROPOSAL_RECORD_FILE), params.record, {
trailingNewline: true,
});
try {
runOpenClawStateWriteTransaction(
({ db }) => {
const kysely = getNodeSqliteKysely<SkillWorkshopDatabase>(db);
const existing = executeSqliteQueryTakeFirstSync(
db,
kysely
.selectFrom("skill_workshop_proposals")
.select("proposal_id")
.where("proposal_id", "=", params.record.id),
);
if (existing) {
throw new Error(`Skill proposal already exists: ${params.record.id}`);
}
const count = executeSqliteQueryTakeFirstSync(
db,
kysely
.selectFrom("skill_workshop_proposals")
.select((eb) => eb.fn.countAll<number>().as("count"))
.where("workspace_dir", "=", path.resolve(params.workspaceDir))
.where("status", "in", ["pending", "quarantined"]),
);
if ((count?.count ?? 0) >= params.maxPending) {
throw new Error(`Skill Workshop pending proposal limit reached (${params.maxPending}).`);
}
insertProposal(db, {
record: params.record,
ownerAgentId: params.ownerAgentId ?? params.record.origin?.agentId ?? null,
workspaceDir: params.workspaceDir,
});
},
databaseOptions(params.store),
{ operationLabel: "skill-workshop.proposal.create" },
);
} catch (error) {
await removePathWithinRoot({
rootDir: resolveSkillWorkshopStateDir(params.store),
relativePath: relativeDir,
recursive: true,
}).catch(() => undefined);
throw error;
}
}
export async function replaceSkillProposalDraft(params: {
@@ -247,30 +309,52 @@ export async function replaceSkillProposalDraft(params: {
mkdir: true,
});
}
await stateRoot.writeJson(path.join(relativeDir, PROPOSAL_RECORD_FILE), params.record, {
trailingNewline: true,
});
for (const file of params.previousSupportFiles ?? []) {
const filePath = normalizeWorkspaceSkillSupportPath(file.path);
if (!nextSupportPaths.has(filePath)) {
await stateRoot.remove(path.join(relativeDir, filePath)).catch(() => undefined);
}
}
await refreshSkillProposalManifest(params.store);
await updateSkillProposalRecord({
record: params.record,
store: params.store,
invalidateRollback: true,
});
}
export async function updateSkillProposalRecord(params: {
record: SkillProposalRecord;
store?: SkillWorkshopStoreOptions;
invalidateRollback?: boolean;
}): Promise<void> {
assertProposalId(params.record.id);
const stateRoot = await root(resolveSkillWorkshopStateDir(params.store));
await stateRoot.writeJson(
path.join(proposalRelativeDir(params.record.id), PROPOSAL_RECORD_FILE),
params.record,
{ trailingNewline: true },
ensureSkillWorkshopSchema(params.store);
runOpenClawStateWriteTransaction(
({ db }) => {
const kysely = getNodeSqliteKysely<SkillWorkshopDatabase>(db);
const current = executeSqliteQueryTakeFirstSync(
db,
kysely
.selectFrom("skill_workshop_proposals")
.selectAll()
.where("proposal_id", "=", params.record.id),
);
if (!current || !parseSkillProposalRow(current)) {
throw new Error(`Skill proposal not found: ${params.record.id}`);
}
if (params.invalidateRollback) {
executeSqliteQuerySync(
db,
kysely
.deleteFrom("skill_workshop_proposal_rollbacks")
.where("proposal_id", "=", params.record.id),
);
}
updateProposal(db, current, params.record);
},
databaseOptions(params.store),
{ operationLabel: "skill-workshop.proposal.update" },
);
await refreshSkillProposalManifest(params.store);
}
export async function withSkillProposalTargetLock<T>(
@@ -278,97 +362,163 @@ export async function withSkillProposalTargetLock<T>(
fn: () => Promise<T>,
options: SkillWorkshopStoreOptions = {},
): Promise<T> {
const lockFile = path.join(
resolveSkillWorkshopStateDir(options),
TARGET_LOCKS_REL_DIR,
`${hashSkillProposalContent(record.target.skillFile)}.target`,
ensureSkillWorkshopSchema(options);
return await withOpenClawStateLease(
{
scope: "skill-workshop-target",
key: hashSkillProposalContent(record.target.skillFile),
database: { scope: "shared", options: databaseOptions(options) },
leaseMs: TARGET_LEASE_MS,
waitMs: TARGET_LEASE_WAIT_MS,
leaseLabel: "Skill Workshop target lease",
operationLabel: "skill-workshop.target-lease",
},
async () => await fn(),
);
return await withSkillWorkshopLock(lockFile, fn);
}
export async function writeSkillProposalRollback(params: {
proposalId: string;
rollback: SkillProposalRollback;
store?: SkillWorkshopStoreOptions;
}): Promise<void> {
const stateRoot = await root(resolveSkillWorkshopStateDir(params.store));
await stateRoot.writeJson(
path.join(proposalRelativeDir(params.proposalId), PROPOSAL_ROLLBACK_FILE),
params.rollback,
{ trailingNewline: true },
);
function listStoredProposals(
options: SkillWorkshopStoreOptions,
scope: SkillProposalLookupScope,
): Array<{ record: SkillProposalRecord; row: SkillProposalRow }> {
const { database, kysely } = openSkillWorkshopStore(options);
let query = kysely.selectFrom("skill_workshop_proposals").selectAll();
if (scope.agentId) {
query = query.where((eb) =>
eb.or([
eb("owner_agent_id", "=", scope.agentId!),
...(scope.workspaceDir
? [
eb.and([
eb("owner_agent_id", "is", null),
eb("workspace_dir", "=", path.resolve(scope.workspaceDir)),
]),
]
: []),
]),
);
} else if (scope.workspaceDir) {
query = query.where("workspace_dir", "=", path.resolve(scope.workspaceDir));
}
return executeSqliteQuerySync(
database.db,
query.orderBy("updated_at", "desc").orderBy("proposal_id", "asc"),
).rows.flatMap((row) => {
const record = parseSkillProposalRow(row);
return record ? [{ record, row }] : [];
});
}
export async function readSkillProposalManifest(
options: SkillWorkshopStoreOptions = {},
scope: SkillProposalLookupScope = {},
): Promise<SkillProposalManifest> {
return await readSkillProposalManifestUnlocked(options);
}
async function readSkillProposalManifestUnlocked(
options: SkillWorkshopStoreOptions = {},
): Promise<SkillProposalManifest> {
const manifestPath = path.join(resolveSkillWorkshopStateDir(options), MANIFEST_REL_PATH);
const parsed = parseSkillProposalManifest(await tryReadJson<unknown>(manifestPath));
if (parsed) {
return parsed;
}
return await refreshSkillProposalManifestUnlocked(options);
}
export async function refreshSkillProposalManifest(
options: SkillWorkshopStoreOptions = {},
): Promise<SkillProposalManifest> {
return await withSkillProposalManifestLock(options, async () => {
return await refreshSkillProposalManifestUnlocked(options);
});
}
async function refreshSkillProposalManifestUnlocked(
options: SkillWorkshopStoreOptions = {},
): Promise<SkillProposalManifest> {
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(entry.name, options);
if (!record) {
continue;
}
proposals.push(manifestEntryFromRecord(record));
}
const manifest: SkillProposalManifest = {
const before = listStoredProposals(options, scope);
await Promise.all(
before
.filter(({ record }) => record.status === "pending")
.map(({ record }) => reconcileInterruptedApply(record.id, options)),
);
const proposals = listStoredProposals(options, scope).map(({ record, row }) =>
manifestEntryFromRecord(record, row.workspace_dir, scope.workspaceDir),
);
return {
schema: SKILL_WORKSHOP_MANIFEST_SCHEMA,
updatedAt: new Date().toISOString(),
proposals: proposals.toSorted((a, b) => b.updatedAt.localeCompare(a.updatedAt)),
updatedAt: proposals[0]?.updatedAt ?? new Date(0).toISOString(),
proposals,
};
await stateRoot.writeJson(MANIFEST_REL_PATH, manifest, {
mkdir: true,
trailingNewline: true,
});
return manifest;
}
async function withSkillProposalManifestLock<T>(
async function reconcileInterruptedApply(
proposalId: string,
options: SkillWorkshopStoreOptions,
fn: () => Promise<T>,
): Promise<T> {
const lockFile = path.join(resolveSkillWorkshopStateDir(options), MANIFEST_LOCK_REL_PATH);
return await withSkillWorkshopLock(lockFile, fn);
}
async function withSkillWorkshopLock<T>(lockFile: string, fn: () => Promise<T>): Promise<T> {
const lockKey = path.resolve(lockFile);
return await skillWorkshopProcessLocks.enqueue(lockKey, async () => {
await fs.mkdir(path.dirname(lockFile), { recursive: true });
return await withFileLock(lockFile, SKILL_WORKSHOP_LOCK_OPTIONS, fn);
});
): Promise<boolean> {
const stored = readStoredProposal(proposalId, options);
if (!stored || stored.record.status !== "pending") {
return false;
}
const rollback = await readSkillProposalRollback(proposalId, options);
if (
!rollback ||
rollback.action !== stored.record.kind ||
path.resolve(rollback.targetSkillFile) !== path.resolve(stored.record.target.skillFile)
) {
return false;
}
let draftContent: string;
try {
const stateRoot = await root(resolveSkillWorkshopStateDir(options));
const draft = await stateRoot.read(
path.join(proposalRelativeDir(proposalId), PROPOSAL_DRAFT_FILE),
{ hardlinks: "reject", maxBytes: MAX_PROPOSAL_BYTES, symlinks: "reject" },
);
draftContent = draft.buffer.toString("utf8");
} catch {
return false;
}
if (hashSkillProposalContent(draftContent) !== stored.record.draftHash) {
return false;
}
let proposedContent: string;
try {
proposedContent = stripProposalFrontmatterForSkill(draftContent);
} catch {
return false;
}
try {
const targetContent = await readWorkspaceSkillFile(stored.record.target.skillFile);
if (
targetContent === null ||
hashSkillProposalContent(targetContent) !== hashSkillProposalContent(proposedContent)
) {
return false;
}
for (const file of stored.record.supportFiles ?? []) {
const targetSupportContent = await readWorkspaceSupportFile({
skillDir: stored.record.target.skillDir,
relativePath: file.path,
});
if (
targetSupportContent === null ||
hashSkillProposalContent(targetSupportContent) !== file.hash
) {
return false;
}
}
} catch {
return false;
}
const now = new Date().toISOString();
return runOpenClawStateWriteTransaction(
({ db }) => {
const kysely = getNodeSqliteKysely<SkillWorkshopDatabase>(db);
const current = executeSqliteQueryTakeFirstSync(
db,
kysely
.selectFrom("skill_workshop_proposals")
.selectAll()
.where("proposal_id", "=", proposalId),
);
const record = current ? parseSkillProposalRow(current) : null;
if (
!current ||
!record ||
current.record_json !== stored.row.record_json ||
record.status !== "pending"
) {
return false;
}
updateProposal(db, current, {
...record,
status: "applied",
updatedAt: now,
appliedAt: now,
});
return true;
},
databaseOptions(options),
{ operationLabel: "skill-workshop.apply.reconcile" },
);
}
export async function readProposalSupportFiles(
@@ -421,13 +571,75 @@ export function createSkillProposalRollback(params: {
};
}
function assertProposalId(proposalId: string): void {
if (!PROPOSAL_ID_PATTERN.test(proposalId)) {
throw new Error("Invalid skill proposal id.");
}
export function importLegacySkillProposal(params: {
record: SkillProposalRecord;
rollback?: SkillProposalRollback;
ownerAgentId?: string;
workspaceDir: string;
store?: SkillWorkshopStoreOptions;
}): "imported" | "already-imported" {
assertProposalId(params.record.id);
ensureSkillWorkshopSchema(params.store);
return runOpenClawStateWriteTransaction(
({ db }) => {
const kysely = getNodeSqliteKysely<SkillWorkshopDatabase>(db);
const current = executeSqliteQueryTakeFirstSync(
db,
kysely
.selectFrom("skill_workshop_proposals")
.selectAll()
.where("proposal_id", "=", params.record.id),
);
if (current) {
const existing = parseSkillProposalRow(current);
if (
!existing ||
existing.draftHash !== params.record.draftHash ||
existing.target.skillFile !== params.record.target.skillFile
) {
throw new Error(`Legacy skill proposal conflicts with SQLite: ${params.record.id}`);
}
} else {
insertProposal(db, {
record: params.record,
ownerAgentId: params.ownerAgentId ?? params.record.origin?.agentId ?? null,
workspaceDir: params.workspaceDir,
});
}
if (params.rollback) {
executeSqliteQuerySync(
db,
kysely
.insertInto("skill_workshop_proposal_rollbacks")
.values({
proposal_id: params.record.id,
written_at: params.rollback.writtenAt,
target_skill_file: params.rollback.targetSkillFile,
action: params.rollback.action,
previous_content_hash: params.rollback.previousContentHash ?? null,
previous_content: params.rollback.previousContent ?? null,
support_files_json: params.rollback.supportFiles
? JSON.stringify(params.rollback.supportFiles)
: null,
})
.onConflict((conflict) => conflict.column("proposal_id").doNothing()),
);
}
return current ? "already-imported" : "imported";
},
databaseOptions(params.store),
{ operationLabel: "doctor.skill-workshop.import" },
);
}
function manifestEntryFromRecord(record: SkillProposalRecord): SkillProposalManifestEntry {
function manifestEntryFromRecord(
record: SkillProposalRecord,
boundWorkspaceDir: string,
currentWorkspaceDir?: string,
): SkillProposalManifestEntry {
const workspaceMismatch =
currentWorkspaceDir !== undefined &&
path.resolve(boundWorkspaceDir) !== path.resolve(currentWorkspaceDir);
return {
id: record.id,
kind: record.kind,
@@ -439,109 +651,6 @@ function manifestEntryFromRecord(record: SkillProposalRecord): SkillProposalMani
createdAt: record.createdAt,
updatedAt: record.updatedAt,
scanState: record.scan.state,
...(workspaceMismatch ? { workspaceMismatch: true } : {}),
};
}
function parseSkillProposalRecord(raw: unknown): SkillProposalRecord | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return null;
}
const record = raw as SkillProposalRecord;
if (
record.schema !== SKILL_WORKSHOP_SCHEMA ||
!PROPOSAL_ID_PATTERN.test(record.id) ||
(record.kind !== "create" && record.kind !== "update") ||
!["pending", "applied", "rejected", "quarantined", "stale"].includes(record.status) ||
typeof record.title !== "string" ||
typeof record.description !== "string" ||
typeof record.createdAt !== "string" ||
typeof record.updatedAt !== "string" ||
typeof record.draftHash !== "string" ||
record.draftFile !== PROPOSAL_DRAFT_FILE ||
!hasValidProposalOriginProvenance(record) ||
!isValidSupportFileList(record.supportFiles) ||
!record.target ||
typeof record.target !== "object" ||
typeof record.target.skillName !== "string" ||
typeof record.target.skillKey !== "string" ||
typeof record.target.skillDir !== "string" ||
typeof record.target.skillFile !== "string" ||
!record.scan ||
typeof record.scan !== "object"
) {
return null;
}
return record;
}
function isValidSupportFileList(value: unknown): boolean {
if (value === undefined) {
return true;
}
if (!Array.isArray(value) || value.length > MAX_PROPOSAL_SUPPORT_FILES) {
return false;
}
const seen = new Set<string>();
for (const item of value) {
if (!item || typeof item !== "object" || Array.isArray(item)) {
return false;
}
const file = item as SkillProposalSupportFile;
if (
typeof file.path !== "string" ||
typeof file.hash !== "string" ||
!/^[a-f0-9]{64}$/i.test(file.hash) ||
typeof file.sizeBytes !== "number" ||
!Number.isSafeInteger(file.sizeBytes) ||
file.sizeBytes < 0 ||
file.sizeBytes > MAX_WORKSPACE_SKILL_SUPPORT_FILE_BYTES ||
(file.targetExisted !== undefined && typeof file.targetExisted !== "boolean") ||
(file.targetContentHash !== undefined &&
(typeof file.targetContentHash !== "string" ||
!/^[a-f0-9]{64}$/i.test(file.targetContentHash)))
) {
return false;
}
let normalized: string;
try {
normalized = normalizeWorkspaceSkillSupportPath(file.path);
} catch {
return false;
}
if (seen.has(normalized)) {
return false;
}
seen.add(normalized);
}
return true;
}
function parseSkillProposalManifest(raw: unknown): SkillProposalManifest | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return null;
}
const manifest = raw as SkillProposalManifest;
if (
manifest.schema !== SKILL_WORKSHOP_MANIFEST_SCHEMA ||
typeof manifest.updatedAt !== "string" ||
!Array.isArray(manifest.proposals)
) {
return null;
}
const proposals = manifest.proposals.filter((entry) => {
return (
entry &&
typeof entry === "object" &&
PROPOSAL_ID_PATTERN.test(normalizeOptionalString(entry.id) ?? "") &&
typeof entry.skillName === "string" &&
typeof entry.skillKey === "string" &&
typeof entry.updatedAt === "string"
);
});
return { ...manifest, proposals };
}
function proposalRelativeDir(proposalId: string): string {
assertProposalId(proposalId);
return path.join(PROPOSALS_REL_DIR, proposalId);
}
+6
View File
@@ -124,6 +124,8 @@ export type SkillProposalManifestEntry = {
createdAt: string;
updatedAt: string;
scanState: SkillProposalScannerState;
/** The proposal remains bound to an earlier workspace for this agent. */
workspaceMismatch?: true;
};
export type SkillProposalManifest = {
@@ -155,6 +157,7 @@ export type SkillProposalSupportFileInput = {
export type SkillProposalCreateInput = {
workspaceDir: string;
agentId?: string;
config?: OpenClawConfig;
env?: NodeJS.ProcessEnv;
name: string;
@@ -169,6 +172,7 @@ export type SkillProposalCreateInput = {
export type SkillProposalUpdateInput = {
workspaceDir: string;
agentId?: string;
config?: OpenClawConfig;
env?: NodeJS.ProcessEnv;
skillName: string;
@@ -183,6 +187,7 @@ export type SkillProposalUpdateInput = {
export type SkillProposalReviseInput = {
workspaceDir: string;
agentId?: string;
config?: OpenClawConfig;
env?: NodeJS.ProcessEnv;
proposalId: string;
@@ -196,6 +201,7 @@ export type SkillProposalReviseInput = {
export type SkillProposalActionInput = {
workspaceDir: string;
agentId?: string;
config?: OpenClawConfig;
env?: NodeJS.ProcessEnv;
proposalId: string;
+7 -1
View File
@@ -7,7 +7,13 @@ export const OPENCLAW_STATE_SCHEMA_VERSION = 6;
export const OPENCLAW_STATE_STRICT_SCHEMA_VERSION = 3;
// Added after v6 shipped. These tables stay optional until their feature-local
// lazy ensures run; fold them into the next natural schema-version bump.
export const LAZY_ADDITIVE_STATE_TABLES = ["model_catalog_remote", "sidebar_sections"] as const;
export const LAZY_ADDITIVE_STATE_TABLES = [
"model_catalog_remote",
"sidebar_sections",
"skill_workshop_proposal_origin_runs",
"skill_workshop_proposal_rollbacks",
"skill_workshop_proposals",
] as const;
/** Maximum time one synchronous SQLite call may wait for a lock. */
export const OPENCLAW_SQLITE_BUSY_TIMEOUT_MS = 5_000;
/** User-facing guide for schema refusals; lives here so error sites avoid import cycles. */
+41
View File
@@ -1140,6 +1140,44 @@ export interface SkillUsage {
use_count: number;
}
export interface SkillWorkshopProposalOriginRuns {
mutation_count: number;
position: number;
proposal_id: string;
run_id: string;
}
export interface SkillWorkshopProposalRollbacks {
action: string;
previous_content: string | null;
previous_content_hash: string | null;
proposal_id: string;
support_files_json: string | null;
target_skill_file: string;
written_at: string;
}
export interface SkillWorkshopProposals {
applied_at: string | null;
created_at: string;
draft_hash: string;
kind: string;
origin_agent_id: string | null;
origin_message_id: string | null;
origin_run_id: string | null;
origin_session_key: string | null;
owner_agent_id: string | null;
proposal_id: string;
quarantined_at: string | null;
record_json: string;
rejected_at: string | null;
stale_at: string | null;
status: string;
status_reason: string | null;
updated_at: string;
workspace_dir: string;
}
export interface StateLeases {
created_at: number;
expires_at: number | null;
@@ -1577,6 +1615,9 @@ export interface DB {
skill_upload_chunks: SkillUploadChunks;
skill_uploads: SkillUploads;
skill_usage: SkillUsage;
skill_workshop_proposal_origin_runs: SkillWorkshopProposalOriginRuns;
skill_workshop_proposal_rollbacks: SkillWorkshopProposalRollbacks;
skill_workshop_proposals: SkillWorkshopProposals;
state_leases: StateLeases;
subagent_runs: SubagentRuns;
task_delivery_state: TaskDeliveryState;
@@ -73,6 +73,47 @@ CREATE TABLE IF NOT EXISTS skill_curator_state (
last_result_json TEXT NOT NULL
) STRICT;
CREATE TABLE IF NOT EXISTS skill_workshop_proposals (
proposal_id TEXT NOT NULL PRIMARY KEY,
record_json TEXT NOT NULL,
owner_agent_id TEXT,
workspace_dir TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('create', 'update')),
status TEXT NOT NULL CHECK (status IN ('pending', 'applied', 'rejected', 'quarantined', 'stale')),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
draft_hash TEXT NOT NULL,
origin_agent_id TEXT,
origin_session_key TEXT,
origin_run_id TEXT,
origin_message_id TEXT,
applied_at TEXT,
rejected_at TEXT,
quarantined_at TEXT,
stale_at TEXT,
status_reason TEXT
) STRICT;
CREATE TABLE IF NOT EXISTS skill_workshop_proposal_origin_runs (
proposal_id TEXT NOT NULL,
run_id TEXT NOT NULL,
position INTEGER NOT NULL,
mutation_count INTEGER NOT NULL CHECK (mutation_count > 0),
PRIMARY KEY (proposal_id, run_id),
FOREIGN KEY (proposal_id) REFERENCES skill_workshop_proposals(proposal_id) ON DELETE CASCADE
) STRICT;
CREATE TABLE IF NOT EXISTS skill_workshop_proposal_rollbacks (
proposal_id TEXT NOT NULL PRIMARY KEY,
written_at TEXT NOT NULL,
target_skill_file TEXT NOT NULL,
action TEXT NOT NULL CHECK (action IN ('create', 'update')),
previous_content_hash TEXT,
previous_content TEXT,
support_files_json TEXT,
FOREIGN KEY (proposal_id) REFERENCES skill_workshop_proposals(proposal_id) ON DELETE CASCADE
) STRICT;
CREATE TABLE IF NOT EXISTS audit_events (
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
event_id TEXT NOT NULL UNIQUE,
+41
View File
@@ -68,6 +68,47 @@ CREATE TABLE IF NOT EXISTS skill_curator_state (
last_result_json TEXT NOT NULL
) STRICT;
CREATE TABLE IF NOT EXISTS skill_workshop_proposals (
proposal_id TEXT NOT NULL PRIMARY KEY,
record_json TEXT NOT NULL,
owner_agent_id TEXT,
workspace_dir TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('create', 'update')),
status TEXT NOT NULL CHECK (status IN ('pending', 'applied', 'rejected', 'quarantined', 'stale')),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
draft_hash TEXT NOT NULL,
origin_agent_id TEXT,
origin_session_key TEXT,
origin_run_id TEXT,
origin_message_id TEXT,
applied_at TEXT,
rejected_at TEXT,
quarantined_at TEXT,
stale_at TEXT,
status_reason TEXT
) STRICT;
CREATE TABLE IF NOT EXISTS skill_workshop_proposal_origin_runs (
proposal_id TEXT NOT NULL,
run_id TEXT NOT NULL,
position INTEGER NOT NULL,
mutation_count INTEGER NOT NULL CHECK (mutation_count > 0),
PRIMARY KEY (proposal_id, run_id),
FOREIGN KEY (proposal_id) REFERENCES skill_workshop_proposals(proposal_id) ON DELETE CASCADE
) STRICT;
CREATE TABLE IF NOT EXISTS skill_workshop_proposal_rollbacks (
proposal_id TEXT NOT NULL PRIMARY KEY,
written_at TEXT NOT NULL,
target_skill_file TEXT NOT NULL,
action TEXT NOT NULL CHECK (action IN ('create', 'update')),
previous_content_hash TEXT,
previous_content TEXT,
support_files_json TEXT,
FOREIGN KEY (proposal_id) REFERENCES skill_workshop_proposals(proposal_id) ON DELETE CASCADE
) STRICT;
CREATE TABLE IF NOT EXISTS audit_events (
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
event_id TEXT NOT NULL UNIQUE,