refactor(skills): isolate workshop transition persistence

This commit is contained in:
Vincent Koc
2026-07-29 17:17:41 +08:00
parent 3c7de5eb2c
commit af8e8a7fb2
5 changed files with 137 additions and 29 deletions
+5
View File
@@ -0,0 +1,5 @@
import { sha256Hex } from "../../infra/crypto-digest.js";
export function hashSkillProposalContent(content: string): string {
return sha256Hex(content);
}
@@ -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<boolean> {
assertProposalId(params.proposalId);
ensureSkillWorkshopSchema(params.store);
return runOpenClawStateWriteTransaction(
({ db }) => {
const kysely = getNodeSqliteKysely<SkillWorkshopDatabase>(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" },
);
}
@@ -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<SkillWorkshopDatabase>(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 },
);
}
+8 -29
View File
@@ -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<T>(
record: SkillProposalRecord,
fn: () => Promise<T>,
options: SkillWorkshopStoreOptions = {},
): Promise<T> {
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,
+31
View File
@@ -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<T>(
record: SkillProposalRecord,
fn: () => Promise<T>,
options: SkillWorkshopStoreOptions = {},
): Promise<T> {
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(),
);
}