From f1cab04966fa3d4e8de2771519def4d2ab89d02e Mon Sep 17 00:00:00 2001 From: Josh Lehman Date: Thu, 18 Jun 2026 20:52:46 -0700 Subject: [PATCH] refactor: route SDK session compatibility through seam (#89203) --- .../.generated/plugin-sdk-api-baseline.sha256 | 4 +- docs/plugins/sdk-runtime.md | 4 +- docs/plugins/sdk-subpaths.md | 2 +- src/config/sessions/session-accessor.ts | 3 + src/plugin-sdk/config-runtime.test.ts | 18 +- src/plugin-sdk/config-runtime.ts | 22 +- src/plugin-sdk/session-store-runtime.test.ts | 257 +++++++++++++++++- src/plugin-sdk/session-store-runtime.ts | 171 ++++++++++-- src/plugins/runtime/index.test.ts | 44 ++- src/plugins/runtime/runtime-agent.ts | 121 ++++++++- src/plugins/runtime/types-core.ts | 75 ++++- 11 files changed, 655 insertions(+), 66 deletions(-) diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 1d399e543e4..a5cf685acc4 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -f24065e760a9fafbd2a50962beba4d752b2d6166043170d37cdd6137640e7eef plugin-sdk-api-baseline.json -89a332c206f639d5faef730bac2d23f75751b306419e5dfeae1b731166bbc41c plugin-sdk-api-baseline.jsonl +d991fae868626a78a65ea66389f3d62a48479fe6af3bb2db71a22802aa41ceea plugin-sdk-api-baseline.json +4aa4f623cfc221bca16c00995cd278b15d1706aac074d8016d14e97e1d9423f7 plugin-sdk-api-baseline.jsonl diff --git a/docs/plugins/sdk-runtime.md b/docs/plugins/sdk-runtime.md index cf2cd331a9b..449a1f195e7 100644 --- a/docs/plugins/sdk-runtime.md +++ b/docs/plugins/sdk-runtime.md @@ -164,7 +164,9 @@ two-party event loops that do not go through the shared inbound reply runner. }); ``` - Prefer `getSessionEntry(...)`, `listSessionEntries(...)`, `patchSessionEntry(...)`, or `upsertSessionEntry(...)` for session workflows. These helpers address sessions by agent/session identity so plugins do not depend on the legacy `sessions.json` storage shape. Use `preserveActivity: true` for metadata-only patches that should not refresh session activity, and `replaceEntry: true` only when the callback returns a complete entry and deleted fields must stay deleted. `loadSessionStore(...)` remains as a deprecated compatibility escape hatch for callers that intentionally need a mutable whole-store clone. + Prefer `getSessionEntry(...)`, `listSessionEntries(...)`, `patchSessionEntry(...)`, or `upsertSessionEntry(...)` for session workflows. These helpers address sessions by agent/session identity so plugins do not depend on the legacy `sessions.json` storage shape. Use `preserveActivity: true` for metadata-only patches that should not refresh session activity, and `replaceEntry: true` only when the callback returns a complete entry and deleted fields must stay deleted. + + `loadSessionStore(...)`, `saveSessionStore(...)`, `updateSessionStore(...)`, and `resolveSessionFilePath(...)` are kept only during the transition before SQLite migration for plugins that still intentionally depend on the legacy whole-store or transcript-file shape. New plugin code must not use those helpers, and existing callers must migrate to entry helpers before the SQLite storage flip. diff --git a/docs/plugins/sdk-subpaths.md b/docs/plugins/sdk-subpaths.md index ddb1385cf2b..60f4cb90402 100644 --- a/docs/plugins/sdk-subpaths.md +++ b/docs/plugins/sdk-subpaths.md @@ -247,7 +247,7 @@ usage endpoint failed or returned no usable usage data. | `plugin-sdk/reply-history` | Shared short-window reply-history helpers. New message-turn code should use `createChannelHistoryWindow`; lower-level map helpers remain deprecated compatibility exports only | | `plugin-sdk/reply-reference` | `createReplyReferencePlanner` | | `plugin-sdk/reply-chunking` | Narrow text/markdown chunking helpers | - | `plugin-sdk/session-store-runtime` | Session workflow helpers (`getSessionEntry`, `listSessionEntries`, `patchSessionEntry`, `upsertSessionEntry`), legacy session store path/session-key helpers, updated-at reads, and deprecated whole-store mutation helpers | + | `plugin-sdk/session-store-runtime` | Session workflow helpers (`getSessionEntry`, `listSessionEntries`, `patchSessionEntry`, `upsertSessionEntry`), legacy session store path/session-key helpers, updated-at reads, and transition-only whole-store/file-path compatibility helpers | | `plugin-sdk/sqlite-runtime` | Focused SQLite agent-schema, path, and transaction helpers for first-party runtime | | `plugin-sdk/cron-store-runtime` | Cron store path/load/save helpers | | `plugin-sdk/state-paths` | State/OAuth dir path helpers | diff --git a/src/config/sessions/session-accessor.ts b/src/config/sessions/session-accessor.ts index 7e6d7868164..a641d96647d 100644 --- a/src/config/sessions/session-accessor.ts +++ b/src/config/sessions/session-accessor.ts @@ -278,6 +278,8 @@ export type SessionEntryUpdateOptions = { skipMaintenance?: boolean; /** Let the writer cache retain the updated object without cloning. */ takeCacheOwnership?: boolean; + /** Throw when best-effort store recovery cannot confirm the requested write. */ + requireWriteSuccess?: boolean; }; export type SessionLifecycleTranscriptInfo = { @@ -625,6 +627,7 @@ export async function updateSessionEntry( sessionKey: scope.sessionKey, skipMaintenance: options.skipMaintenance, takeCacheOwnership: options.takeCacheOwnership, + requireWriteSuccess: options.requireWriteSuccess, update, }); } diff --git a/src/plugin-sdk/config-runtime.test.ts b/src/plugin-sdk/config-runtime.test.ts index 2e7940cb982..f31599ca6f8 100644 --- a/src/plugin-sdk/config-runtime.test.ts +++ b/src/plugin-sdk/config-runtime.test.ts @@ -2,11 +2,6 @@ * Tests config runtime exports and snapshot/cache behavior exposed through the SDK. */ import { describe, expect, it } from "vitest"; -import { - listSessionEntries as listAccessorSessionEntries, - loadSessionEntry, - readSessionUpdatedAt as readAccessorSessionUpdatedAt, -} from "../config/sessions/session-accessor.js"; import { getSessionEntry, listSessionEntries, @@ -15,12 +10,17 @@ import { resolvePluginConfigObject, type OpenClawConfig, } from "./config-runtime.js"; +import { + getSessionEntry as getSessionStoreEntry, + listSessionEntries as listSessionStoreEntries, + readSessionUpdatedAt as readSessionStoreUpdatedAt, +} from "./session-store-runtime.js"; describe("config-runtime session read exports", () => { - it("routes read helpers through the session accessor seam", () => { - expect(getSessionEntry).toBe(loadSessionEntry); - expect(listSessionEntries).toBe(listAccessorSessionEntries); - expect(readSessionUpdatedAt).toBe(readAccessorSessionUpdatedAt); + it("re-exports the session-store runtime seam wrappers", () => { + expect(getSessionEntry).toBe(getSessionStoreEntry); + expect(listSessionEntries).toBe(listSessionStoreEntries); + expect(readSessionUpdatedAt).toBe(readSessionStoreUpdatedAt); }); }); diff --git a/src/plugin-sdk/config-runtime.ts b/src/plugin-sdk/config-runtime.ts index d779e90d974..44b823d7829 100644 --- a/src/plugin-sdk/config-runtime.ts +++ b/src/plugin-sdk/config-runtime.ts @@ -4,20 +4,23 @@ * config-mutation, and runtime-config-snapshot. */ -import { - listSessionEntries, - loadSessionEntry as getSessionEntry, - readSessionUpdatedAt, -} from "../config/sessions/session-accessor.js"; import { loadSessionStore as loadSessionStoreImpl } from "../config/sessions/store-load.js"; +export { + getSessionEntry, + listSessionEntries, + patchSessionEntry, + readSessionUpdatedAt, + updateSessionStoreEntry, + upsertSessionEntry, +} from "./session-store-runtime.js"; /** * @deprecated Use getSessionEntry/listSessionEntries for reads and - * patchSessionEntry/upsertSessionEntry for writes. loadSessionStore keeps the - * legacy mutable whole-store shape and will remain a compatibility escape hatch. + * patchSessionEntry/upsertSessionEntry for writes. This whole-store helper is + * kept only during the transition before SQLite migration. Callers must + * migrate away from reading sessions.json directly. */ export const loadSessionStore = loadSessionStoreImpl; -export { getSessionEntry, listSessionEntries, readSessionUpdatedAt }; export { resolveDefaultAgentId } from "../agents/agent-scope.js"; export { @@ -147,13 +150,10 @@ export type { } from "../config/types.js"; export { clearSessionStoreCacheForTest, - patchSessionEntry, recordSessionMetaFromInbound, saveSessionStore, updateLastRoute, updateSessionStore, - updateSessionStoreEntry, - upsertSessionEntry, resolveSessionStoreEntry, } from "../config/sessions/store.js"; export { resolveSessionKey } from "../config/sessions/session-key.js"; diff --git a/src/plugin-sdk/session-store-runtime.test.ts b/src/plugin-sdk/session-store-runtime.test.ts index 9bc775f92c4..159870804b6 100644 --- a/src/plugin-sdk/session-store-runtime.test.ts +++ b/src/plugin-sdk/session-store-runtime.test.ts @@ -1,19 +1,254 @@ -import { describe, expect, it } from "vitest"; -import { - listSessionEntries as listAccessorSessionEntries, - loadSessionEntry, - readSessionUpdatedAt as readAccessorSessionUpdatedAt, -} from "../config/sessions/session-accessor.js"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as jsonFiles from "../infra/json-files.js"; import { getSessionEntry, listSessionEntries, + patchSessionEntry, readSessionUpdatedAt, + saveSessionStore, + updateSessionStore, + updateSessionStoreEntry, + upsertSessionEntry, } from "./session-store-runtime.js"; -describe("session-store-runtime", () => { - it("routes read helpers through the session accessor seam", () => { - expect(getSessionEntry).toBe(loadSessionEntry); - expect(listSessionEntries).toBe(listAccessorSessionEntries); - expect(readSessionUpdatedAt).toBe(readAccessorSessionUpdatedAt); +const DAY_MS = 24 * 60 * 60 * 1000; + +describe("session-store-runtime compatibility surface", () => { + let tempDir: string; + let storePath: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sdk-session-store-")); + storePath = path.join(tempDir, "sessions.json"); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it("keeps the public session read shape while using accessor-backed exports", async () => { + const sessionKey = "agent:main:main"; + await upsertSessionEntry({ + sessionKey, + storePath, + entry: { + model: "gpt-5.5", + sessionId: "session-1", + updatedAt: 10, + }, + }); + + expect(getSessionEntry({ sessionKey, storePath })).toMatchObject({ + model: "gpt-5.5", + sessionId: "session-1", + updatedAt: 10, + }); + expect(readSessionUpdatedAt({ sessionKey, storePath })).toEqual(expect.any(Number)); + expect(listSessionEntries({ storePath })).toEqual([ + { + sessionKey, + entry: expect.objectContaining({ + model: "gpt-5.5", + sessionId: "session-1", + updatedAt: 10, + }), + }, + ]); + + await upsertSessionEntry({ + sessionKey, + storePath, + entry: { + sessionId: "session-1", + updatedAt: 20, + }, + }); + expect(getSessionEntry({ sessionKey, storePath })?.model).toBeUndefined(); + }); + + it("keeps the public entry mutation signature while delegating to the seam", async () => { + const sessionKey = "agent:main:main"; + + await expect( + updateSessionStoreEntry({ + sessionKey, + storePath, + update: () => ({ model: "gpt-5.5" }), + }), + ).resolves.toBeNull(); + + await upsertSessionEntry({ + sessionKey, + storePath, + entry: { + sessionId: "session-1", + updatedAt: 10, + }, + }); + + const beforePatch = getSessionEntry({ sessionKey, storePath }); + await expect( + patchSessionEntry({ + sessionKey, + storePath, + preserveActivity: true, + update: (_entry, context) => ({ + providerOverride: context.existingEntry ? "openai" : "missing", + updatedAt: 20, + }), + }), + ).resolves.toMatchObject({ + providerOverride: "openai", + sessionId: "session-1", + updatedAt: beforePatch?.updatedAt, + }); + + await expect( + updateSessionStoreEntry({ + sessionKey, + storePath, + update: () => ({ model: "gpt-5.5" }), + }), + ).resolves.toMatchObject({ + model: "gpt-5.5", + providerOverride: "openai", + sessionId: "session-1", + }); + }); + + it("preserves resolved maintenance settings through entry patches", async () => { + const staleSessionKey = "agent:main:stale"; + const activeSessionKey = "agent:main:active"; + await saveSessionStore( + storePath, + { + [staleSessionKey]: { + sessionId: "session-stale", + updatedAt: 10, + }, + [activeSessionKey]: { + sessionId: "session-active", + updatedAt: 20, + }, + }, + { skipMaintenance: true }, + ); + + await expect( + patchSessionEntry({ + sessionKey: activeSessionKey, + storePath, + maintenanceConfig: { + mode: "enforce", + pruneAfterMs: 7 * DAY_MS, + maxEntries: 1, + resetArchiveRetentionMs: 7 * DAY_MS, + maxDiskBytes: null, + highWaterBytes: null, + }, + update: () => ({ model: "gpt-5.5" }), + }), + ).resolves.toMatchObject({ + model: "gpt-5.5", + sessionId: "session-active", + }); + + expect(getSessionEntry({ sessionKey: activeSessionKey, storePath })).toMatchObject({ + model: "gpt-5.5", + sessionId: "session-active", + }); + expect(getSessionEntry({ sessionKey: staleSessionKey, storePath })).toBeUndefined(); + }); + + it("keeps deprecated whole-store mutations grouped as one compatibility operation", async () => { + const firstSessionKey = "agent:main:first"; + const secondSessionKey = "agent:main:second"; + const deletedSessionKey = "agent:main:deleted"; + await saveSessionStore( + storePath, + { + [firstSessionKey]: { + sessionId: "session-1", + updatedAt: 10, + }, + [secondSessionKey]: { + sessionId: "session-2", + updatedAt: 10, + }, + [deletedSessionKey]: { + sessionId: "session-3", + updatedAt: 10, + }, + }, + { skipMaintenance: true }, + ); + + await expect( + updateSessionStore( + storePath, + (store) => { + const first = store[firstSessionKey]; + const second = store[secondSessionKey]; + if (!first || !second) { + throw new Error("seed session entries missing"); + } + store[firstSessionKey] = { + ...first, + model: "gpt-5.5", + updatedAt: 20, + }; + store[secondSessionKey] = { + ...second, + providerOverride: "openai", + updatedAt: 30, + }; + delete store[deletedSessionKey]; + return "whole-store-updated"; + }, + { skipMaintenance: true }, + ), + ).resolves.toBe("whole-store-updated"); + + expect(getSessionEntry({ sessionKey: firstSessionKey, storePath })).toMatchObject({ + model: "gpt-5.5", + sessionId: "session-1", + updatedAt: 20, + }); + expect(getSessionEntry({ sessionKey: secondSessionKey, storePath })).toMatchObject({ + providerOverride: "openai", + sessionId: "session-2", + updatedAt: 30, + }); + expect(getSessionEntry({ sessionKey: deletedSessionKey, storePath })).toBeUndefined(); + }); + + it("preserves requireWriteSuccess for critical session entry updates", async () => { + const sessionKey = "agent:main:main"; + await upsertSessionEntry({ + sessionKey, + storePath, + entry: { + sessionId: "session-1", + updatedAt: 10, + }, + }); + const writeError = Object.assign(new Error("write failed"), { code: "ENOENT" }); + const writeSpy = vi.spyOn(jsonFiles, "writeTextAtomic").mockRejectedValue(writeError); + + try { + await expect( + updateSessionStoreEntry({ + sessionKey, + storePath, + requireWriteSuccess: true, + update: () => ({ model: "gpt-5.5" }), + }), + ).rejects.toBe(writeError); + } finally { + writeSpy.mockRestore(); + } }); }); diff --git a/src/plugin-sdk/session-store-runtime.ts b/src/plugin-sdk/session-store-runtime.ts index 3b331352a7b..d070efc0c5f 100644 --- a/src/plugin-sdk/session-store-runtime.ts +++ b/src/plugin-sdk/session-store-runtime.ts @@ -1,26 +1,161 @@ // Narrow session-store helpers for channel hot paths. import { - listSessionEntries, - loadSessionEntry as getSessionEntry, - readSessionUpdatedAt, + listSessionEntries as listAccessorSessionEntries, + loadSessionEntry, + patchSessionEntry as patchAccessorSessionEntry, + readSessionUpdatedAt as readAccessorSessionUpdatedAt, + replaceSessionEntry, + type SessionAccessScope, + updateSessionEntry, } from "../config/sessions/session-accessor.js"; import { loadSessionStore as loadSessionStoreImpl } from "../config/sessions/store-load.js"; +import type { ResolvedSessionMaintenanceConfig } from "../config/sessions/store.js"; +import type { SessionEntry } from "../config/sessions/types.js"; + +type SessionStoreReadParams = { + agentId?: string; + env?: NodeJS.ProcessEnv; + hydrateSkillPromptRefs?: boolean; + sessionKey: string; + storePath?: string; +}; + +type SessionStoreListParams = Partial>; + +type SessionStoreEntrySummary = { + sessionKey: string; + entry: SessionEntry; +}; + +type SessionStoreEntryUpdate = ( + entry: SessionEntry, +) => Promise | null> | Partial | null; + +type SessionStoreEntryPatch = ( + entry: SessionEntry, + context: { existingEntry?: SessionEntry }, +) => Promise | null> | Partial | null; + +type PatchSessionEntryParams = SessionStoreReadParams & { + fallbackEntry?: SessionEntry; + maintenanceConfig?: ResolvedSessionMaintenanceConfig; + preserveActivity?: boolean; + replaceEntry?: boolean; + update: SessionStoreEntryPatch; +}; + +type ReadSessionUpdatedAtParams = SessionStoreReadParams; + +type UpdateSessionStoreEntryParams = { + storePath: string; + sessionKey: string; + update: SessionStoreEntryUpdate; + skipMaintenance?: boolean; + takeCacheOwnership?: boolean; + requireWriteSuccess?: boolean; +}; + +type UpsertSessionEntryParams = SessionStoreReadParams & { + entry: SessionEntry; +}; + +function toSessionAccessScope(params: SessionStoreReadParams): SessionAccessScope { + // Maintainer note: keep this adapter narrow so plugin callers retain the + // object-parameter API while internal accessor-only options stay private. + return { + sessionKey: params.sessionKey, + ...(params.agentId !== undefined ? { agentId: params.agentId } : {}), + ...(params.env !== undefined ? { env: params.env } : {}), + ...(params.hydrateSkillPromptRefs !== undefined + ? { hydrateSkillPromptRefs: params.hydrateSkillPromptRefs } + : {}), + ...(params.storePath !== undefined ? { storePath: params.storePath } : {}), + }; +} /** * @deprecated Use getSessionEntry/listSessionEntries for reads and - * patchSessionEntry/upsertSessionEntry for writes. loadSessionStore keeps the - * legacy mutable whole-store shape and will remain a compatibility escape hatch. + * patchSessionEntry/upsertSessionEntry for writes. This whole-store helper is + * kept only during the transition before SQLite migration. Callers must + * migrate away from reading sessions.json directly. */ export const loadSessionStore = loadSessionStoreImpl; -export { getSessionEntry, listSessionEntries, readSessionUpdatedAt }; + +/** Loads one session entry by agent/session identity. */ +export function getSessionEntry(params: SessionStoreReadParams): SessionEntry | undefined { + return loadSessionEntry(toSessionAccessScope(params)); +} + +/** Lists session entries for one agent. */ +export function listSessionEntries( + params: SessionStoreListParams = {}, +): SessionStoreEntrySummary[] { + return listAccessorSessionEntries({ + ...(params.agentId !== undefined ? { agentId: params.agentId } : {}), + ...(params.env !== undefined ? { env: params.env } : {}), + ...(params.hydrateSkillPromptRefs !== undefined + ? { hydrateSkillPromptRefs: params.hydrateSkillPromptRefs } + : {}), + ...(params.storePath !== undefined ? { storePath: params.storePath } : {}), + }); +} + +/** Patches one session entry by agent/session identity. */ +export async function patchSessionEntry( + params: PatchSessionEntryParams, +): Promise { + return await patchAccessorSessionEntry(toSessionAccessScope(params), params.update, { + fallbackEntry: params.fallbackEntry, + maintenanceConfig: params.maintenanceConfig, + preserveActivity: params.preserveActivity, + replaceEntry: params.replaceEntry, + }); +} + +/** Reads the last activity timestamp for one session entry. */ +export function readSessionUpdatedAt(params: ReadSessionUpdatedAtParams): number | undefined { + return readAccessorSessionUpdatedAt(toSessionAccessScope(params)); +} + +/** Updates an existing session entry by store path and session key. */ +export async function updateSessionStoreEntry( + params: UpdateSessionStoreEntryParams, +): Promise { + return await updateSessionEntry( + { + sessionKey: params.sessionKey, + storePath: params.storePath, + }, + params.update, + { + skipMaintenance: params.skipMaintenance, + takeCacheOwnership: params.takeCacheOwnership, + requireWriteSuccess: params.requireWriteSuccess, + }, + ); +} + +/** Replaces or creates one session entry by agent/session identity. */ +export async function upsertSessionEntry(params: UpsertSessionEntryParams): Promise { + await replaceSessionEntry(toSessionAccessScope(params), params.entry); +} export { resolveSessionStoreEntry } from "../config/sessions/store-entry.js"; -export { - resolveSessionFilePath, - resolveSessionTranscriptPathInDir, - resolveStorePath, -} from "../config/sessions/paths.js"; +export { resolveSessionTranscriptPathInDir, resolveStorePath } from "../config/sessions/paths.js"; +/** + * @deprecated Use getSessionEntry to read session metadata by agent/session + * identity instead of resolving transcript file paths. This file-path helper + * is kept only during the transition before SQLite migration. Callers must + * migrate away from resolving transcript file paths directly. + */ +export { resolveSessionFilePath } from "../config/sessions/paths.js"; +/** + * @deprecated Use patchSessionEntry/upsertSessionEntry to persist session + * metadata by agent/session identity. This file-path helper is kept only during + * the transition before SQLite migration. Callers must migrate away from + * persisting transcript file paths directly. + */ export { resolveAndPersistSessionFile } from "../config/sessions/session-file.js"; export { readLatestAssistantTextFromSessionTranscript } from "../config/sessions/transcript.js"; export { resolveSessionKey } from "../config/sessions/session-key.js"; @@ -28,14 +163,18 @@ export { resolveGroupSessionKey } from "../config/sessions/group.js"; export { canonicalizeMainSessionAlias } from "../config/sessions/main-session.js"; export { clearSessionStoreCacheForTest, - patchSessionEntry, recordSessionMetaFromInbound, - saveSessionStore, updateLastRoute, - updateSessionStore, - updateSessionStoreEntry, - upsertSessionEntry, } from "../config/sessions/store.js"; +/** + * @deprecated Use patchSessionEntry/upsertSessionEntry for writes. These + * whole-store helpers are kept only during the transition before SQLite + * migration. Callers must migrate away from reading or writing sessions.json. + */ +export { saveSessionStore, updateSessionStore } from "../config/sessions/store.js"; +// Maintainer note: keep saveSessionStore/updateSessionStore grouped as one +// compatibility operation. A SQLite bridge must diff before/after store shapes, +// apply changed/deleted rows in one write transaction, and publish after commit. export { evaluateSessionFreshness, resolveChannelResetConfig, diff --git a/src/plugins/runtime/index.test.ts b/src/plugins/runtime/index.test.ts index 4587ea23268..30275aa3693 100644 --- a/src/plugins/runtime/index.test.ts +++ b/src/plugins/runtime/index.test.ts @@ -1,4 +1,7 @@ // Plugin runtime index tests cover runtime entrypoint exports and registry setup. +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../agents/defaults.js"; import { @@ -6,13 +9,13 @@ import { setRuntimeConfigSnapshot, type OpenClawConfig, } from "../../config/config.js"; -import { listSessionEntries, loadSessionEntry } from "../../config/sessions/session-accessor.js"; import { onAgentEvent } from "../../infra/agent-events.js"; import { requestHeartbeat, resetHeartbeatWakeStateForTests, setHeartbeatWakeHandler, } from "../../infra/heartbeat-wake.js"; +import * as jsonFiles from "../../infra/json-files.js"; import * as execModule from "../../process/exec.js"; import { onSessionTranscriptUpdate } from "../../sessions/transcript-events.js"; import { VERSION } from "../../version.js"; @@ -314,16 +317,16 @@ describe("plugin runtime command execution", () => { ]); expect(runtime.agent.runEmbeddedPiAgent).toBe(runtime.agent.runEmbeddedAgent); expectFunctionKeys(runtime.agent.session as Record, [ + "loadSessionStore", "getSessionEntry", "listSessionEntries", "patchSessionEntry", "upsertSessionEntry", + "saveSessionStore", "updateSessionStore", "updateSessionStoreEntry", "resolveSessionFilePath", ]); - expect(runtime.agent.session.getSessionEntry).toBe(loadSessionEntry); - expect(runtime.agent.session.listSessionEntries).toBe(listSessionEntries); }, }, { @@ -340,6 +343,41 @@ describe("plugin runtime command execution", () => { expectRuntimeShape(assert); }); + it("preserves requireWriteSuccess through runtime session entry updates", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-runtime-session-store-")); + const storePath = path.join(tempDir, "sessions.json"); + const sessionKey = "agent:main:main"; + const runtime = createPluginRuntime(); + + try { + await runtime.agent.session.upsertSessionEntry({ + sessionKey, + storePath, + entry: { + sessionId: "session-1", + updatedAt: 10, + }, + }); + const writeError = Object.assign(new Error("write failed"), { code: "ENOENT" }); + const writeSpy = vi.spyOn(jsonFiles, "writeTextAtomic").mockRejectedValue(writeError); + + try { + await expect( + runtime.agent.session.updateSessionStoreEntry({ + sessionKey, + storePath, + requireWriteSuccess: true, + update: () => ({ model: "gpt-5.5" }), + }), + ).rejects.toBe(writeError); + } finally { + writeSpy.mockRestore(); + } + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + it("modelAuth wrappers strip agentDir and store to prevent credential steering", async () => { // The wrappers should not forward agentDir or store from plugin callers. // We verify this by checking the wrapper functions exist and are not the diff --git a/src/plugins/runtime/runtime-agent.ts b/src/plugins/runtime/runtime-agent.ts index 35783d5871c..26e810360ac 100644 --- a/src/plugins/runtime/runtime-agent.ts +++ b/src/plugins/runtime/runtime-agent.ts @@ -12,21 +12,65 @@ import { normalizeThinkLevel, resolveThinkingProfile } from "../../auto-reply/th import { getRuntimeConfig } from "../../config/config.js"; import { resolveSessionFilePath, resolveStorePath } from "../../config/sessions/paths.js"; import { - listSessionEntries, - loadSessionEntry as getSessionEntry, + listSessionEntries as listAccessorSessionEntries, + loadSessionEntry, + patchSessionEntry as patchAccessorSessionEntry, + replaceSessionEntry, + type SessionAccessScope, + updateSessionEntry, } from "../../config/sessions/session-accessor.js"; import { loadSessionStore, - patchSessionEntry, saveSessionStore, updateSessionStore, - updateSessionStoreEntry, - upsertSessionEntry, + type ResolvedSessionMaintenanceConfig, } from "../../config/sessions/store.js"; +import type { SessionEntry } from "../../config/sessions/types.js"; import { createLazyRuntimeMethod, createLazyRuntimeModule } from "../../shared/lazy-runtime.js"; import { defineCachedValue } from "./runtime-cache.js"; import type { PluginRuntime } from "./types.js"; +type RuntimeSessionStoreReadParams = { + agentId?: string; + env?: NodeJS.ProcessEnv; + hydrateSkillPromptRefs?: boolean; + sessionKey: string; + storePath?: string; +}; + +type RuntimeSessionStoreListParams = Partial>; + +type RuntimeSessionStoreEntrySummary = { + sessionKey: string; + entry: SessionEntry; +}; + +type RuntimeSessionStoreEntryUpdateParams = { + storePath: string; + sessionKey: string; + update: ( + entry: SessionEntry, + ) => Promise | null> | Partial | null; + skipMaintenance?: boolean; + takeCacheOwnership?: boolean; + requireWriteSuccess?: boolean; +}; + +type RuntimeSessionStoreEntryPatchParams = RuntimeSessionStoreReadParams & { + fallbackEntry?: SessionEntry; + maintenanceConfig?: ResolvedSessionMaintenanceConfig; + preserveActivity?: boolean; + replaceEntry?: boolean; + update: ( + entry: SessionEntry, + context: { existingEntry?: SessionEntry }, + ) => Promise | null> | Partial | null; +}; + +type RuntimeUpsertSessionEntryParams = RuntimeSessionStoreReadParams & { + entry: SessionEntry; +}; + const loadEmbeddedAgentRuntime = createLazyRuntimeModule( () => import("./runtime-embedded-agent.runtime.js"), ); @@ -41,6 +85,73 @@ function resolveRuntimeThinkingCatalog( return configuredCatalog.length > 0 ? configuredCatalog : undefined; } +function toSessionAccessScope(params: RuntimeSessionStoreReadParams): SessionAccessScope { + // Keep plugin runtime parameters aligned with the public SDK wrapper while + // avoiding direct exposure of internal accessor-only options. + return { + sessionKey: params.sessionKey, + ...(params.agentId !== undefined ? { agentId: params.agentId } : {}), + ...(params.env !== undefined ? { env: params.env } : {}), + ...(params.hydrateSkillPromptRefs !== undefined + ? { hydrateSkillPromptRefs: params.hydrateSkillPromptRefs } + : {}), + ...(params.storePath !== undefined ? { storePath: params.storePath } : {}), + }; +} + +function getSessionEntry(params: RuntimeSessionStoreReadParams): SessionEntry | undefined { + return loadSessionEntry(toSessionAccessScope(params)); +} + +function listSessionEntries( + params: RuntimeSessionStoreListParams = {}, +): RuntimeSessionStoreEntrySummary[] { + return listAccessorSessionEntries({ + ...(params.agentId !== undefined ? { agentId: params.agentId } : {}), + ...(params.env !== undefined ? { env: params.env } : {}), + ...(params.hydrateSkillPromptRefs !== undefined + ? { hydrateSkillPromptRefs: params.hydrateSkillPromptRefs } + : {}), + ...(params.storePath !== undefined ? { storePath: params.storePath } : {}), + }); +} + +async function patchSessionEntry( + params: RuntimeSessionStoreEntryPatchParams, +): Promise { + return await patchAccessorSessionEntry(toSessionAccessScope(params), params.update, { + fallbackEntry: params.fallbackEntry, + maintenanceConfig: params.maintenanceConfig, + preserveActivity: params.preserveActivity, + replaceEntry: params.replaceEntry, + }); +} + +async function updateSessionStoreEntry( + params: RuntimeSessionStoreEntryUpdateParams, +): Promise { + // Maintainer note: keep the legacy object-parameter API here, but route + // mutations through the session accessor boundary. + return await updateSessionEntry( + { + sessionKey: params.sessionKey, + storePath: params.storePath, + }, + params.update, + { + skipMaintenance: params.skipMaintenance, + takeCacheOwnership: params.takeCacheOwnership, + requireWriteSuccess: params.requireWriteSuccess, + }, + ); +} + +async function upsertSessionEntry(params: RuntimeUpsertSessionEntryParams): Promise { + // Maintainer note: this compatibility helper has full-entry replacement + // semantics, so removed fields must not survive as merge leftovers. + await replaceSessionEntry(toSessionAccessScope(params), params.entry); +} + /** Creates the plugin runtime agent facade with lazy embedded-agent/session helpers. */ export function createRuntimeAgent(): PluginRuntime["agent"] { const agentRuntime = { diff --git a/src/plugins/runtime/types-core.ts b/src/plugins/runtime/types-core.ts index 369a9649699..28814fece34 100644 --- a/src/plugins/runtime/types-core.ts +++ b/src/plugins/runtime/types-core.ts @@ -59,6 +59,42 @@ type RuntimeReplaceConfigFileParams = { afterWrite: RuntimeConfigAfterWrite; writeOptions?: RuntimeWriteConfigOptions; }; +type RuntimeSessionEntry = import("../../config/sessions/types.js").SessionEntry; +type RuntimeSessionStoreReadParams = { + agentId?: string; + env?: NodeJS.ProcessEnv; + hydrateSkillPromptRefs?: boolean; + sessionKey: string; + storePath?: string; +}; +type RuntimeSessionStoreListParams = Partial>; +type RuntimeSessionStoreEntrySummary = { + sessionKey: string; + entry: RuntimeSessionEntry; +}; +type RuntimeSessionStoreEntryPatchParams = RuntimeSessionStoreReadParams & { + fallbackEntry?: RuntimeSessionEntry; + maintenanceConfig?: import("../../config/sessions/store.js").ResolvedSessionMaintenanceConfig; + preserveActivity?: boolean; + replaceEntry?: boolean; + update: ( + entry: RuntimeSessionEntry, + context: { existingEntry?: RuntimeSessionEntry }, + ) => Promise | null> | Partial | null; +}; +type RuntimeUpsertSessionEntryParams = RuntimeSessionStoreReadParams & { + entry: RuntimeSessionEntry; +}; +type RuntimeSessionStoreEntryUpdateParams = { + storePath: string; + sessionKey: string; + update: ( + entry: RuntimeSessionEntry, + ) => Promise | null> | Partial | null; + skipMaintenance?: boolean; + takeCacheOwnership?: boolean; + requireWriteSuccess?: boolean; +}; export type PluginRuntimeThinkingPolicyRequest = { provider?: string | null; model?: string | null; @@ -205,19 +241,44 @@ export type PluginRuntimeCore = { ensureAgentWorkspace: typeof import("../../agents/workspace.js").ensureAgentWorkspace; session: { resolveStorePath: typeof import("../../config/sessions/paths.js").resolveStorePath; - getSessionEntry: typeof import("../../config/sessions/session-accessor.js").loadSessionEntry; - listSessionEntries: typeof import("../../config/sessions/session-accessor.js").listSessionEntries; - patchSessionEntry: typeof import("../../config/sessions/store.js").patchSessionEntry; - upsertSessionEntry: typeof import("../../config/sessions/store.js").upsertSessionEntry; + getSessionEntry: (params: RuntimeSessionStoreReadParams) => RuntimeSessionEntry | undefined; + listSessionEntries: ( + params?: RuntimeSessionStoreListParams, + ) => RuntimeSessionStoreEntrySummary[]; + patchSessionEntry: ( + params: RuntimeSessionStoreEntryPatchParams, + ) => Promise; + upsertSessionEntry: (params: RuntimeUpsertSessionEntryParams) => Promise; /** * @deprecated Use getSessionEntry/listSessionEntries for reads and - * patchSessionEntry/upsertSessionEntry for writes. This keeps the legacy - * mutable whole-store compatibility shape. + * patchSessionEntry/upsertSessionEntry for writes. This whole-store + * helper is kept only during the transition before SQLite migration. + * Callers must migrate away from reading sessions.json directly. */ loadSessionStore: typeof import("../../config/sessions/store-load.js").loadSessionStore; + /** + * @deprecated Use patchSessionEntry/upsertSessionEntry for writes. This + * whole-store helper is kept only during the transition before SQLite + * migration. Callers must migrate away from writing sessions.json + * directly. + */ saveSessionStore: import("../../config/sessions/runtime-types.js").SaveSessionStore; + /** + * @deprecated Use patchSessionEntry/upsertSessionEntry for writes. This + * whole-store helper is kept only during the transition before SQLite + * migration. Callers must migrate away from updating sessions.json + * directly. + */ updateSessionStore: typeof import("../../config/sessions/store.js").updateSessionStore; - updateSessionStoreEntry: typeof import("../../config/sessions/store.js").updateSessionStoreEntry; + updateSessionStoreEntry: ( + params: RuntimeSessionStoreEntryUpdateParams, + ) => Promise; + /** + * @deprecated Use getSessionEntry to read session metadata by + * agent/session identity. This file-path helper is kept only during the + * transition before SQLite migration. Callers must migrate away from + * resolving transcript file paths directly. + */ resolveSessionFilePath: typeof import("../../config/sessions/paths.js").resolveSessionFilePath; }; };