mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 17:53:39 +00:00
refactor(models): prepare persisted catalogs off the Gateway thread (#163425)
* refactor(models): prepare persisted catalogs off the Gateway thread * fix(tooling): retain catalog worker imports in PR wrapper Include the catalog read operation and its runtime import closure in trusted wrapper extraction. The existing wrapper closure checks reproduce the missing modules and pass with the repaired inventory.
This commit is contained in:
parent
4ee88721f0
commit
93625aa3d1
19 changed files with 933 additions and 142 deletions
|
|
@ -133,7 +133,11 @@ transcript content.
|
|||
|
||||
Move an existing domain operation across its worker boundary instead of creating
|
||||
a second store, generic SQL service, or cache manager. Read-only operations use the
|
||||
existing read-only worker scope and the relevant domain reader. Shared-state
|
||||
existing read-only worker scope and the relevant domain reader. Typed domain
|
||||
handlers register with the scoped transport; it owns bounded result transfer,
|
||||
cancellation, and child cleanup. Catalog preparation captures these worker-read
|
||||
facts before registry publication, while catalog writes retain the agent executor.
|
||||
Shared-state
|
||||
fixed reads and session transcript/history reads retain
|
||||
their established adapters and cleanup owners. A Promise around synchronous SQL,
|
||||
or `withOpenClawAgentDatabaseReadOnly` alone, does not move execution off thread.
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ packages/acp-core/package.json
|
|||
packages/acp-core/src/structured-auth-redaction.ts
|
||||
packages/agent-core/package.json
|
||||
packages/agent-core/src/harness/env/kill-tree.ts
|
||||
packages/agent-core/src/harness/session/tool-result-pairing.ts
|
||||
packages/gateway-client/package.json
|
||||
packages/gateway-client/src/timeouts.ts
|
||||
packages/gateway-protocol/package.json
|
||||
|
|
@ -31,6 +32,7 @@ packages/gateway-protocol/src/schema/worker-gateway-tool.ts
|
|||
packages/gateway-protocol/src/schema/worker-protocol-primitives.ts
|
||||
packages/gateway-protocol/src/schema/worker-session-tools.ts
|
||||
packages/gateway-protocol/src/secret-ref-contract.ts
|
||||
packages/gateway-protocol/src/session-agent-status.ts
|
||||
packages/gateway-protocol/src/svg-image.ts
|
||||
packages/gateway-protocol/src/theme-ids.ts
|
||||
packages/gateway-protocol/src/theme.ts
|
||||
|
|
@ -223,6 +225,9 @@ src/agents/model-selection-shared.ts
|
|||
src/agents/openai-model-routes.ts
|
||||
src/agents/openai-routing.ts
|
||||
src/agents/owner-display.ts
|
||||
src/agents/plugin-model-catalog-repair.ts
|
||||
src/agents/plugin-model-catalog.kernel.ts
|
||||
src/agents/plugin-model-catalog.read-operation.ts
|
||||
src/agents/provider-auth-aliases.ts
|
||||
src/agents/provider-model-auth-source-plan.ts
|
||||
src/agents/provider-model-normalization.runtime.ts
|
||||
|
|
@ -235,6 +240,7 @@ src/agents/sandbox/browser-network.ts
|
|||
src/agents/sandbox/config-contract.ts
|
||||
src/agents/sandbox/host-paths.ts
|
||||
src/agents/sandbox/network-mode.ts
|
||||
src/agents/session-dirs.ts
|
||||
src/agents/tool-catalog.ts
|
||||
src/agents/tool-description-presets.ts
|
||||
src/agents/tool-policy-match.ts
|
||||
|
|
@ -288,6 +294,7 @@ src/channels/bundled-channel-catalog-read.ts
|
|||
src/channels/bundled-channel-ids.generated.ts
|
||||
src/channels/chat-meta-shared.ts
|
||||
src/channels/chat-meta.ts
|
||||
src/channels/chat-type.ts
|
||||
src/channels/config-metadata.ts
|
||||
src/channels/config-presence.ts
|
||||
src/channels/ids.ts
|
||||
|
|
@ -320,6 +327,7 @@ src/cli/parse-bytes.ts
|
|||
src/cli/parse-duration.ts
|
||||
src/cli/profile-utils.ts
|
||||
src/cli/program/command-descriptor-utils.ts
|
||||
src/cli/quote-cli-arg.ts
|
||||
src/cli/signal-exit-barrier.ts
|
||||
src/cli/wait.ts
|
||||
src/commands/doctor/shared/channel-legacy-config-migrate.ts
|
||||
|
|
@ -508,12 +516,44 @@ src/config/sensitive-paths.ts
|
|||
src/config/sessions/archive-compression.ts
|
||||
src/config/sessions/artifacts.ts
|
||||
src/config/sessions/auth-profile-override-provenance.ts
|
||||
src/config/sessions/conversation-link.ts
|
||||
src/config/sessions/migration-required.ts
|
||||
src/config/sessions/model-override-provenance.ts
|
||||
src/config/sessions/paths.ts
|
||||
src/config/sessions/session-accessor.sqlite-entry-inventory.ts
|
||||
src/config/sessions/session-accessor.sqlite-owner-projection.ts
|
||||
src/config/sessions/session-accessor.sqlite-scope-helpers.ts
|
||||
src/config/sessions/session-accessor.sqlite-status.ts
|
||||
src/config/sessions/session-canonical-key.ts
|
||||
src/config/sessions/session-canonical-row.ts
|
||||
src/config/sessions/session-canonical-validation-deferral.ts
|
||||
src/config/sessions/session-cold-storage-state.ts
|
||||
src/config/sessions/session-entry-codec.ts
|
||||
src/config/sessions/session-entry-json.ts
|
||||
src/config/sessions/session-entry-snapshots.ts
|
||||
src/config/sessions/session-entry-state-format.ts
|
||||
src/config/sessions/session-history-worker-errors.ts
|
||||
src/config/sessions/session-incognito-actor.ts
|
||||
src/config/sessions/session-incognito-side-data-contract.ts
|
||||
src/config/sessions/session-model-context-projection.ts
|
||||
src/config/sessions/session-sqlite-target-paths.ts
|
||||
src/config/sessions/session-sqlite-target.ts
|
||||
src/config/sessions/session-store-config.ts
|
||||
src/config/sessions/session-store-owner.ts
|
||||
src/config/sessions/session-store-read-candidates.ts
|
||||
src/config/sessions/session-transcript-projection-error.ts
|
||||
src/config/sessions/session-transcript-read-fence.ts
|
||||
src/config/sessions/session-transcript-read-worker-runtime.ts
|
||||
src/config/sessions/session-transcript-report-facts.ts
|
||||
src/config/sessions/store-entry-shape.ts
|
||||
src/config/sessions/store-entry.ts
|
||||
src/config/sessions/targets-collision.ts
|
||||
src/config/sessions/targets-configured-agents.ts
|
||||
src/config/sessions/targets-path-validation.ts
|
||||
src/config/sessions/targets.ts
|
||||
src/config/sessions/transcript-payload.ts
|
||||
src/config/sessions/transcript-target-binding.ts
|
||||
src/config/sessions/version.ts
|
||||
src/config/shell-env-expected-keys.ts
|
||||
src/config/source-value-projection.ts
|
||||
src/config/state-dir.ts
|
||||
|
|
@ -733,6 +773,8 @@ src/infra/kysely-sync.ts
|
|||
src/infra/lifecycle-write-custody.ts
|
||||
src/infra/lru-cache.ts
|
||||
src/infra/map-size.ts
|
||||
src/infra/native-error-response-schema.ts
|
||||
src/infra/native-error-response.ts
|
||||
src/infra/net/configured-local-origin-bypass.ts
|
||||
src/infra/net/fetch-guard.ts
|
||||
src/infra/net/fetch-request-authority.ts
|
||||
|
|
@ -785,6 +827,8 @@ src/infra/runtime-worker-url.ts
|
|||
src/infra/safe-cwd.ts
|
||||
src/infra/sealed-runtime-registry.ts
|
||||
src/infra/semver.ts
|
||||
src/infra/session-cost-usage-cache-migration.ts
|
||||
src/infra/session-cost-usage-rollup-codec.ts
|
||||
src/infra/shell-env.ts
|
||||
src/infra/shell-inline-command.ts
|
||||
src/infra/shell-wrapper-resolution.ts
|
||||
|
|
@ -802,6 +846,8 @@ src/infra/sqlite-files.ts
|
|||
src/infra/sqlite-handle-lifecycle.ts
|
||||
src/infra/sqlite-index-recovery.ts
|
||||
src/infra/sqlite-index-schema.ts
|
||||
src/infra/sqlite-integrity-worker-error.ts
|
||||
src/infra/sqlite-integrity-worker.ts
|
||||
src/infra/sqlite-integrity.ts
|
||||
src/infra/sqlite-live-snapshot.ts
|
||||
src/infra/sqlite-lifecycle-errors.ts
|
||||
|
|
@ -818,10 +864,12 @@ src/infra/sqlite-readonly-location.ts
|
|||
src/infra/sqlite-readonly-location.worker.ts
|
||||
src/infra/sqlite-readonly-native-resource.client.ts
|
||||
src/infra/sqlite-readonly-native-resource.types.ts
|
||||
src/infra/sqlite-readonly-operation-registry.ts
|
||||
src/infra/sqlite-readonly-worker-context.ts
|
||||
src/infra/sqlite-readonly-worker-protocol.ts
|
||||
src/infra/sqlite-readonly-worker-session.ts
|
||||
src/infra/sqlite-readonly-worker.ts
|
||||
src/infra/sqlite-recovery-files.ts
|
||||
src/infra/sqlite-runtime-version.ts
|
||||
src/infra/sqlite-schema-contract-assembly.ts
|
||||
src/infra/sqlite-schema-contract.ts
|
||||
|
|
@ -868,7 +916,9 @@ src/infra/startup-maintenance-required.ts
|
|||
src/infra/startup-migration-checkpoint.ts
|
||||
src/infra/state-database-maintenance.ts
|
||||
src/infra/state-lease-process-owner.ts
|
||||
src/infra/state-migrations.agent-owner-guidance.ts
|
||||
src/infra/state-migrations.cron-run-logs.ts
|
||||
src/infra/state-migrations.media-persistence-targets.ts
|
||||
src/infra/state-migrations.receipts.ts
|
||||
src/infra/tcp-port.ts
|
||||
src/infra/temp-artifact-cleanup.ts
|
||||
|
|
@ -1230,6 +1280,7 @@ src/process/exec-runner.ts
|
|||
src/process/exec-spawn.ts
|
||||
src/process/exec-termination.ts
|
||||
src/process/exec.ts
|
||||
src/process/gateway-work-admission.ts
|
||||
src/process/kill-tree.ts
|
||||
src/process/output-drain.ts
|
||||
src/process/process-deadline.ts
|
||||
|
|
@ -1270,6 +1321,7 @@ src/proxy-capture/store.kernel.ts
|
|||
src/proxy-capture/store.sqlite.ts
|
||||
src/routing/account-id.ts
|
||||
src/routing/binding-scope.ts
|
||||
src/routing/conversation-ref.ts
|
||||
src/routing/session-key.ts
|
||||
src/runtime.ts
|
||||
src/secrets/channel-contract-api.ts
|
||||
|
|
@ -1295,11 +1347,14 @@ src/secrets/unsupported-surface-policy.ts
|
|||
src/security/config-regex.ts
|
||||
src/security/safe-regex.ts
|
||||
src/security/scan-paths.ts
|
||||
src/sessions/session-chat-type-shared.ts
|
||||
src/sessions/session-diff-parser.ts
|
||||
src/sessions/session-diff-revisions.ts
|
||||
src/sessions/session-diff.runtime.ts
|
||||
src/sessions/session-key-utils.ts
|
||||
src/sessions/session-row-changes.ts
|
||||
src/sessions/transcript-events.ts
|
||||
src/sessions/user-turn-transcript-admission.ts
|
||||
src/shared/assistant-error-format.ts
|
||||
src/shared/async-work-scope.ts
|
||||
src/shared/avatar-limits.ts
|
||||
|
|
@ -1321,6 +1376,7 @@ src/shared/json-schema-defaults.ts
|
|||
src/shared/lazy-promise.ts
|
||||
src/shared/lazy-runtime.ts
|
||||
src/shared/listeners.ts
|
||||
src/shared/model-context-message.ts
|
||||
src/shared/model-key.ts
|
||||
src/shared/node-list-parse.ts
|
||||
src/shared/path-array-index.ts
|
||||
|
|
@ -1328,35 +1384,94 @@ src/shared/pid-alive.ts
|
|||
src/shared/regexp.ts
|
||||
src/shared/sandbox-workspace-paths.ts
|
||||
src/shared/settle-within.ts
|
||||
src/shared/store-writer-queue.ts
|
||||
src/shared/update-outcome.ts
|
||||
src/skills/discovery/agent-filter.ts
|
||||
src/skills/discovery/filter.ts
|
||||
src/skills/lifecycle/upload-store-error.ts
|
||||
src/state/agent-creation-claim.ts
|
||||
src/state/agent-database-admission.ts
|
||||
src/state/agent-database-startup.ts
|
||||
src/state/agent-deletion-cleanup.ts
|
||||
src/state/agent-deletion-discovery.ts
|
||||
src/state/agent-deletion-journal-history.ts
|
||||
src/state/agent-deletion-journal-recovery.ts
|
||||
src/state/agent-deletion-journal.read.ts
|
||||
src/state/agent-deletion-journal.ts
|
||||
src/state/agent-provenance.kernel.ts
|
||||
src/state/agent-provenance.schema.ts
|
||||
src/state/agent-provenance.ts
|
||||
src/state/config-machine-state-write.ts
|
||||
src/state/config-machine-state.ts
|
||||
src/state/creator-namespace-migration.ts
|
||||
src/state/openclaw-agent-board-schema.ts
|
||||
src/state/openclaw-agent-canonical-validation-receipt.ts
|
||||
src/state/openclaw-agent-canonical-validation-schema.ts
|
||||
src/state/openclaw-agent-context-engine-turn-outbox-schema.ts
|
||||
src/state/openclaw-agent-db-additive-columns.ts
|
||||
src/state/openclaw-agent-db-admission.ts
|
||||
src/state/openclaw-agent-db-contract.ts
|
||||
src/state/openclaw-agent-db-existing-write.ts
|
||||
src/state/openclaw-agent-db-identity.ts
|
||||
src/state/openclaw-agent-db-lease.ts
|
||||
src/state/openclaw-agent-db-legacy-schema.ts
|
||||
src/state/openclaw-agent-db-lifecycle.ts
|
||||
src/state/openclaw-agent-db-metadata-write.ts
|
||||
src/state/openclaw-agent-db-metadata.ts
|
||||
src/state/openclaw-agent-db-migration-required.ts
|
||||
src/state/openclaw-agent-db-permissions.ts
|
||||
src/state/openclaw-agent-db-read-error.ts
|
||||
src/state/openclaw-agent-db-readonly-companion.ts
|
||||
src/state/openclaw-agent-db-readonly-open.ts
|
||||
src/state/openclaw-agent-db-readonly-scope.ts
|
||||
src/state/openclaw-agent-db-readonly.ts
|
||||
src/state/openclaw-agent-db-registry-listing.ts
|
||||
src/state/openclaw-agent-db-registry.read.ts
|
||||
src/state/openclaw-agent-db-registry.ts
|
||||
src/state/openclaw-agent-db-resources.ts
|
||||
src/state/openclaw-agent-db-schema-compatibility.ts
|
||||
src/state/openclaw-agent-db-schema-helpers.ts
|
||||
src/state/openclaw-agent-db-schema-read.ts
|
||||
src/state/openclaw-agent-db-schema.ts
|
||||
src/state/openclaw-agent-db-session-migrations.ts
|
||||
src/state/openclaw-agent-db-session-nodes-migration.ts
|
||||
src/state/openclaw-agent-db-session-provenance.ts
|
||||
src/state/openclaw-agent-db-terminal.ts
|
||||
src/state/openclaw-agent-db-validation-cache.ts
|
||||
src/state/openclaw-agent-db.paths.ts
|
||||
src/state/openclaw-agent-db.ts
|
||||
src/state/openclaw-agent-db.wal.ts
|
||||
src/state/openclaw-agent-execution-cleanup.ts
|
||||
src/state/openclaw-agent-execution-contract.ts
|
||||
src/state/openclaw-agent-execution-incognito.ts
|
||||
src/state/openclaw-agent-execution-native.ts
|
||||
src/state/openclaw-agent-execution.ts
|
||||
src/state/openclaw-agent-goal-operations-schema.ts
|
||||
src/state/openclaw-agent-message-tool-outcome-schema.ts
|
||||
src/state/openclaw-agent-participants-migration.ts
|
||||
src/state/openclaw-agent-pending-inputs-schema.ts
|
||||
src/state/openclaw-agent-progress-card-schema.ts
|
||||
src/state/openclaw-agent-schema-inspection-response.ts
|
||||
src/state/openclaw-agent-schema-inspection-worker.ts
|
||||
src/state/openclaw-agent-schema.sql
|
||||
src/state/openclaw-agent-schema.ts
|
||||
src/state/openclaw-agent-session-participants-schema.ts
|
||||
src/state/openclaw-agent-session-sharing-schema.ts
|
||||
src/state/openclaw-agent-session-snapshots-migration.ts
|
||||
src/state/openclaw-agent-session-snapshots-schema.ts
|
||||
src/state/openclaw-agent-session-transcript-archive-schema.ts
|
||||
src/state/openclaw-agent-standing-intents-schema.ts
|
||||
src/state/openclaw-agent-storage-schema.ts
|
||||
src/state/openclaw-agent-transcript-fts-schema.ts
|
||||
src/state/openclaw-agent-transcript-payload-migration.ts
|
||||
src/state/openclaw-agent-write-admission.ts
|
||||
src/state/openclaw-database-preflight-agent-scheduler.ts
|
||||
src/state/openclaw-database-preflight-cleanup.ts
|
||||
src/state/openclaw-database-preflight-targets.ts
|
||||
src/state/openclaw-database-preflight.messages.ts
|
||||
src/state/openclaw-database-preflight.ts
|
||||
src/state/openclaw-database-verify.impl.ts
|
||||
src/state/openclaw-database-verify.ts
|
||||
src/state/openclaw-quarantine-error.ts
|
||||
src/state/openclaw-quarantine-store.ts
|
||||
src/state/openclaw-state-db-additive-columns.ts
|
||||
|
|
@ -1430,6 +1545,7 @@ src/state/openclaw-state-read-request.ts
|
|||
src/state/openclaw-state-read-scope.ts
|
||||
src/state/openclaw-state-read-worker.ts
|
||||
src/state/openclaw-state-schema-compatibility.ts
|
||||
src/state/openclaw-state-schema-inspection.ts
|
||||
src/state/openclaw-state-schema-publication.ts
|
||||
src/state/openclaw-state-schema.sql
|
||||
src/state/openclaw-state-schema.ts
|
||||
|
|
@ -1445,9 +1561,12 @@ src/state/openclaw-state.worker.ts
|
|||
src/state/openclaw-update-schema-refusal.ts
|
||||
src/state/session-metadata-unavailable-error.ts
|
||||
src/state/session-watch-cursor-provenance.ts
|
||||
src/state/worker-operation-registry.ts
|
||||
src/system-agent/agent-id.ts
|
||||
src/test-utils/bun-process.ts
|
||||
src/utils.ts
|
||||
src/utils/boolean.ts
|
||||
src/utils/delivery-context.read.ts
|
||||
src/utils/fetch-timeout.ts
|
||||
src/utils/parse-json-compat.ts
|
||||
src/utils/run-with-concurrency.ts
|
||||
|
|
|
|||
|
|
@ -38,10 +38,10 @@ import {
|
|||
withPluginModelCatalogAuthObservations,
|
||||
type PluginModelCatalogAuthSnapshot,
|
||||
} from "./plugin-model-catalog-auth.js";
|
||||
import { loadPersistedPluginModelCatalogs } from "./plugin-model-catalog-execution.js";
|
||||
import { repairPluginModelCatalogTransportMetadata } from "./plugin-model-catalog-repair.js";
|
||||
import {
|
||||
decodePluginModelCatalogRelativePathPluginId,
|
||||
loadPersistedPluginModelCatalogsReadOnly,
|
||||
replacePersistedPluginModelCatalogs,
|
||||
type PersistedPluginModelCatalog,
|
||||
} from "./plugin-model-catalog.js";
|
||||
|
|
@ -87,7 +87,11 @@ async function buildModelsJsonFingerprint(context: PreparedModelsConfigContext):
|
|||
const authProfilesWalMtimeMs = await readFileMtimeMs(`${authProfilesSqlitePath}-wal`);
|
||||
const modelsFileMtimeMs = await readFileMtimeMs(path.join(context.agentDir, "models.json"));
|
||||
const pluginCatalogFingerprint = createHash("sha256")
|
||||
.update(stableStringify(loadPersistedPluginModelCatalogsReadOnly(context.agentDir)))
|
||||
.update(
|
||||
stableStringify(
|
||||
await loadPersistedPluginModelCatalogs(context.agentDir, undefined, context.env),
|
||||
),
|
||||
)
|
||||
.digest("base64url");
|
||||
const pluginMetadataSnapshotIndexFingerprint = context.pluginMetadataSnapshot
|
||||
? resolveInstalledManifestRegistryIndexFingerprint(context.pluginMetadataSnapshot.index)
|
||||
|
|
@ -306,12 +310,12 @@ export async function ensureOpenClawModelsJson(
|
|||
const pending = MODELS_JSON_STATE.writeQueue.enqueue(targetPath, async () => {
|
||||
const existingModelsFile = await readExistingModelsFile(targetPath);
|
||||
const authSnapshot = await capturePluginModelCatalogAuth(agentDir, context.env);
|
||||
const plan = await withPluginModelCatalogAuthObservations(authSnapshot, () =>
|
||||
const plan = await withPluginModelCatalogAuthObservations(authSnapshot, async () =>
|
||||
planOpenClawModelsJson({
|
||||
context,
|
||||
existingRaw: existingModelsFile.raw,
|
||||
existingParsed: existingModelsFile.parsed,
|
||||
pluginCatalogs: loadPersistedPluginModelCatalogsReadOnly(agentDir),
|
||||
pluginCatalogs: await loadPersistedPluginModelCatalogs(agentDir, undefined, context.env),
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
@ -375,7 +379,11 @@ export async function planOpenClawModelsJsonSource(
|
|||
const context = await prepareModelsConfigContext(config, agentDirOverride, options);
|
||||
const { agentDir } = context;
|
||||
const existingModelsFile = await readExistingModelsFile(path.join(agentDir, "models.json"));
|
||||
const existingPluginCatalogs = loadPersistedPluginModelCatalogsReadOnly(agentDir);
|
||||
const existingPluginCatalogs = await loadPersistedPluginModelCatalogs(
|
||||
agentDir,
|
||||
undefined,
|
||||
context.env,
|
||||
);
|
||||
const plan = await planOpenClawModelsJson({
|
||||
context,
|
||||
...(authStore ? { authStore } : {}),
|
||||
|
|
|
|||
|
|
@ -1,14 +1,29 @@
|
|||
import { cloneEnvWithPlatformSemantics } from "../config/config-env-vars.js";
|
||||
import { runtimeProcessEntrypoints } from "../infra/runtime-process-entrypoints.js";
|
||||
import { resolveRuntimeWorkerUrl } from "../infra/runtime-worker-url.js";
|
||||
import { runSqliteReadOnlyOperation } from "../infra/sqlite-readonly-worker.js";
|
||||
import {
|
||||
assertExistingDatabaseIdentity,
|
||||
readDatabasePathIdentitySync,
|
||||
} from "../infra/sqlite-worker-identity.js";
|
||||
import { createSqliteWorkerOperationAdmission } from "../infra/sqlite-worker-operation-admission.js";
|
||||
import type { SqliteWorkerStore } from "../infra/sqlite-worker-store.js";
|
||||
import type { OpenClawAgentDatabaseOptions } from "../state/openclaw-agent-db-contract.js";
|
||||
import { registerOpenClawAgentDatabaseAsyncResource } from "../state/openclaw-agent-db-resources.js";
|
||||
import { captureOpenClawAgentDatabaseExecution } from "../state/openclaw-agent-execution.js";
|
||||
import {
|
||||
openOpenClawAgentSqliteWorkerStore,
|
||||
type OpenClawAgentSqliteWorkerStore,
|
||||
} from "../state/openclaw-agent-worker-store.js";
|
||||
import { runOpenClawAgentWorkerWrite } from "../state/openclaw-agent-write-admission.js";
|
||||
import { registerOpenClawStateDatabaseAsyncResource } from "../state/openclaw-state-db-cache.js";
|
||||
import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js";
|
||||
import { captureOpenClawStateReadContext } from "../state/openclaw-state-worker-context.js";
|
||||
import {
|
||||
resolveAuthProfileDatabaseOwnerId,
|
||||
resolveAuthProfileDatabasePath,
|
||||
} from "./auth-profiles/sqlite.js";
|
||||
import type { PersistedPluginModelCatalog } from "./plugin-model-catalog.read-operation.js";
|
||||
import type { PluginModelCatalogCredentialOperations } from "./plugin-model-catalog.worker.js";
|
||||
|
||||
/** The canonical executor owns preparation, publication, and custody settlement. */
|
||||
|
|
@ -58,3 +73,61 @@ export async function withPluginModelCatalogWorker<T>(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Preparation retains read authority through worker settlement, without admitting a writer. */
|
||||
export async function loadPersistedPluginModelCatalogs(
|
||||
agentDir: string,
|
||||
pluginIds?: readonly string[],
|
||||
environment: NodeJS.ProcessEnv = process.env,
|
||||
): Promise<PersistedPluginModelCatalog[]> {
|
||||
if (pluginIds?.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const env = cloneEnvWithPlatformSemantics(environment);
|
||||
const options = {
|
||||
agentId: resolveAuthProfileDatabaseOwnerId(agentDir),
|
||||
path: resolveAuthProfileDatabasePath(agentDir),
|
||||
env,
|
||||
};
|
||||
const identity = readDatabasePathIdentitySync(options.path);
|
||||
if (!identity.key.startsWith("file:")) {
|
||||
return [];
|
||||
}
|
||||
const root = captureOpenClawStateReadContext(resolveOpenClawStateSqlitePath(env));
|
||||
const controller = new AbortController();
|
||||
let reading: Promise<PersistedPluginModelCatalog[]> | undefined;
|
||||
const revoke = () => controller.abort(new Error("Plugin catalog read owner was revoked"));
|
||||
const close = async () => {
|
||||
revoke();
|
||||
await reading?.catch(() => undefined);
|
||||
};
|
||||
const assertCurrent = () => {
|
||||
controller.signal.throwIfAborted();
|
||||
root.admission.assertCurrent();
|
||||
root.maintenanceScope?.assertAdmission();
|
||||
assertExistingDatabaseIdentity(options.path, identity.key, identity.birthtime);
|
||||
};
|
||||
assertCurrent();
|
||||
const unregisterAgent = registerOpenClawAgentDatabaseAsyncResource({ ...options, revoke, close });
|
||||
let unregisterRoot: (() => void) | undefined;
|
||||
try {
|
||||
unregisterRoot = registerOpenClawStateDatabaseAsyncResource({
|
||||
close: async (closedIdentity) => {
|
||||
if (!closedIdentity || closedIdentity.key === root.admission.identity.key) {
|
||||
await close();
|
||||
}
|
||||
},
|
||||
});
|
||||
reading = runSqliteReadOnlyOperation(
|
||||
options.path,
|
||||
{ type: "pluginCatalog.read", input: { agentId: options.agentId, pluginIds } },
|
||||
{ source: "canonical", expectedIdentity: identity.key, env, signal: controller.signal },
|
||||
);
|
||||
const catalogs = await reading;
|
||||
assertCurrent();
|
||||
return catalogs;
|
||||
} finally {
|
||||
unregisterRoot?.();
|
||||
unregisterAgent();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,60 @@
|
|||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { normalizeAgentId } from "@openclaw/normalization-core/agent-id";
|
||||
import { executeSqliteQuerySync, getNodeSqliteKysely } from "../infra/kysely-sync.js";
|
||||
import { OpenClawAgentDatabaseReadOnlyScope } from "../state/openclaw-agent-db-readonly-scope.js";
|
||||
import { withOpenClawAgentDatabaseReadOnly } from "../state/openclaw-agent-db-readonly.js";
|
||||
import type { DB as OpenClawAgentKyselyDatabase } from "../state/openclaw-agent-db.generated.js";
|
||||
import { stripPluginModelCatalogCredentials } from "./plugin-model-catalog-repair.js";
|
||||
import {
|
||||
createPluginModelCatalogReadOperations,
|
||||
type PersistedPluginModelCatalog,
|
||||
} from "./plugin-model-catalog.read-operation.js";
|
||||
|
||||
export type { PersistedPluginModelCatalog } from "./plugin-model-catalog.read-operation.js";
|
||||
|
||||
export const PLUGIN_MODEL_CATALOG_CACHE_SCOPE = "plugin-model-catalog-v1";
|
||||
export const PLUGIN_MODEL_CATALOG_MIGRATION_SCOPE = "plugin-model-catalog-migration-v1";
|
||||
|
||||
type PluginModelCatalogDatabase = Pick<OpenClawAgentKyselyDatabase, "cache_entries">;
|
||||
|
||||
export function readPluginModelCatalogEntries(
|
||||
options: { agentId: string; path: string; env?: NodeJS.ProcessEnv },
|
||||
scope: string,
|
||||
pluginIds?: readonly string[],
|
||||
): PersistedPluginModelCatalog[] {
|
||||
if (pluginIds?.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const allowed = pluginIds && new Set(pluginIds);
|
||||
const result = withOpenClawAgentDatabaseReadOnly(({ db }) => {
|
||||
const query = getNodeSqliteKysely<PluginModelCatalogDatabase>(db)
|
||||
.selectFrom("cache_entries")
|
||||
.select(["key", "value_json"])
|
||||
.where("scope", "=", scope)
|
||||
.orderBy("key");
|
||||
return executeSqliteQuerySync(db, query).rows.flatMap((row) =>
|
||||
row.value_json === null || (allowed && !allowed.has(row.key))
|
||||
? []
|
||||
: [{ pluginId: row.key, contents: row.value_json }],
|
||||
);
|
||||
}, options);
|
||||
return result.found ? result.value : [];
|
||||
}
|
||||
|
||||
export const pluginModelCatalogReadOperations = createPluginModelCatalogReadOperations(
|
||||
(input, context) => {
|
||||
const scope = new OpenClawAgentDatabaseReadOnlyScope();
|
||||
const options = { ...context, agentId: normalizeAgentId(input.agentId) };
|
||||
try {
|
||||
return scope.run(options, () =>
|
||||
readPluginModelCatalogEntries(options, PLUGIN_MODEL_CATALOG_CACHE_SCOPE, input.pluginIds),
|
||||
);
|
||||
} finally {
|
||||
scope.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/** The admitted worker or Doctor transaction owns the connection and commit. */
|
||||
export function replacePluginModelCatalogEntriesInDatabase(params: {
|
||||
database: DatabaseSync;
|
||||
|
|
|
|||
12
src/agents/plugin-model-catalog.read-operation.ts
Normal file
12
src/agents/plugin-model-catalog.read-operation.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import type { SqliteReadOnlyOperationContext } from "../infra/sqlite-readonly-operation-types.js";
|
||||
|
||||
export type PersistedPluginModelCatalog = { pluginId: string; contents: string };
|
||||
|
||||
export function createPluginModelCatalogReadOperations(
|
||||
read: (
|
||||
input: { agentId: string; pluginIds?: readonly string[] },
|
||||
context: SqliteReadOnlyOperationContext,
|
||||
) => PersistedPluginModelCatalog[],
|
||||
) {
|
||||
return { "pluginCatalog.read": read };
|
||||
}
|
||||
|
|
@ -15,7 +15,6 @@ import { hasErrnoCode } from "../infra/errno.js";
|
|||
import { executeSqliteQuerySync, getNodeSqliteKysely } from "../infra/kysely-sync.js";
|
||||
import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js";
|
||||
import { isProviderCatalogSourceAllowed } from "../plugins/provider-config-owner.js";
|
||||
import { withOpenClawAgentDatabaseReadOnly } from "../state/openclaw-agent-db-readonly.js";
|
||||
import type { DB as OpenClawAgentKyselyDatabase } from "../state/openclaw-agent-db.generated.js";
|
||||
import { runOpenClawAgentWriteTransaction } from "../state/openclaw-agent-db.js";
|
||||
import {
|
||||
|
|
@ -31,11 +30,14 @@ import {
|
|||
import {
|
||||
PLUGIN_MODEL_CATALOG_CACHE_SCOPE,
|
||||
PLUGIN_MODEL_CATALOG_MIGRATION_SCOPE,
|
||||
readPluginModelCatalogEntries,
|
||||
replacePluginModelCatalogEntriesInDatabase,
|
||||
type PersistedPluginModelCatalog,
|
||||
} from "./plugin-model-catalog.kernel.js";
|
||||
|
||||
export { isGeneratedPluginModelCatalog };
|
||||
export { PLUGIN_MODEL_CATALOG_GENERATED_BY } from "./plugin-model-catalog-repair.js";
|
||||
export type { PersistedPluginModelCatalog } from "./plugin-model-catalog.kernel.js";
|
||||
|
||||
// The in-memory planning key retains the established owner encoding; generated
|
||||
// payloads themselves are persisted only in the agent SQLite cache.
|
||||
|
|
@ -51,11 +53,6 @@ function isPluginModelCatalogMigrationFile(filename: string): boolean {
|
|||
|
||||
type PluginModelCatalogDatabase = Pick<OpenClawAgentKyselyDatabase, "cache_entries">;
|
||||
|
||||
export type PersistedPluginModelCatalog = {
|
||||
pluginId: string;
|
||||
contents: string;
|
||||
};
|
||||
|
||||
function pluginModelCatalogDatabaseOptions(agentDir: string) {
|
||||
return {
|
||||
agentId: resolveAuthProfileDatabaseOwnerId(agentDir),
|
||||
|
|
@ -63,34 +60,14 @@ function pluginModelCatalogDatabaseOptions(agentDir: string) {
|
|||
};
|
||||
}
|
||||
|
||||
function readPersistedPluginModelCatalogEntries(
|
||||
agentDir: string,
|
||||
scope: string,
|
||||
): PersistedPluginModelCatalog[] {
|
||||
const result = withOpenClawAgentDatabaseReadOnly((database) => {
|
||||
const kysely = getNodeSqliteKysely<PluginModelCatalogDatabase>(database.db);
|
||||
return executeSqliteQuerySync(
|
||||
database.db,
|
||||
kysely
|
||||
.selectFrom("cache_entries")
|
||||
.select(["key", "value_json"])
|
||||
.where("scope", "=", scope)
|
||||
.orderBy("key"),
|
||||
).rows.flatMap((row) =>
|
||||
row.value_json === null ? [] : [{ pluginId: row.key, contents: row.value_json }],
|
||||
);
|
||||
}, pluginModelCatalogDatabaseOptions(agentDir));
|
||||
return result.found ? result.value : [];
|
||||
}
|
||||
|
||||
function readPersistedPluginModelCatalogs(agentDir: string): PersistedPluginModelCatalog[] {
|
||||
return readPersistedPluginModelCatalogEntries(agentDir, PLUGIN_MODEL_CATALOG_CACHE_SCOPE);
|
||||
return readPluginModelCatalogEntries(
|
||||
pluginModelCatalogDatabaseOptions(agentDir),
|
||||
PLUGIN_MODEL_CATALOG_CACHE_SCOPE,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads an exact plugin-catalog generation without migration or repair writes.
|
||||
* Lifecycle preparation uses this for configured providers before atomic publication.
|
||||
*/
|
||||
/** Native Doctor inspection and the public synchronous ModelRegistry SDK contract. */
|
||||
export function loadPersistedPluginModelCatalogsReadOnly(
|
||||
agentDir: string,
|
||||
pluginIds?: readonly string[],
|
||||
|
|
@ -98,12 +75,11 @@ export function loadPersistedPluginModelCatalogsReadOnly(
|
|||
if (pluginIds?.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const catalogs = readPersistedPluginModelCatalogs(agentDir);
|
||||
if (!pluginIds) {
|
||||
return catalogs;
|
||||
}
|
||||
const allowed = new Set(pluginIds);
|
||||
return catalogs.filter(({ pluginId }) => allowed.has(pluginId));
|
||||
return readPluginModelCatalogEntries(
|
||||
pluginModelCatalogDatabaseOptions(agentDir),
|
||||
PLUGIN_MODEL_CATALOG_CACHE_SCOPE,
|
||||
pluginIds,
|
||||
);
|
||||
}
|
||||
|
||||
/** Applies Doctor's repair to unchanged persisted catalog bytes. */
|
||||
|
|
@ -182,9 +158,10 @@ function readPersistedPluginModelCatalogMigrationPayloads(
|
|||
agentDir: string,
|
||||
): ReadonlyMap<string, string> {
|
||||
return new Map(
|
||||
readPersistedPluginModelCatalogEntries(agentDir, PLUGIN_MODEL_CATALOG_MIGRATION_SCOPE).map(
|
||||
(catalog) => [catalog.pluginId, catalog.contents],
|
||||
),
|
||||
readPluginModelCatalogEntries(
|
||||
pluginModelCatalogDatabaseOptions(agentDir),
|
||||
PLUGIN_MODEL_CATALOG_MIGRATION_SCOPE,
|
||||
).map((catalog) => [catalog.pluginId, catalog.contents]),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -41,10 +41,8 @@ import {
|
|||
parseConfiguredModelVisibilityEntries,
|
||||
} from "./model-selection-shared.js";
|
||||
import { prepareImplicitProviderStaticCatalog } from "./models-config.providers.implicit.js";
|
||||
import {
|
||||
loadPersistedPluginModelCatalogsReadOnly,
|
||||
resolvePluginModelCatalogOwnerPluginId,
|
||||
} from "./plugin-model-catalog.js";
|
||||
import { loadPersistedPluginModelCatalogs } from "./plugin-model-catalog-execution.js";
|
||||
import { resolvePluginModelCatalogOwnerPluginId } from "./plugin-model-catalog.js";
|
||||
import { prepareAgentFacts } from "./prepared-model-runtime.agent-facts.js";
|
||||
import type {
|
||||
PreparedModelRuntimeAgentBaseFacts,
|
||||
|
|
@ -621,10 +619,12 @@ export async function prepareConfiguredRuntimeFactsBatch(params: {
|
|||
const modelsJsonContents = captureModelsJsonContents(facts.input.agentDir);
|
||||
const oauthProviders = facts.templateAuthStorage.getOAuthProviders();
|
||||
// Root files remain authored inventory even when static preparation returned an empty result.
|
||||
const pluginCatalogs = loadPersistedPluginModelCatalogsReadOnly(
|
||||
const pluginCatalogs = await loadPersistedPluginModelCatalogs(
|
||||
facts.input.agentDir,
|
||||
facts.configuredGeneratedCatalogPluginIds,
|
||||
facts.env,
|
||||
);
|
||||
params.assertCurrent?.(facts.input);
|
||||
const key = fingerprintPreparedRuntimeFacts({
|
||||
config: hashRuntimeConfigValue(facts.input.config),
|
||||
sourceModels: projectConfigOntoRuntimeSourceSnapshot(facts.input.config).models,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { modelCatalogRowToEntry } from "./model-catalog-entry.js";
|
|||
import { createPreparedModelCatalogProviderNormalizer } from "./model-catalog-provider-normalizer.js";
|
||||
import type { ModelCatalogSnapshot } from "./model-catalog.types.js";
|
||||
import { ensureOpenClawModelsJson, planOpenClawModelsJsonSource } from "./models-config.js";
|
||||
import { loadPersistedPluginModelCatalogsReadOnly } from "./plugin-model-catalog.js";
|
||||
import { loadPersistedPluginModelCatalogs } from "./plugin-model-catalog-execution.js";
|
||||
import type {
|
||||
PreparedModelRuntimeAgentFacts,
|
||||
PreparedModelRuntimeCatalogSource,
|
||||
|
|
@ -138,7 +138,7 @@ export async function prepareAgentCatalogSource(
|
|||
// publish a different workspace generation before full-catalog parsing begins.
|
||||
return {
|
||||
modelsJsonContents: captureModelsJsonContents(input.agentDir),
|
||||
pluginCatalogs: loadPersistedPluginModelCatalogsReadOnly(input.agentDir),
|
||||
pluginCatalogs: await loadPersistedPluginModelCatalogs(input.agentDir, undefined, env),
|
||||
providerOutcomes: resultOutcomes(),
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,14 +1,28 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { setImmediate as nextTurn } from "node:timers/promises";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
awaitGateBeforeSettlement,
|
||||
createDeferred,
|
||||
withinTest,
|
||||
} from "../../test/helpers/promise.js";
|
||||
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
|
||||
import { createConfigIO } from "../config/io.js";
|
||||
import type { ModelDefinitionConfig, ModelProviderConfig } from "../config/types.models.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import * as sqliteReads from "../infra/sqlite-readonly-worker.js";
|
||||
import { createPluginMetadataSnapshotFixture } from "../plugins/plugin-metadata.test-support.js";
|
||||
import type { PreparedProviderStaticCatalog } from "../plugins/provider-discovery.js";
|
||||
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
|
||||
import { createCurrentOpenClawAgentDatabaseFixtures } from "../state/openclaw-agent-db.test-support.js";
|
||||
import { observeMainThreadSql } from "../test-utils/main-thread-sql-spies.test-support.js";
|
||||
import { resolveAuthProfileDatabaseOwnerId } from "./auth-profiles/sqlite.js";
|
||||
import { resetModelsJsonReadyCacheForTest } from "./models-config-state.test-support.js";
|
||||
import { ensureOpenClawModelsJson, planOpenClawModelsJsonSource } from "./models-config.js";
|
||||
import * as modelsPlan from "./models-config.plan.js";
|
||||
import * as catalogAuth from "./plugin-model-catalog-auth.js";
|
||||
import { PLUGIN_MODEL_CATALOG_GENERATED_BY } from "./plugin-model-catalog.js";
|
||||
import type { PreparedModelRuntimeAgentFacts } from "./prepared-model-runtime.catalog-contract.js";
|
||||
import {
|
||||
|
|
@ -19,6 +33,7 @@ import {
|
|||
createPreparedModelRuntimeSnapshot,
|
||||
prepareFullCatalogFacts,
|
||||
} from "./prepared-model-runtime.full-catalog.js";
|
||||
import { prepareAgentCatalogSource } from "./prepared-model-runtime.scoped-catalog.js";
|
||||
import { AuthStorage } from "./sessions/auth-storage.js";
|
||||
import { ModelRegistry } from "./sessions/model-registry.js";
|
||||
|
||||
|
|
@ -92,7 +107,144 @@ function fixture(mode: "merge" | "replace" = "merge") {
|
|||
return { facts, generation, configured, staticConfig, modelsJsonContents };
|
||||
}
|
||||
|
||||
function persistedFixture() {
|
||||
const prepared = fixture();
|
||||
const { facts, configured } = prepared;
|
||||
const agentDir = facts.input.agentDir;
|
||||
const env = { ...process.env, OPENCLAW_STATE_DIR: path.join(agentDir, "state") };
|
||||
facts.env = env;
|
||||
facts.input = { ...facts.input, env, readOnly: true };
|
||||
facts.configuredGeneratedCatalogPluginIds = [pluginId];
|
||||
const databasePath = path.join(agentDir, "openclaw-agent.sqlite");
|
||||
createCurrentOpenClawAgentDatabaseFixtures(path.join(agentDir, "template.sqlite"), [
|
||||
{ path: databasePath, agentId: resolveAuthProfileDatabaseOwnerId(agentDir) },
|
||||
]);
|
||||
const catalogs = [
|
||||
{
|
||||
pluginId,
|
||||
contents: JSON.stringify({
|
||||
generatedBy: PLUGIN_MODEL_CATALOG_GENERATED_BY,
|
||||
providers: { [providerId]: { ...configured, models: [model("persisted-only")] } },
|
||||
}),
|
||||
},
|
||||
];
|
||||
const database = new DatabaseSync(databasePath);
|
||||
try {
|
||||
database
|
||||
.prepare("INSERT INTO cache_entries (scope, key, value_json, updated_at) VALUES (?, ?, ?, ?)")
|
||||
.run("plugin-model-catalog-v1", pluginId, catalogs[0]!.contents, 1);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
return { ...prepared, catalogs };
|
||||
}
|
||||
|
||||
describe("prepared catalog source composition", () => {
|
||||
it("prepares persisted catalog sources, fingerprints, and queued plans without caller-thread SQLite", async () => {
|
||||
const { facts, generation, catalogs, modelsJsonContents } = persistedFixture();
|
||||
const planner = vi
|
||||
.spyOn(modelsPlan, "planOpenClawModelsJson")
|
||||
.mockResolvedValue({ action: "skip" });
|
||||
const authCapture = vi
|
||||
.spyOn(catalogAuth, "capturePluginModelCatalogAuth")
|
||||
.mockResolvedValue([]);
|
||||
const workerRead = vi.spyOn(sqliteReads, "runSqliteReadOnlyOperation");
|
||||
const sql = observeMainThreadSql({ includeClose: true });
|
||||
const scope = sqliteReads.createSqliteReadOnlyWorkerScope();
|
||||
resetModelsJsonReadyCacheForTest();
|
||||
try {
|
||||
sql.calibrate();
|
||||
await scope.run(async () => {
|
||||
const batch = await prepareConfiguredRuntimeFactsBatch({
|
||||
agentFacts: [facts],
|
||||
pluginGeneration: generation,
|
||||
});
|
||||
expect(
|
||||
batch.catalogs.get(facts.input)?.templateModelRegistry.find(providerId, "persisted-only"),
|
||||
).toMatchObject({ id: "persisted-only", provider: providerId });
|
||||
const source = await prepareAgentCatalogSource(facts, generation, "static");
|
||||
expect(source).toMatchObject({ modelsJsonContents, pluginCatalogs: catalogs });
|
||||
const options = {
|
||||
env: facts.env,
|
||||
pluginMetadataSnapshot: generation.pluginMetadataSnapshot,
|
||||
};
|
||||
await expect(
|
||||
ensureOpenClawModelsJson(facts.input.config, facts.input.agentDir, options),
|
||||
).resolves.toEqual({ agentDir: facts.input.agentDir, wrote: false });
|
||||
expect(planner).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ pluginCatalogs: catalogs }),
|
||||
);
|
||||
await expect(
|
||||
planOpenClawModelsJsonSource(facts.input.config, facts.input.agentDir, options),
|
||||
).resolves.toMatchObject({ modelsJsonContents, pluginCatalogs: catalogs });
|
||||
expect(planner).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ pluginCatalogs: catalogs }),
|
||||
);
|
||||
});
|
||||
await scope.close();
|
||||
expect(workerRead).toHaveBeenCalled();
|
||||
sql.expectIdle();
|
||||
} finally {
|
||||
try {
|
||||
await scope.close();
|
||||
} finally {
|
||||
sql.restore();
|
||||
workerRead.mockRestore();
|
||||
planner.mockRestore();
|
||||
authCapture.mockRestore();
|
||||
resetModelsJsonReadyCacheForTest();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it.for(["retired", "rejected"] as const)(
|
||||
"does not construct a configured registry after a delayed catalog read is %s",
|
||||
async (outcome, { signal }) => {
|
||||
const { facts, generation, catalogs } = persistedFixture();
|
||||
const started = createDeferred();
|
||||
const reply = createDeferred<typeof catalogs>();
|
||||
const failure = new Error(`catalog preparation ${outcome}`);
|
||||
const workerRead = vi
|
||||
.spyOn(sqliteReads, "runSqliteReadOnlyOperation")
|
||||
.mockImplementationOnce(async () => {
|
||||
started.resolve();
|
||||
return await reply.promise;
|
||||
});
|
||||
let current = true;
|
||||
const registries: PreparedConfiguredModelRegistries = new Map();
|
||||
const pending = prepareConfiguredRuntimeFactsBatch({
|
||||
agentFacts: [facts],
|
||||
pluginGeneration: generation,
|
||||
registries,
|
||||
assertCurrent() {
|
||||
if (!current) {
|
||||
throw failure;
|
||||
}
|
||||
},
|
||||
});
|
||||
const rejected = expect(pending).rejects.toBe(failure);
|
||||
try {
|
||||
await withinTest(
|
||||
awaitGateBeforeSettlement(started.promise, pending, "Catalog read was skipped"),
|
||||
signal,
|
||||
);
|
||||
expect(registries.get(generation.pluginMetadataSnapshot)?.size).toBe(0);
|
||||
if (outcome === "retired") {
|
||||
current = false;
|
||||
reply.resolve(catalogs);
|
||||
} else {
|
||||
reply.reject(failure);
|
||||
}
|
||||
await rejected;
|
||||
expect(registries.get(generation.pluginMetadataSnapshot)?.size).toBe(0);
|
||||
} finally {
|
||||
reply.resolve(catalogs);
|
||||
await Promise.allSettled([pending, rejected]);
|
||||
workerRead.mockRestore();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("retains inherited catalogs and current request settings without custom model rows", async () => {
|
||||
vi.stubEnv("OPENAI_API_KEY", undefined);
|
||||
const { facts, staticConfig } = fixture();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import type { SqliteReadOnlyOperationResult } from "./sqlite-readonly-operation-types.js";
|
||||
import type { SqliteAuthProfileRows } from "./sqlite-readonly-worker-protocol.js";
|
||||
import {
|
||||
SQLITE_WORKER_TRANSFER_FRAME_BYTES,
|
||||
|
|
@ -6,7 +7,7 @@ import {
|
|||
type SqliteWorkerTransferFrame,
|
||||
} from "./sqlite-worker-transfer.js";
|
||||
|
||||
export type SqliteAuthTransferRequest = { type: "next" | "end"; transferId: number };
|
||||
type SqliteAuthTransferRequest = { type: "next" | "end"; transferId: number };
|
||||
|
||||
/** JSON IPC carries only one bounded byte frame; aggregate records retain the transfer contract. */
|
||||
export function encodeSqliteAuthTransferFrame(frame: SqliteWorkerTransferFrame) {
|
||||
|
|
@ -23,7 +24,7 @@ export function encodeSqliteAuthTransferFrame(frame: SqliteWorkerTransferFrame)
|
|||
};
|
||||
}
|
||||
|
||||
function decodeFrame(value: unknown): SqliteWorkerTransferFrame {
|
||||
function decodeFrame(value: unknown, label: string): SqliteWorkerTransferFrame {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
typeof value.id !== "number" ||
|
||||
|
|
@ -31,7 +32,7 @@ function decodeFrame(value: unknown): SqliteWorkerTransferFrame {
|
|||
typeof value.sequence !== "number" ||
|
||||
!Number.isSafeInteger(value.sequence)
|
||||
) {
|
||||
throw new Error("Invalid auth profile transfer frame");
|
||||
throw new Error(`Invalid ${label} transfer frame`);
|
||||
}
|
||||
const { id, sequence } = value;
|
||||
if (value.done === true && Array.isArray(value.counts)) {
|
||||
|
|
@ -45,7 +46,7 @@ function decodeFrame(value: unknown): SqliteWorkerTransferFrame {
|
|||
!Number.isSafeInteger(entry[1]) ||
|
||||
entry[1] < 0
|
||||
) {
|
||||
throw new Error("Invalid auth profile transfer counts");
|
||||
throw new Error(`Invalid ${label} transfer counts`);
|
||||
}
|
||||
counts.push([entry[0], entry[1]]);
|
||||
}
|
||||
|
|
@ -60,11 +61,11 @@ function decodeFrame(value: unknown): SqliteWorkerTransferFrame {
|
|||
typeof value.bytes !== "string" ||
|
||||
value.bytes.length > 4 * Math.ceil(SQLITE_WORKER_TRANSFER_FRAME_BYTES / 3)
|
||||
) {
|
||||
throw new Error("Invalid auth profile transfer bytes");
|
||||
throw new Error(`Invalid ${label} transfer bytes`);
|
||||
}
|
||||
const bytes = Buffer.from(value.bytes, "base64");
|
||||
if (bytes.toString("base64") !== value.bytes) {
|
||||
throw new Error("Invalid auth profile transfer encoding");
|
||||
throw new Error(`Invalid ${label} transfer encoding`);
|
||||
}
|
||||
return {
|
||||
id,
|
||||
|
|
@ -78,19 +79,21 @@ function decodeFrame(value: unknown): SqliteWorkerTransferFrame {
|
|||
};
|
||||
}
|
||||
|
||||
export function createSqliteAuthTransferReceiver() {
|
||||
function createSqliteReadOnlyTransferReceiver<T>(options: {
|
||||
kinds: string[];
|
||||
label: string;
|
||||
readHandle?: (handle: Record<string, unknown>) => void;
|
||||
readResult: (records: Map<string, unknown>) => T;
|
||||
}) {
|
||||
let receiver: ReturnType<typeof createSqliteWorkerTransferReceiver> | undefined;
|
||||
let transferId: number | undefined;
|
||||
let cacheable = false;
|
||||
let ending = false;
|
||||
let completed = false;
|
||||
const records = new Map<string, unknown>();
|
||||
return {
|
||||
accept(
|
||||
value: unknown,
|
||||
): { request: SqliteAuthTransferRequest } | { rows: SqliteAuthProfileRows } {
|
||||
accept(value: unknown): { request: SqliteAuthTransferRequest } | { value: T } {
|
||||
if (!isRecord(value) || completed) {
|
||||
throw new Error("Invalid auth profile transfer response");
|
||||
throw new Error(`Invalid ${options.label} transfer response`);
|
||||
}
|
||||
if (value.type === "start" && !receiver) {
|
||||
const handle = value.handle;
|
||||
|
|
@ -99,21 +102,19 @@ export function createSqliteAuthTransferReceiver() {
|
|||
typeof handle.id !== "number" ||
|
||||
!Number.isSafeInteger(handle.id) ||
|
||||
handle.id < 1 ||
|
||||
typeof handle.cacheable !== "boolean" ||
|
||||
!Array.isArray(handle.kinds) ||
|
||||
handle.kinds.length !== 2 ||
|
||||
handle.kinds[0] !== "store" ||
|
||||
handle.kinds[1] !== "state"
|
||||
handle.kinds.length !== options.kinds.length ||
|
||||
handle.kinds.some((kind, index) => kind !== options.kinds[index])
|
||||
) {
|
||||
throw new Error("Invalid auth profile transfer handle");
|
||||
throw new Error(`Invalid ${options.label} transfer handle`);
|
||||
}
|
||||
transferId = handle.id;
|
||||
cacheable = handle.cacheable;
|
||||
options.readHandle?.(handle);
|
||||
receiver = createSqliteWorkerTransferReceiver(
|
||||
{ id: transferId, kinds: ["store", "state"] },
|
||||
{ id: transferId, kinds: options.kinds },
|
||||
({ kind, value: record }) => {
|
||||
if (records.has(kind)) {
|
||||
throw new Error("Duplicate auth profile transfer record");
|
||||
throw new Error(`Duplicate ${options.label} transfer record`);
|
||||
}
|
||||
records.set(kind, record);
|
||||
},
|
||||
|
|
@ -121,13 +122,18 @@ export function createSqliteAuthTransferReceiver() {
|
|||
return { request: { type: "next", transferId } };
|
||||
}
|
||||
if (!receiver || transferId === undefined) {
|
||||
throw new Error("Auth profile transfer has not started");
|
||||
throw new Error(
|
||||
`${options.label.charAt(0).toUpperCase()}${options.label.slice(1)} transfer has not started`,
|
||||
);
|
||||
}
|
||||
if (value.type === "frame" && !ending) {
|
||||
const counts = receiver.accept(decodeFrame(value.frame));
|
||||
const counts = receiver.accept(decodeFrame(value.frame, options.label));
|
||||
if (counts) {
|
||||
if (!records.has("store") || !records.has("state") || records.size !== 2) {
|
||||
throw new Error("Incomplete auth profile transfer result");
|
||||
if (
|
||||
records.size !== options.kinds.length ||
|
||||
options.kinds.some((kind) => !records.has(kind))
|
||||
) {
|
||||
throw new Error(`Incomplete ${options.label} transfer result`);
|
||||
}
|
||||
ending = true;
|
||||
}
|
||||
|
|
@ -135,11 +141,46 @@ export function createSqliteAuthTransferReceiver() {
|
|||
}
|
||||
if (value.type === "complete" && ending) {
|
||||
completed = true;
|
||||
const rows = { store: records.get("store"), state: records.get("state"), cacheable };
|
||||
const result = options.readResult(records);
|
||||
records.clear();
|
||||
return { rows };
|
||||
return { value: result };
|
||||
}
|
||||
throw new Error("Auth profile transfer response is out of order");
|
||||
throw new Error(
|
||||
`${options.label.charAt(0).toUpperCase()}${options.label.slice(1)} transfer response is out of order`,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createSqliteAuthTransferReceiver() {
|
||||
let cacheable = false;
|
||||
return createSqliteReadOnlyTransferReceiver<SqliteAuthProfileRows>({
|
||||
kinds: ["store", "state"],
|
||||
label: "auth profile",
|
||||
readHandle(handle) {
|
||||
if (typeof handle.cacheable !== "boolean") {
|
||||
throw new Error("Invalid auth profile transfer handle");
|
||||
}
|
||||
cacheable = handle.cacheable;
|
||||
},
|
||||
readResult: (records) => ({
|
||||
store: records.get("store"),
|
||||
state: records.get("state"),
|
||||
cacheable,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function createSqliteOperationTransferReceiver(operation: string) {
|
||||
return createSqliteReadOnlyTransferReceiver<SqliteReadOnlyOperationResult>({
|
||||
kinds: ["result"],
|
||||
label: "SQLite operation",
|
||||
readResult(records) {
|
||||
const result = records.get("result");
|
||||
if (!isRecord(result) || result.operation !== operation || !("value" in result)) {
|
||||
throw new Error("SQLite read-only worker returned a different operation");
|
||||
}
|
||||
return { operation, value: result.value };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import path from "node:path";
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
import { deserialize } from "node:v8";
|
||||
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { isPrivateDirectoryCreationRefused } from "./private-directory-creation.js";
|
||||
import { SQLITE_READONLY_CHILD_ARG } from "./runtime-process-entrypoints.js";
|
||||
|
|
@ -20,6 +21,7 @@ import {
|
|||
SqliteSourceChangedError,
|
||||
} from "./sqlite-readonly-location.js";
|
||||
import type { PreparedSqliteReadOnlyLocation } from "./sqlite-readonly-location.types.js";
|
||||
import { sqliteReadOnlyOperations } from "./sqlite-readonly-operation-registry.js";
|
||||
import {
|
||||
SQLITE_READONLY_WORKER_MAX_BUFFER,
|
||||
SQLITE_INSPECTION_CONTENTION_PREFIX,
|
||||
|
|
@ -164,7 +166,7 @@ function runSession(): void {
|
|||
let busy = false;
|
||||
let closeRequested = false;
|
||||
const transfers = createSqliteWorkerTransferOwner();
|
||||
let activeTransfer: { requestId: number; transferId: number } | undefined;
|
||||
let activeTransfer: { requestId: number; transferId: number; label: string } | undefined;
|
||||
const send = (id: number, result: unknown, failed = false) => {
|
||||
process.send?.({ id, result }, (error) => {
|
||||
if (error || failed) {
|
||||
|
|
@ -199,10 +201,10 @@ function runSession(): void {
|
|||
message.id === activeTransfer.requestId &&
|
||||
isRecord(message.transfer)
|
||||
) {
|
||||
const { requestId, transferId } = activeTransfer;
|
||||
const { requestId, transferId, label } = activeTransfer;
|
||||
try {
|
||||
if (message.transfer.transferId !== transferId) {
|
||||
throw new Error("Auth profile transfer identity changed");
|
||||
throw new Error(`${label} transfer identity changed`);
|
||||
}
|
||||
if (message.transfer.type === "next") {
|
||||
send(requestId, {
|
||||
|
|
@ -215,7 +217,7 @@ function runSession(): void {
|
|||
busy = false;
|
||||
send(requestId, { type: "complete" });
|
||||
} else {
|
||||
throw new Error("Invalid auth profile transfer command");
|
||||
throw new Error(`Invalid ${label.toLowerCase()} transfer command`);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(requestId, error);
|
||||
|
|
@ -229,22 +231,54 @@ function runSession(): void {
|
|||
Number.isSafeInteger(message.id) &&
|
||||
Array.isArray(message.args) &&
|
||||
message.args.length === 2 &&
|
||||
message.args[0] === "auth-profile-rows" &&
|
||||
(message.args[0] === "auth-profile-rows" || message.args[0] === "operation") &&
|
||||
typeof message.args[1] === "string"
|
||||
) {
|
||||
const id = message.id;
|
||||
const pathname = message.args[1];
|
||||
const auth = message.auth;
|
||||
const operation = message.args[0] === "operation";
|
||||
const read = operation ? message.operation : message.auth;
|
||||
busy = true;
|
||||
void (async () => {
|
||||
if (
|
||||
!isRecord(auth) ||
|
||||
typeof auth.expectedIdentity !== "string" ||
|
||||
!auth.expectedIdentity.startsWith("file:")
|
||||
!isRecord(read) ||
|
||||
typeof read.expectedIdentity !== "string" ||
|
||||
!read.expectedIdentity.startsWith("file:")
|
||||
) {
|
||||
throw new Error("Auth profile read requires captured physical ownership");
|
||||
throw new Error(
|
||||
operation
|
||||
? "SQLite read requires captured physical ownership"
|
||||
: "Auth profile read requires captured physical ownership",
|
||||
);
|
||||
}
|
||||
const { expectedIdentity } = read;
|
||||
if (operation) {
|
||||
if (typeof read.command !== "string") {
|
||||
throw new Error("Invalid SQLite read-only operation encoding");
|
||||
}
|
||||
const command: unknown = deserialize(Buffer.from(read.command, "base64"));
|
||||
if (!isRecord(command) || typeof command.type !== "string" || !("input" in command)) {
|
||||
throw new Error("Invalid SQLite read-only operation");
|
||||
}
|
||||
const request = { type: command.type, input: command.input };
|
||||
await sqliteReadOnlyOperations.prepare(request.type);
|
||||
if (!sqliteReadOnlyOperations.has(request)) {
|
||||
throw new Error(`Unknown SQLite read-only operation: ${request.type}`);
|
||||
}
|
||||
assertExistingDatabaseIdentity(pathname, expectedIdentity);
|
||||
const value = sqliteReadOnlyOperations.execute(request, {
|
||||
path: pathname,
|
||||
env: process.env,
|
||||
});
|
||||
assertExistingDatabaseIdentity(pathname, expectedIdentity);
|
||||
const handle = transfers.start(
|
||||
[{ kind: "result", value: { operation: request.type, value } }].values(),
|
||||
{ kinds: ["result"] },
|
||||
);
|
||||
activeTransfer = { requestId: id, transferId: handle.id, label: "SQLite operation" };
|
||||
send(id, { type: "start", handle });
|
||||
return;
|
||||
}
|
||||
const { expectedIdentity } = auth;
|
||||
// Domain code stays child-only; importing it from the host would reverse storage ownership.
|
||||
const { readAuthProfileRowsReadOnly } =
|
||||
await import("../agents/auth-profiles/sqlite-json.js");
|
||||
|
|
@ -258,7 +292,7 @@ function runSession(): void {
|
|||
].values(),
|
||||
{ kinds: ["store", "state"] },
|
||||
);
|
||||
activeTransfer = { requestId: id, transferId: handle.id };
|
||||
activeTransfer = { requestId: id, transferId: handle.id, label: "Auth profile" };
|
||||
send(id, { type: "start", handle: { ...handle, cacheable: rows.cacheable } });
|
||||
})().catch((error: unknown) => fail(id, error));
|
||||
return;
|
||||
|
|
|
|||
20
src/infra/sqlite-readonly-operation-registry.ts
Normal file
20
src/infra/sqlite-readonly-operation-registry.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import type { createPluginModelCatalogReadOperations } from "../agents/plugin-model-catalog.read-operation.js";
|
||||
import {
|
||||
createWorkerOperationRegistry,
|
||||
type WorkerOperations,
|
||||
} from "../state/worker-operation-registry.js";
|
||||
import type { SqliteReadOnlyOperationContext } from "./sqlite-readonly-operation-types.js";
|
||||
|
||||
export type SqliteReadOnlyOperations = WorkerOperations<
|
||||
ReturnType<typeof createPluginModelCatalogReadOperations>
|
||||
>;
|
||||
|
||||
export const sqliteReadOnlyOperations = createWorkerOperationRegistry<
|
||||
SqliteReadOnlyOperations,
|
||||
SqliteReadOnlyOperationContext
|
||||
>({
|
||||
pluginCatalog: () =>
|
||||
import("../agents/plugin-model-catalog.kernel.js").then(
|
||||
(module) => module.pluginModelCatalogReadOperations,
|
||||
),
|
||||
});
|
||||
3
src/infra/sqlite-readonly-operation-types.ts
Normal file
3
src/infra/sqlite-readonly-operation-types.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export type SqliteReadOnlyOperationContext = { path: string; env: NodeJS.ProcessEnv };
|
||||
export type SqliteReadOnlyOperationCommand = { type: string; input: unknown };
|
||||
export type SqliteReadOnlyOperationResult = { operation: string; value: unknown };
|
||||
|
|
@ -12,12 +12,12 @@ export type SqliteReadOnlyWorkerScope = {
|
|||
pending: Set<Promise<unknown>>;
|
||||
deadlineOwnedByCaller: boolean;
|
||||
worker?: ReturnType<typeof createSqliteReadOnlyWorkerSession>;
|
||||
authWorker?: {
|
||||
readWorker?: {
|
||||
source: SqliteAuthProfileReadOptions["source"];
|
||||
launch: SqliteReadOnlyWorkerLaunch;
|
||||
session: ReturnType<typeof createSqliteReadOnlyWorkerSession>;
|
||||
};
|
||||
authTail: Promise<void>;
|
||||
readTail: Promise<void>;
|
||||
};
|
||||
export const readOnlyWorkerScope = new AsyncLocalStorage<SqliteReadOnlyWorkerScope>();
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@ import path from "node:path";
|
|||
import { toUSVString } from "node:util";
|
||||
import { sliceUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
||||
import { markPrivateDirectoryCreationRefused } from "./private-directory-creation.js";
|
||||
import type {
|
||||
SqliteReadOnlyOperationCommand,
|
||||
SqliteReadOnlyOperationResult,
|
||||
} from "./sqlite-readonly-operation-types.js";
|
||||
import { readDatabaseFileIdentity, type DatabaseFileIdentity } from "./sqlite-worker-identity.js";
|
||||
|
||||
// Keep the one-shot execFile output limit when inspections use IPC.
|
||||
|
|
@ -14,6 +18,7 @@ export type SqliteReadOnlyWorkerMode =
|
|||
| "consolidated"
|
||||
| "reclaim"
|
||||
| "auth-profile-rows"
|
||||
| "operation"
|
||||
| "staging-create"
|
||||
| "staging-create-legacy"
|
||||
| "staging-reconcile"
|
||||
|
|
@ -51,10 +56,15 @@ export type SqliteAuthProfileReadOptions = {
|
|||
signal?: AbortSignal;
|
||||
stagingRoot?: never;
|
||||
};
|
||||
export type SqliteReadOnlyOperationOptions = Omit<SqliteAuthProfileReadOptions, "mode"> & {
|
||||
mode: "operation";
|
||||
command: SqliteReadOnlyOperationCommand;
|
||||
};
|
||||
export type SqliteReadOnlyWorkerOptions =
|
||||
| SqliteAuthProfileReadOptions
|
||||
| SqliteReadOnlyOperationOptions
|
||||
| {
|
||||
mode: Exclude<SqliteReadOnlyWorkerMode, "auth-profile-rows">;
|
||||
mode: Exclude<SqliteReadOnlyWorkerMode, "auth-profile-rows" | "operation">;
|
||||
stagingRoot?: string;
|
||||
signal?: AbortSignal;
|
||||
expectedSourceIdentity?: DatabaseFileIdentity;
|
||||
|
|
@ -66,7 +76,9 @@ export function sqliteReadOnlyWorkerRequestArgs(
|
|||
const args = [options.mode, path.resolve(pathname)];
|
||||
const stagingRoot = options.stagingRoot && path.resolve(options.stagingRoot);
|
||||
const expected =
|
||||
options.mode === "auth-profile-rows" ? undefined : options.expectedSourceIdentity;
|
||||
options.mode === "auth-profile-rows" || options.mode === "operation"
|
||||
? undefined
|
||||
: options.expectedSourceIdentity;
|
||||
if (expected !== undefined) {
|
||||
if (options.mode !== "sync") {
|
||||
throw new Error(
|
||||
|
|
@ -81,7 +93,11 @@ export function sqliteReadOnlyWorkerRequestArgs(
|
|||
}
|
||||
|
||||
export type SqliteReadOnlyWorkerOutput = { failure?: string; stderr: string; stdout: string };
|
||||
export type SqliteReadOnlyWorkerValue = string | string[] | SqliteAuthProfileRows;
|
||||
export type SqliteReadOnlyWorkerValue =
|
||||
| string
|
||||
| string[]
|
||||
| SqliteAuthProfileRows
|
||||
| SqliteReadOnlyOperationResult;
|
||||
export const SQLITE_READONLY_STDERR_TAIL_CHARS = 4_000;
|
||||
|
||||
export function isSqliteReadOnlyWorkerResult(value: unknown): value is SqliteReadOnlyWorkerResult {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process";
|
||||
import { serialize } from "node:v8";
|
||||
import { sliceUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
||||
import { BrokerChild } from "../process/spawn-broker/child.js";
|
||||
import type { SpawnBrokerHost } from "../process/spawn-broker/host.js";
|
||||
import { recordChildProcessSpawn } from "../process/spawn-diagnostics.js";
|
||||
import { createDeferredCore } from "../shared/deferred.js";
|
||||
import type { RuntimeWorkerGeneration } from "./runtime-worker-generation.js";
|
||||
import { createSqliteAuthTransferReceiver } from "./sqlite-readonly-auth-transfer.js";
|
||||
import {
|
||||
createSqliteAuthTransferReceiver,
|
||||
createSqliteOperationTransferReceiver,
|
||||
} from "./sqlite-readonly-auth-transfer.js";
|
||||
import { retainSnapshotWork } from "./sqlite-readonly-location-cleanup.js";
|
||||
import {
|
||||
createSqliteReadOnlyWorkerError,
|
||||
|
|
@ -99,7 +103,9 @@ export function createSqliteReadOnlyWorkerSession(
|
|||
reject: (error: unknown) => void;
|
||||
cleanup: () => void;
|
||||
failure?: unknown;
|
||||
auth?: ReturnType<typeof createSqliteAuthTransferReceiver>;
|
||||
transfer?:
|
||||
| ReturnType<typeof createSqliteAuthTransferReceiver>
|
||||
| ReturnType<typeof createSqliteOperationTransferReceiver>;
|
||||
}
|
||||
| undefined;
|
||||
const { promise: closeSignal, resolve: resolveClosed } = createDeferredCore();
|
||||
|
|
@ -199,7 +205,7 @@ export function createSqliteReadOnlyWorkerSession(
|
|||
try {
|
||||
let value: SqliteReadOnlyWorkerValue;
|
||||
if (
|
||||
pending.auth &&
|
||||
pending.transfer &&
|
||||
!(
|
||||
typeof message.result === "object" &&
|
||||
message.result !== null &&
|
||||
|
|
@ -207,7 +213,7 @@ export function createSqliteReadOnlyWorkerSession(
|
|||
message.result.ok === false
|
||||
)
|
||||
) {
|
||||
const reply = pending.auth.accept(message.result);
|
||||
const reply = pending.transfer.accept(message.result);
|
||||
if ("request" in reply) {
|
||||
child.send({ id: pending.id, transfer: reply.request }, (error) => {
|
||||
if (error) {
|
||||
|
|
@ -216,7 +222,7 @@ export function createSqliteReadOnlyWorkerSession(
|
|||
});
|
||||
return;
|
||||
}
|
||||
value = reply.rows;
|
||||
value = reply.value;
|
||||
} else {
|
||||
value = readSqliteReadOnlyWorkerValue(
|
||||
{ stdout: JSON.stringify(message.result), stderr },
|
||||
|
|
@ -283,8 +289,10 @@ export function createSqliteReadOnlyWorkerSession(
|
|||
id,
|
||||
mode: options.mode,
|
||||
...(options.mode === "auth-profile-rows"
|
||||
? { auth: createSqliteAuthTransferReceiver() }
|
||||
: {}),
|
||||
? { transfer: createSqliteAuthTransferReceiver() }
|
||||
: options.mode === "operation"
|
||||
? { transfer: createSqliteOperationTransferReceiver(options.command.type) }
|
||||
: {}),
|
||||
resolve,
|
||||
reject,
|
||||
cleanup: () => {
|
||||
|
|
@ -302,24 +310,35 @@ export function createSqliteReadOnlyWorkerSession(
|
|||
return;
|
||||
}
|
||||
try {
|
||||
child.send(
|
||||
{
|
||||
id,
|
||||
args: host.requestArgs(pathname, options),
|
||||
...(options.mode === "auth-profile-rows"
|
||||
const request = {
|
||||
id,
|
||||
args: host.requestArgs(pathname, options),
|
||||
...(options.mode === "auth-profile-rows"
|
||||
? {
|
||||
auth: {
|
||||
expectedIdentity: options.expectedIdentity,
|
||||
},
|
||||
}
|
||||
: options.mode === "operation"
|
||||
? {
|
||||
auth: {
|
||||
operation: {
|
||||
expectedIdentity: options.expectedIdentity,
|
||||
command: serialize(options.command).toString("base64"),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
(error) => {
|
||||
if (error) {
|
||||
retire(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
if (
|
||||
options.mode === "operation" &&
|
||||
Buffer.byteLength(JSON.stringify(request)) > SQLITE_READONLY_WORKER_MAX_BUFFER
|
||||
) {
|
||||
throw new Error("SQLite read-only operation exceeded its request buffer");
|
||||
}
|
||||
child.send(request, (error) => {
|
||||
if (error) {
|
||||
retire(error);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
retire(error);
|
||||
}
|
||||
|
|
|
|||
236
src/infra/sqlite-readonly-worker.operations.test.ts
Normal file
236
src/infra/sqlite-readonly-worker.operations.test.ts
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync, StatementSync } from "node:sqlite";
|
||||
import { deserialize } from "node:v8";
|
||||
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { afterAll, beforeAll, expect, it, vi } from "vitest";
|
||||
import { awaitGateBeforeSettlement, withinTest } from "../../test/helpers/promise.js";
|
||||
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
|
||||
import { createDeferredCore } from "../shared/deferred.js";
|
||||
import { createCurrentOpenClawAgentDatabaseFixtures } from "../state/openclaw-agent-db.test-support.js";
|
||||
import {
|
||||
createSqliteReadOnlyWorkerScope,
|
||||
runSqliteReadOnlyOperation,
|
||||
} from "./sqlite-readonly-worker.js";
|
||||
import { readDatabasePathIdentitySync } from "./sqlite-worker-identity.js";
|
||||
import { SQLITE_WORKER_TRANSFER_FRAME_BYTES } from "./sqlite-worker-transfer.js";
|
||||
|
||||
vi.mock("node:child_process", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:child_process")>();
|
||||
return { ...actual, spawn: vi.fn(actual.spawn) };
|
||||
});
|
||||
|
||||
const scope = createSqliteReadOnlyWorkerScope();
|
||||
const tempDirs = useAutoCleanupTempDirTracker((cleanup) =>
|
||||
afterAll(async () => {
|
||||
await scope.close();
|
||||
vi.restoreAllMocks();
|
||||
cleanup();
|
||||
}),
|
||||
);
|
||||
let source: string;
|
||||
let expectedIdentity: string;
|
||||
let env: NodeJS.ProcessEnv;
|
||||
let child: ChildProcess;
|
||||
let sourceDigest: string;
|
||||
const contents = JSON.stringify({
|
||||
providers: {
|
||||
fixture: {
|
||||
models: [{ id: "fixture-model", name: `${"catalog".repeat(1_300_000)}🌊` }],
|
||||
},
|
||||
},
|
||||
});
|
||||
const digest = (value: string | Buffer) => createHash("sha256").update(value).digest("hex");
|
||||
|
||||
function read(pluginIds: string[], signal?: AbortSignal) {
|
||||
return scope.run(() =>
|
||||
runSqliteReadOnlyOperation(
|
||||
source,
|
||||
{ type: "pluginCatalog.read", input: { agentId: "main", pluginIds } },
|
||||
{ source: "canonical", expectedIdentity, env, signal },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const root = tempDirs.make("openclaw-readonly-operations-");
|
||||
source = path.join(root, "openclaw-agent.sqlite");
|
||||
env = { ...process.env, OPENCLAW_STATE_DIR: root };
|
||||
createCurrentOpenClawAgentDatabaseFixtures(path.join(root, "template.sqlite"), [
|
||||
{ path: source, agentId: "main" },
|
||||
]);
|
||||
const database = new DatabaseSync(source);
|
||||
try {
|
||||
const insert = database.prepare(
|
||||
"INSERT INTO cache_entries (scope, key, value_json, updated_at) VALUES (?, ?, ?, ?)",
|
||||
);
|
||||
insert.run("plugin-model-catalog-v1", "fixture", contents, 1);
|
||||
insert.run("plugin-model-catalog-v1", "small", "small catalog", 1);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
expectedIdentity = readDatabasePathIdentitySync(source).key;
|
||||
sourceDigest = digest(readFileSync(source));
|
||||
await read(["small"]);
|
||||
child = vi.mocked(spawn).mock.results[0]?.value;
|
||||
});
|
||||
|
||||
it("preserves bounded catalog reads, queued inputs, and cancellation ownership", async ({
|
||||
signal,
|
||||
}) => {
|
||||
const frames: number[] = [];
|
||||
const observeFrame = (message: unknown) => {
|
||||
if (!isRecord(message) || !isRecord(message.result)) {
|
||||
return;
|
||||
}
|
||||
const frame = message.result.frame;
|
||||
if (isRecord(frame) && typeof frame.bytes === "string") {
|
||||
frames.push(Buffer.from(frame.bytes, "base64").byteLength);
|
||||
}
|
||||
};
|
||||
child.on("message", observeFrame);
|
||||
const sql = [
|
||||
...(["exec", "prepare", "close"] as const).map((method) =>
|
||||
vi.spyOn(DatabaseSync.prototype, method),
|
||||
),
|
||||
...(["all", "get", "run", "iterate"] as const).map((method) =>
|
||||
vi.spyOn(StatementSync.prototype, method),
|
||||
),
|
||||
];
|
||||
try {
|
||||
const rows = await read(["fixture"]);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]?.pluginId).toBe("fixture");
|
||||
expect(digest(rows[0]?.contents ?? "")).toBe(digest(contents));
|
||||
expect(frames.length).toBeGreaterThan(1);
|
||||
expect(frames.every((bytes) => bytes > 0 && bytes <= SQLITE_WORKER_TRANSFER_FRAME_BYTES)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(frames.reduce((total, bytes) => total + bytes, 0)).toBeGreaterThan(
|
||||
SQLITE_WORKER_TRANSFER_FRAME_BYTES,
|
||||
);
|
||||
expect(digest(readFileSync(source))).toBe(sourceDigest);
|
||||
for (const operation of sql) {
|
||||
expect(operation).not.toHaveBeenCalled();
|
||||
}
|
||||
} finally {
|
||||
child.off("message", observeFrame);
|
||||
for (const operation of sql) {
|
||||
operation.mockRestore();
|
||||
}
|
||||
}
|
||||
const send = vi.spyOn(child, "send");
|
||||
const emit = child.emit.bind(child);
|
||||
let paused = createDeferredCore<() => boolean>();
|
||||
let pauseNextStart = true;
|
||||
const receiving = vi.spyOn(child, "emit").mockImplementation((event, ...args) => {
|
||||
const message: unknown = args[0];
|
||||
if (
|
||||
event === "message" &&
|
||||
pauseNextStart &&
|
||||
isRecord(message) &&
|
||||
isRecord(message.result) &&
|
||||
message.result.type === "start"
|
||||
) {
|
||||
pauseNextStart = false;
|
||||
paused.resolve(() => emit(event, ...args));
|
||||
return true;
|
||||
}
|
||||
return emit(event, ...args);
|
||||
});
|
||||
const queuedAbort = new AbortController();
|
||||
const activeAbort = new AbortController();
|
||||
const pending: Promise<unknown>[] = [];
|
||||
try {
|
||||
const first = read(["small"]);
|
||||
pending.push(first);
|
||||
const release = await withinTest(
|
||||
awaitGateBeforeSettlement(paused.promise, first, "Read settled before its transfer started"),
|
||||
signal,
|
||||
);
|
||||
const command = {
|
||||
type: "pluginCatalog.read" as const,
|
||||
input: {
|
||||
agentId: "main",
|
||||
pluginIds: ["small"],
|
||||
synthetic: {
|
||||
count: 9_007_199_254_740_993n,
|
||||
absent: undefined,
|
||||
owners: new Map([["fixture", "captured"]]),
|
||||
selected: new Set(["small"]),
|
||||
},
|
||||
},
|
||||
};
|
||||
const captured = scope.run(() =>
|
||||
runSqliteReadOnlyOperation(source, command, {
|
||||
source: "canonical",
|
||||
expectedIdentity,
|
||||
env,
|
||||
}),
|
||||
);
|
||||
pending.push(captured);
|
||||
command.input.pluginIds[0] = "fixture";
|
||||
command.input.synthetic.count = 1n;
|
||||
command.input.synthetic.owners.set("fixture", "changed");
|
||||
command.input.synthetic.selected.add("fixture");
|
||||
const queuedFailure = new Error("queued catalog read cancelled");
|
||||
const queued = read(["fixture"], queuedAbort.signal);
|
||||
pending.push(queued);
|
||||
const rejectedQueued = expect(queued).rejects.toBe(queuedFailure);
|
||||
queuedAbort.abort(queuedFailure);
|
||||
release();
|
||||
await expect(first).resolves.toEqual([{ pluginId: "small", contents: "small catalog" }]);
|
||||
await expect(captured).resolves.toEqual([{ pluginId: "small", contents: "small catalog" }]);
|
||||
await rejectedQueued;
|
||||
const requests = () =>
|
||||
send.mock.calls.flatMap(([message]) =>
|
||||
isRecord(message) && isRecord(message.operation) ? [message.operation] : [],
|
||||
);
|
||||
expect(requests()).toHaveLength(2);
|
||||
const encoded = requests()[1]?.command;
|
||||
if (typeof encoded !== "string") {
|
||||
throw new Error("Expected a serialized operation command");
|
||||
}
|
||||
const decoded: unknown = deserialize(Buffer.from(encoded, "base64"));
|
||||
expect(decoded).toStrictEqual({
|
||||
type: "pluginCatalog.read",
|
||||
input: {
|
||||
agentId: "main",
|
||||
pluginIds: ["small"],
|
||||
synthetic: {
|
||||
count: 9_007_199_254_740_993n,
|
||||
absent: undefined,
|
||||
owners: new Map([["fixture", "captured"]]),
|
||||
selected: new Set(["small"]),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
paused = createDeferredCore<() => boolean>();
|
||||
pauseNextStart = true;
|
||||
const active = read(["small"], activeAbort.signal);
|
||||
pending.push(active);
|
||||
const activeFailure = new Error("active catalog read cancelled");
|
||||
const rejectedActive = expect(active).rejects.toBe(activeFailure);
|
||||
await withinTest(
|
||||
awaitGateBeforeSettlement(paused.promise, active, "Read settled before cancellation"),
|
||||
signal,
|
||||
);
|
||||
activeAbort.abort(activeFailure);
|
||||
await rejectedActive;
|
||||
expect(requests()).toHaveLength(3);
|
||||
expect(child.signalCode).toBe("SIGKILL");
|
||||
expect(child.connected).toBe(false);
|
||||
expect(digest(readFileSync(source))).toBe(sourceDigest);
|
||||
expect(spawn).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
queuedAbort.abort();
|
||||
activeAbort.abort();
|
||||
receiving.mockRestore();
|
||||
send.mockRestore();
|
||||
await scope.close();
|
||||
await Promise.allSettled(pending);
|
||||
}
|
||||
});
|
||||
|
|
@ -16,6 +16,7 @@ import { captureRuntimeWorkerSource } from "./runtime-worker-generation.js";
|
|||
import { resolveRuntimeWorkerArgv, resolveRuntimeWorkerUrl } from "./runtime-worker-url.js";
|
||||
import { tryProcessCwd } from "./safe-cwd.js";
|
||||
import { retainSnapshotWork } from "./sqlite-readonly-location-cleanup.js";
|
||||
import type { SqliteReadOnlyOperations } from "./sqlite-readonly-operation-registry.js";
|
||||
import {
|
||||
readOnlyWorkerScope,
|
||||
type SqliteReadOnlyWorkerScope,
|
||||
|
|
@ -28,6 +29,7 @@ import {
|
|||
type SqliteReadOnlyWorkerOutput,
|
||||
type SqliteReadOnlyWorkerValue,
|
||||
type SqliteAuthProfileReadOptions,
|
||||
type SqliteReadOnlyOperationOptions,
|
||||
type SqliteAuthProfileRows,
|
||||
} from "./sqlite-readonly-worker-protocol.js";
|
||||
import {
|
||||
|
|
@ -136,7 +138,7 @@ export function createSqliteReadOnlyWorkerScope(options?: {
|
|||
controller: new AbortController(),
|
||||
pending: new Set(),
|
||||
deadlineOwnedByCaller: options?.deadlineOwnedByCaller ?? false,
|
||||
authTail: Promise.resolve(),
|
||||
readTail: Promise.resolve(),
|
||||
};
|
||||
const abort = () => scope.controller.abort(options?.signal.reason);
|
||||
options?.signal.addEventListener("abort", abort, { once: true });
|
||||
|
|
@ -156,7 +158,7 @@ export function createSqliteReadOnlyWorkerScope(options?: {
|
|||
await Promise.allSettled(scope.pending);
|
||||
const closed = await Promise.allSettled([
|
||||
scope.worker?.close(),
|
||||
scope.authWorker?.session.close(),
|
||||
scope.readWorker?.session.close(),
|
||||
]);
|
||||
const failures = closed.flatMap((result) =>
|
||||
result.status === "rejected" ? [result.reason] : [],
|
||||
|
|
@ -246,7 +248,7 @@ export function captureSqliteReadOnlyWorkerLaunch(
|
|||
env?: NodeJS.ProcessEnv,
|
||||
source?: SqliteAuthProfileReadOptions["source"],
|
||||
): SqliteReadOnlyWorkerLaunch {
|
||||
// Snapshots require native process close before byte cleanup; only canonical Auth uses a broker.
|
||||
// Snapshots require native process close before byte cleanup; canonical reads use a broker.
|
||||
const broker = source === "canonical" ? getSpawnBroker() : undefined;
|
||||
return {
|
||||
runtimeGeneration: captureRuntimeWorkerSource(
|
||||
|
|
@ -283,6 +285,32 @@ export function createScopedSqliteReadOnlyWorker(
|
|||
});
|
||||
}
|
||||
|
||||
export async function runSqliteReadOnlyOperation<Key extends keyof SqliteReadOnlyOperations>(
|
||||
pathname: string,
|
||||
command: { type: Key; input: SqliteReadOnlyOperations[Key]["input"] },
|
||||
options: Omit<SqliteReadOnlyOperationOptions, "mode" | "command">,
|
||||
): Promise<SqliteReadOnlyOperations[Key]["output"]> {
|
||||
const capturedCommand = structuredClone(command);
|
||||
const result = await runSqliteReadOnlyWorker(pathname, {
|
||||
...options,
|
||||
mode: "operation",
|
||||
command: capturedCommand,
|
||||
});
|
||||
if (
|
||||
typeof result !== "object" ||
|
||||
!("operation" in result) ||
|
||||
result.operation !== capturedCommand.type
|
||||
) {
|
||||
throw new Error("SQLite read-only worker returned a different operation");
|
||||
}
|
||||
// SAFETY: The registered handler and validated transfer envelope bind this command to its result.
|
||||
return result.value as SqliteReadOnlyOperations[Key]["output"];
|
||||
}
|
||||
|
||||
export function runSqliteReadOnlyWorker(
|
||||
pathname: string,
|
||||
options: SqliteReadOnlyOperationOptions,
|
||||
): Promise<SqliteReadOnlyWorkerValue>;
|
||||
export function runSqliteReadOnlyWorker(
|
||||
pathname: string,
|
||||
options: SqliteAuthProfileReadOptions,
|
||||
|
|
@ -331,16 +359,16 @@ export function runSqliteReadOnlyWorker(
|
|||
if (useScopedWorker) {
|
||||
scope.busy = true;
|
||||
}
|
||||
const authRequest =
|
||||
scopedOptions.mode === "auth-profile-rows"
|
||||
const readRequest =
|
||||
scopedOptions.mode === "auth-profile-rows" || scopedOptions.mode === "operation"
|
||||
? {
|
||||
options: scopedOptions,
|
||||
launch: captureSqliteReadOnlyWorkerLaunch(scopedOptions.env, scopedOptions.source),
|
||||
}
|
||||
: undefined;
|
||||
const operation = authRequest
|
||||
? scope.authTail.then(() =>
|
||||
runSqliteAuthProfileWorker(pathname, authRequest.options, authRequest.launch, scope),
|
||||
const operation = readRequest
|
||||
? scope.readTail.then(() =>
|
||||
runSqliteScopedReadWorker(pathname, readRequest.options, readRequest.launch, scope),
|
||||
)
|
||||
: (async () => {
|
||||
if (!useScopedWorker) {
|
||||
|
|
@ -358,9 +386,9 @@ export function runSqliteReadOnlyWorker(
|
|||
scope.busy = false;
|
||||
}
|
||||
})();
|
||||
if (scopedOptions.mode === "auth-profile-rows") {
|
||||
// Source locks are process-owned. Keep auth requests serial even for different databases.
|
||||
scope.authTail = operation.then(
|
||||
if (readRequest) {
|
||||
// Source locks are process-owned. Keep admitted reads serial even for different databases.
|
||||
scope.readTail = operation.then(
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
|
|
@ -373,28 +401,28 @@ export function runSqliteReadOnlyWorker(
|
|||
return operation;
|
||||
}
|
||||
|
||||
async function runSqliteAuthProfileWorker(
|
||||
async function runSqliteScopedReadWorker(
|
||||
pathname: string,
|
||||
options: SqliteAuthProfileReadOptions,
|
||||
options: SqliteAuthProfileReadOptions | SqliteReadOnlyOperationOptions,
|
||||
launch: SqliteReadOnlyWorkerLaunch,
|
||||
scope?: SqliteReadOnlyWorkerScope,
|
||||
): Promise<SqliteReadOnlyWorkerValue> {
|
||||
options.signal?.throwIfAborted();
|
||||
if (
|
||||
scope?.authWorker &&
|
||||
(scope.authWorker.source !== options.source ||
|
||||
!isSameSqliteReadOnlyWorkerLaunch(scope.authWorker.launch, launch) ||
|
||||
scope.authWorker.session.isRetired())
|
||||
scope?.readWorker &&
|
||||
(scope.readWorker.source !== options.source ||
|
||||
!isSameSqliteReadOnlyWorkerLaunch(scope.readWorker.launch, launch) ||
|
||||
scope.readWorker.session.isRetired())
|
||||
) {
|
||||
await scope.authWorker.session.close();
|
||||
scope.authWorker = undefined;
|
||||
await scope.readWorker.session.close();
|
||||
scope.readWorker = undefined;
|
||||
options.signal?.throwIfAborted();
|
||||
}
|
||||
let worker = scope?.authWorker?.session ?? createScopedSqliteReadOnlyWorker(launch);
|
||||
let worker = scope?.readWorker?.session ?? createScopedSqliteReadOnlyWorker(launch);
|
||||
while (true) {
|
||||
if (scope) {
|
||||
// A confirmed native replacement still belongs to this captured broker request.
|
||||
scope.authWorker = { source: options.source, launch, session: worker };
|
||||
scope.readWorker = { source: options.source, launch, session: worker };
|
||||
}
|
||||
let outcome: { value: SqliteReadOnlyWorkerValue } | { error: unknown };
|
||||
try {
|
||||
|
|
@ -409,7 +437,7 @@ async function runSqliteAuthProfileWorker(
|
|||
try {
|
||||
await worker.close();
|
||||
if (scope) {
|
||||
scope.authWorker = undefined;
|
||||
scope.readWorker = undefined;
|
||||
}
|
||||
} catch (error) {
|
||||
cleanupFailure = { error };
|
||||
|
|
@ -419,7 +447,9 @@ async function runSqliteAuthProfileWorker(
|
|||
if ("error" in outcome) {
|
||||
throw new AggregateError(
|
||||
[outcome.error, cleanupFailure.error],
|
||||
"Auth read and child cleanup failed",
|
||||
options.mode === "auth-profile-rows"
|
||||
? "Auth read and child cleanup failed"
|
||||
: "SQLite read and child cleanup failed",
|
||||
{ cause: outcome.error },
|
||||
);
|
||||
}
|
||||
|
|
@ -446,9 +476,9 @@ export function runSqliteReadOnlyWorkerOnce(
|
|||
deadlineOwnedByCaller?: boolean;
|
||||
},
|
||||
): Promise<SqliteReadOnlyWorkerValue> {
|
||||
if (options.mode === "auth-profile-rows") {
|
||||
if (options.mode === "auth-profile-rows" || options.mode === "operation") {
|
||||
// CLI and bounded readers without a lifecycle owner must join their child before returning.
|
||||
return runSqliteAuthProfileWorker(
|
||||
return runSqliteScopedReadWorker(
|
||||
pathname,
|
||||
options,
|
||||
captureSqliteReadOnlyWorkerLaunch(options.env, options.source),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue