mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 08:37:41 +00:00
refactor(sessions): add sessions.resolve + label helper (#570)
This commit is contained in:
@@ -103,6 +103,8 @@ import {
|
||||
SessionsPatchParamsSchema,
|
||||
type SessionsResetParams,
|
||||
SessionsResetParamsSchema,
|
||||
type SessionsResolveParams,
|
||||
SessionsResolveParamsSchema,
|
||||
type ShutdownEvent,
|
||||
ShutdownEventSchema,
|
||||
type SkillsInstallParams,
|
||||
@@ -201,6 +203,9 @@ export const validateNodeInvokeParams = ajv.compile<NodeInvokeParams>(
|
||||
export const validateSessionsListParams = ajv.compile<SessionsListParams>(
|
||||
SessionsListParamsSchema,
|
||||
);
|
||||
export const validateSessionsResolveParams = ajv.compile<SessionsResolveParams>(
|
||||
SessionsResolveParamsSchema,
|
||||
);
|
||||
export const validateSessionsPatchParams = ajv.compile<SessionsPatchParams>(
|
||||
SessionsPatchParamsSchema,
|
||||
);
|
||||
@@ -417,6 +422,7 @@ export type {
|
||||
NodeListParams,
|
||||
NodeInvokeParams,
|
||||
SessionsListParams,
|
||||
SessionsResolveParams,
|
||||
SessionsPatchParams,
|
||||
SessionsResetParams,
|
||||
SessionsDeleteParams,
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { type Static, type TSchema, Type } from "@sinclair/typebox";
|
||||
import { SESSION_LABEL_MAX_LENGTH } from "../../sessions/session-label.js";
|
||||
|
||||
const NonEmptyString = Type.String({ minLength: 1 });
|
||||
const SessionLabelString = Type.String({ minLength: 1, maxLength: 64 });
|
||||
const SessionLabelString = Type.String({
|
||||
minLength: 1,
|
||||
maxLength: SESSION_LABEL_MAX_LENGTH,
|
||||
});
|
||||
|
||||
export const PresenceEntrySchema = Type.Object(
|
||||
{
|
||||
@@ -323,6 +327,18 @@ export const SessionsListParamsSchema = Type.Object(
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const SessionsResolveParamsSchema = Type.Object(
|
||||
{
|
||||
key: Type.Optional(NonEmptyString),
|
||||
label: Type.Optional(SessionLabelString),
|
||||
agentId: Type.Optional(NonEmptyString),
|
||||
spawnedBy: Type.Optional(NonEmptyString),
|
||||
includeGlobal: Type.Optional(Type.Boolean()),
|
||||
includeUnknown: Type.Optional(Type.Boolean()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const SessionsPatchParamsSchema = Type.Object(
|
||||
{
|
||||
key: NonEmptyString,
|
||||
@@ -938,6 +954,7 @@ export const ProtocolSchemas: Record<string, TSchema> = {
|
||||
NodeDescribeParams: NodeDescribeParamsSchema,
|
||||
NodeInvokeParams: NodeInvokeParamsSchema,
|
||||
SessionsListParams: SessionsListParamsSchema,
|
||||
SessionsResolveParams: SessionsResolveParamsSchema,
|
||||
SessionsPatchParams: SessionsPatchParamsSchema,
|
||||
SessionsResetParams: SessionsResetParamsSchema,
|
||||
SessionsDeleteParams: SessionsDeleteParamsSchema,
|
||||
@@ -1014,6 +1031,7 @@ export type NodeListParams = Static<typeof NodeListParamsSchema>;
|
||||
export type NodeDescribeParams = Static<typeof NodeDescribeParamsSchema>;
|
||||
export type NodeInvokeParams = Static<typeof NodeInvokeParamsSchema>;
|
||||
export type SessionsListParams = Static<typeof SessionsListParamsSchema>;
|
||||
export type SessionsResolveParams = Static<typeof SessionsResolveParamsSchema>;
|
||||
export type SessionsPatchParams = Static<typeof SessionsPatchParamsSchema>;
|
||||
export type SessionsResetParams = Static<typeof SessionsResetParamsSchema>;
|
||||
export type SessionsDeleteParams = Static<typeof SessionsDeleteParamsSchema>;
|
||||
|
||||
@@ -24,7 +24,6 @@ import { buildConfigSchema } from "../config/schema.js";
|
||||
import {
|
||||
loadSessionStore,
|
||||
resolveMainSessionKey,
|
||||
resolveStorePath,
|
||||
type SessionEntry,
|
||||
saveSessionStore,
|
||||
} from "../config/sessions.js";
|
||||
@@ -45,6 +44,7 @@ import {
|
||||
type SessionsListParams,
|
||||
type SessionsPatchParams,
|
||||
type SessionsResetParams,
|
||||
type SessionsResolveParams,
|
||||
validateChatAbortParams,
|
||||
validateChatHistoryParams,
|
||||
validateChatSendParams,
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
validateSessionsListParams,
|
||||
validateSessionsPatchParams,
|
||||
validateSessionsResetParams,
|
||||
validateSessionsResolveParams,
|
||||
validateTalkModeParams,
|
||||
} from "./protocol/index.js";
|
||||
import type { ChatRunEntry } from "./server-chat.js";
|
||||
@@ -70,8 +71,10 @@ import {
|
||||
archiveFileOnDisk,
|
||||
capArrayByJsonBytes,
|
||||
listSessionsFromStore,
|
||||
loadCombinedSessionStoreForGateway,
|
||||
loadSessionEntry,
|
||||
readSessionMessages,
|
||||
resolveGatewaySessionStoreTarget,
|
||||
resolveSessionModelRef,
|
||||
resolveSessionTranscriptCandidates,
|
||||
type SessionsPatchResult,
|
||||
@@ -288,8 +291,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
|
||||
}
|
||||
const p = params as SessionsListParams;
|
||||
const cfg = loadConfig();
|
||||
const storePath = resolveStorePath(cfg.session?.store);
|
||||
const store = loadSessionStore(storePath);
|
||||
const { storePath, store } = loadCombinedSessionStoreForGateway(cfg);
|
||||
const result = listSessionsFromStore({
|
||||
cfg,
|
||||
storePath,
|
||||
@@ -298,6 +300,109 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
|
||||
});
|
||||
return { ok: true, payloadJSON: JSON.stringify(result) };
|
||||
}
|
||||
case "sessions.resolve": {
|
||||
const params = parseParams();
|
||||
if (!validateSessionsResolveParams(params)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: `invalid sessions.resolve params: ${formatValidationErrors(validateSessionsResolveParams.errors)}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const p = params as SessionsResolveParams;
|
||||
const cfg = loadConfig();
|
||||
|
||||
const key = typeof p.key === "string" ? p.key.trim() : "";
|
||||
const label = typeof p.label === "string" ? p.label.trim() : "";
|
||||
const hasKey = key.length > 0;
|
||||
const hasLabel = label.length > 0;
|
||||
if (hasKey && hasLabel) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: "Provide either key or label (not both)",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (!hasKey && !hasLabel) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: "Either key or label is required",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (hasKey) {
|
||||
const target = resolveGatewaySessionStoreTarget({ cfg, key });
|
||||
const store = loadSessionStore(target.storePath);
|
||||
const existingKey = target.storeKeys.find(
|
||||
(candidate) => store[candidate],
|
||||
);
|
||||
if (!existingKey) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: `No session found: ${key}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
payloadJSON: JSON.stringify({
|
||||
ok: true,
|
||||
key: target.canonicalKey,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const { storePath, store } = loadCombinedSessionStoreForGateway(cfg);
|
||||
const list = listSessionsFromStore({
|
||||
cfg,
|
||||
storePath,
|
||||
store,
|
||||
opts: {
|
||||
includeGlobal: p.includeGlobal === true,
|
||||
includeUnknown: p.includeUnknown === true,
|
||||
label,
|
||||
agentId: p.agentId,
|
||||
spawnedBy: p.spawnedBy,
|
||||
limit: 2,
|
||||
},
|
||||
});
|
||||
if (list.sessions.length === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: `No session found with label: ${label}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (list.sessions.length > 1) {
|
||||
const keys = list.sessions.map((s) => s.key).join(", ");
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: `Multiple sessions found with label: ${label} (${keys})`,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
payloadJSON: JSON.stringify({
|
||||
ok: true,
|
||||
key: list.sessions[0]?.key,
|
||||
}),
|
||||
};
|
||||
}
|
||||
case "sessions.patch": {
|
||||
const params = parseParams();
|
||||
if (!validateSessionsPatchParams(params)) {
|
||||
@@ -323,12 +428,21 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
|
||||
}
|
||||
|
||||
const cfg = loadConfig();
|
||||
const storePath = resolveStorePath(cfg.session?.store);
|
||||
const target = resolveGatewaySessionStoreTarget({ cfg, key });
|
||||
const storePath = target.storePath;
|
||||
const store = loadSessionStore(storePath);
|
||||
const primaryKey = target.storeKeys[0] ?? key;
|
||||
const existingKey = target.storeKeys.find(
|
||||
(candidate) => store[candidate],
|
||||
);
|
||||
if (existingKey && existingKey !== primaryKey && !store[primaryKey]) {
|
||||
store[primaryKey] = store[existingKey];
|
||||
delete store[existingKey];
|
||||
}
|
||||
const applied = await applySessionsPatchToStore({
|
||||
cfg,
|
||||
store,
|
||||
storeKey: key,
|
||||
storeKey: primaryKey,
|
||||
patch: p,
|
||||
loadGatewayModelCatalog: ctx.loadGatewayModelCatalog,
|
||||
});
|
||||
@@ -346,7 +460,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
|
||||
const payload: SessionsPatchResult = {
|
||||
ok: true,
|
||||
path: storePath,
|
||||
key,
|
||||
key: target.canonicalKey,
|
||||
entry: applied.entry,
|
||||
};
|
||||
return { ok: true, payloadJSON: JSON.stringify(payload) };
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
validateSessionsListParams,
|
||||
validateSessionsPatchParams,
|
||||
validateSessionsResetParams,
|
||||
validateSessionsResolveParams,
|
||||
} from "../protocol/index.js";
|
||||
import {
|
||||
archiveFileOnDisk,
|
||||
@@ -60,6 +61,122 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
||||
});
|
||||
respond(true, result, undefined);
|
||||
},
|
||||
"sessions.resolve": ({ params, respond }) => {
|
||||
if (!validateSessionsResolveParams(params)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`invalid sessions.resolve params: ${formatValidationErrors(validateSessionsResolveParams.errors)}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const p = params as import("../protocol/index.js").SessionsResolveParams;
|
||||
const cfg = loadConfig();
|
||||
|
||||
const key = typeof p.key === "string" ? p.key.trim() : "";
|
||||
const label = typeof p.label === "string" ? p.label.trim() : "";
|
||||
const hasKey = key.length > 0;
|
||||
const hasLabel = label.length > 0;
|
||||
if (hasKey && hasLabel) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
"Provide either key or label (not both)",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!hasKey && !hasLabel) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
"Either key or label is required",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasKey) {
|
||||
if (!key) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "key required"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const target = resolveGatewaySessionStoreTarget({ cfg, key });
|
||||
const store = loadSessionStore(target.storePath);
|
||||
const existingKey = target.storeKeys.find(
|
||||
(candidate) => store[candidate],
|
||||
);
|
||||
if (!existingKey) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, `No session found: ${key}`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
respond(true, { ok: true, key: target.canonicalKey }, undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!label) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "label required"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { storePath, store } = loadCombinedSessionStoreForGateway(cfg);
|
||||
const list = listSessionsFromStore({
|
||||
cfg,
|
||||
storePath,
|
||||
store,
|
||||
opts: {
|
||||
includeGlobal: p.includeGlobal === true,
|
||||
includeUnknown: p.includeUnknown === true,
|
||||
label,
|
||||
agentId: p.agentId,
|
||||
spawnedBy: p.spawnedBy,
|
||||
limit: 2,
|
||||
},
|
||||
});
|
||||
if (list.sessions.length === 0) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`No session found with label: ${label}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (list.sessions.length > 1) {
|
||||
const keys = list.sessions.map((s) => s.key).join(", ");
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`Multiple sessions found with label: ${label} (${keys})`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
respond(true, { ok: true, key: list.sessions[0]?.key }, undefined);
|
||||
},
|
||||
"sessions.patch": async ({ params, respond, context }) => {
|
||||
if (!validateSessionsPatchParams(params)) {
|
||||
respond(
|
||||
|
||||
@@ -642,6 +642,17 @@ describe("gateway server node/bridge", () => {
|
||||
expect(typeof payload.count).toBe("number");
|
||||
expect(typeof payload.path).toBe("string");
|
||||
|
||||
const resolveRes = await bridgeCall?.onRequest?.("ios-node", {
|
||||
id: "r2",
|
||||
method: "sessions.resolve",
|
||||
paramsJSON: JSON.stringify({ key: "main" }),
|
||||
});
|
||||
expect(resolveRes?.ok).toBe(true);
|
||||
const resolvedPayload = JSON.parse(
|
||||
String((resolveRes as { payloadJSON?: string }).payloadJSON ?? "{}"),
|
||||
) as { key?: string };
|
||||
expect(resolvedPayload.key).toBe("agent:main:main");
|
||||
|
||||
await server.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -87,6 +87,14 @@ describe("gateway server sessions", () => {
|
||||
]),
|
||||
);
|
||||
|
||||
const resolvedByKey = await rpcReq<{ ok: true; key: string }>(
|
||||
ws,
|
||||
"sessions.resolve",
|
||||
{ key: "main" },
|
||||
);
|
||||
expect(resolvedByKey.ok).toBe(true);
|
||||
expect(resolvedByKey.payload?.key).toBe("agent:main:main");
|
||||
|
||||
const list1 = await rpcReq<{
|
||||
path: string;
|
||||
sessions: Array<{
|
||||
@@ -197,6 +205,14 @@ describe("gateway server sessions", () => {
|
||||
"agent:main:subagent:one",
|
||||
]);
|
||||
|
||||
const resolvedByLabel = await rpcReq<{ ok: true; key: string }>(
|
||||
ws,
|
||||
"sessions.resolve",
|
||||
{ label: "Briefing", agentId: "main" },
|
||||
);
|
||||
expect(resolvedByLabel.ok).toBe(true);
|
||||
expect(resolvedByLabel.payload?.key).toBe("agent:main:subagent:one");
|
||||
|
||||
const spawnedOnly = await rpcReq<{
|
||||
sessions: Array<{ key: string }>;
|
||||
}>(ws, "sessions.list", {
|
||||
|
||||
@@ -21,6 +21,7 @@ import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { SessionEntry } from "../config/sessions.js";
|
||||
import { isSubagentSessionKey } from "../routing/session-key.js";
|
||||
import { normalizeSendPolicy } from "../sessions/send-policy.js";
|
||||
import { parseSessionLabel } from "../sessions/session-label.js";
|
||||
import {
|
||||
ErrorCodes,
|
||||
type ErrorShape,
|
||||
@@ -28,28 +29,10 @@ import {
|
||||
type SessionsPatchParams,
|
||||
} from "./protocol/index.js";
|
||||
|
||||
export const SESSION_LABEL_MAX_LENGTH = 64;
|
||||
|
||||
function invalid(message: string): { ok: false; error: ErrorShape } {
|
||||
return { ok: false, error: errorShape(ErrorCodes.INVALID_REQUEST, message) };
|
||||
}
|
||||
|
||||
function normalizeLabel(
|
||||
raw: unknown,
|
||||
): { ok: true; label: string } | ReturnType<typeof invalid> {
|
||||
const trimmed =
|
||||
typeof raw === "string"
|
||||
? raw.trim()
|
||||
: typeof raw === "number" || typeof raw === "boolean"
|
||||
? String(raw).trim()
|
||||
: "";
|
||||
if (!trimmed) return invalid("invalid label: empty");
|
||||
if (trimmed.length > SESSION_LABEL_MAX_LENGTH) {
|
||||
return invalid(`invalid label: too long (max ${SESSION_LABEL_MAX_LENGTH})`);
|
||||
}
|
||||
return { ok: true, label: trimmed };
|
||||
}
|
||||
|
||||
export async function applySessionsPatchToStore(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
store: Record<string, SessionEntry>;
|
||||
@@ -93,15 +76,15 @@ export async function applySessionsPatchToStore(params: {
|
||||
if (raw === null) {
|
||||
delete next.label;
|
||||
} else if (raw !== undefined) {
|
||||
const normalized = normalizeLabel(raw);
|
||||
if (!normalized.ok) return normalized;
|
||||
const parsed = parseSessionLabel(raw);
|
||||
if (!parsed.ok) return invalid(parsed.error);
|
||||
for (const [key, entry] of Object.entries(store)) {
|
||||
if (key === storeKey) continue;
|
||||
if (entry?.label === normalized.label) {
|
||||
return invalid(`label already in use: ${normalized.label}`);
|
||||
if (entry?.label === parsed.label) {
|
||||
return invalid(`label already in use: ${parsed.label}`);
|
||||
}
|
||||
}
|
||||
next.label = normalized.label;
|
||||
next.label = parsed.label;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user