mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
refactor: route SDK session compatibility through seam (#89203)
This commit is contained in:
parent
87854e841e
commit
f1cab04966
11 changed files with 655 additions and 66 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="api.runtime.agent.defaults">
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<Omit<SessionStoreReadParams, "sessionKey">>;
|
||||
|
||||
type SessionStoreEntrySummary = {
|
||||
sessionKey: string;
|
||||
entry: SessionEntry;
|
||||
};
|
||||
|
||||
type SessionStoreEntryUpdate = (
|
||||
entry: SessionEntry,
|
||||
) => Promise<Partial<SessionEntry> | null> | Partial<SessionEntry> | null;
|
||||
|
||||
type SessionStoreEntryPatch = (
|
||||
entry: SessionEntry,
|
||||
context: { existingEntry?: SessionEntry },
|
||||
) => Promise<Partial<SessionEntry> | null> | Partial<SessionEntry> | 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<SessionEntry | null> {
|
||||
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<SessionEntry | null> {
|
||||
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<void> {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>, [
|
||||
"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
|
||||
|
|
|
|||
|
|
@ -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<Omit<RuntimeSessionStoreReadParams, "sessionKey">>;
|
||||
|
||||
type RuntimeSessionStoreEntrySummary = {
|
||||
sessionKey: string;
|
||||
entry: SessionEntry;
|
||||
};
|
||||
|
||||
type RuntimeSessionStoreEntryUpdateParams = {
|
||||
storePath: string;
|
||||
sessionKey: string;
|
||||
update: (
|
||||
entry: SessionEntry,
|
||||
) => Promise<Partial<SessionEntry> | null> | Partial<SessionEntry> | 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<Partial<SessionEntry> | null> | Partial<SessionEntry> | 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<SessionEntry | null> {
|
||||
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<SessionEntry | null> {
|
||||
// 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<void> {
|
||||
// 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 = {
|
||||
|
|
|
|||
|
|
@ -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<Omit<RuntimeSessionStoreReadParams, "sessionKey">>;
|
||||
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<Partial<RuntimeSessionEntry> | null> | Partial<RuntimeSessionEntry> | null;
|
||||
};
|
||||
type RuntimeUpsertSessionEntryParams = RuntimeSessionStoreReadParams & {
|
||||
entry: RuntimeSessionEntry;
|
||||
};
|
||||
type RuntimeSessionStoreEntryUpdateParams = {
|
||||
storePath: string;
|
||||
sessionKey: string;
|
||||
update: (
|
||||
entry: RuntimeSessionEntry,
|
||||
) => Promise<Partial<RuntimeSessionEntry> | null> | Partial<RuntimeSessionEntry> | 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<RuntimeSessionEntry | null>;
|
||||
upsertSessionEntry: (params: RuntimeUpsertSessionEntryParams) => Promise<void>;
|
||||
/**
|
||||
* @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<RuntimeSessionEntry | null>;
|
||||
/**
|
||||
* @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;
|
||||
};
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue