mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 13:26:04 -06:00
fix(skills): recover complete apply transitions
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user