mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
fix(beam): define share route contract
This commit is contained in:
@@ -155,6 +155,38 @@ export default definePluginEntry({
|
||||
`onHost(host)` callback as each host settles; the returned host array remains
|
||||
required as the final compatibility snapshot.
|
||||
|
||||
A provider may declare one readable transcript route with `shareRoute`. This
|
||||
is a closed contract, not a free-form routing hint:
|
||||
|
||||
```ts
|
||||
const shareRoute = {
|
||||
kind: "thread-id-prefix",
|
||||
routeSegment: "my-sessions",
|
||||
hostId: "gateway",
|
||||
identifierAlphabet: "lowercase-hex",
|
||||
fullLength: 32,
|
||||
minPrefixLength: 12,
|
||||
lookup: "catalog-list-search-by-thread-id-prefix",
|
||||
ambiguity: "multiple-results-or-next-cursor",
|
||||
} as const;
|
||||
```
|
||||
|
||||
The provider must return lowercase hexadecimal `threadId` values of exactly
|
||||
32 characters on the declared host. When `list(...)` receives a `search`
|
||||
value that is a valid 12-32 character prefix, that host must return only rows
|
||||
whose `threadId` starts with the prefix. Return every match up to the requested
|
||||
limit and set `nextCursor` when more may exist. The Control UI resolves only
|
||||
one result with no next page; multiple rows or `nextCursor` are explicitly
|
||||
ambiguous and never select the first row.
|
||||
|
||||
`routeSegment` must not use the first segment of a built-in Control UI route
|
||||
or alias, and it must be unique across active session catalogs. Invalid,
|
||||
unsupported, reserved, or multiply owned descriptors fail closed; catalog
|
||||
sessions remain available through the generic
|
||||
`/chat/<agent>?catalog=...&host=...&thread=...` URL. Keep one plugin-owned
|
||||
descriptor constant and reuse it for registration, prefix lookup, and URL
|
||||
generation so those obligations cannot drift.
|
||||
|
||||
CLI-backed catalogs that expose the same local-plus-paired-node shape can use
|
||||
`createSessionCatalogFamily(...)`. The family composer owns canonical cursor
|
||||
validation, node payload validation, host projection, adopted-session
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
readJsonWebhookBodyOrReject,
|
||||
} from "openclaw/plugin-sdk/webhook-ingress";
|
||||
import type { BeamStore } from "./store.js";
|
||||
import { BEAM_MAX_BODY_BYTES, BEAM_ROUTE_SEGMENT, parseBeamUpload } from "./types.js";
|
||||
import { BEAM_MAX_BODY_BYTES, BEAM_SESSION_SHARE_ROUTE, parseBeamUpload } from "./types.js";
|
||||
|
||||
function sendJson(res: ServerResponse, status: number, value: unknown): void {
|
||||
res.statusCode = status;
|
||||
@@ -108,7 +108,7 @@ export function createBeamRequestHandler(params: {
|
||||
ok: true,
|
||||
beamId: parsed.value.beamId,
|
||||
url: buildControlUiCatalogSharePath({
|
||||
routeSegment: BEAM_ROUTE_SEGMENT,
|
||||
shareRoute: BEAM_SESSION_SHARE_ROUTE,
|
||||
threadId: parsed.value.beamId,
|
||||
basePath: params.resolveControlUiBasePath(),
|
||||
}),
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
} from "openclaw/plugin-sdk/session-catalog";
|
||||
import { isControlUiCatalogShareId } from "openclaw/plugin-sdk/session-catalog-runtime";
|
||||
import type { BeamStore } from "./store.js";
|
||||
import { BEAM_HOST_ID, BEAM_ROUTE_SEGMENT, type BeamStoredSession } from "./types.js";
|
||||
import { BEAM_HOST_ID, BEAM_SESSION_SHARE_ROUTE, type BeamStoredSession } from "./types.js";
|
||||
|
||||
const DEFAULT_LIMIT = 50;
|
||||
const MAX_LIMIT = 100;
|
||||
@@ -87,11 +87,12 @@ export function createBeamSessionCatalog(store: BeamStore): SessionCatalogProvid
|
||||
return {
|
||||
id: "beam",
|
||||
label: "Beam",
|
||||
shareRoute: { routeSegment: BEAM_ROUTE_SEGMENT, hostId: BEAM_HOST_ID },
|
||||
shareRoute: BEAM_SESSION_SHARE_ROUTE,
|
||||
supportsProcessHomeIsolation: true,
|
||||
async list(params) {
|
||||
const search = params.search?.trim().toLowerCase();
|
||||
const shareId = search && isControlUiCatalogShareId(search) ? search : undefined;
|
||||
const shareId =
|
||||
search && isControlUiCatalogShareId(BEAM_SESSION_SHARE_ROUTE, search) ? search : undefined;
|
||||
const sessions = (await store.list())
|
||||
.filter(
|
||||
(session) =>
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import type { SessionCatalogProvider } from "openclaw/plugin-sdk/session-catalog";
|
||||
import {
|
||||
isRecord,
|
||||
normalizeBoundedOptionalString as readBoundedString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
|
||||
export const BEAM_HOST_ID = "gateway";
|
||||
export const BEAM_ROUTE_SEGMENT = "beam";
|
||||
export const BEAM_SESSION_SHARE_ROUTE = {
|
||||
kind: "thread-id-prefix",
|
||||
routeSegment: "beam",
|
||||
hostId: BEAM_HOST_ID,
|
||||
identifierAlphabet: "lowercase-hex",
|
||||
fullLength: 32,
|
||||
minPrefixLength: 12,
|
||||
lookup: "catalog-list-search-by-thread-id-prefix",
|
||||
ambiguity: "multiple-results-or-next-cursor",
|
||||
} as const satisfies NonNullable<SessionCatalogProvider["shareRoute"]>;
|
||||
export const BEAM_MAX_BODY_BYTES = 56 * 1024;
|
||||
export const BEAM_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
export const BEAM_MAX_SESSIONS = 500;
|
||||
|
||||
@@ -7,6 +7,7 @@ export type {
|
||||
SessionCatalogLocator,
|
||||
SessionCatalogPullRequestSummary,
|
||||
SessionCatalogSession,
|
||||
SessionCatalogShareRoute,
|
||||
SessionCatalogTranscriptItem,
|
||||
SessionsCatalogArchiveParams,
|
||||
SessionsCatalogArchiveResult,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Value } from "typebox/value";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
SessionCatalogShareRouteSchema,
|
||||
SessionsCatalogHostEventSchema,
|
||||
SessionsCatalogListParamsSchema,
|
||||
SessionsCatalogListResultSchema,
|
||||
@@ -8,6 +9,34 @@ import {
|
||||
SessionsCatalogStartTerminalResultSchema,
|
||||
} from "./sessions-catalog.js";
|
||||
|
||||
const SHARE_ROUTE = {
|
||||
kind: "thread-id-prefix",
|
||||
routeSegment: "shared-sessions",
|
||||
hostId: "gateway",
|
||||
identifierAlphabet: "lowercase-hex",
|
||||
fullLength: 32,
|
||||
minPrefixLength: 12,
|
||||
lookup: "catalog-list-search-by-thread-id-prefix",
|
||||
ambiguity: "multiple-results-or-next-cursor",
|
||||
} as const;
|
||||
|
||||
describe("SessionCatalogShareRouteSchema", () => {
|
||||
it("accepts only the closed prefix-search contract", () => {
|
||||
expect(Value.Check(SessionCatalogShareRouteSchema, SHARE_ROUTE)).toBe(true);
|
||||
for (const invalid of [
|
||||
{ ...SHARE_ROUTE, kind: "future-route" },
|
||||
{ ...SHARE_ROUTE, identifierAlphabet: "hex" },
|
||||
{ ...SHARE_ROUTE, fullLength: 64 },
|
||||
{ ...SHARE_ROUTE, minPrefixLength: 8 },
|
||||
{ ...SHARE_ROUTE, lookup: "arbitrary-search" },
|
||||
{ ...SHARE_ROUTE, ambiguity: "first-result" },
|
||||
{ ...SHARE_ROUTE, unexpected: true },
|
||||
]) {
|
||||
expect(Value.Check(SessionCatalogShareRouteSchema, invalid)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("SessionsCatalogListResultSchema", () => {
|
||||
it("accepts a closed catalog result with hosts", () => {
|
||||
expect(
|
||||
@@ -25,6 +54,7 @@ describe("SessionsCatalogListResultSchema", () => {
|
||||
},
|
||||
openTerminal: true,
|
||||
},
|
||||
shareRoute: SHARE_ROUTE,
|
||||
hosts: [
|
||||
{
|
||||
hostId: "gateway:local",
|
||||
|
||||
@@ -28,8 +28,14 @@ export const SessionCatalogCapabilitiesSchema = closedObject({
|
||||
});
|
||||
|
||||
export const SessionCatalogShareRouteSchema = closedObject({
|
||||
kind: Type.Literal("thread-id-prefix"),
|
||||
routeSegment: Type.String({ pattern: "^[a-z][a-z0-9-]*$" }),
|
||||
hostId: NonEmptyString,
|
||||
identifierAlphabet: Type.Literal("lowercase-hex"),
|
||||
fullLength: Type.Literal(32),
|
||||
minPrefixLength: Type.Literal(12),
|
||||
lookup: Type.Literal("catalog-list-search-by-thread-id-prefix"),
|
||||
ambiguity: Type.Literal("multiple-results-or-next-cursor"),
|
||||
});
|
||||
|
||||
export const SessionCatalogDescriptorSchema = closedObject({
|
||||
|
||||
@@ -200,6 +200,7 @@ export const validateSecretsStoreSetParams = compile(S.SecretsStoreSetParamsSche
|
||||
export const validateSecretsStoreDeleteParams = compile(S.SecretsStoreDeleteParamsSchema);
|
||||
export const validateSecretsStoreMutationResult = compile(S.SecretsStoreMutationResultSchema);
|
||||
export const validateSessionsListParams = compile(S.SessionsListParamsSchema);
|
||||
export const validateSessionCatalogShareRoute = compile(S.SessionCatalogShareRouteSchema);
|
||||
export const validateSessionsCatalogListParams = compile(S.SessionsCatalogListParamsSchema);
|
||||
export const validateSessionsCatalogReadParams = compile(S.SessionsCatalogReadParamsSchema);
|
||||
export const validateSessionsCatalogContinueParams = compile(S.SessionsCatalogContinueParamsSchema);
|
||||
|
||||
@@ -6,6 +6,17 @@ import {
|
||||
controlUiSessionSlug,
|
||||
} from "./index.js";
|
||||
|
||||
const SHARE_ROUTE = {
|
||||
kind: "thread-id-prefix",
|
||||
routeSegment: "beam",
|
||||
hostId: "gateway",
|
||||
identifierAlphabet: "lowercase-hex",
|
||||
fullLength: 32,
|
||||
minPrefixLength: 12,
|
||||
lookup: "catalog-list-search-by-thread-id-prefix",
|
||||
ambiguity: "multiple-results-or-next-cursor",
|
||||
} as const;
|
||||
|
||||
type ChatParams = Omit<Parameters<typeof buildControlUiSessionPath>[0], "namespace">;
|
||||
|
||||
const UUID_KEY = "agent:main:dashboard:12345678-90ab-cdef-1234-567890abcdef";
|
||||
@@ -85,8 +96,8 @@ describe("buildControlUiCatalogSharePath", () => {
|
||||
])("builds a lowercase 12-character share id for $label", ({ basePath, expected }) => {
|
||||
expect(
|
||||
buildControlUiCatalogSharePath({
|
||||
routeSegment: "beam",
|
||||
threadId: "0123456789ABCDEF0123456789ABCDEF",
|
||||
shareRoute: SHARE_ROUTE,
|
||||
threadId: "0123456789abcdef0123456789abcdef",
|
||||
basePath,
|
||||
}),
|
||||
).toBe(expected);
|
||||
@@ -95,20 +106,27 @@ describe("buildControlUiCatalogSharePath", () => {
|
||||
it("can retain the full id for an unambiguous fallback", () => {
|
||||
expect(
|
||||
buildControlUiCatalogSharePath({
|
||||
routeSegment: "beam",
|
||||
shareRoute: SHARE_ROUTE,
|
||||
threadId: "0123456789abcdef0123456789abcdef",
|
||||
shortIdLength: 32,
|
||||
prefixLength: SHARE_ROUTE.fullLength,
|
||||
}),
|
||||
).toBe("/beam/0123456789abcdef0123456789abcdef");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ routeSegment: "Beam", threadId: "0123456789abcdef0123456789abcdef" },
|
||||
{ routeSegment: "beam/extra", threadId: "0123456789abcdef0123456789abcdef" },
|
||||
{ routeSegment: "beam", threadId: "0123456789ab" },
|
||||
{ routeSegment: "beam", threadId: "not-hex" },
|
||||
])("rejects invalid catalog share input %#", (params) => {
|
||||
expect(buildControlUiCatalogSharePath(params)).toBeNull();
|
||||
{
|
||||
shareRoute: { ...SHARE_ROUTE, routeSegment: "Beam" },
|
||||
threadId: "0123456789abcdef0123456789abcdef",
|
||||
},
|
||||
{
|
||||
shareRoute: { ...SHARE_ROUTE, routeSegment: "beam/extra" },
|
||||
threadId: "0123456789abcdef0123456789abcdef",
|
||||
},
|
||||
{ shareRoute: SHARE_ROUTE, threadId: "0123456789ab" },
|
||||
{ shareRoute: SHARE_ROUTE, threadId: "0123456789ABCDEF0123456789ABCDEF" },
|
||||
{ shareRoute: SHARE_ROUTE, threadId: "not-hex" },
|
||||
])("rejects invalid catalog share input %#", ({ shareRoute, threadId }) => {
|
||||
expect(buildControlUiCatalogSharePath({ shareRoute, threadId })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -11,11 +11,10 @@ export { normalizeControlUiBasePath };
|
||||
export * from "./focus.js";
|
||||
export {
|
||||
buildControlUiCatalogSharePath,
|
||||
CONTROL_UI_CATALOG_SHARE_FULL_ID_LENGTH,
|
||||
CONTROL_UI_CATALOG_SHARE_SHORT_ID_LENGTH,
|
||||
isControlUiCatalogShareId,
|
||||
isControlUiCatalogShareRouteSegment,
|
||||
matchControlUiCatalogSharePath,
|
||||
type ControlUiCatalogShareRoute,
|
||||
type ControlUiCatalogSharePathMatch,
|
||||
} from "./share.js";
|
||||
|
||||
|
||||
@@ -300,7 +300,6 @@ describe("matchControlUiCatalogSharePath", () => {
|
||||
expect(matchControlUiCatalogSharePath({ pathname, basePath })).toEqual({
|
||||
routeSegment: "beam",
|
||||
shortId,
|
||||
valid: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -310,11 +309,10 @@ describe("matchControlUiCatalogSharePath", () => {
|
||||
"/beam/0123456789abcdef0123456789abcdef0",
|
||||
"/beam/not-hex-value",
|
||||
"/beam/0123456789ab/extra",
|
||||
])("classifies strict invalid form %s", (pathname) => {
|
||||
])("parses the route owner before descriptor validation for %s", (pathname) => {
|
||||
expect(matchControlUiCatalogSharePath({ pathname })).toEqual({
|
||||
routeSegment: "beam",
|
||||
shortId: pathname.slice("/beam/".length),
|
||||
valid: false,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,21 +1,34 @@
|
||||
import { normalizeNullableString } from "@openclaw/normalization-core/string-coerce";
|
||||
import { normalizeControlUiBasePath } from "./grammar.js";
|
||||
|
||||
export const CONTROL_UI_CATALOG_SHARE_SHORT_ID_LENGTH = 12;
|
||||
export const CONTROL_UI_CATALOG_SHARE_FULL_ID_LENGTH = 32;
|
||||
|
||||
const CATALOG_SHARE_ID_RE = /^[0-9a-f]{12,32}$/u;
|
||||
const CATALOG_SHARE_THREAD_ID_RE = /^[0-9a-f]{32}$/iu;
|
||||
const LOWERCASE_HEX_RE = /^[0-9a-f]+$/u;
|
||||
const CATALOG_SHARE_ROUTE_SEGMENT_RE = /^[a-z][a-z0-9-]*$/u;
|
||||
|
||||
export type ControlUiCatalogShareRoute = {
|
||||
kind: "thread-id-prefix";
|
||||
routeSegment: string;
|
||||
hostId: string;
|
||||
identifierAlphabet: "lowercase-hex";
|
||||
fullLength: 32;
|
||||
minPrefixLength: 12;
|
||||
lookup: "catalog-list-search-by-thread-id-prefix";
|
||||
ambiguity: "multiple-results-or-next-cursor";
|
||||
};
|
||||
|
||||
export type ControlUiCatalogSharePathMatch = {
|
||||
routeSegment: string;
|
||||
shortId: string;
|
||||
valid: boolean;
|
||||
};
|
||||
|
||||
export function isControlUiCatalogShareId(value: string): boolean {
|
||||
return CATALOG_SHARE_ID_RE.test(value);
|
||||
export function isControlUiCatalogShareId(
|
||||
shareRoute: ControlUiCatalogShareRoute,
|
||||
value: string,
|
||||
): boolean {
|
||||
return (
|
||||
value.length >= shareRoute.minPrefixLength &&
|
||||
value.length <= shareRoute.fullLength &&
|
||||
LOWERCASE_HEX_RE.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
export function isControlUiCatalogShareRouteSegment(value: string): boolean {
|
||||
@@ -43,30 +56,31 @@ export function matchControlUiCatalogSharePath(params: {
|
||||
return {
|
||||
routeSegment,
|
||||
shortId,
|
||||
valid: idSegments.length === 1 && isControlUiCatalogShareId(shortId),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildControlUiCatalogSharePath(params: {
|
||||
routeSegment: string;
|
||||
shareRoute: ControlUiCatalogShareRoute;
|
||||
threadId: string;
|
||||
basePath?: string;
|
||||
shortIdLength?: number;
|
||||
prefixLength?: number;
|
||||
}): string | null {
|
||||
const threadId = normalizeNullableString(params.threadId);
|
||||
const shareRoute = params.shareRoute;
|
||||
if (
|
||||
!threadId ||
|
||||
!isControlUiCatalogShareRouteSegment(params.routeSegment) ||
|
||||
!CATALOG_SHARE_THREAD_ID_RE.test(threadId)
|
||||
!isControlUiCatalogShareRouteSegment(shareRoute.routeSegment) ||
|
||||
threadId.length !== shareRoute.fullLength ||
|
||||
!LOWERCASE_HEX_RE.test(threadId)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const length = Math.min(
|
||||
CONTROL_UI_CATALOG_SHARE_FULL_ID_LENGTH,
|
||||
shareRoute.fullLength,
|
||||
Math.max(
|
||||
CONTROL_UI_CATALOG_SHARE_SHORT_ID_LENGTH,
|
||||
Math.floor(params.shortIdLength ?? CONTROL_UI_CATALOG_SHARE_SHORT_ID_LENGTH),
|
||||
shareRoute.minPrefixLength,
|
||||
Math.floor(params.prefixLength ?? shareRoute.minPrefixLength),
|
||||
),
|
||||
);
|
||||
return `${normalizeControlUiBasePath(params.basePath)}/${params.routeSegment}/${threadId.toLowerCase().slice(0, length)}`;
|
||||
return `${normalizeControlUiBasePath(params.basePath)}/${shareRoute.routeSegment}/${threadId.slice(0, length)}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { SessionCatalogProvider } from "../../plugins/session-catalog.js";
|
||||
|
||||
export const TEST_SESSION_CATALOG_SHARE_ROUTE = {
|
||||
kind: "thread-id-prefix",
|
||||
routeSegment: "shared-sessions",
|
||||
hostId: "gateway",
|
||||
identifierAlphabet: "lowercase-hex",
|
||||
fullLength: 32,
|
||||
minPrefixLength: 12,
|
||||
lookup: "catalog-list-search-by-thread-id-prefix",
|
||||
ambiguity: "multiple-results-or-next-cursor",
|
||||
} as const satisfies NonNullable<SessionCatalogProvider["shareRoute"]>;
|
||||
@@ -5,6 +5,7 @@ import { bindPluginRegistryRuntime } from "../../plugins/registry-runtime-bindin
|
||||
import type { PluginRegistry } from "../../plugins/registry-types.js";
|
||||
import { createPluginRuntime } from "../../plugins/runtime/index.js";
|
||||
import type { SessionCatalogProvider } from "../../plugins/session-catalog.js";
|
||||
import { TEST_SESSION_CATALOG_SHARE_ROUTE as SHARE_ROUTE } from "./session-catalog-share-route.test-support.js";
|
||||
|
||||
type TestPluginRegistry = Omit<PluginRegistry, "sessionCatalogs"> & {
|
||||
sessionCatalogs: Array<{
|
||||
@@ -142,25 +143,25 @@ describe("session catalog Gateway methods", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("projects plugin-owned share routes with catalog results", async () => {
|
||||
hoisted.activeRegistry.sessionCatalogs = [
|
||||
{
|
||||
provider: provider("beam", {
|
||||
shareRoute: { routeSegment: "beam", hostId: "gateway" },
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
const respond = await call("sessions.catalog.list", { catalogId: "beam" });
|
||||
|
||||
expect(respond).toHaveBeenCalledWith(true, {
|
||||
catalogs: [
|
||||
expect.objectContaining({
|
||||
id: "beam",
|
||||
shareRoute: { routeSegment: "beam", hostId: "gateway" },
|
||||
}),
|
||||
],
|
||||
it("projects uniquely owned share routes and suppresses collisions", async () => {
|
||||
hoisted.activeRegistry.sessionCatalogs.push({
|
||||
provider: provider("external", { shareRoute: SHARE_ROUTE }),
|
||||
});
|
||||
let catalogs = (
|
||||
(await call("sessions.catalog.list", { catalogId: "external" })).mock.calls[0]![1] as {
|
||||
catalogs: Array<{ shareRoute?: unknown }>;
|
||||
}
|
||||
).catalogs;
|
||||
expect(catalogs[0]?.shareRoute).toEqual(SHARE_ROUTE);
|
||||
|
||||
hoisted.activeRegistry.sessionCatalogs = [
|
||||
{ provider: provider("first", { shareRoute: SHARE_ROUTE }) },
|
||||
{ provider: provider("second", { shareRoute: SHARE_ROUTE }) },
|
||||
];
|
||||
catalogs = (
|
||||
(await call("sessions.catalog.list", {})).mock.calls[0]![1] as { catalogs: typeof catalogs }
|
||||
).catalogs;
|
||||
expect(catalogs.every((catalog) => catalog.shareRoute === undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it("streams completed hosts to only the requesting connection", async () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
errorShape,
|
||||
type SessionCatalog,
|
||||
type SessionCatalogLocator,
|
||||
type SessionCatalogShareRoute,
|
||||
type SessionsCatalogArchiveParams,
|
||||
type SessionsCatalogContinueParams,
|
||||
type SessionsCatalogListParams,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
validateSessionsCatalogContinueParams,
|
||||
validateSessionsCatalogListParams,
|
||||
validateSessionsCatalogReadParams,
|
||||
validateSessionCatalogShareRoute,
|
||||
} from "../../../packages/gateway-protocol/src/index.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { pruneMapToMaxSize } from "../../infra/map-size.js";
|
||||
@@ -75,6 +77,7 @@ type CatalogRegistrationSnapshot = {
|
||||
source: PluginRegistry["sessionCatalogs"] | undefined;
|
||||
registrations: PluginRegistry["sessionCatalogs"];
|
||||
providers: SessionCatalogProvider[];
|
||||
shareRoutes: ReadonlyMap<SessionCatalogProvider, SessionCatalogShareRoute>;
|
||||
};
|
||||
|
||||
let cachedCatalogRegistrations: CatalogRegistrationSnapshot | undefined;
|
||||
@@ -91,6 +94,21 @@ function catalogRegistrationSnapshot(): CatalogRegistrationSnapshot {
|
||||
const sortedRegistrations = (source ?? []).toSorted((left, right) =>
|
||||
left.provider.id.localeCompare(right.provider.id),
|
||||
);
|
||||
const providerList = sortedRegistrations.map((entry) => entry.provider);
|
||||
const validRoutes = providerList.flatMap((provider) =>
|
||||
provider.shareRoute && validateSessionCatalogShareRoute(provider.shareRoute)
|
||||
? [{ provider, route: provider.shareRoute }]
|
||||
: [],
|
||||
);
|
||||
const routeCounts = new Map<string, number>();
|
||||
for (const { route } of validRoutes) {
|
||||
routeCounts.set(route.routeSegment, (routeCounts.get(route.routeSegment) ?? 0) + 1);
|
||||
}
|
||||
const shareRoutes = new Map(
|
||||
validRoutes
|
||||
.filter(({ route }) => routeCounts.get(route.routeSegment) === 1)
|
||||
.map(({ provider, route }) => [provider, route] as const),
|
||||
);
|
||||
// Plugin registration arrays are process-stable until the active registry seam changes. Hoisting
|
||||
// this sort avoids rebuilding identical order every poll; registry/list identity invalidates it.
|
||||
// A stale snapshot would route requests to retired plugin instances, so callers share this owner.
|
||||
@@ -98,7 +116,8 @@ function catalogRegistrationSnapshot(): CatalogRegistrationSnapshot {
|
||||
registry,
|
||||
source,
|
||||
registrations: sortedRegistrations,
|
||||
providers: sortedRegistrations.map((entry) => entry.provider),
|
||||
providers: providerList,
|
||||
shareRoutes,
|
||||
};
|
||||
return cachedCatalogRegistrations;
|
||||
}
|
||||
@@ -310,6 +329,7 @@ function registrationOrRespond(catalogId: string, respond: RespondFn) {
|
||||
|
||||
function catalogResult(
|
||||
provider: SessionCatalogProvider,
|
||||
shareRoute: SessionCatalogShareRoute | undefined,
|
||||
hosts: SessionCatalog["hosts"],
|
||||
error?: SessionCatalog["error"],
|
||||
createSession?: NonNullable<SessionCatalog["capabilities"]["createSession"]>,
|
||||
@@ -323,7 +343,7 @@ function catalogResult(
|
||||
...(provider.openTerminal ? { openTerminal: true } : {}),
|
||||
...(createSession ? { createSession } : {}),
|
||||
},
|
||||
...(provider.shareRoute ? { shareRoute: provider.shareRoute } : {}),
|
||||
...(shareRoute ? { shareRoute } : {}),
|
||||
hosts,
|
||||
};
|
||||
if (error) {
|
||||
@@ -429,6 +449,7 @@ export const sessionCatalogHandlers: GatewayRequestHandlers = {
|
||||
const listNodes = createSessionCatalogRequestNodeSnapshot();
|
||||
const catalogList = await Promise.all(
|
||||
selected.map(async (provider): Promise<SessionCatalog> => {
|
||||
const shareRoute = catalogRegistrations.shareRoutes.get(provider);
|
||||
const createTarget = resolveProviderCreateTarget(provider, resolvedAgent.agentId, config);
|
||||
const createSession = createTarget.ok
|
||||
? {
|
||||
@@ -441,7 +462,13 @@ export const sessionCatalogHandlers: GatewayRequestHandlers = {
|
||||
requestEntries.projectHostCreatedActors(host),
|
||||
visibility,
|
||||
);
|
||||
const catalog = catalogResult(provider, [visibleHost], undefined, createSession);
|
||||
const catalog = catalogResult(
|
||||
provider,
|
||||
shareRoute,
|
||||
[visibleHost],
|
||||
undefined,
|
||||
createSession,
|
||||
);
|
||||
// Progressive frames are an optimization. The final RPC response remains
|
||||
// authoritative when a slow client drops an intermediate host update.
|
||||
for (const subscriber of progressSubscribers.values()) {
|
||||
@@ -471,6 +498,7 @@ export const sessionCatalogHandlers: GatewayRequestHandlers = {
|
||||
});
|
||||
return catalogResult(
|
||||
provider,
|
||||
shareRoute,
|
||||
hosts.map((host) =>
|
||||
filterSessionCatalogHost(requestEntries.projectHostCreatedActors(host), visibility),
|
||||
),
|
||||
@@ -478,7 +506,7 @@ export const sessionCatalogHandlers: GatewayRequestHandlers = {
|
||||
createSession,
|
||||
);
|
||||
} catch (error) {
|
||||
return catalogResult(provider, [], catalogError(error), createSession);
|
||||
return catalogResult(provider, shareRoute, [], catalogError(error), createSession);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
sessionCatalogPaging,
|
||||
sessionCatalogAdoptedSourceKey,
|
||||
type SessionCatalogFamilyOptions,
|
||||
type SessionCatalogProvider,
|
||||
type SessionCatalogSession,
|
||||
} from "./session-catalog.js";
|
||||
|
||||
@@ -26,6 +27,32 @@ const session = (threadId: string): SessionCatalogSession => ({
|
||||
});
|
||||
|
||||
describe("session catalog SDK", () => {
|
||||
it("exposes the closed share-route contract to external providers", () => {
|
||||
const shareRoute = {
|
||||
kind: "thread-id-prefix",
|
||||
routeSegment: "shared-sessions",
|
||||
hostId: "gateway",
|
||||
identifierAlphabet: "lowercase-hex",
|
||||
fullLength: 32,
|
||||
minPrefixLength: 12,
|
||||
lookup: "catalog-list-search-by-thread-id-prefix",
|
||||
ambiguity: "multiple-results-or-next-cursor",
|
||||
} as const satisfies NonNullable<SessionCatalogProvider["shareRoute"]>;
|
||||
const provider = {
|
||||
id: "external",
|
||||
label: "External",
|
||||
shareRoute,
|
||||
async list() {
|
||||
return [];
|
||||
},
|
||||
async read({ hostId, threadId }) {
|
||||
return { hostId, threadId, items: [] };
|
||||
},
|
||||
} satisfies SessionCatalogProvider;
|
||||
|
||||
expect(provider.shareRoute).toEqual(shareRoute);
|
||||
});
|
||||
|
||||
it("owns canonical list/read parameter and cursor parsing", () => {
|
||||
const cursor = sessionCatalogPaging.encodeCursor(2);
|
||||
expect(
|
||||
|
||||
@@ -173,7 +173,7 @@ type SessionCatalogCreateParams = {
|
||||
export type SessionCatalogProvider = {
|
||||
id: string;
|
||||
label: string;
|
||||
/** Optional plugin-owned pretty route backed by exact prefix search on one catalog host. */
|
||||
/** Closed plugin-owned route contract; invalid or colliding declarations are not projected. */
|
||||
shareRoute?: SessionCatalogShareRoute;
|
||||
/** Declares that every HOME-sensitive action honors the host isolation policy. */
|
||||
supportsProcessHomeIsolation?: true;
|
||||
|
||||
@@ -108,6 +108,10 @@ describe("Dynamic route startup bridge", () => {
|
||||
expect(inferBasePathFromPathname("/openclaw/beam/0123456789ab")).toBe("/openclaw");
|
||||
expect(routeIdFromPath("/settings/about")).toBe("about");
|
||||
expect(routeIdFromPath("/workboard/0123456789ab")).toBe("workboard");
|
||||
expect(routeIdFromPath("/plugin/0123456789ab")).toBeNull();
|
||||
expect(routeIdFromPath("/usage/0123456789ab")).toBeNull();
|
||||
expect(routeIdFromPath("/settings/0123456789ab")).toBeNull();
|
||||
expect(routeIdFromPath("/openclaw/skills/0123456789ab", "/openclaw")).toBeNull();
|
||||
});
|
||||
|
||||
it("registers the Updates settings path", () => {
|
||||
|
||||
@@ -77,11 +77,23 @@ const APP_ROUTE_DEFINITIONS = {
|
||||
|
||||
export type RouteId = keyof typeof APP_ROUTE_DEFINITIONS;
|
||||
export const APP_ROUTE_IDS = Object.keys(APP_ROUTE_DEFINITIONS) as RouteId[];
|
||||
const RESERVED_APP_ROUTE_SEGMENTS = new Set(
|
||||
APP_ROUTE_IDS.flatMap((routeId) => {
|
||||
const definition = APP_ROUTE_DEFINITIONS[routeId];
|
||||
const paths: readonly string[] =
|
||||
"aliases" in definition ? [definition.path, ...definition.aliases] : [definition.path];
|
||||
return paths.map((path) => path.split("/").find(Boolean)).filter(Boolean);
|
||||
}),
|
||||
);
|
||||
|
||||
export function isRouteId(routeId: string): routeId is RouteId {
|
||||
return routeId in APP_ROUTE_DEFINITIONS;
|
||||
}
|
||||
|
||||
export function isReservedAppRouteSegment(segment: string): boolean {
|
||||
return RESERVED_APP_ROUTE_SEGMENTS.has(segment.toLowerCase());
|
||||
}
|
||||
|
||||
// Single source for page definitions: ui/src/pages/*/route.ts spreads this
|
||||
// into definePage so router matching can never drift from the table that
|
||||
// drives routeIdFromPath and base-path inference.
|
||||
@@ -239,7 +251,11 @@ export function sessionRouteNamespaceFromPath(pathname: string, basePath = ""):
|
||||
pathname: normalizedPath,
|
||||
basePath: normalizedBasePath,
|
||||
});
|
||||
return catalogShare && !catalogShare.shortId.includes("/") ? "chat" : null;
|
||||
return catalogShare &&
|
||||
!catalogShare.shortId.includes("/") &&
|
||||
!isReservedAppRouteSegment(catalogShare.routeSegment)
|
||||
? "chat"
|
||||
: null;
|
||||
}
|
||||
|
||||
export function workboardBoardIdFromPath(pathname: string, basePath = ""): string | null {
|
||||
|
||||
@@ -10,6 +10,17 @@ const suite = createControlUiE2eSuite({
|
||||
unavailableMessage: (executablePath) => `Playwright Chromium is unavailable at ${executablePath}`,
|
||||
});
|
||||
|
||||
const SHARE_ROUTE = {
|
||||
kind: "thread-id-prefix",
|
||||
routeSegment: "beam",
|
||||
hostId: "gateway",
|
||||
identifierAlphabet: "lowercase-hex",
|
||||
fullLength: 32,
|
||||
minPrefixLength: 12,
|
||||
lookup: "catalog-list-search-by-thread-id-prefix",
|
||||
ambiguity: "multiple-results-or-next-cursor",
|
||||
} as const;
|
||||
|
||||
suite.define(() => {
|
||||
it("opens and refreshes a base-path Beam share without replacing its pretty URL", async () => {
|
||||
const artifactDir = path.resolve(".artifacts/control-ui-e2e/beam-share-url");
|
||||
@@ -36,7 +47,7 @@ suite.define(() => {
|
||||
id: "beam",
|
||||
label: "Beam",
|
||||
capabilities: { continueSession: false, archive: false },
|
||||
shareRoute: { routeSegment: "beam", hostId: "gateway" },
|
||||
shareRoute: SHARE_ROUTE,
|
||||
hosts: [
|
||||
{
|
||||
hostId: "gateway",
|
||||
|
||||
@@ -10,6 +10,17 @@ import {
|
||||
sessionNavigationTarget,
|
||||
} from "./route-navigation.ts";
|
||||
|
||||
const SHARE_ROUTE = {
|
||||
kind: "thread-id-prefix",
|
||||
routeSegment: "beam",
|
||||
hostId: "gateway",
|
||||
identifierAlphabet: "lowercase-hex",
|
||||
fullLength: 32,
|
||||
minPrefixLength: 12,
|
||||
lookup: "catalog-list-search-by-thread-id-prefix",
|
||||
ambiguity: "multiple-results-or-next-cursor",
|
||||
} as const;
|
||||
|
||||
describe("sessionNavigationTarget", () => {
|
||||
it("defaults generic opens to chat and honors a stored dashboard preference", () => {
|
||||
expect(resolveSessionPreferredFace(undefined)).toBe("chat");
|
||||
@@ -54,7 +65,7 @@ describe("sessionNavigationTarget", () => {
|
||||
}),
|
||||
fallbackAgentId: "main",
|
||||
basePath: "/openclaw",
|
||||
catalogShareRoute: { routeSegment: "beam", hostId: "gateway" },
|
||||
catalogShareRoute: SHARE_ROUTE,
|
||||
});
|
||||
|
||||
expect(target).toEqual({
|
||||
@@ -63,6 +74,23 @@ describe("sessionNavigationTarget", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps generic catalog URLs when a descriptor collides with a built-in route", () => {
|
||||
const target = sessionNavigationTarget({
|
||||
face: "chat",
|
||||
sessionKey: buildCatalogSessionKey({
|
||||
catalogId: "external",
|
||||
hostId: "gateway",
|
||||
threadId: "0123456789abcdef0123456789abcdef",
|
||||
}),
|
||||
fallbackAgentId: "main",
|
||||
catalogShareRoute: { ...SHARE_ROUTE, routeSegment: "plugin" },
|
||||
});
|
||||
|
||||
expect(target.href).toBe(
|
||||
"/chat/main?catalog=external&host=gateway&thread=0123456789abcdef0123456789abcdef",
|
||||
);
|
||||
});
|
||||
|
||||
it("requires the destination face while preserving catalog identity", () => {
|
||||
const catalogKey = {
|
||||
catalogId: "claude",
|
||||
|
||||
@@ -2,7 +2,7 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coe
|
||||
import { buildControlUiCatalogSharePath } from "@openclaw/session-url-contract/share";
|
||||
import type { SessionCatalog } from "../../../../packages/gateway-protocol/src/index.ts";
|
||||
import type { GatewaySessionRow } from "../../api/types.ts";
|
||||
import { pathForRoute } from "../../app-route-paths.ts";
|
||||
import { isReservedAppRouteSegment, pathForRoute } from "../../app-route-paths.ts";
|
||||
import { pathForSession } from "../../app-session-path-builder.ts";
|
||||
import type { ApplicationNavigationOptions, ApplicationContext } from "../../app/context.ts";
|
||||
import type { BoardFace } from "../board/settings.ts";
|
||||
@@ -170,9 +170,11 @@ export function sessionNavigationTarget<TRouteId extends string>(
|
||||
const catalogKey = parseCatalogSessionKey(row?.key ?? sessionKey);
|
||||
const catalogShareRoute = params.catalogShareRoute;
|
||||
const catalogSharePath =
|
||||
catalogKey && catalogShareRoute?.hostId === catalogKey.hostId
|
||||
catalogKey &&
|
||||
catalogShareRoute?.hostId === catalogKey.hostId &&
|
||||
!isReservedAppRouteSegment(catalogShareRoute.routeSegment)
|
||||
? buildControlUiCatalogSharePath({
|
||||
...catalogShareRoute,
|
||||
shareRoute: catalogShareRoute,
|
||||
threadId: catalogKey.threadId,
|
||||
basePath,
|
||||
})
|
||||
|
||||
@@ -5,6 +5,16 @@ import { buildCatalogSessionKey } from "../../lib/sessions/catalog-key.ts";
|
||||
import { loadChatRoute } from "./route-loader.ts";
|
||||
|
||||
const fullId = "0123456789abcdef0123456789abcdef";
|
||||
const SHARE_ROUTE = {
|
||||
kind: "thread-id-prefix",
|
||||
routeSegment: "beam",
|
||||
hostId: "gateway",
|
||||
identifierAlphabet: "lowercase-hex",
|
||||
fullLength: 32,
|
||||
minPrefixLength: 12,
|
||||
lookup: "catalog-list-search-by-thread-id-prefix",
|
||||
ambiguity: "multiple-results-or-next-cursor",
|
||||
} as const;
|
||||
|
||||
function catalogContext(
|
||||
request: (method: string, params: Record<string, unknown>) => Promise<unknown>,
|
||||
@@ -27,7 +37,7 @@ function beamCatalog(sessions: Array<{ threadId: string; name: string }>, nextCu
|
||||
id: "beam",
|
||||
label: "Beam",
|
||||
capabilities: { continueSession: false, archive: false },
|
||||
shareRoute: { routeSegment: "beam", hostId: "gateway" },
|
||||
shareRoute: SHARE_ROUTE,
|
||||
hosts: [
|
||||
{
|
||||
hostId: "gateway",
|
||||
@@ -50,6 +60,39 @@ function beamCatalog(sessions: Array<{ threadId: string; name: string }>, nextCu
|
||||
}
|
||||
|
||||
describe("catalog share route resolution", () => {
|
||||
it("resolves an external-style provider descriptor without catalog-specific UI policy", async () => {
|
||||
const shareRoute = { ...SHARE_ROUTE, routeSegment: "shared-sessions" } as const;
|
||||
const request = vi.fn(async () => {
|
||||
const result = beamCatalog([{ threadId: fullId, name: "External session" }]);
|
||||
return {
|
||||
catalogs: [
|
||||
{
|
||||
...result.catalogs[0],
|
||||
id: "external",
|
||||
label: "External",
|
||||
shareRoute,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
await expect(
|
||||
loadChatRoute(
|
||||
catalogContext(request),
|
||||
{ pathname: "/shared-sessions/0123456789ab", search: "", hash: "" },
|
||||
"chat",
|
||||
new AbortController().signal,
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
kind: "session",
|
||||
sessionKey: buildCatalogSessionKey({
|
||||
catalogId: "external",
|
||||
hostId: "gateway",
|
||||
threadId: fullId,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves a base-path share independently of the default agent id", async () => {
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method !== "sessions.catalog.list") {
|
||||
@@ -164,4 +207,37 @@ describe("catalog share route resolution", () => {
|
||||
),
|
||||
).resolves.toMatchObject({ kind: "route-error" });
|
||||
});
|
||||
|
||||
it("keeps paginated empty results ambiguous and rejects duplicate declared hosts", async () => {
|
||||
const paginated = catalogContext(vi.fn(async () => beamCatalog([], "more")));
|
||||
await expect(
|
||||
loadChatRoute(
|
||||
paginated,
|
||||
{ pathname: "/beam/0123456789ab", search: "", hash: "" },
|
||||
"chat",
|
||||
new AbortController().signal,
|
||||
),
|
||||
).resolves.toMatchObject({ kind: "ambiguous", candidates: [], truncated: true });
|
||||
|
||||
const duplicateHost = catalogContext(
|
||||
vi.fn(async () => {
|
||||
const result = beamCatalog([{ threadId: fullId, name: "First" }]);
|
||||
const catalog = result.catalogs[0];
|
||||
if (!catalog) {
|
||||
throw new Error("catalog fixture missing");
|
||||
}
|
||||
return {
|
||||
catalogs: [{ ...catalog, hosts: [...catalog.hosts, { ...catalog.hosts[0] }] }],
|
||||
};
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
loadChatRoute(
|
||||
duplicateHost,
|
||||
{ pathname: "/beam/0123456789ab", search: "", hash: "" },
|
||||
"chat",
|
||||
new AbortController().signal,
|
||||
),
|
||||
).resolves.toMatchObject({ kind: "route-error" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import {
|
||||
buildControlUiCatalogSharePath,
|
||||
isControlUiCatalogShareId,
|
||||
matchControlUiCatalogSharePath,
|
||||
} from "@openclaw/session-url-contract/share";
|
||||
import type { RouteLocation } from "@openclaw/uirouter";
|
||||
import type { SessionsCatalogListResult } from "../../../../packages/gateway-protocol/src/index.js";
|
||||
import { INTERNAL_SESSION_PATH_PARAM, isSessionRouteId } from "../../app-route-paths.ts";
|
||||
import { INTERNAL_SESSION_PATH_PARAM, isReservedAppRouteSegment } from "../../app-route-paths.ts";
|
||||
import type { ApplicationContext } from "../../app/context.ts";
|
||||
import { waitForGatewayClient } from "../../app/gateway-readiness.ts";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
@@ -18,7 +19,7 @@ function targetFromLocation(context: ApplicationContext, location: RouteLocation
|
||||
matchControlUiCatalogSharePath({ pathname, basePath: context.basePath });
|
||||
const internalPath = new URLSearchParams(location.search).get(INTERNAL_SESSION_PATH_PARAM);
|
||||
const target = (internalPath ? matchPath(internalPath) : null) ?? matchPath(location.pathname);
|
||||
return target && !isSessionRouteId(target.routeSegment) ? target : null;
|
||||
return target && !isReservedAppRouteSegment(target.routeSegment) ? target : null;
|
||||
}
|
||||
|
||||
function routeError(message: string): Extract<ChatRouteData, { kind: "route-error" }> {
|
||||
@@ -43,7 +44,7 @@ export async function loadCatalogShareRouteFromLocation(
|
||||
});
|
||||
const listed = await client.request<SessionsCatalogListResult>("sessions.catalog.list", {
|
||||
agentId,
|
||||
...(target.valid ? { search: target.shortId } : {}),
|
||||
search: target.shortId,
|
||||
limitPerHost: 2,
|
||||
});
|
||||
signal.throwIfAborted();
|
||||
@@ -55,37 +56,35 @@ export async function loadCatalogShareRouteFromLocation(
|
||||
return routeError(t("chat.sessionRoute.catalogShareUnavailable"));
|
||||
}
|
||||
const shareRoute = catalog.shareRoute;
|
||||
if (!target.valid) {
|
||||
if (!isControlUiCatalogShareId(shareRoute, target.shortId)) {
|
||||
return routeError(t("chat.sessionRoute.catalogShareInvalid", { catalog: catalog.label }));
|
||||
}
|
||||
if (catalog.error) {
|
||||
return routeError(catalog.error.message);
|
||||
}
|
||||
const host = catalog.hosts.find((candidate) => candidate.hostId === shareRoute.hostId);
|
||||
const matchingHosts = catalog.hosts.filter(
|
||||
(candidate) => candidate.hostId === shareRoute.hostId,
|
||||
);
|
||||
const host = matchingHosts.length === 1 ? matchingHosts[0] : undefined;
|
||||
if (!host) {
|
||||
return routeError(t("chat.sessionRoute.catalogShareUnavailable"));
|
||||
}
|
||||
if (host?.error) {
|
||||
return routeError(host.error.message);
|
||||
}
|
||||
const matches = host.sessions.filter((session) =>
|
||||
session.threadId.toLowerCase().startsWith(target.shortId),
|
||||
const matches = host.sessions.filter(
|
||||
(session) =>
|
||||
isControlUiCatalogShareId(shareRoute, session.threadId) &&
|
||||
session.threadId.length === shareRoute.fullLength &&
|
||||
session.threadId.startsWith(target.shortId),
|
||||
);
|
||||
if (matches.length === 0) {
|
||||
return routeError(
|
||||
t("chat.sessionRoute.catalogShareNotFound", {
|
||||
catalog: catalog.label,
|
||||
shortId: target.shortId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (matches.length > 1 || host.nextCursor) {
|
||||
const candidates = matches.flatMap((session) => {
|
||||
const href = buildControlUiCatalogSharePath({
|
||||
routeSegment: shareRoute.routeSegment,
|
||||
shareRoute,
|
||||
threadId: session.threadId,
|
||||
basePath: context.basePath,
|
||||
shortIdLength: 32,
|
||||
prefixLength: shareRoute.fullLength,
|
||||
});
|
||||
return href
|
||||
? [
|
||||
@@ -106,15 +105,23 @@ export async function loadCatalogShareRouteFromLocation(
|
||||
face: "chat",
|
||||
};
|
||||
}
|
||||
if (matches.length === 0) {
|
||||
return routeError(
|
||||
t("chat.sessionRoute.catalogShareNotFound", {
|
||||
catalog: catalog.label,
|
||||
shortId: target.shortId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const session = matches[0];
|
||||
if (!session) {
|
||||
return routeError(t("chat.sessionRoute.catalogShareUnavailable"));
|
||||
}
|
||||
if (
|
||||
!buildControlUiCatalogSharePath({
|
||||
routeSegment: shareRoute.routeSegment,
|
||||
shareRoute,
|
||||
threadId: session.threadId,
|
||||
shortIdLength: 32,
|
||||
prefixLength: shareRoute.fullLength,
|
||||
})
|
||||
) {
|
||||
return routeError(t("chat.sessionRoute.catalogShareUnavailable"));
|
||||
|
||||
Reference in New Issue
Block a user