diff --git a/src/skills/workshop/proposal-hash.ts b/src/skills/workshop/proposal-hash.ts new file mode 100644 index 000000000000..2bb8a8602bcb --- /dev/null +++ b/src/skills/workshop/proposal-hash.ts @@ -0,0 +1,5 @@ +import { sha256Hex } from "../../infra/crypto-digest.js"; + +export function hashSkillProposalContent(content: string): string { + return sha256Hex(content); +} diff --git a/src/skills/workshop/store-sqlite-rollback.ts b/src/skills/workshop/store-sqlite-rollback.ts index 2980d4e67dff..9765c2082228 100644 --- a/src/skills/workshop/store-sqlite-rollback.ts +++ b/src/skills/workshop/store-sqlite-rollback.ts @@ -135,3 +135,40 @@ export async function readSkillProposalRollback( ...(row.support_files_json ? { supportFiles: parseJson(row.support_files_json) } : {}), }); } + +export async function clearSkillProposalRollback(params: { + proposalId: string; + expectedRecordJson: string; + store?: SkillWorkshopStoreOptions; +}): Promise { + assertProposalId(params.proposalId); + ensureSkillWorkshopSchema(params.store); + return runOpenClawStateWriteTransaction( + ({ db }) => { + const kysely = getNodeSqliteKysely(db); + const proposal = executeSqliteQueryTakeFirstSync( + db, + kysely + .selectFrom("skill_workshop_proposals") + .select(["record_json", "status"]) + .where("proposal_id", "=", params.proposalId), + ); + if ( + !proposal || + proposal.status !== "pending" || + proposal.record_json !== params.expectedRecordJson + ) { + return false; + } + executeSqliteQuerySync( + db, + kysely + .deleteFrom("skill_workshop_proposal_rollbacks") + .where("proposal_id", "=", params.proposalId), + ); + return true; + }, + databaseOptions(params.store), + { operationLabel: "skill-workshop.rollback.clear" }, + ); +} diff --git a/src/skills/workshop/store-sqlite-transition.ts b/src/skills/workshop/store-sqlite-transition.ts new file mode 100644 index 000000000000..2ae49360fe4a --- /dev/null +++ b/src/skills/workshop/store-sqlite-transition.ts @@ -0,0 +1,56 @@ +import { executeSqliteQueryTakeFirstSync, getNodeSqliteKysely } from "../../infra/kysely-sync.js"; +import { runOpenClawStateWriteTransaction } from "../../state/openclaw-state-db.js"; +import { appendSkillProposalEvent, type NewSkillProposalEvent } from "./store-sqlite-event.js"; +import { parseSkillProposalRow, updateProposal } from "./store-sqlite-record.js"; +import { + databaseOptions, + ensureSkillWorkshopSchema, + type SkillWorkshopDatabase, + type SkillWorkshopStoreOptions, +} from "./store-sqlite-schema.js"; +import type { SkillProposalEvent, SkillProposalRecord } from "./types.js"; + +export type PendingSkillProposalTransitionCommit = + | { state: "committed"; event?: SkillProposalEvent } + | { state: "conflict"; current?: SkillProposalRecord }; + +export function commitPendingSkillProposalTransition(params: { + expected: SkillProposalRecord; + record: SkillProposalRecord; + event?: NewSkillProposalEvent; + store?: SkillWorkshopStoreOptions; + operationLabel: string; +}): PendingSkillProposalTransitionCommit { + ensureSkillWorkshopSchema(params.store); + return runOpenClawStateWriteTransaction( + ({ db }) => { + const kysely = getNodeSqliteKysely(db); + const current = executeSqliteQueryTakeFirstSync( + db, + kysely + .selectFrom("skill_workshop_proposals") + .selectAll() + .where("proposal_id", "=", params.expected.id), + ); + const currentRecord = current ? parseSkillProposalRow(current) : null; + if ( + !current || + !currentRecord || + currentRecord.status !== "pending" || + current.record_json !== JSON.stringify(params.expected) + ) { + return { + state: "conflict" as const, + ...(currentRecord ? { current: currentRecord } : {}), + }; + } + updateProposal(db, current, params.record); + return { + state: "committed" as const, + ...(params.event ? { event: appendSkillProposalEvent(db, params.event) } : {}), + }; + }, + databaseOptions(params.store), + { operationLabel: params.operationLabel }, + ); +} diff --git a/src/skills/workshop/store.ts b/src/skills/workshop/store.ts index 2f5bb60ca6a6..131691e9b945 100644 --- a/src/skills/workshop/store.ts +++ b/src/skills/workshop/store.ts @@ -1,7 +1,6 @@ import crypto from "node:crypto"; import path from "node:path"; import { resolveStateDir } from "../../config/paths.js"; -import { sha256Hex } from "../../infra/crypto-digest.js"; import { removePathWithinRoot } from "../../infra/fs-safe-remove.js"; import { root } from "../../infra/fs-safe.js"; import { @@ -10,7 +9,6 @@ import { 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, @@ -21,6 +19,7 @@ import { readWorkspaceSupportFile, } from "../lifecycle/workspace-skill-write.js"; import { stripProposalFrontmatterForSkill } from "./frontmatter.js"; +import { hashSkillProposalContent } from "./proposal-hash.js"; import { hashSkillProposalRevision } from "./revision-hash.js"; import { assertProposalId, @@ -60,14 +59,18 @@ const WORKSHOP_REL_DIR = "skill-workshop"; const PROPOSALS_REL_DIR = path.join(WORKSHOP_REL_DIR, "proposals"); const MAX_PROPOSAL_BYTES = 1024 * 1024; const MAX_PROPOSAL_SUPPORT_FILES_TOTAL_BYTES = 2 * 1024 * 1024; -const TARGET_LEASE_MS = 60_000; -const TARGET_LEASE_WAIT_MS = 5_000; export { MAX_PROPOSAL_SUPPORT_FILES, validateSkillProposalRecord, validateSkillProposalRollback, } from "./store-record.js"; -export { readSkillProposalRollback, writeSkillProposalRollback } from "./store-sqlite-rollback.js"; +export { hashSkillProposalContent } from "./proposal-hash.js"; +export { + clearSkillProposalRollback, + readSkillProposalRollback, + writeSkillProposalRollback, +} from "./store-sqlite-rollback.js"; +export { withSkillProposalTargetLock } from "./target-lock.js"; type SkillProposalLookupScope = { agentId?: string; @@ -86,10 +89,6 @@ export function createSkillProposalId(name: string, now = new Date()): string { return `${normalized.slice(0, 60)}-${date}-${suffix}`; } -export function hashSkillProposalContent(content: string): string { - return sha256Hex(content); -} - function contentSizeBytes(content: string): number { return Buffer.byteLength(content, "utf8"); } @@ -370,26 +369,6 @@ export async function updateSkillProposalRecord(params: { ); } -export async function withSkillProposalTargetLock( - record: SkillProposalRecord, - fn: () => Promise, - options: SkillWorkshopStoreOptions = {}, -): Promise { - 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(), - ); -} - function listStoredProposals( options: SkillWorkshopStoreOptions, scope: SkillProposalLookupScope, diff --git a/src/skills/workshop/target-lock.ts b/src/skills/workshop/target-lock.ts new file mode 100644 index 000000000000..b870bf11fca7 --- /dev/null +++ b/src/skills/workshop/target-lock.ts @@ -0,0 +1,31 @@ +import { withOpenClawStateLease } from "../../state/openclaw-state-lease.js"; +import { hashSkillProposalContent } from "./proposal-hash.js"; +import { + databaseOptions, + ensureSkillWorkshopSchema, + type SkillWorkshopStoreOptions, +} from "./store-sqlite-schema.js"; +import type { SkillProposalRecord } from "./types.js"; + +const TARGET_LEASE_MS = 60_000; +const TARGET_LEASE_WAIT_MS = 5_000; + +export async function withSkillProposalTargetLock( + record: SkillProposalRecord, + fn: () => Promise, + options: SkillWorkshopStoreOptions = {}, +): Promise { + 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(), + ); +}