diff --git a/docs/tools/skill-workshop.md b/docs/tools/skill-workshop.md index 9cf5319e74d7..c51b50e96226 100644 --- a/docs/tools/skill-workshop.md +++ b/docs/tools/skill-workshop.md @@ -350,12 +350,10 @@ proposals. ## Storage ```text -/skill-workshop/ - proposals.json - proposals// - proposal.json +/ + state/openclaw.sqlite + skill-workshop/proposals// 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 diff --git a/src/agents/tools/skill-workshop-tool-helpers.ts b/src/agents/tools/skill-workshop-tool-helpers.ts index 2d6e2bf8d4f1..6ff26d00d830 100644 --- a/src/agents/tools/skill-workshop-tool-helpers.ts +++ b/src/agents/tools/skill-workshop-tool-helpers.ts @@ -126,10 +126,11 @@ export async function readProposalForInspect( params: Record, workspaceDir: string, env?: NodeJS.ProcessEnv, + agentId?: string, ): Promise { 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}`); } diff --git a/src/agents/tools/skill-workshop-tool-presentation.ts b/src/agents/tools/skill-workshop-tool-presentation.ts index 0d1a0dabfd42..8ea6d9c78b2c 100644 --- a/src/agents/tools/skill-workshop-tool-presentation.ts +++ b/src/agents/tools/skill-workshop-tool-presentation.ts @@ -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"); } diff --git a/src/agents/tools/skill-workshop-tool.test.ts b/src/agents/tools/skill-workshop-tool.test.ts index 76c3abff48d0..cd4f699b0d15 100644 --- a/src/agents/tools/skill-workshop-tool.test.ts +++ b/src/agents/tools/skill-workshop-tool.test.ts @@ -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 } }); }); }); diff --git a/src/agents/tools/skill-workshop-tool.ts b/src/agents/tools/skill-workshop-tool.ts index ca216c3a2f52..70cf831e8208 100644 --- a/src/agents/tools/skill-workshop-tool.ts +++ b/src/agents/tools/skill-workshop-tool.ts @@ -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, diff --git a/src/cli/skills-cli.ts b/src/cli/skills-cli.ts index 20b46e279c46..94c891020bbd 100644 --- a/src/cli/skills-cli.ts +++ b/src/cli/skills-cli.ts @@ -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, diff --git a/src/cli/skills-cli.workshop.test.ts b/src/cli/skills-cli.workshop.test.ts index b0093db4bb8a..96f48065b0b1 100644 --- a/src/cli/skills-cli.workshop.test.ts +++ b/src/cli/skills-cli.workshop.test.ts @@ -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!]); diff --git a/src/commands/doctor-skill-workshop-sqlite.test.ts b/src/commands/doctor-skill-workshop-sqlite.test.ts new file mode 100644 index 000000000000..df4e101ba600 --- /dev/null +++ b/src/commands/doctor-skill-workshop-sqlite.test.ts @@ -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(); + }); +}); diff --git a/src/commands/doctor-skill-workshop-sqlite.ts b/src/commands/doctor-skill-workshop-sqlite.ts new file mode 100644 index 000000000000..abc5df95c482 --- /dev/null +++ b/src/commands/doctor-skill-workshop-sqlite.ts @@ -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 { + 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 { + 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 { + 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 { + 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, + }; +} diff --git a/src/commands/doctor/repair-sequencing.ts b/src/commands/doctor/repair-sequencing.ts index f76d8b4c7c89..80ab4e5f68b1 100644 --- a/src/commands/doctor/repair-sequencing.ts +++ b/src/commands/doctor/repair-sequencing.ts @@ -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({ diff --git a/src/gateway/server-methods/skills.proposals.test.ts b/src/gateway/server-methods/skills.proposals.test.ts index 4ecf4fb23b10..34f768f2a53d 100644 --- a/src/gateway/server-methods/skills.proposals.test.ts +++ b/src/gateway/server-methods/skills.proposals.test.ts @@ -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 () => { diff --git a/src/gateway/server-methods/skills.ts b/src/gateway/server-methods/skills.ts index b54f6c81e14b..bf06d3b11230 100644 --- a/src/gateway/server-methods/skills.ts +++ b/src/gateway/server-methods/skills.ts @@ -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, }), diff --git a/src/skills/workshop/config.ts b/src/skills/workshop/config.ts index d865d9909175..4d9c83a62d2e 100644 --- a/src/skills/workshop/config.ts +++ b/src/skills/workshop/config.ts @@ -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; }; diff --git a/src/skills/workshop/service-query.ts b/src/skills/workshop/service-query.ts index 859451686239..5013bc8872d7 100644 --- a/src/skills/workshop/service-query.ts +++ b/src/skills/workshop/service-query.ts @@ -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 { - 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 { - 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 { 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 { - 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; diff --git a/src/skills/workshop/service.test.ts b/src/skills/workshop/service.test.ts index 332f7a571ad3..91b617b305b5 100644 --- a/src/skills/workshop/service.test.ts +++ b/src/skills/workshop/service.test.ts @@ -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 () => { diff --git a/src/skills/workshop/service.ts b/src/skills/workshop/service.ts index 9790a44358f4..22fd9f281d0d 100644 --- a/src/skills/workshop/service.ts +++ b/src/skills/workshop/service.ts @@ -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 { - 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( - input: Pick, + input: Pick, action: "applied" | "quarantined" | "rejected" | "revised", fn: (read: SkillProposalReadResult) => Promise, ): Promise { - 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}.`, diff --git a/src/skills/workshop/store-record.ts b/src/skills/workshop/store-record.ts new file mode 100644 index 000000000000..231706236939 --- /dev/null +++ b/src/skills/workshop/store-record.ts @@ -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(); + 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; +} diff --git a/src/skills/workshop/store-sqlite-record.ts b/src/skills/workshop/store-sqlite-record.ts new file mode 100644 index 000000000000..453ed464a014 --- /dev/null +++ b/src/skills/workshop/store-sqlite-record.ts @@ -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 { + 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(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(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(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); +} diff --git a/src/skills/workshop/store-sqlite-rollback.ts b/src/skills/workshop/store-sqlite-rollback.ts new file mode 100644 index 000000000000..2980d4e67dff --- /dev/null +++ b/src/skills/workshop/store-sqlite-rollback.ts @@ -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(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 { + assertProposalId(params.proposalId); + ensureSkillWorkshopSchema(params.store); + runOpenClawStateWriteTransaction( + ({ db }) => { + const kysely = getNodeSqliteKysely(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 { + 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) } : {}), + }); +} diff --git a/src/skills/workshop/store-sqlite-schema.ts b/src/skills/workshop/store-sqlite-schema.ts new file mode 100644 index 000000000000..e4dcf1f040de --- /dev/null +++ b/src/skills/workshop/store-sqlite-schema.ts @@ -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; +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(); + +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(database.db), + }; +} diff --git a/src/skills/workshop/store.test.ts b/src/skills/workshop/store.test.ts new file mode 100644 index 000000000000..8df0e3e379b3 --- /dev/null +++ b/src/skills/workshop/store.test.ts @@ -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, + }); + }); +}); diff --git a/src/skills/workshop/store.ts b/src/skills/workshop/store.ts index 0ee991c33bb1..036dec76ded2 100644 --- a/src/skills/workshop/store.ts +++ b/src/skills/workshop/store.ts @@ -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; /** 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 { - 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 { - const raw = await tryReadJson(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 { 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 { + 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(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().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 { 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(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( @@ -278,97 +362,163 @@ export async function withSkillProposalTargetLock( fn: () => Promise, options: SkillWorkshopStoreOptions = {}, ): Promise { - 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 { - 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 { - return await readSkillProposalManifestUnlocked(options); -} - -async function readSkillProposalManifestUnlocked( - options: SkillWorkshopStoreOptions = {}, -): Promise { - const manifestPath = path.join(resolveSkillWorkshopStateDir(options), MANIFEST_REL_PATH); - const parsed = parseSkillProposalManifest(await tryReadJson(manifestPath)); - if (parsed) { - return parsed; - } - return await refreshSkillProposalManifestUnlocked(options); -} - -export async function refreshSkillProposalManifest( - options: SkillWorkshopStoreOptions = {}, -): Promise { - return await withSkillProposalManifestLock(options, async () => { - return await refreshSkillProposalManifestUnlocked(options); - }); -} - -async function refreshSkillProposalManifestUnlocked( - options: SkillWorkshopStoreOptions = {}, -): Promise { - 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( +async function reconcileInterruptedApply( + proposalId: string, options: SkillWorkshopStoreOptions, - fn: () => Promise, -): Promise { - const lockFile = path.join(resolveSkillWorkshopStateDir(options), MANIFEST_LOCK_REL_PATH); - return await withSkillWorkshopLock(lockFile, fn); -} - -async function withSkillWorkshopLock(lockFile: string, fn: () => Promise): Promise { - 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 { + 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(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(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(); - 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); -} diff --git a/src/skills/workshop/types.ts b/src/skills/workshop/types.ts index d837c2027490..ba65ddde2a2b 100644 --- a/src/skills/workshop/types.ts +++ b/src/skills/workshop/types.ts @@ -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; diff --git a/src/state/openclaw-state-db-contract.ts b/src/state/openclaw-state-db-contract.ts index e51997993f64..2a3580253bd3 100644 --- a/src/state/openclaw-state-db-contract.ts +++ b/src/state/openclaw-state-db-contract.ts @@ -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. */ diff --git a/src/state/openclaw-state-db.generated.d.ts b/src/state/openclaw-state-db.generated.d.ts index 70395c54a490..612ea9d35456 100644 --- a/src/state/openclaw-state-db.generated.d.ts +++ b/src/state/openclaw-state-db.generated.d.ts @@ -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; diff --git a/src/state/openclaw-state-schema.generated.ts b/src/state/openclaw-state-schema.generated.ts index 418b2ef41c4b..71aa2a9d8dfb 100644 --- a/src/state/openclaw-state-schema.generated.ts +++ b/src/state/openclaw-state-schema.generated.ts @@ -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, diff --git a/src/state/openclaw-state-schema.sql b/src/state/openclaw-state-schema.sql index fe641b174a0d..ed0d44cbc677 100644 --- a/src/state/openclaw-state-schema.sql +++ b/src/state/openclaw-state-schema.sql @@ -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,