fix(skills): recover complete apply transitions

This commit is contained in:
Vincent Koc
2026-07-29 20:28:49 +08:00
parent 8606845bce
commit 9b419ea2ac
4 changed files with 128 additions and 47 deletions
+26 -13
View File
@@ -24,11 +24,13 @@ import { readSkillProposalTargetTreeSha256 } from "./proposal-bundle.js";
import { hashSkillProposalContent } from "./proposal-hash.js";
import { scanProposalBundle } from "./proposal-scan.js";
import { hashSkillProposalRevision } from "./revision-hash.js";
import type { NewSkillProposalEvent } from "./store-sqlite-event.js";
import { readStoredProposal } from "./store-sqlite-record.js";
import { clearSkillProposalRollback, writeSkillProposalRollback } from "./store-sqlite-rollback.js";
import type { SkillWorkshopStoreOptions } from "./store-sqlite-schema.js";
import {
commitPendingSkillProposalTransition,
readCommittedSkillProposalTransition,
type PendingSkillProposalTransitionCommit,
} from "./store-sqlite-transition.js";
import { withSkillProposalTargetLock } from "./target-lock.js";
@@ -324,32 +326,35 @@ export async function applySkillProposalTransition(
operationLabel: "skill-workshop.apply.commit",
});
} catch (error) {
const recovered = await recoverAfterApplyCommitFailure({
const recoveredEvent = await recoverAfterApplyCommitFailure({
error,
expected: record,
applied,
event: eventInput,
mutation,
env: input.env,
workspaceDir: input.workspaceDir,
});
if (!recovered) {
if (!recoveredEvent) {
throw error;
}
commit = { state: "committed" as const };
commit = { state: "committed" as const, event: recoveredEvent };
}
if (commit.state === "conflict") {
const error = new Error("Skill proposal changed before apply status commit.");
const recovered = await recoverAfterApplyCommitFailure({
const recoveredEvent = await recoverAfterApplyCommitFailure({
error,
expected: record,
applied,
event: eventInput,
mutation,
env: input.env,
workspaceDir: input.workspaceDir,
});
if (!recovered) {
if (!recoveredEvent) {
throw error;
}
commit = { state: "committed" as const, event: recoveredEvent };
}
bumpSkillsSnapshotVersion({
@@ -601,21 +606,29 @@ async function recoverAfterApplyCommitFailure(params: {
error: unknown;
expected: SkillProposalRecord;
applied: SkillProposalRecord;
event: NewSkillProposalEvent;
mutation: PreparedWorkspaceSkillMutation;
env?: NodeJS.ProcessEnv;
workspaceDir: string;
}): Promise<boolean> {
}): Promise<SkillProposalEvent | null> {
const committed = readCommittedSkillProposalTransition({
record: params.applied,
event: params.event,
store: storeOptions(params.env),
});
if (committed) {
return committed.event ?? null;
}
const authoritative = readStoredProposal(params.expected.id, storeOptions(params.env));
if (
authoritative?.record.status === "applied" &&
hashSkillProposalRevision(authoritative.record) === hashSkillProposalRevision(params.applied)
) {
return true;
if (authoritative?.record.status === "applied") {
throw new Error("Applied Skill Workshop transition is missing its committed event.", {
cause: params.error,
});
}
requiredApplyStatus("apply_failed");
const stillApplied = await isWorkspaceSkillMutationApplied(params.mutation).catch(() => false);
if (!stillApplied) {
return false;
return null;
}
try {
try {
@@ -640,7 +653,7 @@ async function recoverAfterApplyCommitFailure(params: {
expectedRecordJson: JSON.stringify(params.expected),
store: storeOptions(params.env),
}).catch(() => false);
return false;
return null;
}
function requiredApplyStatus(outcome: SkillProposalApplyOutcome): SkillProposalStatus {
+57 -24
View File
@@ -26,6 +26,18 @@ import type {
} from "./types.js";
export type NewSkillProposalEvent = Omit<SkillProposalEvent, "sequence">;
type StoredSkillProposalEventRow = {
sequence: number;
event_id: string;
proposal_id: string;
proposed_version: string;
revision_hash: string;
event_type: string;
occurred_at: string;
actor_json: string;
correlation_id: string | null;
payload_json: string | null;
};
const STORED_EVENT_DATA_VERSION = 1;
const MAX_SKILL_PROPOSAL_EVENT_DATA_BYTES = MAX_SKILL_PROPOSAL_EVALUATION_BYTES + 64 * 1024;
const MAX_SKILL_PROPOSAL_EVENTS_RESPONSE_BYTES = 2 * 1024 * 1024;
@@ -70,6 +82,18 @@ export function appendSkillProposalEvent(
return { ...event, sequence: inserted.sequence };
}
export function readStoredSkillProposalEvent(
eventId: string,
options: SkillWorkshopStoreOptions = {},
): SkillProposalEvent | null {
const { database, kysely } = openSkillWorkshopStore(options);
const row = executeSqliteQueryTakeFirstSync(
database.db,
kysely.selectFrom("skill_workshop_proposal_events").selectAll().where("event_id", "=", eventId),
);
return row ? parseStoredSkillProposalEventRow(row) : null;
}
export function listStoredSkillProposalEvents(
input: SkillProposalEventsListInput,
options: SkillWorkshopStoreOptions = {},
@@ -128,32 +152,10 @@ export function listStoredSkillProposalEvents(
let responseBytes = 2;
const events: SkillProposalEvent[] = [];
for (const row of rows.slice(0, limit)) {
const actor = parseSkillProposalEventActor(parseJson(row.actor_json));
if (!actor || !isSkillProposalEventType(row.event_type)) {
const event = parseStoredSkillProposalEventRow(row);
if (!event) {
continue;
}
if (
row.payload_json &&
Buffer.byteLength(row.payload_json, "utf8") > MAX_SKILL_PROPOSAL_EVENT_DATA_BYTES
) {
throw new Error(
`Stored Skill Workshop event ${row.event_id} exceeds ${MAX_SKILL_PROPOSAL_EVENT_DATA_BYTES} bytes and cannot be replayed safely.`,
);
}
const storedData = parseSkillProposalEventData(parseJson(row.payload_json));
const event: SkillProposalEvent = {
sequence: row.sequence,
eventId: row.event_id,
proposalId: row.proposal_id,
proposedVersion: row.proposed_version,
revisionHash: row.revision_hash,
type: row.event_type,
occurredAt: row.occurred_at,
actor,
...(row.correlation_id ? { correlationId: row.correlation_id } : {}),
...(storedData.payload ? { payload: storedData.payload } : {}),
...(storedData.evaluation ? { evaluation: storedData.evaluation } : {}),
};
const eventBytes = Buffer.byteLength(JSON.stringify(event), "utf8") + 1;
if (
events.length > 0 &&
@@ -171,6 +173,37 @@ export function listStoredSkillProposalEvents(
};
}
function parseStoredSkillProposalEventRow(
row: StoredSkillProposalEventRow,
): SkillProposalEvent | null {
const actor = parseSkillProposalEventActor(parseJson(row.actor_json));
if (!actor || !isSkillProposalEventType(row.event_type)) {
return null;
}
if (
row.payload_json &&
Buffer.byteLength(row.payload_json, "utf8") > MAX_SKILL_PROPOSAL_EVENT_DATA_BYTES
) {
throw new Error(
`Stored Skill Workshop event ${row.event_id} exceeds ${MAX_SKILL_PROPOSAL_EVENT_DATA_BYTES} bytes and cannot be replayed safely.`,
);
}
const storedData = parseSkillProposalEventData(parseJson(row.payload_json));
return {
sequence: row.sequence,
eventId: row.event_id,
proposalId: row.proposal_id,
proposedVersion: row.proposed_version,
revisionHash: row.revision_hash,
type: row.event_type,
occurredAt: row.occurred_at,
actor,
...(row.correlation_id ? { correlationId: row.correlation_id } : {}),
...(storedData.payload ? { payload: storedData.payload } : {}),
...(storedData.evaluation ? { evaluation: storedData.evaluation } : {}),
};
}
function isSkillProposalEventType(value: string): value is SkillProposalEventType {
return [
"created",
+32 -2
View File
@@ -1,7 +1,15 @@
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 {
appendSkillProposalEvent,
readStoredSkillProposalEvent,
type NewSkillProposalEvent,
} from "./store-sqlite-event.js";
import {
parseSkillProposalRow,
readStoredProposal,
updateProposal,
} from "./store-sqlite-record.js";
import {
databaseOptions,
ensureSkillWorkshopSchema,
@@ -54,3 +62,25 @@ export function commitPendingSkillProposalTransition(params: {
{ operationLabel: params.operationLabel },
);
}
export function readCommittedSkillProposalTransition(params: {
record: SkillProposalRecord;
event: NewSkillProposalEvent;
store?: SkillWorkshopStoreOptions;
}): Extract<PendingSkillProposalTransitionCommit, { state: "committed" }> | null {
const stored = readStoredProposal(params.record.id, params.store);
if (!stored || stored.row.record_json !== JSON.stringify(params.record)) {
return null;
}
const event = readStoredSkillProposalEvent(params.event.eventId, params.store);
if (
!event ||
event.proposalId !== params.event.proposalId ||
event.proposedVersion !== params.event.proposedVersion ||
event.revisionHash !== params.event.revisionHash ||
event.type !== params.event.type
) {
return null;
}
return { state: "committed", event };
}
+13 -8
View File
@@ -12,7 +12,10 @@ import {
import { createSkillProposalEvent } from "./plugin-hooks.js";
import { listSkillProposalEvents, listSkillProposals, proposeCreateSkill } from "./service.js";
import { parseSkillProposalEvaluation } from "./store-record.js";
import { commitPendingSkillProposalTransition } from "./store-sqlite-transition.js";
import {
commitPendingSkillProposalTransition,
readCommittedSkillProposalTransition,
} from "./store-sqlite-transition.js";
import { updateSkillProposalRecord } from "./store.js";
let testState: OpenClawTestState;
@@ -42,14 +45,16 @@ describe("Skill Workshop SQLite store", () => {
updatedAt: "2026-07-29T00:00:00.000Z",
appliedAt: "2026-07-29T00:00:00.000Z",
};
const event = createSkillProposalEvent({ record: applied, type: "applied" });
expect(
commitPendingSkillProposalTransition({
expected: proposal.record,
record: applied,
operationLabel: "skill-workshop.test.commit",
}),
).toMatchObject({ state: "committed" });
const committed = commitPendingSkillProposalTransition({
expected: proposal.record,
record: applied,
event,
operationLabel: "skill-workshop.test.commit",
});
expect(committed).toMatchObject({ state: "committed", event: { eventId: event.eventId } });
expect(readCommittedSkillProposalTransition({ record: applied, event })).toEqual(committed);
expect(
commitPendingSkillProposalTransition({
expected: proposal.record,