229 lines
7.3 KiB
TypeScript
229 lines
7.3 KiB
TypeScript
import type { EvidenceSubject } from "./evidenceContext";
|
|
|
|
// Evidence address codec: URL = subject. This file owns the entire grammar —
|
|
// every EvidenceSubject kind is addressable here, including kinds whose routes
|
|
// are still placeholders (the grammar is fixed once; routes grow into it).
|
|
//
|
|
// turn -> /trace/<turnId>
|
|
// proposal -> /proposals/<proposalId>
|
|
// eval_result -> /evals/<laneId>
|
|
// artifact -> /replay/<artifactId>
|
|
// run -> /runs/<sessionId>
|
|
// pack -> /packs/<packId>
|
|
// vault_entry -> /vault?inspect=vault:<entryIndex>
|
|
// audit_event -> /audit?inspect=audit:<eventId>
|
|
//
|
|
// The `inspect` query param carries the inspector's subject as `<kind>:<id>`;
|
|
// its presence means the inspector is open on that subject.
|
|
|
|
export type AddressableSubject = Exclude<EvidenceSubject, { kind: "none" }>;
|
|
|
|
export const INSPECT_PARAM = "inspect";
|
|
|
|
export function isAddressable(
|
|
subject: EvidenceSubject,
|
|
): subject is AddressableSubject {
|
|
return subject.kind !== "none";
|
|
}
|
|
|
|
export function sameIdentity(a: EvidenceSubject, b: EvidenceSubject): boolean {
|
|
switch (a.kind) {
|
|
case "turn":
|
|
return b.kind === "turn" && b.turnId === a.turnId;
|
|
case "proposal":
|
|
return (
|
|
b.kind === "proposal" &&
|
|
b.proposalId === a.proposalId &&
|
|
(b.domain ?? "cognition") === (a.domain ?? "cognition")
|
|
);
|
|
case "eval_result":
|
|
return b.kind === "eval_result" && b.lane === a.lane;
|
|
case "artifact":
|
|
return b.kind === "artifact" && b.artifactId === a.artifactId;
|
|
case "run":
|
|
return b.kind === "run" && b.sessionId === a.sessionId;
|
|
case "pack":
|
|
return b.kind === "pack" && b.packId === a.packId;
|
|
case "vault_entry":
|
|
return b.kind === "vault_entry" && b.entryIndex === a.entryIndex;
|
|
case "audit_event":
|
|
return b.kind === "audit_event" && b.eventId === a.eventId;
|
|
case "none":
|
|
return b.kind === "none";
|
|
}
|
|
}
|
|
|
|
interface SubjectAddress {
|
|
path: string;
|
|
params: URLSearchParams;
|
|
}
|
|
|
|
function emptyParams(): URLSearchParams {
|
|
return new URLSearchParams();
|
|
}
|
|
|
|
function subjectAddress(subject: AddressableSubject): SubjectAddress {
|
|
switch (subject.kind) {
|
|
case "turn":
|
|
return { path: `/trace/${subject.turnId}`, params: emptyParams() };
|
|
case "proposal":
|
|
{
|
|
const params = emptyParams();
|
|
if (subject.domain === "math") params.set("domain", "math");
|
|
return {
|
|
path: `/proposals/${encodeURIComponent(subject.proposalId)}`,
|
|
params,
|
|
};
|
|
}
|
|
case "eval_result":
|
|
return { path: `/evals/${encodeURIComponent(subject.lane)}`, params: emptyParams() };
|
|
case "artifact":
|
|
return { path: `/replay/${encodeURIComponent(subject.artifactId)}`, params: emptyParams() };
|
|
case "run":
|
|
return { path: `/runs/${encodeURIComponent(subject.sessionId)}`, params: emptyParams() };
|
|
case "pack":
|
|
return { path: `/packs/${encodeURIComponent(subject.packId)}`, params: emptyParams() };
|
|
case "vault_entry":
|
|
{
|
|
const params = emptyParams();
|
|
params.set(INSPECT_PARAM, subjectToInspectValue(subject));
|
|
return { path: "/vault", params };
|
|
}
|
|
case "audit_event":
|
|
{
|
|
const params = emptyParams();
|
|
params.set(INSPECT_PARAM, subjectToInspectValue(subject));
|
|
return { path: "/audit", params };
|
|
}
|
|
}
|
|
}
|
|
|
|
export function subjectToInspectValue(subject: AddressableSubject): string {
|
|
switch (subject.kind) {
|
|
case "turn":
|
|
return `turn:${subject.turnId}`;
|
|
case "proposal":
|
|
return `proposal:${subject.proposalId}`;
|
|
case "eval_result":
|
|
return `eval_result:${subject.lane}`;
|
|
case "artifact":
|
|
return `artifact:${subject.artifactId}`;
|
|
case "run":
|
|
return `run:${subject.sessionId}`;
|
|
case "pack":
|
|
return `pack:${subject.packId}`;
|
|
case "vault_entry":
|
|
return `vault:${subject.entryIndex}`;
|
|
case "audit_event":
|
|
return `audit:${subject.eventId}`;
|
|
}
|
|
}
|
|
|
|
export function subjectToUrl(
|
|
subject: AddressableSubject,
|
|
inspect?: EvidenceSubject | null,
|
|
): string {
|
|
const address = subjectAddress(subject);
|
|
const params = new URLSearchParams(address.params);
|
|
if (inspect && isAddressable(inspect) && !sameIdentity(subject, inspect)) {
|
|
params.set(INSPECT_PARAM, subjectToInspectValue(inspect));
|
|
}
|
|
const query = params.toString();
|
|
return query ? `${address.path}?${query}` : address.path;
|
|
}
|
|
|
|
function parseTurnId(raw: string): number | null {
|
|
if (!/^\d+$/.test(raw)) return null;
|
|
const value = Number(raw);
|
|
return Number.isSafeInteger(value) ? value : null;
|
|
}
|
|
|
|
export function inspectValueToSubject(
|
|
value: string | null,
|
|
): AddressableSubject | null {
|
|
if (value === null) return null;
|
|
const sep = value.indexOf(":");
|
|
if (sep <= 0 || sep === value.length - 1) return null;
|
|
const kind = value.slice(0, sep);
|
|
const id = value.slice(sep + 1);
|
|
switch (kind) {
|
|
case "turn": {
|
|
const turnId = parseTurnId(id);
|
|
return turnId === null ? null : { kind: "turn", turnId };
|
|
}
|
|
case "proposal":
|
|
return { kind: "proposal", proposalId: id };
|
|
case "eval_result":
|
|
return { kind: "eval_result", lane: id };
|
|
case "artifact":
|
|
return { kind: "artifact", artifactId: id };
|
|
case "run":
|
|
return { kind: "run", sessionId: id };
|
|
case "pack":
|
|
return { kind: "pack", packId: id };
|
|
case "vault": {
|
|
const entryIndex = parseTurnId(id);
|
|
return entryIndex === null ? null : { kind: "vault_entry", entryIndex };
|
|
}
|
|
case "audit":
|
|
return { kind: "audit_event", eventId: id };
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function routeParamsToSubject(
|
|
params: Readonly<Record<string, string | undefined>>,
|
|
searchParams: URLSearchParams,
|
|
): AddressableSubject | null {
|
|
// React Router populates exactly one of these keys per matched route;
|
|
// precedence below only matters for hand-built (malformed) inputs.
|
|
if (params.turnId !== undefined) {
|
|
const turnId = parseTurnId(params.turnId);
|
|
return turnId === null ? null : { kind: "turn", turnId };
|
|
}
|
|
if (params.proposalId !== undefined) {
|
|
if (params.proposalId === "") return null;
|
|
return searchParams.get("domain") === "math"
|
|
? { kind: "proposal", proposalId: params.proposalId, domain: "math" }
|
|
: { kind: "proposal", proposalId: params.proposalId };
|
|
}
|
|
if (params.sessionId !== undefined) {
|
|
return params.sessionId === ""
|
|
? null
|
|
: { kind: "run", sessionId: params.sessionId };
|
|
}
|
|
if (params.packId !== undefined) {
|
|
return params.packId === ""
|
|
? null
|
|
: { kind: "pack", packId: params.packId };
|
|
}
|
|
if (params.laneId !== undefined) {
|
|
return params.laneId === ""
|
|
? null
|
|
: { kind: "eval_result", lane: params.laneId };
|
|
}
|
|
if (params.artifactId !== undefined) {
|
|
return params.artifactId === ""
|
|
? null
|
|
: { kind: "artifact", artifactId: params.artifactId };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export interface UrlSubjects {
|
|
route: AddressableSubject | null;
|
|
inspect: AddressableSubject | null;
|
|
}
|
|
|
|
// Total inverse of subjectToUrl: malformed input yields null, never throws.
|
|
// Returned subjects carry identity only — `data` loads via the route's query.
|
|
export function urlToSubject(
|
|
params: Readonly<Record<string, string | undefined>>,
|
|
searchParams: URLSearchParams,
|
|
): UrlSubjects {
|
|
return {
|
|
route: routeParamsToSubject(params, searchParams),
|
|
inspect: inspectValueToSubject(searchParams.get(INSPECT_PARAM)),
|
|
};
|
|
}
|