mirror of
https://github.com/clawdbot/clawdbot.git
synced 2026-02-01 03:47:45 +01:00
refactor(sessions): add sessions.resolve + label helper (#570)
This commit is contained in:
@@ -703,6 +703,39 @@ public struct SessionsListParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsResolveParams: Codable, Sendable {
|
||||
public let key: String?
|
||||
public let label: String?
|
||||
public let agentid: String?
|
||||
public let spawnedby: String?
|
||||
public let includeglobal: Bool?
|
||||
public let includeunknown: Bool?
|
||||
|
||||
public init(
|
||||
key: String?,
|
||||
label: String?,
|
||||
agentid: String?,
|
||||
spawnedby: String?,
|
||||
includeglobal: Bool?,
|
||||
includeunknown: Bool?
|
||||
) {
|
||||
self.key = key
|
||||
self.label = label
|
||||
self.agentid = agentid
|
||||
self.spawnedby = spawnedby
|
||||
self.includeglobal = includeglobal
|
||||
self.includeunknown = includeunknown
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
case label
|
||||
case agentid = "agentId"
|
||||
case spawnedby = "spawnedBy"
|
||||
case includeglobal = "includeGlobal"
|
||||
case includeunknown = "includeUnknown"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsPatchParams: Codable, Sendable {
|
||||
public let key: String
|
||||
public let label: AnyCodable?
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
normalizeAgentId,
|
||||
parseAgentSessionKey,
|
||||
} from "../../routing/session-key.js";
|
||||
import { SESSION_LABEL_MAX_LENGTH } from "../../sessions/session-label.js";
|
||||
import { readLatestAssistantReply, runAgentStep } from "./agent-step.js";
|
||||
import type { AnyAgentTool } from "./common.js";
|
||||
import { jsonResult, readStringParam } from "./common.js";
|
||||
@@ -40,7 +41,8 @@ const SessionsSendToolSchema = Type.Union([
|
||||
),
|
||||
Type.Object(
|
||||
{
|
||||
label: Type.String({ minLength: 1, maxLength: 64 }),
|
||||
label: Type.String({ minLength: 1, maxLength: SESSION_LABEL_MAX_LENGTH }),
|
||||
agentId: Type.Optional(Type.String({ minLength: 1, maxLength: 64 })),
|
||||
message: Type.String(),
|
||||
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
},
|
||||
@@ -80,8 +82,28 @@ export function createSessionsSendTool(opts?: {
|
||||
requesterInternalKey &&
|
||||
!isSubagentSessionKey(requesterInternalKey);
|
||||
|
||||
const routingA2A = cfg.tools?.agentToAgent;
|
||||
const a2aEnabled = routingA2A?.enabled === true;
|
||||
const allowPatterns = Array.isArray(routingA2A?.allow)
|
||||
? routingA2A.allow
|
||||
: [];
|
||||
const matchesAllow = (agentId: string) => {
|
||||
if (allowPatterns.length === 0) return true;
|
||||
return allowPatterns.some((pattern) => {
|
||||
const raw = String(pattern ?? "").trim();
|
||||
if (!raw) return false;
|
||||
if (raw === "*") return true;
|
||||
if (!raw.includes("*")) return raw === agentId;
|
||||
const escaped = raw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const re = new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`, "i");
|
||||
return re.test(agentId);
|
||||
});
|
||||
};
|
||||
|
||||
const sessionKeyParam = readStringParam(params, "sessionKey");
|
||||
const labelParam = readStringParam(params, "label")?.trim() || undefined;
|
||||
const labelAgentIdParam =
|
||||
readStringParam(params, "agentId")?.trim() || undefined;
|
||||
if (sessionKeyParam && labelParam) {
|
||||
return jsonResult({
|
||||
runId: crypto.randomUUID(),
|
||||
@@ -101,20 +123,86 @@ export function createSessionsSendTool(opts?: {
|
||||
|
||||
let sessionKey = sessionKeyParam;
|
||||
if (!sessionKey && labelParam) {
|
||||
const agentIdForLookup = requesterInternalKey
|
||||
const requesterAgentId = requesterInternalKey
|
||||
? normalizeAgentId(
|
||||
parseAgentSessionKey(requesterInternalKey)?.agentId,
|
||||
)
|
||||
: undefined;
|
||||
const listParams: Record<string, unknown> = {
|
||||
includeGlobal: false,
|
||||
includeUnknown: false,
|
||||
const requestedAgentId = labelAgentIdParam
|
||||
? normalizeAgentId(labelAgentIdParam)
|
||||
: undefined;
|
||||
|
||||
if (
|
||||
restrictToSpawned &&
|
||||
requestedAgentId &&
|
||||
requesterAgentId &&
|
||||
requestedAgentId !== requesterAgentId
|
||||
) {
|
||||
return jsonResult({
|
||||
runId: crypto.randomUUID(),
|
||||
status: "forbidden",
|
||||
error:
|
||||
"Sandboxed sessions_send label lookup is limited to this agent",
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
requesterAgentId &&
|
||||
requestedAgentId &&
|
||||
requestedAgentId !== requesterAgentId
|
||||
) {
|
||||
if (!a2aEnabled) {
|
||||
return jsonResult({
|
||||
runId: crypto.randomUUID(),
|
||||
status: "forbidden",
|
||||
error:
|
||||
"Agent-to-agent messaging is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent sends.",
|
||||
});
|
||||
}
|
||||
if (
|
||||
!matchesAllow(requesterAgentId) ||
|
||||
!matchesAllow(requestedAgentId)
|
||||
) {
|
||||
return jsonResult({
|
||||
runId: crypto.randomUUID(),
|
||||
status: "forbidden",
|
||||
error:
|
||||
"Agent-to-agent messaging denied by tools.agentToAgent.allow.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const resolveParams: Record<string, unknown> = {
|
||||
label: labelParam,
|
||||
...(requestedAgentId ? { agentId: requestedAgentId } : {}),
|
||||
...(restrictToSpawned ? { spawnedBy: requesterInternalKey } : {}),
|
||||
};
|
||||
if (restrictToSpawned) listParams.spawnedBy = requesterInternalKey;
|
||||
if (agentIdForLookup) listParams.agentId = agentIdForLookup;
|
||||
const matches = await listSessions(listParams);
|
||||
if (matches.length === 0) {
|
||||
let resolvedKey = "";
|
||||
try {
|
||||
const resolved = (await callGateway({
|
||||
method: "sessions.resolve",
|
||||
params: resolveParams,
|
||||
timeoutMs: 10_000,
|
||||
})) as { key?: unknown };
|
||||
resolvedKey =
|
||||
typeof resolved?.key === "string" ? resolved.key.trim() : "";
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (restrictToSpawned) {
|
||||
return jsonResult({
|
||||
runId: crypto.randomUUID(),
|
||||
status: "forbidden",
|
||||
error: `Session not visible from this sandboxed agent session: label=${labelParam}`,
|
||||
});
|
||||
}
|
||||
return jsonResult({
|
||||
runId: crypto.randomUUID(),
|
||||
status: "error",
|
||||
error: msg || `No session found with label: ${labelParam}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (!resolvedKey) {
|
||||
if (restrictToSpawned) {
|
||||
return jsonResult({
|
||||
runId: crypto.randomUUID(),
|
||||
@@ -128,26 +216,7 @@ export function createSessionsSendTool(opts?: {
|
||||
error: `No session found with label: ${labelParam}`,
|
||||
});
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
const keys = matches
|
||||
.map((entry) => (typeof entry?.key === "string" ? entry.key : ""))
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
return jsonResult({
|
||||
runId: crypto.randomUUID(),
|
||||
status: "error",
|
||||
error: `Multiple sessions found with label: ${labelParam}${keys ? ` (${keys})` : ""}`,
|
||||
});
|
||||
}
|
||||
const key = matches[0]?.key;
|
||||
if (typeof key !== "string" || !key.trim()) {
|
||||
return jsonResult({
|
||||
runId: crypto.randomUUID(),
|
||||
status: "error",
|
||||
error: `Invalid session entry for label: ${labelParam}`,
|
||||
});
|
||||
}
|
||||
sessionKey = key;
|
||||
sessionKey = resolvedKey;
|
||||
}
|
||||
|
||||
if (!sessionKey) {
|
||||
@@ -165,17 +234,11 @@ export function createSessionsSendTool(opts?: {
|
||||
});
|
||||
|
||||
if (restrictToSpawned) {
|
||||
const agentIdForLookup = requesterInternalKey
|
||||
? normalizeAgentId(
|
||||
parseAgentSessionKey(requesterInternalKey)?.agentId,
|
||||
)
|
||||
: undefined;
|
||||
const sessions = await listSessions({
|
||||
includeGlobal: false,
|
||||
includeUnknown: false,
|
||||
limit: 500,
|
||||
spawnedBy: requesterInternalKey,
|
||||
...(agentIdForLookup ? { agentId: agentIdForLookup } : {}),
|
||||
});
|
||||
const ok = sessions.some((entry) => entry?.key === resolvedKey);
|
||||
if (!ok) {
|
||||
@@ -205,24 +268,6 @@ export function createSessionsSendTool(opts?: {
|
||||
alias,
|
||||
mainKey,
|
||||
});
|
||||
|
||||
const routingA2A = cfg.tools?.agentToAgent;
|
||||
const a2aEnabled = routingA2A?.enabled === true;
|
||||
const allowPatterns = Array.isArray(routingA2A?.allow)
|
||||
? routingA2A.allow
|
||||
: [];
|
||||
const matchesAllow = (agentId: string) => {
|
||||
if (allowPatterns.length === 0) return true;
|
||||
return allowPatterns.some((pattern) => {
|
||||
const raw = String(pattern ?? "").trim();
|
||||
if (!raw) return false;
|
||||
if (raw === "*") return true;
|
||||
if (!raw.includes("*")) return raw === agentId;
|
||||
const escaped = raw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const re = new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`, "i");
|
||||
return re.test(agentId);
|
||||
});
|
||||
};
|
||||
const requesterAgentId = normalizeAgentId(
|
||||
parseAgentSessionKey(requesterInternalKey)?.agentId,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
20
src/sessions/session-label.ts
Normal file
20
src/sessions/session-label.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export const SESSION_LABEL_MAX_LENGTH = 64;
|
||||
|
||||
export type ParsedSessionLabel =
|
||||
| { ok: true; label: string }
|
||||
| { ok: false; error: string };
|
||||
|
||||
export function parseSessionLabel(raw: unknown): ParsedSessionLabel {
|
||||
if (typeof raw !== "string") {
|
||||
return { ok: false, error: "invalid label: must be a string" };
|
||||
}
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return { ok: false, error: "invalid label: empty" };
|
||||
if (trimmed.length > SESSION_LABEL_MAX_LENGTH) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `invalid label: too long (max ${SESSION_LABEL_MAX_LENGTH})`,
|
||||
};
|
||||
}
|
||||
return { ok: true, label: trimmed };
|
||||
}
|
||||
Reference in New Issue
Block a user