ccf10af5bd
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
442 lines
20 KiB
TypeScript
442 lines
20 KiB
TypeScript
/*
|
|
* SPEC: PJ2026-010405 云端控制台;PJ2026-010103 HWPOD 服务。
|
|
* Implementation reference: draft-2026-07-13-p0-cloud-console。
|
|
* Responsibility: 由 Cloud API HWPOD domain 生成有界 typed Node/设备 topology,不让浏览器拼装业务真相。
|
|
*/
|
|
|
|
export const HWPOD_TOPOLOGY_CONTRACT_VERSION = "hwpod-topology-v1";
|
|
|
|
const DEFAULT_PAGE_LIMIT = 50;
|
|
const MAX_PAGE_LIMIT = 100;
|
|
|
|
export const HWPOD_NODE_READINESS_DEFINITIONS = [
|
|
{ id: "downloaded", label: "已下载", authority: "user-device", nextAction: { label: "从节点接入页下载并校验已发布 Python 单文件", entrypoint: "cloud-web-hwpod-onboarding" } },
|
|
{ id: "installed", label: "已安装", authority: "python-node-runtime", nextAction: { label: "按 owning YAML 选择的交互用户与原生 Python 启动节点", entrypoint: "yaml-managed-hwpod-node" } },
|
|
{ id: "desktop-visible", label: "桌面可见", authority: "python-node-runtime", nextAction: { label: "在目标交互登录会话确认窗口或托盘可见", entrypoint: "hwlab-node-ui" } },
|
|
{ id: "registered", label: "已注册", authority: "websocket-registry", nextAction: { label: "保持节点运行并通过受控节点状态入口检查注册", entrypoint: "hwpod-node-status" } },
|
|
{ id: "workspace-ready", label: "工作区就绪", authority: "hwpod-spec-probe", nextAction: { label: "通过显式 readiness 探测检查 owning workspace", entrypoint: "hwpod-readiness-probe" } },
|
|
{ id: "hwpod-ready", label: "HWPOD 就绪", authority: "hwpod-topology", nextAction: { label: "检查挂载 HWPOD 的 blocker、能力与占用状态", entrypoint: "hwpod-topology" } }
|
|
] as const;
|
|
|
|
export function buildHwpodTopologyReadModel(specs: any[], registrySnapshot: any = {}, options: any = {}) {
|
|
const observedAt = text(options.observedAt) || new Date().toISOString();
|
|
const onlineNodes = normalizeOnlineNodes(registrySnapshot);
|
|
const invalidSpecs = specs.filter((spec) => spec?.ok === false).map((spec) => invalidSpecSummary(spec));
|
|
const validSpecs = specs.filter((spec) => spec?.ok !== false && text(spec?.nodeId) && text(spec?.hwpodId ?? spec?.name));
|
|
const devices = validSpecs.map((spec) => buildDevice(spec, onlineNodes.get(text(spec.nodeId)), observedAt));
|
|
const nodeIds = new Set([...onlineNodes.keys(), ...validSpecs.map((spec) => text(spec.nodeId)).filter(Boolean)]);
|
|
const nodes = [...nodeIds].map((nodeId) => buildNode(nodeId, onlineNodes.get(nodeId), devices.filter((device) => device.nodeId === nodeId), observedAt));
|
|
const resource = text(options.resource) === "device" ? "device" : "node";
|
|
const sourceItems = resource === "device" ? devices : nodes;
|
|
const filteredItems = filterItems(sourceItems, options);
|
|
const sortedItems = sortItems(filteredItems, options);
|
|
const page = paginate(sortedItems, options);
|
|
const partial = invalidSpecs.length > 0;
|
|
|
|
return {
|
|
ok: true,
|
|
status: partial ? "partial" : "ready",
|
|
contractVersion: HWPOD_TOPOLOGY_CONTRACT_VERSION,
|
|
route: "/v1/hwpod/topology",
|
|
owner: "cloud-api-hwpod-domain",
|
|
resource,
|
|
service: {
|
|
ready: true,
|
|
status: partial ? "partial" : "ready",
|
|
websocketMode: text(registrySnapshot?.mode) || "hwpod-node-outbound-native-ws",
|
|
connectedNodeCount: onlineNodes.size,
|
|
pendingOperationCount: integer(registrySnapshot?.pendingCount),
|
|
invalidSpecCount: invalidSpecs.length
|
|
},
|
|
summary: {
|
|
nodeCount: nodes.length,
|
|
onlineNodeCount: nodes.filter((node) => node.online).length,
|
|
offlineNodeCount: nodes.filter((node) => !node.online).length,
|
|
unconfiguredNodeCount: nodes.filter((node) => node.deviceCount === 0).length,
|
|
deviceCount: devices.length,
|
|
availableDeviceCount: devices.filter((device) => device.available).length,
|
|
busyDeviceCount: devices.filter((device) => device.busy).length,
|
|
capabilityMismatchCount: devices.filter((device) => device.capabilityMismatch).length,
|
|
invalidSpecCount: invalidSpecs.length
|
|
},
|
|
items: page.items,
|
|
page: page.page,
|
|
invalidSpecs,
|
|
valuesRedacted: true,
|
|
observedAt
|
|
};
|
|
}
|
|
|
|
function normalizeOnlineNodes(snapshot: any) {
|
|
const map = new Map<string, any>();
|
|
for (const raw of array(snapshot?.nodes)) {
|
|
const nodeId = text(raw?.nodeId);
|
|
if (!nodeId) continue;
|
|
const actualCapabilities = uniqueStrings(raw?.capabilities);
|
|
const inFlightCount = integer(raw?.inFlightCount ?? raw?.pendingCount);
|
|
const maxInFlight = positiveInteger(raw?.maxInFlight, 1);
|
|
map.set(nodeId, {
|
|
nodeId,
|
|
name: text(raw?.name) || nodeId,
|
|
online: raw?.status !== "offline",
|
|
platform: text(raw?.platform ?? raw?.labels?.platform) || "unknown",
|
|
version: text(raw?.version ?? raw?.labels?.version) || null,
|
|
runtimeKind: text(raw?.runtimeKind ?? raw?.runtime?.kind ?? raw?.labels?.runtimeKind) || null,
|
|
installed: booleanOrNull(raw?.installed ?? raw?.runtime?.installed),
|
|
desktopVisible: booleanOrNull(raw?.desktopVisible ?? raw?.runtime?.desktopVisible),
|
|
actualCapabilities,
|
|
inFlightCount,
|
|
maxInFlight,
|
|
busy: raw?.busy === true || inFlightCount >= maxInFlight,
|
|
firstSeenAt: text(raw?.firstSeenAt) || null,
|
|
lastSeenAt: text(raw?.lastSeenAt) || null,
|
|
ageMs: integer(raw?.ageMs),
|
|
latestOperation: operationSummary(raw?.latestOperation),
|
|
latestReadinessProbe: operationSummary(raw?.latestReadinessProbe),
|
|
diagnostic: diagnosticSummary(raw?.lastDiagnostic)
|
|
});
|
|
}
|
|
return map;
|
|
}
|
|
|
|
function buildDevice(spec: any, node: any, observedAt: string) {
|
|
const document = object(spec.document);
|
|
const body = object(document.spec);
|
|
const declaredCapabilities = declaredCapabilitiesFor(body);
|
|
const actualCapabilities = node?.actualCapabilities ?? [];
|
|
const missingCapabilities = node ? declaredCapabilities.filter((capability) => !actualCapabilities.includes(capability)) : declaredCapabilities;
|
|
const capabilityCoverage = buildCapabilityCoverage(declaredCapabilities, actualCapabilities);
|
|
const availability = availabilitySummary(spec.availability);
|
|
const online = Boolean(node?.online);
|
|
const busy = Boolean(node?.busy);
|
|
const capabilityMismatch = online && missingCapabilities.length > 0;
|
|
const blockers = deviceBlockers({ online, busy, capabilityMismatch, missingCapabilities, availability });
|
|
const status = online ? "online" : "offline";
|
|
const available = online && !busy && !capabilityMismatch && availability.ok === true;
|
|
return {
|
|
resourceType: "hwpod",
|
|
hwpodId: text(spec.hwpodId ?? spec.name),
|
|
uid: text(spec.uid) || null,
|
|
name: text(spec.name ?? spec.hwpodId),
|
|
nodeId: text(spec.nodeId),
|
|
nodeName: node?.name ?? text(spec.nodeId),
|
|
status,
|
|
online,
|
|
available,
|
|
busy,
|
|
capabilityMismatch,
|
|
declaredCapabilities,
|
|
actualCapabilities,
|
|
missingCapabilities,
|
|
capabilityCoverage,
|
|
elements: hwpodElements(body),
|
|
availability,
|
|
operation: node?.latestOperation?.hwpodId === text(spec.hwpodId ?? spec.name) ? node.latestOperation : null,
|
|
diagnostic: node?.diagnostic ?? null,
|
|
blockers,
|
|
source: {
|
|
authority: text(spec.authority) || "workspace-or-registry",
|
|
kind: text(spec.source?.kind) || "workspace-or-registry",
|
|
specPath: text(spec.specPath) || null
|
|
},
|
|
observedAt
|
|
};
|
|
}
|
|
|
|
function buildNode(nodeId: string, onlineNode: any, devices: any[], observedAt: string) {
|
|
const declaredCapabilities = uniqueStrings(devices.flatMap((device) => device.declaredCapabilities));
|
|
const actualCapabilities = onlineNode?.actualCapabilities ?? [];
|
|
const missingCapabilities = onlineNode ? declaredCapabilities.filter((capability) => !actualCapabilities.includes(capability)) : declaredCapabilities;
|
|
const capabilityCoverage = buildCapabilityCoverage(declaredCapabilities, actualCapabilities);
|
|
const busy = Boolean(onlineNode?.busy);
|
|
const online = Boolean(onlineNode?.online);
|
|
const capabilityMismatch = online && missingCapabilities.length > 0;
|
|
const hasSpec = devices.length > 0;
|
|
const status = online ? "online" : "offline";
|
|
const blockers = nodeBlockers({ online, hasSpec, busy, capabilityMismatch, missingCapabilities, devices });
|
|
const workspaceReady = devices.some((device) => device.availability.ok === true);
|
|
const hwpodReady = devices.some((device) => device.available);
|
|
return {
|
|
resourceType: "hwpod-node",
|
|
nodeId,
|
|
name: onlineNode?.name ?? nodeId,
|
|
status,
|
|
online,
|
|
platform: onlineNode?.platform ?? "unknown",
|
|
version: onlineNode?.version ?? null,
|
|
runtimeKind: onlineNode?.runtimeKind ?? null,
|
|
firstSeenAt: onlineNode?.firstSeenAt ?? null,
|
|
lastSeenAt: onlineNode?.lastSeenAt ?? null,
|
|
ageMs: onlineNode?.ageMs ?? null,
|
|
deviceCount: devices.length,
|
|
hwpods: devices.map((device) => ({
|
|
hwpodId: device.hwpodId,
|
|
name: device.name,
|
|
status: device.status,
|
|
busy: device.busy,
|
|
capabilityMismatch: device.capabilityMismatch
|
|
})),
|
|
declaredCapabilities,
|
|
actualCapabilities,
|
|
missingCapabilities,
|
|
capabilityCoverage,
|
|
capabilityMismatch,
|
|
inFlight: {
|
|
count: onlineNode?.inFlightCount ?? 0,
|
|
max: onlineNode?.maxInFlight ?? 1,
|
|
busy
|
|
},
|
|
operation: onlineNode?.latestOperation ?? null,
|
|
readinessProbe: onlineNode?.latestReadinessProbe ?? null,
|
|
diagnostic: onlineNode?.diagnostic ?? null,
|
|
readiness: nodeReadiness({ onlineNode, online, workspaceReady, hwpodReady, busy, blockers }),
|
|
blockers,
|
|
observedAt
|
|
};
|
|
}
|
|
|
|
function declaredCapabilitiesFor(spec: any) {
|
|
const explicit = uniqueStrings([...array(spec?.capabilities), ...array(spec?.nodeBinding?.capabilities)]);
|
|
if (explicit.length > 0) return explicit;
|
|
const capabilities = ["node.health", "node.version", "node.inventory", "node.diagnostics"];
|
|
if (Object.keys(object(spec?.workspace)).length > 0) {
|
|
capabilities.push("workspace.ls", "workspace.cat", "workspace.rg", "workspace.apply-patch", "workspace.write", "workspace.replace", "workspace.insert-after", "cmd.run");
|
|
}
|
|
if (Object.keys(object(spec?.debugProbe)).length > 0) capabilities.push("debug.build", "debug.download", "debug.reset");
|
|
if (Object.keys(object(spec?.ioProbe?.uart)).length > 0) capabilities.push("io.uart.read", "io.uart.write", "io.uart.jsonrpc");
|
|
return uniqueStrings(capabilities);
|
|
}
|
|
|
|
function hwpodElements(spec: any) {
|
|
const target = object(spec.targetDevice);
|
|
const workspace = object(spec.workspace);
|
|
const debugProbe = object(spec.debugProbe);
|
|
const ioProbe = object(spec.ioProbe);
|
|
const uart = object(ioProbe.uart);
|
|
return {
|
|
target: {
|
|
label: text(target.board ?? target.name ?? target.id) || "未声明目标板",
|
|
mcu: text(target.mcu) || null
|
|
},
|
|
workspace: {
|
|
label: text(workspace.projectRoot ?? workspace.path) || "未声明工作区",
|
|
path: text(workspace.path) || null,
|
|
toolchain: text(workspace.toolchain) || null
|
|
},
|
|
debugProbe: {
|
|
label: text(debugProbe.probeName ?? debugProbe.name ?? debugProbe.type ?? debugProbe.id) || "未声明调试探头",
|
|
type: text(debugProbe.type ?? debugProbe.kind) || null
|
|
},
|
|
ioProbe: {
|
|
label: text(uart.id ?? uart.port ?? ioProbe.name ?? ioProbe.id) || "未声明 IO 探头",
|
|
kind: Object.keys(uart).length > 0 ? "uart" : text(ioProbe.kind) || "unknown"
|
|
}
|
|
};
|
|
}
|
|
|
|
function availabilitySummary(value: any) {
|
|
const availability = object(value);
|
|
const blocker = object(availability.blocker);
|
|
return {
|
|
ok: booleanOrNull(availability.ok),
|
|
status: text(availability.status) || "unprobed",
|
|
checkedAt: text(availability.checkedAt) || null,
|
|
blocker: Object.keys(blocker).length > 0 ? {
|
|
code: text(blocker.code) || "hwpod_unavailable",
|
|
summary: text(blocker.summary ?? blocker.message) || "HWPOD 不可用",
|
|
retryable: blocker.retryable !== false
|
|
} : null
|
|
};
|
|
}
|
|
|
|
function buildCapabilityCoverage(declaredCapabilities: string[], actualCapabilities: string[]) {
|
|
const matched = declaredCapabilities.filter((capability) => actualCapabilities.includes(capability)).length;
|
|
const extra = actualCapabilities.filter((capability) => !declaredCapabilities.includes(capability)).length;
|
|
return {
|
|
matched,
|
|
declared: declaredCapabilities.length,
|
|
actual: actualCapabilities.length,
|
|
missing: declaredCapabilities.length - matched,
|
|
extra
|
|
};
|
|
}
|
|
|
|
function deviceBlockers(input: any) {
|
|
const blockers: any[] = [];
|
|
if (!input.online) blockers.push(blocker("hwpod_node_offline", "声明的 HWPOD Node 当前离线。", true));
|
|
if (input.capabilityMismatch) blockers.push(blocker("hwpod_capability_mismatch", `节点缺少 ${input.missingCapabilities.length} 项声明能力。`, false));
|
|
if (input.busy) blockers.push(blocker("hwpod_node_busy", "节点已达 in-flight 并发上限。", true));
|
|
if (input.availability.blocker) blockers.push(input.availability.blocker);
|
|
return dedupeBlockers(blockers);
|
|
}
|
|
|
|
function nodeBlockers(input: any) {
|
|
const blockers: any[] = [];
|
|
if (!input.online) blockers.push(blocker("hwpod_node_offline", "Owning 配置已声明该节点,但未观测到主动出站 WebSocket。", true));
|
|
if (input.online && !input.hasSpec) blockers.push(blocker("hwpod_node_spec_missing", "节点已在线,但没有任何 HWPOD spec 绑定。", false));
|
|
if (input.capabilityMismatch) blockers.push(blocker("hwpod_capability_mismatch", `节点缺少 ${input.missingCapabilities.length} 项所属 HWPOD 声明能力。`, false));
|
|
if (input.busy) blockers.push(blocker("hwpod_node_busy", "节点已达 in-flight 并发上限。", true));
|
|
for (const device of input.devices) blockers.push(...device.blockers);
|
|
return dedupeBlockers(blockers);
|
|
}
|
|
|
|
function nodeReadiness(input: any) {
|
|
const localObserved = Boolean(input.onlineNode);
|
|
const stageBlocker = input.blockers[0] ?? null;
|
|
return [
|
|
readinessStage("downloaded", input.onlineNode?.installed === true ? "ready" : "unknown", input.onlineNode?.installed === true ? null : blocker("hwpod_download_not_observed", "云端不以浏览器下载动作作为安装事实。", false)),
|
|
readinessStage("installed", input.onlineNode?.installed === true ? "ready" : "unknown", input.onlineNode?.installed === true ? null : blocker("hwpod_install_not_observed", "等待 Python 节点报告安装运行时。", true)),
|
|
readinessStage("desktop-visible", input.onlineNode?.desktopVisible === true ? "ready" : localObserved ? "blocked" : "unknown", input.onlineNode?.desktopVisible === true ? null : blocker("hwpod_desktop_not_visible", "尚未证明图形界面与托盘属于交互登录会话。", true)),
|
|
readinessStage("registered", input.online ? "ready" : "blocked", input.online ? null : blocker("hwpod_node_offline", "未观测到已认证的主动出站 WebSocket。", true)),
|
|
readinessStage("workspace-ready", input.workspaceReady ? "ready" : "blocked", input.workspaceReady ? null : stageBlocker ?? blocker("hwpod_workspace_not_ready", "工作区尚未通过显式 readiness 探测。", true)),
|
|
readinessStage("hwpod-ready", input.hwpodReady && !input.busy ? "ready" : "blocked", input.hwpodReady && !input.busy ? null : stageBlocker ?? blocker("hwpod_not_ready", "尚未观测到可用且空闲的挂载 HWPOD。", true))
|
|
];
|
|
}
|
|
|
|
function readinessStage(id: string, status: string, stageBlocker: any) {
|
|
const definition = HWPOD_NODE_READINESS_DEFINITIONS.find((stage) => stage.id === id)!;
|
|
return { ...definition, status, blocker: stageBlocker, nextAction: status === "ready" ? null : definition.nextAction };
|
|
}
|
|
|
|
function invalidSpecSummary(spec: any) {
|
|
return {
|
|
specPath: text(spec?.specPath) || null,
|
|
status: "invalid",
|
|
blocker: blocker(text(spec?.error?.code) || "invalid_hwpod_spec", text(spec?.error?.message) || "HWPOD spec 无法解析。", false)
|
|
};
|
|
}
|
|
|
|
function operationSummary(value: any) {
|
|
const operation = object(value);
|
|
if (Object.keys(operation).length === 0) return null;
|
|
return {
|
|
requestId: text(operation.requestId) || null,
|
|
planId: text(operation.planId) || null,
|
|
hwpodId: text(operation.hwpodId) || null,
|
|
status: text(operation.status) || "unknown",
|
|
opCount: integer(operation.opCount),
|
|
startedAt: text(operation.startedAt) || null,
|
|
finishedAt: text(operation.finishedAt) || null,
|
|
blockerCode: text(operation.blockerCode) || null
|
|
};
|
|
}
|
|
|
|
function diagnosticSummary(value: any) {
|
|
const diagnostic = object(value);
|
|
if (Object.keys(diagnostic).length === 0) return null;
|
|
return {
|
|
level: text(diagnostic.level) || "ERROR",
|
|
source: text(diagnostic.source) || "unknown",
|
|
message: redactDiagnosticMessage(diagnostic.message),
|
|
observedAt: text(diagnostic.observedAt ?? diagnostic.receivedAt) || null,
|
|
valuesRedacted: true
|
|
};
|
|
}
|
|
|
|
function redactDiagnosticMessage(value: unknown) {
|
|
return text(value)
|
|
.replace(/\b(Bearer|Basic)\s+\S+/giu, "$1 [REDACTED]")
|
|
.replace(/([?&](?:api[_-]?key|token|secret|password|authorization)=)[^&\s]+/giu, "$1[REDACTED]")
|
|
.replace(/\b(api[_-]?key|token|secret|password|authorization)\s*[:=]\s*\S+/giu, "$1=[REDACTED]")
|
|
.slice(0, 500);
|
|
}
|
|
|
|
function filterItems(items: any[], options: any) {
|
|
const query = text(options.q).toLowerCase();
|
|
const statuses = new Set(uniqueStrings(String(options.status ?? "").split(",")));
|
|
return items.filter((item) => {
|
|
if (statuses.size > 0 && !statuses.has(text(item.status))) return false;
|
|
if (!query) return true;
|
|
const haystack = [item.nodeId, item.name, item.hwpodId, item.status, ...array(item.missingCapabilities)].map(text).join(" ").toLowerCase();
|
|
return haystack.includes(query);
|
|
});
|
|
}
|
|
|
|
function sortItems(items: any[], options: any) {
|
|
const sort = ["name", "status", "lastSeenAt", "deviceCount"].includes(text(options.sort)) ? text(options.sort) : "name";
|
|
const direction = text(options.direction) === "desc" ? -1 : 1;
|
|
return [...items].sort((left, right) => compareValues(left?.[sort], right?.[sort]) * direction);
|
|
}
|
|
|
|
function paginate(items: any[], options: any) {
|
|
const limit = Math.min(positiveInteger(options.limit, DEFAULT_PAGE_LIMIT), MAX_PAGE_LIMIT);
|
|
const offset = decodeCursor(options.cursor);
|
|
const pageItems = items.slice(offset, offset + limit);
|
|
const nextOffset = offset + pageItems.length;
|
|
return {
|
|
items: pageItems,
|
|
page: {
|
|
limit,
|
|
returned: pageItems.length,
|
|
total: items.length,
|
|
cursor: offset > 0 ? encodeCursor(offset) : null,
|
|
previousCursor: offset > 0 ? encodeCursor(Math.max(0, offset - limit)) : null,
|
|
nextCursor: nextOffset < items.length ? encodeCursor(nextOffset) : null
|
|
}
|
|
};
|
|
}
|
|
|
|
function encodeCursor(offset: number) {
|
|
return Buffer.from(`offset:${offset}`, "utf8").toString("base64url");
|
|
}
|
|
|
|
function decodeCursor(value: unknown) {
|
|
const cursor = text(value);
|
|
if (!cursor) return 0;
|
|
try {
|
|
const parsed = Buffer.from(cursor, "base64url").toString("utf8").match(/^offset:(\d+)$/u);
|
|
return parsed ? integer(parsed[1]) : 0;
|
|
} catch {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
function blocker(code: string, summary: string, retryable: boolean) {
|
|
return { code, summary, retryable };
|
|
}
|
|
|
|
function dedupeBlockers(values: any[]) {
|
|
const seen = new Set<string>();
|
|
return values.filter((value) => {
|
|
const code = text(value?.code);
|
|
if (!code || seen.has(code)) return false;
|
|
seen.add(code);
|
|
return true;
|
|
}).slice(0, 8);
|
|
}
|
|
|
|
function compareValues(left: unknown, right: unknown) {
|
|
if (typeof left === "number" || typeof right === "number") return Number(left ?? 0) - Number(right ?? 0);
|
|
return text(left).localeCompare(text(right), "zh-CN");
|
|
}
|
|
|
|
function uniqueStrings(values: unknown) {
|
|
return [...new Set(array(values).map(text).filter(Boolean))].sort();
|
|
}
|
|
|
|
function array(value: unknown): any[] {
|
|
return Array.isArray(value) ? value : [];
|
|
}
|
|
|
|
function object(value: unknown): any {
|
|
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
}
|
|
|
|
function text(value: unknown) {
|
|
return typeof value === "string" && value.trim() ? value.trim() : "";
|
|
}
|
|
|
|
function integer(value: unknown) {
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) && parsed >= 0 ? Math.trunc(parsed) : 0;
|
|
}
|
|
|
|
function positiveInteger(value: unknown, fallback: number) {
|
|
const parsed = Number(value);
|
|
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
}
|
|
|
|
function booleanOrNull(value: unknown) {
|
|
return typeof value === "boolean" ? value : null;
|
|
}
|