mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
refactor(skills): prepare pinned library resources off-thread (#148560)
Read immutable pin descriptions and eligible manifests through the canonical shared-state reader, retaining admission, snapshot scope, exact artifact bytes, and first-resource error order. Capture pin and catalog facts before awaits so caller mutation cannot mix revisions or change the selected filesystem host. Await the existing per-root node fixture cleanup before deleting test state. No schema, retention, durability, permission, or dependency change is introduced.
This commit is contained in:
parent
7205715961
commit
609fa2d9cf
15 changed files with 787 additions and 119 deletions
|
|
@ -1440,6 +1440,13 @@ runtimes discard late discovery results. Registration's alias bootstrap, tab
|
|||
mutations, and the final synchronous ownership check before closing a browser
|
||||
target retain their existing owners.
|
||||
|
||||
Selected library resources read cold pin descriptions and eligible manifests
|
||||
through the shared read-only worker. Resource preparation retains its captured
|
||||
state root and admission through both reads and file preparation, preserving
|
||||
snapshot scopes, selected revision bytes, hidden-pin omission, and the first
|
||||
resource failure. Synchronous discovery and borrowed-database readers keep their
|
||||
existing contracts. This changes no schema, migration, or persistent data.
|
||||
|
||||
### Preserve the data and concurrency contracts
|
||||
|
||||
Task, flow, and Cron receipt execution identity bindings run in the shared-state
|
||||
|
|
|
|||
|
|
@ -24,9 +24,8 @@ import {
|
|||
} from "../skills/library/selection.js";
|
||||
import { listSkillLibrary, readSkillLibrary, saveSkillLibrary } from "../skills/library/service.js";
|
||||
import { buildSkillSnapshot } from "../skills/loading/workspace-skill-prompt.js";
|
||||
import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js";
|
||||
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
|
||||
import { ensureProfileForEmail } from "../state/user-profiles.js";
|
||||
import { cleanupSessionStateForTest } from "../test-utils/session-state-cleanup.js";
|
||||
import { invokeNodeClaudeCliRun } from "./node-agent-cli-runtime.js";
|
||||
import { NodeRegistry, type NodeRegistryOptions } from "./node-registry.js";
|
||||
import {
|
||||
|
|
@ -41,8 +40,9 @@ import { createWorkerSessionPlacementStore } from "./worker-environments/placeme
|
|||
const temps = useAutoCleanupTempDirTracker((cleanup) =>
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
closeOpenClawAgentDatabasesForTest();
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
for (const stateDir of temps.dirs) {
|
||||
await cleanupSessionStateForTest({ stateDir });
|
||||
}
|
||||
vi.unstubAllEnvs();
|
||||
cleanup();
|
||||
}),
|
||||
|
|
|
|||
370
src/skills/library/resource-read.test.ts
Normal file
370
src/skills/library/resource-read.test.ts
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js";
|
||||
import { requireNodeSqlite } from "../../infra/node-sqlite.js";
|
||||
import { createDeferredCore } from "../../shared/deferred.js";
|
||||
import * as stateReads from "../../state/openclaw-state-db-readonly.js";
|
||||
import { tableExists } from "../../state/openclaw-state-db-schema-helpers.js";
|
||||
import {
|
||||
closeOpenClawStateDatabaseAsync,
|
||||
closeOpenClawStateDatabaseForTest,
|
||||
openOpenClawStateDatabase,
|
||||
} from "../../state/openclaw-state-db.js";
|
||||
import { ensureProfileForEmail } from "../../state/user-profiles.js";
|
||||
import { withEnvAsync } from "../../test-utils/env.js";
|
||||
import { materializeSkill } from "../loading/skill-materializer.js";
|
||||
import { prepareSkillResourceDelivery } from "../runtime/resources.js";
|
||||
import type { SkillSnapshot } from "../types.js";
|
||||
import * as bundles from "./bundle.js";
|
||||
import { decodeSkillLibraryFile } from "./bundle.js";
|
||||
import * as selectionReads from "./selection-read.js";
|
||||
import * as selection from "./selection.js";
|
||||
import { loadSkillLibrarySelection } from "./selection.js";
|
||||
import { saveSkillLibrary } from "./service.js";
|
||||
|
||||
const dirs = useAutoCleanupTempDirTracker((cleanup) =>
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await closeOpenClawStateDatabaseAsync();
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
cleanup();
|
||||
}),
|
||||
);
|
||||
const content =
|
||||
"---\nname: guide\ndescription: Saved procedure\n---\n# Guide\nRead references/data.bin.\n";
|
||||
|
||||
async function fixture() {
|
||||
const root = dirs.make("skill-library-resource-read-");
|
||||
const options = { env: { OPENCLAW_STATE_DIR: root } };
|
||||
const profile = ensureProfileForEmail("reader@example.test", options);
|
||||
const authority = {
|
||||
profileId: profile.id,
|
||||
scopes: ["operator.read", "operator.write"],
|
||||
getConfig: () => ({}),
|
||||
assertCurrent() {},
|
||||
};
|
||||
const saved = await saveSkillLibrary(
|
||||
authority,
|
||||
{
|
||||
slug: "guide",
|
||||
content,
|
||||
expectedRevision: null,
|
||||
files: [{ path: "references/data.bin", content: "AP+A", encoding: "base64" }],
|
||||
},
|
||||
options,
|
||||
);
|
||||
const pin = {
|
||||
skillId: saved.entry.skillId,
|
||||
revision: saved.entry.revision,
|
||||
name: saved.entry.name,
|
||||
ownerProfileId: saved.entry.ownerProfileId,
|
||||
};
|
||||
const snapshot: SkillSnapshot = {
|
||||
prompt: "",
|
||||
skills: [{ name: pin.name }],
|
||||
librarySelections: [pin],
|
||||
};
|
||||
const databasePath = openOpenClawStateDatabase(options).path;
|
||||
await closeOpenClawStateDatabaseAsync();
|
||||
return { root, options, authority, pin, snapshot, databasePath };
|
||||
}
|
||||
|
||||
function observeParentSqlite() {
|
||||
const native = requireNodeSqlite();
|
||||
return [
|
||||
vi.spyOn(native.DatabaseSync.prototype, "prepare"),
|
||||
vi.spyOn(native.DatabaseSync.prototype, "exec"),
|
||||
...(["get", "all", "run", "iterate"] as const).map((method) =>
|
||||
vi.spyOn(native.StatementSync.prototype, method),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function familyHashes(databasePath: string) {
|
||||
return ["", "-wal", "-shm", "-journal"].map((suffix) => {
|
||||
const file = `${databasePath}${suffix}`;
|
||||
return fs.existsSync(file)
|
||||
? [suffix, createHash("sha256").update(fs.readFileSync(file)).digest("hex")]
|
||||
: [suffix, null];
|
||||
});
|
||||
}
|
||||
|
||||
it.each(["ordinary", "artifact-preserving", "disposable"] as const)(
|
||||
"delivers cold and warm immutable pins without parent SQL under %s reads",
|
||||
async (mode) => {
|
||||
const { root, snapshot, pin, databasePath } = await fixture();
|
||||
const before = familyHashes(databasePath);
|
||||
const execute = vi.spyOn(stateReads, "executeExistingOpenClawStateRead");
|
||||
const counters = observeParentSqlite();
|
||||
const run = () =>
|
||||
withEnvAsync({ OPENCLAW_STATE_DIR: root }, async () => {
|
||||
const first = await prepareSkillResourceDelivery(snapshot, () => {});
|
||||
expect(first?.skills).toHaveLength(1);
|
||||
const skill = first!.skills[0]!;
|
||||
expect(skill.revision).toBe(pin.revision);
|
||||
expect(
|
||||
decodeSkillLibraryFile(skill.files.find((file) => file.path === "SKILL.md")!).toString(
|
||||
"utf8",
|
||||
),
|
||||
).toBe(content);
|
||||
expect(
|
||||
decodeSkillLibraryFile(skill.files.find((file) => file.path === "references/data.bin")!),
|
||||
).toEqual(Buffer.from([0, 255, 128]));
|
||||
// The synchronous caller must reuse the same cache key as async preparation.
|
||||
expect(loadSkillLibrarySelection([pin])[0]?.skill.name).toBe(pin.name);
|
||||
expect(await prepareSkillResourceDelivery(structuredClone(snapshot), () => {})).toEqual(
|
||||
first,
|
||||
);
|
||||
await closeOpenClawStateDatabaseAsync();
|
||||
});
|
||||
try {
|
||||
if (mode === "artifact-preserving") {
|
||||
await stateReads.withArtifactPreservingStateReads(run);
|
||||
} else if (mode === "disposable") {
|
||||
await stateReads.withArtifactPreservingStateReads(() =>
|
||||
stateReads.withDisposableOpenClawStateReads(databasePath, run),
|
||||
);
|
||||
} else {
|
||||
await run();
|
||||
}
|
||||
expect(counters.map((counter) => counter.mock.calls.length)).toEqual([0, 0, 0, 0, 0, 0]);
|
||||
expect(execute.mock.calls.map(([, command]) => command.type)).toEqual([
|
||||
"skills.library.descriptions",
|
||||
"skills.library.manifests",
|
||||
"skills.library.manifests",
|
||||
]);
|
||||
if (mode === "artifact-preserving") {
|
||||
expect(familyHashes(databasePath)).toEqual(before);
|
||||
}
|
||||
} finally {
|
||||
for (const counter of counters) {
|
||||
counter.mockRestore();
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("reads both phases from the active snapshot without fetching hidden manifests", async () => {
|
||||
const { root, options, authority, pin, snapshot } = await fixture();
|
||||
const hidden = await saveSkillLibrary(
|
||||
authority,
|
||||
{
|
||||
slug: "hidden",
|
||||
content: content.replace("guide", "hidden"),
|
||||
expectedRevision: null,
|
||||
},
|
||||
options,
|
||||
);
|
||||
snapshot.librarySelections!.push({
|
||||
skillId: hidden.entry.skillId,
|
||||
revision: hidden.entry.revision,
|
||||
name: hidden.entry.name,
|
||||
ownerProfileId: hidden.entry.ownerProfileId,
|
||||
});
|
||||
await closeOpenClawStateDatabaseAsync();
|
||||
const execute = vi.spyOn(stateReads, "executeExistingOpenClawStateRead");
|
||||
await withEnvAsync({ OPENCLAW_STATE_DIR: root }, () =>
|
||||
stateReads.withOpenClawStateDatabaseReadSnapshot(async () => {
|
||||
const database = openOpenClawStateDatabase(options);
|
||||
database.db
|
||||
.prepare(
|
||||
"UPDATE skill_library_revisions SET description = ?, files_json = ? WHERE skill_id = ?",
|
||||
)
|
||||
.run("Later live description", "invalid live manifest", pin.skillId);
|
||||
const delivery = await prepareSkillResourceDelivery(snapshot, () => {});
|
||||
expect(delivery?.skills).toHaveLength(1);
|
||||
expect(delivery?.skills[0]?.description).toBe("Saved procedure");
|
||||
expect(delivery?.skills[0]?.revision).toBe(pin.revision);
|
||||
const manifests = execute.mock.calls
|
||||
.map(([, command]) => command)
|
||||
.find((command) => command.type === "skills.library.manifests");
|
||||
expect(manifests?.input).toEqual([{ skillId: pin.skillId, revision: pin.revision }]);
|
||||
}, options),
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["root", "authority", "admission"] as const)(
|
||||
"retains the captured owner when %s changes while descriptions are pending",
|
||||
async (change) => {
|
||||
const { root, snapshot } = await fixture();
|
||||
const other = dirs.make("skill-library-other-root-");
|
||||
const entered = createDeferredCore();
|
||||
const gate = createDeferredCore();
|
||||
const original = selectionReads.readSkillLibrarySelectionDescriptions;
|
||||
vi.spyOn(selectionReads, "readSkillLibrarySelectionDescriptions").mockImplementation(
|
||||
async (...args) => {
|
||||
const descriptions = await original(...args);
|
||||
entered.resolve();
|
||||
await gate.promise;
|
||||
return descriptions;
|
||||
},
|
||||
);
|
||||
const closed = new Error("Synthetic resource owner closed");
|
||||
let current = true;
|
||||
await withEnvAsync({ OPENCLAW_STATE_DIR: root }, async () => {
|
||||
const pending = prepareSkillResourceDelivery(snapshot, () => {
|
||||
if (!current) {
|
||||
throw closed;
|
||||
}
|
||||
});
|
||||
const outcome = pending.then(
|
||||
(value) => ({ value }),
|
||||
(error: unknown) => ({ error }),
|
||||
);
|
||||
await entered.promise;
|
||||
if (change === "root") {
|
||||
process.env.OPENCLAW_STATE_DIR = other;
|
||||
} else if (change === "admission") {
|
||||
await closeOpenClawStateDatabaseAsync();
|
||||
} else {
|
||||
current = false;
|
||||
}
|
||||
gate.resolve();
|
||||
const result = await outcome;
|
||||
if (change === "authority") {
|
||||
expect(result).toEqual({ error: closed });
|
||||
} else if (change === "admission") {
|
||||
expect(result).toHaveProperty("error");
|
||||
} else {
|
||||
expect(result).toMatchObject({ value: { skills: [{ description: "Saved procedure" }] } });
|
||||
expect(fs.existsSync(path.join(other, "state", "openclaw.sqlite"))).toBe(false);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["selection", "descriptions", "manifests"] as const)(
|
||||
"retains pin values and eligibility when the caller mutates them during %s",
|
||||
async (phase) => {
|
||||
const { root, pin, snapshot } = await fixture();
|
||||
const originalPin = { ...pin };
|
||||
const entered = createDeferredCore();
|
||||
const gate = createDeferredCore();
|
||||
const descriptions = selectionReads.readSkillLibrarySelectionDescriptions;
|
||||
const manifests = selectionReads.readSkillLibrarySelectionManifests;
|
||||
async function hold<T>(value: T) {
|
||||
entered.resolve();
|
||||
await gate.promise;
|
||||
return value;
|
||||
}
|
||||
if (phase === "manifests") {
|
||||
vi.spyOn(selectionReads, "readSkillLibrarySelectionManifests").mockImplementation(
|
||||
async (...args) => hold(await manifests(...args)),
|
||||
);
|
||||
} else {
|
||||
vi.spyOn(selectionReads, "readSkillLibrarySelectionDescriptions").mockImplementation(
|
||||
async (...args) => hold(await descriptions(...args)),
|
||||
);
|
||||
}
|
||||
await withEnvAsync({ OPENCLAW_STATE_DIR: root }, async () => {
|
||||
const pending =
|
||||
phase === "selection"
|
||||
? selection
|
||||
.prepareSkillLibrarySelection(snapshot.librarySelections!, {}, () => {})
|
||||
.then((entries) => entries.map((entry) => entry.skill))
|
||||
: prepareSkillResourceDelivery(snapshot, () => {}).then((delivery) => delivery?.skills);
|
||||
const outcome = pending.then(
|
||||
(value) => ({ value }),
|
||||
(error: unknown) => ({ error }),
|
||||
);
|
||||
try {
|
||||
await entered.promise;
|
||||
Object.assign(pin, {
|
||||
skillId: "changed",
|
||||
revision: "0".repeat(64),
|
||||
name: "changed",
|
||||
ownerProfileId: null,
|
||||
});
|
||||
snapshot.librarySelections!.push({ ...pin });
|
||||
snapshot.skills[0]!.name = "changed";
|
||||
gate.resolve();
|
||||
expect(await outcome).toMatchObject({
|
||||
value: [{ name: originalPin.name, description: "Saved procedure" }],
|
||||
});
|
||||
expect(loadSkillLibrarySelection([originalPin])[0]?.skill.name).toBe(originalPin.name);
|
||||
} finally {
|
||||
gate.resolve();
|
||||
await outcome;
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["file", "table"] as const)(
|
||||
"does not create missing library %s storage",
|
||||
async (missing) => {
|
||||
const root = dirs.make("skill-library-missing-read-");
|
||||
const options = { env: { OPENCLAW_STATE_DIR: root } };
|
||||
if (missing === "table") {
|
||||
openOpenClawStateDatabase(options);
|
||||
await closeOpenClawStateDatabaseAsync();
|
||||
}
|
||||
const snapshot: SkillSnapshot = {
|
||||
prompt: "",
|
||||
skills: [{ name: "missing" }],
|
||||
librarySelections: [
|
||||
{ skillId: "missing", revision: "0".repeat(64), name: "missing", ownerProfileId: null },
|
||||
],
|
||||
};
|
||||
await withEnvAsync({ OPENCLAW_STATE_DIR: root }, async () => {
|
||||
await expect(prepareSkillResourceDelivery(snapshot, () => {})).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
});
|
||||
if (missing === "file") {
|
||||
expect(fs.existsSync(path.join(root, "state", "openclaw.sqlite"))).toBe(false);
|
||||
} else {
|
||||
expect(tableExists(openOpenClawStateDatabase(options).db, "skill_library_entries")).toBe(
|
||||
false,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["workspace", "library"] as const)(
|
||||
"preserves the first %s failure and resource context before reading later candidates",
|
||||
async (first) => {
|
||||
const root = dirs.make("skill-library-read-order-");
|
||||
const skills = ["workspace", "library"].map((name) =>
|
||||
materializeSkill({
|
||||
name,
|
||||
description: name,
|
||||
content,
|
||||
frontmatter: {},
|
||||
filePath: path.join(root, name, "SKILL.md"),
|
||||
baseDir: path.join(root, name),
|
||||
source: "openclaw-workspace",
|
||||
sourceOptions: { source: "openclaw-workspace" },
|
||||
}),
|
||||
);
|
||||
const [workspace, library] = skills;
|
||||
const snapshot: SkillSnapshot = {
|
||||
prompt: "",
|
||||
skills: skills.map(({ name }) => ({ name })),
|
||||
resolvedSkills: first === "workspace" ? skills : [library!, workspace!],
|
||||
librarySelections: [
|
||||
{ skillId: "library", revision: "0".repeat(64), name: "library", ownerProfileId: null },
|
||||
],
|
||||
};
|
||||
const workspaceError = new Error("Synthetic workspace read failed");
|
||||
const libraryError = new Error("Synthetic database read failed");
|
||||
vi.spyOn(selection, "prepareSkillLibrarySelection").mockResolvedValue([]);
|
||||
const readWorkspace = vi
|
||||
.spyOn(bundles, "readSkillBundleTree")
|
||||
.mockRejectedValue(workspaceError);
|
||||
const readManifests = vi
|
||||
.spyOn(selectionReads, "readSkillLibrarySelectionManifests")
|
||||
.mockRejectedValue(libraryError);
|
||||
await withEnvAsync({ OPENCLAW_STATE_DIR: root }, async () => {
|
||||
await expect(prepareSkillResourceDelivery(snapshot, () => {})).rejects.toMatchObject({
|
||||
code: "INVALID_BUNDLE",
|
||||
message: expect.stringContaining(`skill="${first}"`),
|
||||
cause: first === "workspace" ? workspaceError : libraryError,
|
||||
});
|
||||
});
|
||||
expect(readWorkspace).toHaveBeenCalledTimes(first === "workspace" ? 1 : 0);
|
||||
expect(readManifests).toHaveBeenCalledTimes(first === "library" ? 1 : 0);
|
||||
},
|
||||
);
|
||||
63
src/skills/library/selection-read.kernel.ts
Normal file
63
src/skills/library/selection-read.kernel.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { SkillLibrarySelection } from "../../../packages/gateway-protocol/src/schema/skill-library.js";
|
||||
import { executeSqliteQuerySync, getNodeSqliteKysely } from "../../infra/kysely-sync.js";
|
||||
import type { DB as StateDatabase } from "../../state/openclaw-state-db.generated.js";
|
||||
|
||||
type RevisionSelection = Pick<SkillLibrarySelection, "skillId" | "revision">;
|
||||
export type SkillLibraryReadOnlyOperations = {
|
||||
"skills.library.descriptions": {
|
||||
input: readonly RevisionSelection[];
|
||||
output: Array<{ description: string } | undefined> | undefined;
|
||||
};
|
||||
"skills.library.manifests": {
|
||||
input: readonly RevisionSelection[];
|
||||
output: Array<{ files_json: string } | undefined> | undefined;
|
||||
};
|
||||
};
|
||||
|
||||
function revisionBatchQuery(db: DatabaseSync, selections: readonly RevisionSelection[]) {
|
||||
return getNodeSqliteKysely<Pick<StateDatabase, "skill_library_revisions">>(db)
|
||||
.selectFrom("skill_library_revisions")
|
||||
.where((eb) =>
|
||||
eb.or(
|
||||
selections.map((pin) =>
|
||||
eb.and([eb("skill_id", "=", pin.skillId), eb("revision", "=", pin.revision)]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolve a bounded session selection in its original order, including repeated pins. */
|
||||
export function selectSkillLibraryRevisionMetadataBatch(
|
||||
db: DatabaseSync,
|
||||
selections: readonly RevisionSelection[],
|
||||
) {
|
||||
const rows = executeSqliteQuerySync(
|
||||
db,
|
||||
revisionBatchQuery(db, selections).select(["skill_id", "revision", "description"]),
|
||||
).rows;
|
||||
const metadata = new Map(
|
||||
rows.map((row) => [
|
||||
JSON.stringify([row.skill_id, row.revision]),
|
||||
{ description: row.description },
|
||||
]),
|
||||
);
|
||||
return selections.map((pin) => metadata.get(JSON.stringify([pin.skillId, pin.revision])));
|
||||
}
|
||||
|
||||
export function selectSkillLibraryRevisionManifestsBatch(
|
||||
db: DatabaseSync,
|
||||
selections: readonly RevisionSelection[],
|
||||
) {
|
||||
const rows = executeSqliteQuerySync(
|
||||
db,
|
||||
revisionBatchQuery(db, selections).select(["skill_id", "revision", "files_json"]),
|
||||
).rows;
|
||||
const manifests = new Map(
|
||||
rows.map((row) => [
|
||||
JSON.stringify([row.skill_id, row.revision]),
|
||||
{ files_json: row.files_json },
|
||||
]),
|
||||
);
|
||||
return selections.map((pin) => manifests.get(JSON.stringify([pin.skillId, pin.revision])));
|
||||
}
|
||||
40
src/skills/library/selection-read.ts
Normal file
40
src/skills/library/selection-read.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import type { SkillLibrarySelection } from "../../../packages/gateway-protocol/src/schema/skill-library.js";
|
||||
import { executeExistingOpenClawStateRead } from "../../state/openclaw-state-db-readonly.js";
|
||||
import type { OpenClawStateDatabaseOptions } from "../../state/openclaw-state-db.js";
|
||||
|
||||
export async function readSkillLibrarySelectionDescriptions(
|
||||
selections: readonly Pick<SkillLibrarySelection, "skillId" | "revision">[],
|
||||
options: Pick<OpenClawStateDatabaseOptions, "path" | "env">,
|
||||
) {
|
||||
const result = await executeExistingOpenClawStateRead(options, {
|
||||
type: "skills.library.descriptions",
|
||||
input: selections.map(({ skillId, revision }) => ({ skillId, revision })),
|
||||
});
|
||||
if (result === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (result.ok && result.type === "skills.library.descriptions") {
|
||||
return result.value;
|
||||
}
|
||||
throw new Error("Unexpected skill library descriptions result");
|
||||
}
|
||||
|
||||
export async function readSkillLibrarySelectionManifests(
|
||||
selections: readonly Pick<SkillLibrarySelection, "skillId" | "revision">[],
|
||||
options: Pick<OpenClawStateDatabaseOptions, "path" | "env">,
|
||||
) {
|
||||
if (!selections.length) {
|
||||
return [];
|
||||
}
|
||||
const result = await executeExistingOpenClawStateRead(options, {
|
||||
type: "skills.library.manifests",
|
||||
input: selections.map(({ skillId, revision }) => ({ skillId, revision })),
|
||||
});
|
||||
if (result === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (result.ok && result.type === "skills.library.manifests") {
|
||||
return result.value;
|
||||
}
|
||||
throw new Error("Unexpected skill library manifests result");
|
||||
}
|
||||
|
|
@ -19,6 +19,8 @@ import { materializeSkill } from "../loading/skill-materializer.js";
|
|||
import type { SkillEntry } from "../types.js";
|
||||
import { readSkillLibraryManifestTree, skillLibraryRevisionDir } from "./bundle.js";
|
||||
import { SkillLibraryError } from "./errors.js";
|
||||
import { readSkillLibrarySelectionDescriptions } from "./selection-read.js";
|
||||
import { selectSkillLibraryRevisionMetadataBatch } from "./selection-read.kernel.js";
|
||||
import {
|
||||
projectSkillLibraryEntry,
|
||||
readSkillLibraryStore,
|
||||
|
|
@ -26,7 +28,6 @@ import {
|
|||
resolveSkillLibraryActor,
|
||||
selectSkillLibraryRevision,
|
||||
selectSkillLibraryRevisionMetadata,
|
||||
selectSkillLibraryRevisionMetadataBatch,
|
||||
skillLibraryDb,
|
||||
type SkillLibraryAuthority,
|
||||
} from "./store.js";
|
||||
|
|
@ -43,6 +44,16 @@ export function assertPreparedSkillLibrarySelection(
|
|||
|
||||
const selectedEntryCache = new Map<string, SkillEntry[]>();
|
||||
|
||||
/** Async preparation retains pin values even when a caller later edits its snapshot. */
|
||||
export function captureSkillLibrarySelection(selections: readonly SkillLibrarySelection[]) {
|
||||
return selections.map(({ skillId, revision, name, ownerProfileId }) => ({
|
||||
skillId,
|
||||
revision,
|
||||
name,
|
||||
ownerProfileId,
|
||||
}));
|
||||
}
|
||||
|
||||
/** The session owner has already authorized this exact immutable pin. */
|
||||
export async function readSelectedSkillLibraryFiles(
|
||||
selection: SkillLibrarySelection,
|
||||
|
|
@ -208,71 +219,115 @@ export function loadSkillLibrarySelection(
|
|||
if (selections.length > SKILL_LIBRARY_MAX_SELECTIONS) {
|
||||
throw new SkillLibraryError("LIMIT", "Invalid session skill selection.");
|
||||
}
|
||||
const entries = readSkillLibraryStore((db) => {
|
||||
const revisions = selectSkillLibraryRevisionMetadataBatch(db, selections);
|
||||
return selections.map((selection, index) => {
|
||||
const revision = revisions[index];
|
||||
if (!revision) {
|
||||
throw new SkillLibraryError(
|
||||
"NOT_FOUND",
|
||||
"A pinned skill revision is unavailable; restore the library artifact or detach it explicitly.",
|
||||
);
|
||||
}
|
||||
const baseDir = skillLibraryRevisionDir(selection.skillId, selection.revision, options.env);
|
||||
const filePath = path.join(baseDir, "SKILL.md");
|
||||
const opened = openRootFileSync({
|
||||
absolutePath: filePath,
|
||||
rootPath: baseDir,
|
||||
boundaryLabel: "skill library revision",
|
||||
maxBytes: SKILL_LIBRARY_MAX_FILE_BYTES,
|
||||
rejectHardlinks: true,
|
||||
symlinks: "reject",
|
||||
});
|
||||
if (!opened.ok) {
|
||||
throw new SkillLibraryError(
|
||||
"INVALID_BUNDLE",
|
||||
"Pinned skill instructions could not be read; restore the library artifact or detach it explicitly.",
|
||||
undefined,
|
||||
{ cause: opened.error },
|
||||
);
|
||||
}
|
||||
let content: string;
|
||||
try {
|
||||
content = readFileDescriptorBoundedSync(opened.fd, SKILL_LIBRARY_MAX_FILE_BYTES).toString(
|
||||
"utf8",
|
||||
);
|
||||
} finally {
|
||||
fs.closeSync(opened.fd);
|
||||
}
|
||||
const frontmatter = parseSkillFrontmatter(content);
|
||||
const metadata = resolveSkillManifestMetadata(frontmatter);
|
||||
const invocation = resolveSkillInvocationPolicy(frontmatter);
|
||||
const name = selection.name;
|
||||
return {
|
||||
skill: materializeSkill({
|
||||
content,
|
||||
frontmatter,
|
||||
name,
|
||||
description: revision.description,
|
||||
baseDir,
|
||||
filePath,
|
||||
source: "openclaw-library",
|
||||
sourceOptions: { source: "openclaw-library" },
|
||||
}),
|
||||
frontmatter,
|
||||
invocation,
|
||||
// Untrusted frontmatter can constrain executable eligibility, but cannot claim global credentials/config.
|
||||
metadata: {
|
||||
skillKey: name,
|
||||
os: metadata?.os,
|
||||
requires: metadata?.requires,
|
||||
},
|
||||
disableCommandDispatch: true,
|
||||
syncSourceDir: baseDir,
|
||||
syncDirName: `library-${selection.skillId}-${selection.revision}`,
|
||||
} satisfies SkillEntry;
|
||||
const entries = readSkillLibraryStore(
|
||||
(db) =>
|
||||
materializeSkillLibrarySelection(
|
||||
selections,
|
||||
selectSkillLibraryRevisionMetadataBatch(db, selections),
|
||||
options.env,
|
||||
),
|
||||
options,
|
||||
);
|
||||
return cacheSkillLibrarySelection(cacheKey, entries);
|
||||
}
|
||||
|
||||
/** Prepare immutable pins without borrowing a caller's synchronous database handle. */
|
||||
export async function prepareSkillLibrarySelection(
|
||||
inputSelections: readonly SkillLibrarySelection[],
|
||||
options: Pick<OpenClawStateDatabaseOptions, "path" | "env">,
|
||||
assertCurrent: () => void,
|
||||
): Promise<SkillEntry[]> {
|
||||
assertCurrent();
|
||||
if (!inputSelections.length) {
|
||||
return [];
|
||||
}
|
||||
const selections = captureSkillLibrarySelection(inputSelections);
|
||||
const cacheKey = JSON.stringify([resolveStateDir(options.env), options.path, selections]);
|
||||
const cached = selectedEntryCache.get(cacheKey);
|
||||
if (cached) {
|
||||
return [...cached];
|
||||
}
|
||||
if (selections.length > SKILL_LIBRARY_MAX_SELECTIONS) {
|
||||
throw new SkillLibraryError("LIMIT", "Invalid session skill selection.");
|
||||
}
|
||||
const revisions = await readSkillLibrarySelectionDescriptions(selections, options);
|
||||
assertCurrent();
|
||||
return cacheSkillLibrarySelection(
|
||||
cacheKey,
|
||||
revisions && materializeSkillLibrarySelection(selections, revisions, options.env),
|
||||
);
|
||||
}
|
||||
|
||||
function materializeSkillLibrarySelection(
|
||||
selections: readonly SkillLibrarySelection[],
|
||||
revisions: ReturnType<typeof selectSkillLibraryRevisionMetadataBatch>,
|
||||
env: NodeJS.ProcessEnv | undefined,
|
||||
): SkillEntry[] {
|
||||
return selections.map((selection, index) => {
|
||||
const revision = revisions[index];
|
||||
if (!revision) {
|
||||
throw new SkillLibraryError(
|
||||
"NOT_FOUND",
|
||||
"A pinned skill revision is unavailable; restore the library artifact or detach it explicitly.",
|
||||
);
|
||||
}
|
||||
const baseDir = skillLibraryRevisionDir(selection.skillId, selection.revision, env);
|
||||
const filePath = path.join(baseDir, "SKILL.md");
|
||||
const opened = openRootFileSync({
|
||||
absolutePath: filePath,
|
||||
rootPath: baseDir,
|
||||
boundaryLabel: "skill library revision",
|
||||
maxBytes: SKILL_LIBRARY_MAX_FILE_BYTES,
|
||||
rejectHardlinks: true,
|
||||
symlinks: "reject",
|
||||
});
|
||||
}, options);
|
||||
if (!opened.ok) {
|
||||
throw new SkillLibraryError(
|
||||
"INVALID_BUNDLE",
|
||||
"Pinned skill instructions could not be read; restore the library artifact or detach it explicitly.",
|
||||
undefined,
|
||||
{ cause: opened.error },
|
||||
);
|
||||
}
|
||||
let content: string;
|
||||
try {
|
||||
content = readFileDescriptorBoundedSync(opened.fd, SKILL_LIBRARY_MAX_FILE_BYTES).toString(
|
||||
"utf8",
|
||||
);
|
||||
} finally {
|
||||
fs.closeSync(opened.fd);
|
||||
}
|
||||
const frontmatter = parseSkillFrontmatter(content);
|
||||
const metadata = resolveSkillManifestMetadata(frontmatter);
|
||||
const invocation = resolveSkillInvocationPolicy(frontmatter);
|
||||
const name = selection.name;
|
||||
return {
|
||||
skill: materializeSkill({
|
||||
content,
|
||||
frontmatter,
|
||||
name,
|
||||
description: revision.description,
|
||||
baseDir,
|
||||
filePath,
|
||||
source: "openclaw-library",
|
||||
sourceOptions: { source: "openclaw-library" },
|
||||
}),
|
||||
frontmatter,
|
||||
invocation,
|
||||
// Untrusted frontmatter can constrain executable eligibility, but cannot claim global credentials/config.
|
||||
metadata: {
|
||||
skillKey: name,
|
||||
os: metadata?.os,
|
||||
requires: metadata?.requires,
|
||||
},
|
||||
disableCommandDispatch: true,
|
||||
syncSourceDir: baseDir,
|
||||
syncDirName: `library-${selection.skillId}-${selection.revision}`,
|
||||
} satisfies SkillEntry;
|
||||
});
|
||||
}
|
||||
|
||||
function cacheSkillLibrarySelection(cacheKey: string, entries: SkillEntry[] | undefined) {
|
||||
if (!entries) {
|
||||
throw new SkillLibraryError(
|
||||
"NOT_FOUND",
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js"
|
|||
import { declareAgentWorkspaceAccess } from "../../agents/workspace-access.js";
|
||||
import { tableExists, tableHasColumn } from "../../state/openclaw-state-db-schema-helpers.js";
|
||||
import {
|
||||
closeOpenClawStateDatabaseAsync,
|
||||
closeOpenClawStateDatabaseForTest,
|
||||
openOpenClawStateDatabase,
|
||||
} from "../../state/openclaw-state-db.js";
|
||||
|
|
@ -39,7 +40,8 @@ import {
|
|||
import type { SkillLibraryAuthority } from "./store.js";
|
||||
|
||||
const tempDirs = useAutoCleanupTempDirTracker((cleanup) =>
|
||||
afterEach(() => {
|
||||
afterEach(async () => {
|
||||
await closeOpenClawStateDatabaseAsync();
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
cleanup();
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { SelectQueryBuilder } from "kysely";
|
||||
import type {
|
||||
SkillLibraryEntry,
|
||||
SkillLibrarySelection,
|
||||
} from "../../../packages/gateway-protocol/src/schema/skill-library.js";
|
||||
import type { SkillLibraryEntry } from "../../../packages/gateway-protocol/src/schema/skill-library.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { authorizeOperatorScopesForRequiredScope } from "../../gateway/method-scopes.js";
|
||||
import { resolveOperatorRolePolicyForAssignment } from "../../gateway/operator-role-policy.js";
|
||||
|
|
@ -225,33 +222,6 @@ export function selectSkillLibraryRevisionMetadata(
|
|||
);
|
||||
}
|
||||
|
||||
/** Resolve a bounded session selection in its original order, including repeated pins. */
|
||||
export function selectSkillLibraryRevisionMetadataBatch(
|
||||
db: DatabaseSync,
|
||||
selections: readonly Pick<SkillLibrarySelection, "skillId" | "revision">[],
|
||||
) {
|
||||
const rows = executeSqliteQuerySync(
|
||||
db,
|
||||
skillLibraryDb(db)
|
||||
.selectFrom("skill_library_revisions")
|
||||
.select(["skill_id", "revision", "description"])
|
||||
.where((eb) =>
|
||||
eb.or(
|
||||
selections.map((pin) =>
|
||||
eb.and([eb("skill_id", "=", pin.skillId), eb("revision", "=", pin.revision)]),
|
||||
),
|
||||
),
|
||||
),
|
||||
).rows;
|
||||
const metadata = new Map(
|
||||
rows.map((row) => [
|
||||
JSON.stringify([row.skill_id, row.revision]),
|
||||
{ description: row.description },
|
||||
]),
|
||||
);
|
||||
return selections.map((pin) => metadata.get(JSON.stringify([pin.skillId, pin.revision])));
|
||||
}
|
||||
|
||||
export function selectSkillLibraryOwner(db: DatabaseSync, profileId: string) {
|
||||
// Actor resolution stays separate because existing profile tables may omit its optional role.
|
||||
return selectResolvedUserProfile(
|
||||
|
|
|
|||
|
|
@ -1,13 +1,17 @@
|
|||
import { loadSkillLibrarySelection } from "../library/selection.js";
|
||||
import type { SkillSnapshot } from "../types.js";
|
||||
import type { SkillSnapshot, SkillEntry } from "../types.js";
|
||||
|
||||
/** Resource access includes eligible immutable pins, not just the prompt projection. */
|
||||
export function resolveSkillResourceCandidates(snapshot: SkillSnapshot | undefined) {
|
||||
export function resolveSkillResourceCandidates(
|
||||
snapshot: SkillSnapshot | undefined,
|
||||
libraryEntries?: readonly SkillEntry[],
|
||||
) {
|
||||
if (!snapshot) {
|
||||
return undefined;
|
||||
}
|
||||
const candidates = [...(snapshot.resolvedSkills ?? [])];
|
||||
for (const entry of loadSkillLibrarySelection(snapshot.librarySelections ?? [])) {
|
||||
for (const entry of libraryEntries ??
|
||||
loadSkillLibrarySelection(snapshot.librarySelections ?? [])) {
|
||||
if (
|
||||
snapshot.skills.some((skill) => skill.name === entry.skill.name) &&
|
||||
!candidates.some((skill) => skill.name === entry.skill.name)
|
||||
|
|
|
|||
|
|
@ -18,13 +18,20 @@ import {
|
|||
import { isMissingPathError } from "../../infra/errors.js";
|
||||
import { removeTemporaryArtifacts } from "../../infra/temp-artifact-cleanup.js";
|
||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
import { captureOpenClawStateWorkerContext } from "../../state/openclaw-state-worker-context.js";
|
||||
import {
|
||||
prepareSkillBundle,
|
||||
readSkillBundleTree,
|
||||
readSkillLibraryManifestTree,
|
||||
skillLibraryRevisionDir,
|
||||
SkillTreeDirectoryError,
|
||||
} from "../library/bundle.js";
|
||||
import { SkillLibraryError } from "../library/errors.js";
|
||||
import { readSelectedSkillLibraryFiles } from "../library/selection.js";
|
||||
import { readSkillLibrarySelectionManifests } from "../library/selection-read.js";
|
||||
import {
|
||||
captureSkillLibrarySelection,
|
||||
prepareSkillLibrarySelection,
|
||||
} from "../library/selection.js";
|
||||
import { loadSingleSkillDirectory } from "../loading/local-loader.js";
|
||||
import { createSyntheticSourceInfo, type Skill } from "../loading/skill-contract.js";
|
||||
import { shouldSyncSkillPath } from "../loading/skill-paths.js";
|
||||
|
|
@ -103,22 +110,30 @@ const localSkillResourceReader: SkillResourceSourceReader = {
|
|||
|
||||
// The caller retains these bytes for its turn. Catalog versions do not version supporting files.
|
||||
export async function prepareSkillResourceDelivery(
|
||||
snapshot: SkillSnapshot | undefined,
|
||||
inputSnapshot: SkillSnapshot | undefined,
|
||||
assertCurrent: () => void,
|
||||
explicitSelections: readonly ExplicitSkillSelection[] = [],
|
||||
inputExplicitSelections: readonly ExplicitSkillSelection[] = [],
|
||||
workspaceDir?: string,
|
||||
): Promise<SkillResourceDelivery | undefined> {
|
||||
if (!snapshot) {
|
||||
if (!inputSnapshot) {
|
||||
return undefined;
|
||||
}
|
||||
assertCurrent();
|
||||
if (
|
||||
!snapshot.resolvedSkills?.length &&
|
||||
!snapshot.librarySelections?.length &&
|
||||
!explicitSelections.length
|
||||
!inputSnapshot.resolvedSkills?.length &&
|
||||
!inputSnapshot.librarySelections?.length &&
|
||||
!inputExplicitSelections.length
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const snapshot = {
|
||||
...inputSnapshot,
|
||||
librarySelections: captureSkillLibrarySelection(inputSnapshot.librarySelections ?? []),
|
||||
skills: inputSnapshot.skills.map((skill) => ({ ...skill })),
|
||||
resolvedSkills: inputSnapshot.resolvedSkills?.map((skill) => ({ ...skill })),
|
||||
skillRoots: inputSnapshot.skillRoots && { ...inputSnapshot.skillRoots },
|
||||
};
|
||||
const explicitSelections = inputExplicitSelections.map((selection) => ({ ...selection }));
|
||||
// Library-only and node-native catalogs need no workspace filesystem access.
|
||||
let sourceReader: SkillResourceSourceReader | undefined;
|
||||
const getSourceReader = (skill?: Skill) => {
|
||||
|
|
@ -142,7 +157,24 @@ export async function prepareSkillResourceDelivery(
|
|||
};
|
||||
const skills: SkillResourceDelivery["skills"] = [];
|
||||
let total = 0;
|
||||
const candidates = resolveSkillResourceCandidates(snapshot)!;
|
||||
const libraryContext = snapshot.librarySelections?.length
|
||||
? captureOpenClawStateWorkerContext()
|
||||
: undefined;
|
||||
const assertLibraryCurrent = () => {
|
||||
assertCurrent();
|
||||
libraryContext?.maintenanceScope?.assertAdmission();
|
||||
libraryContext?.admission.assertCurrent();
|
||||
};
|
||||
const libraryOptions = { env: libraryContext?.environment };
|
||||
const libraryEntries = libraryContext
|
||||
? await prepareSkillLibrarySelection(
|
||||
snapshot.librarySelections ?? [],
|
||||
libraryOptions,
|
||||
assertLibraryCurrent,
|
||||
)
|
||||
: [];
|
||||
assertLibraryCurrent();
|
||||
const candidates = resolveSkillResourceCandidates(snapshot, libraryEntries)!;
|
||||
for (const selected of explicitSelections) {
|
||||
if (
|
||||
selected.path.startsWith("node://") ||
|
||||
|
|
@ -165,7 +197,7 @@ export async function prepareSkillResourceDelivery(
|
|||
} catch (error) {
|
||||
throw contextualizeSkillResourceError({ name: selected.name, baseDir: skillDir }, error);
|
||||
}
|
||||
assertCurrent();
|
||||
assertLibraryCurrent();
|
||||
if (
|
||||
!loaded ||
|
||||
loaded.filePath !== selected.path ||
|
||||
|
|
@ -180,6 +212,17 @@ export async function prepareSkillResourceDelivery(
|
|||
}
|
||||
candidates.push(loaded);
|
||||
}
|
||||
const pins = [
|
||||
...new Set(
|
||||
candidates.flatMap((skill) => {
|
||||
const pin =
|
||||
!skill.filePath.startsWith("node://") &&
|
||||
snapshot.librarySelections?.find((selection) => selection.name === skill.name);
|
||||
return pin ? [pin] : [];
|
||||
}),
|
||||
),
|
||||
];
|
||||
let manifests: Awaited<ReturnType<typeof readSkillLibrarySelectionManifests>>;
|
||||
for (const skill of candidates) {
|
||||
if (skill.filePath.startsWith("node://")) {
|
||||
continue;
|
||||
|
|
@ -190,15 +233,30 @@ export async function prepareSkillResourceDelivery(
|
|||
);
|
||||
let files: SkillLibraryFile[] | null;
|
||||
try {
|
||||
files = pin
|
||||
? await readSelectedSkillLibraryFiles(pin)
|
||||
: await getSourceReader(skill).readSkillFiles(skill, {
|
||||
allowMissingRoot: !explicitlySelected,
|
||||
});
|
||||
if (pin) {
|
||||
assertLibraryCurrent();
|
||||
if (!manifests) {
|
||||
manifests = await readSkillLibrarySelectionManifests(pins, libraryOptions);
|
||||
assertLibraryCurrent();
|
||||
}
|
||||
const manifest = manifests?.[pins.indexOf(pin)];
|
||||
if (!manifest) {
|
||||
throw new SkillLibraryError("NOT_FOUND", "Selected skill revision is unavailable.");
|
||||
}
|
||||
files = await readSkillLibraryManifestTree(
|
||||
skillLibraryRevisionDir(pin.skillId, pin.revision, libraryContext?.environment),
|
||||
manifest.files_json,
|
||||
pin.revision,
|
||||
);
|
||||
} else {
|
||||
files = await getSourceReader(skill).readSkillFiles(skill, {
|
||||
allowMissingRoot: !explicitlySelected,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
throw contextualizeSkillResourceError(skill, error);
|
||||
}
|
||||
assertCurrent();
|
||||
assertLibraryCurrent();
|
||||
if (files === null) {
|
||||
if (explicitlySelected) {
|
||||
throw contextualizeSkillResourceError(
|
||||
|
|
|
|||
|
|
@ -737,6 +737,36 @@ it("captures update history filters and charges retained selectors before dispat
|
|||
}
|
||||
});
|
||||
|
||||
it.each(["skills.library.descriptions", "skills.library.manifests"] as const)(
|
||||
"retains library pins and their byte charge while dispatch waits (%s)",
|
||||
async (type) => {
|
||||
const { options } = source();
|
||||
const input = [{ skillId: "技能🦞".repeat(512), revision: "版本🦞".repeat(512) }];
|
||||
const expected = structuredClone(input);
|
||||
const dispatch = createDeferredCore();
|
||||
const task = queueTask(dispatch.promise);
|
||||
const result = executeExistingOpenClawStateRead(options, { type, input });
|
||||
const returned: OpenClawStateReadReply = { ok: true, type, sourceAdmitted: true, value: [] };
|
||||
try {
|
||||
const submitted = await task.submitted;
|
||||
input[0]!.skillId = "changed";
|
||||
input[0]!.revision = "changed";
|
||||
input.push({ skillId: "extra", revision: "extra" });
|
||||
expect(submitted.inputBytes).toBeGreaterThanOrEqual(
|
||||
Buffer.byteLength(expected[0]!.skillId) + Buffer.byteLength(expected[0]!.revision),
|
||||
);
|
||||
dispatch.resolve();
|
||||
expect((await task.captured).command).toEqual({ type, input: expected });
|
||||
task.result.resolve(returned);
|
||||
expect(await result).toEqual(returned);
|
||||
} finally {
|
||||
dispatch.resolve();
|
||||
task.result.resolve(returned);
|
||||
await Promise.allSettled([result]);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
input: {
|
||||
|
|
|
|||
|
|
@ -96,6 +96,15 @@ function captureCommand(command: OpenClawStateReadCommand): OpenClawStateReadCom
|
|||
if (command.type === "updateRuns.list") {
|
||||
return { ...command, input: { ...command.input } };
|
||||
}
|
||||
if (
|
||||
command.type === "skills.library.descriptions" ||
|
||||
command.type === "skills.library.manifests"
|
||||
) {
|
||||
return {
|
||||
type: command.type,
|
||||
input: command.input.map(({ skillId, revision }) => ({ skillId, revision })),
|
||||
};
|
||||
}
|
||||
if (command.type === "audit.run.inspect") {
|
||||
const input = command.input;
|
||||
const common = {
|
||||
|
|
@ -151,6 +160,16 @@ function commandBytes(command: OpenClawStateReadRequest["command"]): number {
|
|||
(command.input.active === undefined ? 0 : 1)
|
||||
);
|
||||
}
|
||||
if (
|
||||
command.type === "skills.library.descriptions" ||
|
||||
command.type === "skills.library.manifests"
|
||||
) {
|
||||
return command.input.reduce(
|
||||
(total, pin) =>
|
||||
total + Buffer.byteLength(pin.skillId, "utf8") + Buffer.byteLength(pin.revision, "utf8"),
|
||||
bytes,
|
||||
);
|
||||
}
|
||||
if (command.type === "fleet.get") {
|
||||
return bytes + Buffer.byteLength(command.tenantId, "utf8");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import type {
|
|||
PluginBlobReadReply,
|
||||
} from "../plugin-state/plugin-blob-worker-contract.js";
|
||||
import type { AsyncWorkScope } from "../shared/async-work-scope.js";
|
||||
import type { SkillLibraryReadOnlyOperations } from "../skills/library/selection-read.kernel.js";
|
||||
import type { OnboardingRecommendationsRecord } from "./onboarding-recommendations.contract.js";
|
||||
import type { OpenClawAgentDatabaseRegistryReadResult } from "./openclaw-agent-db-contract.js";
|
||||
import type { ConfigMachineState } from "./openclaw-state-db.generated.js";
|
||||
|
|
@ -45,6 +46,12 @@ export type OpenClawStateReadAuthority = {
|
|||
export type OpenClawStateReadCommand =
|
||||
| PluginBlobReadCommand
|
||||
| { type: "exec-approvals.read" }
|
||||
| {
|
||||
[Kind in keyof SkillLibraryReadOnlyOperations]: {
|
||||
type: Kind;
|
||||
input: SkillLibraryReadOnlyOperations[Kind]["input"];
|
||||
};
|
||||
}[keyof SkillLibraryReadOnlyOperations]
|
||||
| { type: "agentDatabaseRegistry.read" }
|
||||
| { type: "onboardingRecommendations.read"; configKey: string }
|
||||
| { type: "userProfiles.avatar.reconcile"; profileId: string }
|
||||
|
|
@ -70,6 +77,14 @@ export type OpenClawStateReadRequest = {
|
|||
};
|
||||
export type OpenClawStateReadReply = (
|
||||
| PluginBlobReadReply
|
||||
| {
|
||||
[Kind in keyof SkillLibraryReadOnlyOperations]: {
|
||||
ok: true;
|
||||
type: Kind;
|
||||
sourceAdmitted: true;
|
||||
value: SkillLibraryReadOnlyOperations[Kind]["output"];
|
||||
};
|
||||
}[keyof SkillLibraryReadOnlyOperations]
|
||||
| {
|
||||
ok: true;
|
||||
type: "agentDatabaseRegistry.read";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { toStringifiedError } from "@openclaw/normalization-core/error-coercion";
|
||||
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { SKILL_LIBRARY_MAX_SELECTIONS } from "../../packages/gateway-protocol/src/schema/skill-library.js";
|
||||
import {
|
||||
readSandboxBrowserRegistryInDatabase,
|
||||
readSandboxRegistryEntryInDatabase,
|
||||
|
|
@ -21,6 +22,10 @@ import {
|
|||
pluginBlobEntriesInDatabase,
|
||||
} from "../plugin-state/plugin-blob-store.sqlite.js";
|
||||
import { isPluginBlobReadCommand } from "../plugin-state/plugin-blob-worker-contract.js";
|
||||
import {
|
||||
selectSkillLibraryRevisionMetadataBatch,
|
||||
selectSkillLibraryRevisionManifestsBatch,
|
||||
} from "../skills/library/selection-read.kernel.js";
|
||||
import { readConfigMachineStateRowInDatabase } from "./config-machine-state.js";
|
||||
import { readOnboardingRecommendationsInDatabase } from "./onboarding-recommendations.kernel.js";
|
||||
import { readRegisteredAgentDatabaseRows } from "./openclaw-agent-db-registry.read.js";
|
||||
|
|
@ -61,6 +66,14 @@ function isReadRequest(input: unknown): input is OpenClawStateReadRequest {
|
|||
(isPluginBlobReadCommand(input.command) ||
|
||||
input.command.type === "admit" ||
|
||||
input.command.type === "exec-approvals.read" ||
|
||||
((input.command.type === "skills.library.descriptions" ||
|
||||
input.command.type === "skills.library.manifests") &&
|
||||
Array.isArray(input.command.input) &&
|
||||
input.command.input.length <= SKILL_LIBRARY_MAX_SELECTIONS &&
|
||||
input.command.input.every(
|
||||
(pin) =>
|
||||
isRecord(pin) && typeof pin.skillId === "string" && typeof pin.revision === "string",
|
||||
)) ||
|
||||
input.command.type === "agentDatabaseRegistry.read" ||
|
||||
(input.command.type === "userProfiles.avatar.reconcile" &&
|
||||
typeof input.command.profileId === "string") ||
|
||||
|
|
@ -200,6 +213,26 @@ serveOwnedWorkerTasks(
|
|||
row: readExecApprovalsConfigRow(db),
|
||||
};
|
||||
}
|
||||
if (command.type === "skills.library.descriptions") {
|
||||
return {
|
||||
ok: true,
|
||||
type: command.type,
|
||||
sourceAdmitted,
|
||||
value: tableExists(db, "skill_library_entries")
|
||||
? selectSkillLibraryRevisionMetadataBatch(db, command.input)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
if (command.type === "skills.library.manifests") {
|
||||
return {
|
||||
ok: true,
|
||||
type: command.type,
|
||||
sourceAdmitted,
|
||||
value: tableExists(db, "skill_library_entries")
|
||||
? selectSkillLibraryRevisionManifestsBatch(db, command.input)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
if (command.type === "onboardingRecommendations.read") {
|
||||
return {
|
||||
ok: true,
|
||||
|
|
|
|||
|
|
@ -124,6 +124,8 @@ export const databaseWorkerCoreTestFiles = [
|
|||
"src/hooks/hooks-install.test.ts",
|
||||
"src/infra/state-migrations.audit-logs.windows.test.ts",
|
||||
"src/infra/state-migrations.media-persistence.lifecycle-recovery.test.ts",
|
||||
"src/skills/library/resource-read.test.ts",
|
||||
"src/skills/library/service.test.ts",
|
||||
"src/skills/workshop/collection-restore.test.ts",
|
||||
"src/skills/workshop/experience-review.apply.test.ts",
|
||||
"src/skills/workshop/policy.test.ts",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue