feat(foundry): add managed data consumers and agent settings
This commit is contained in:
@@ -18,10 +18,8 @@ import {
|
||||
FieldFrame,
|
||||
GlassSurface,
|
||||
GlassMaterialSurface,
|
||||
HeaderAvatar,
|
||||
HeaderNavigation,
|
||||
HeaderProfile,
|
||||
HeaderProfileButton,
|
||||
HeaderWorkspace,
|
||||
Icon,
|
||||
IconButton,
|
||||
@@ -40,6 +38,7 @@ import {
|
||||
TextAreaField,
|
||||
TextField,
|
||||
Toolbar,
|
||||
UserProfileMenu,
|
||||
useApplicationWorkspace,
|
||||
Window,
|
||||
WindowFooterActions,
|
||||
@@ -62,6 +61,7 @@ import {
|
||||
type DesignProfileStatus,
|
||||
} from "./applicationManifest.js";
|
||||
import { MapFixturePreview, type MapFixturePreviewHandle, type MapPageLayout } from "./MapFixturePreview.js";
|
||||
import { FoundrySettingsModal } from "./FoundrySettingsModal.js";
|
||||
|
||||
type CatalogSection = "controls" | "media" | "glass" | "modals" | "icons";
|
||||
type StudioContext = "visual" | "pages" | "applications";
|
||||
@@ -333,6 +333,7 @@ export function CatalogApp() {
|
||||
const [applicationError, setApplicationError] = useState("");
|
||||
const [sessionProfile, setSessionProfile] = useState<FoundrySessionProfile | null>(null);
|
||||
const [platformSettingsOpen, setPlatformSettingsOpen] = useState(false);
|
||||
const [foundrySettingsOpen, setFoundrySettingsOpen] = useState(false);
|
||||
const [cesiumIonToken, setCesiumIonToken] = useState("");
|
||||
const [cesiumIonStatus, setCesiumIonStatus] = useState<CesiumIonSecretStatus | null>(null);
|
||||
const [cesiumIonSaveState, setCesiumIonSaveState] = useState<"idle" | "loading" | "saving" | "saved" | "error">("idle");
|
||||
@@ -1815,15 +1816,15 @@ export function CatalogApp() {
|
||||
right={
|
||||
<HeaderProfile>
|
||||
<IconButton label="Уведомления"><Icon name="inbox" size={20} strokeWidth={1.7} /></IconButton>
|
||||
<HeaderProfileButton
|
||||
title={sessionProfile?.user?.displayName || "Профиль NODE.DC"}
|
||||
onClick={() => { if (sessionProfile?.profileUrl) window.location.assign(sessionProfile.profileUrl); }}
|
||||
>
|
||||
{sessionProfile?.user?.displayName || "Профиль"}
|
||||
</HeaderProfileButton>
|
||||
<HeaderAvatar
|
||||
label={sessionProfile?.user?.displayName || sessionProfile?.user?.initials || "DC"}
|
||||
imageUrl={sessionProfile?.user?.avatarUrl || undefined}
|
||||
<UserProfileMenu
|
||||
displayName={sessionProfile?.user?.displayName || "Профиль"}
|
||||
subtitle={sessionProfile?.user?.email || "NODE.DC Foundry"}
|
||||
avatarUrl={sessionProfile?.user?.avatarUrl || undefined}
|
||||
actions={[
|
||||
{ id: "profile", label: "Профиль", icon: "profile", href: sessionProfile?.profileUrl || undefined, disabled: !sessionProfile?.profileUrl },
|
||||
{ id: "settings", label: "Настройки", icon: "settings", onSelect: () => setFoundrySettingsOpen(true) },
|
||||
{ id: "logout", label: "Выйти", icon: "external", href: "/auth/logout" },
|
||||
]}
|
||||
/>
|
||||
</HeaderProfile>
|
||||
}
|
||||
@@ -2144,6 +2145,8 @@ export function CatalogApp() {
|
||||
</div>
|
||||
</Window>
|
||||
|
||||
<FoundrySettingsModal open={foundrySettingsOpen} onClose={() => setFoundrySettingsOpen(false)} />
|
||||
|
||||
<Window
|
||||
open={createModalOpen}
|
||||
title="Создать проект"
|
||||
|
||||
@@ -448,6 +448,10 @@ function runtimePointColor(fact: MapRuntimeFact) {
|
||||
// This is a semantic default for the generic Map entity-stream adapter,
|
||||
// not a provider style. A renderer-neutral style profile can refine it
|
||||
// later without changing a data product or its L2 workflow.
|
||||
if (fact.presentationStatus === "stale") return Color.fromCssColorString("#f5a623");
|
||||
if (["inactive", "no-position", "no_position"].includes(fact.presentationStatus)) {
|
||||
return Color.fromCssColorString("#7d8491");
|
||||
}
|
||||
return fact.semanticType === "map.moving_object" ? accent : violet;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Button, Icon, SettingsCard, StatusBadge, TextField } from "@nodedc/ui-react";
|
||||
|
||||
interface FoundryAgentDevice {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
lastUsedAt: string | null;
|
||||
lastCheckAt: string | null;
|
||||
revokedAt: string | null;
|
||||
}
|
||||
|
||||
interface FoundryAgent {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
status: "active" | "revoked";
|
||||
devices: FoundryAgentDevice[];
|
||||
deviceCount: number;
|
||||
lastUsedAt: string | null;
|
||||
lastCheckAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface SetupCommand {
|
||||
command: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
async function api<T>(pathname: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(pathname, {
|
||||
cache: "no-store",
|
||||
...init,
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
...(init?.body ? { "content-type": "application/json" } : {}),
|
||||
...init?.headers,
|
||||
},
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok || payload?.ok === false) throw new Error(payload?.error || `foundry_agent_api_${response.status}`);
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
function formatMoment(value: string | null) {
|
||||
if (!value) return "—";
|
||||
const date = new Date(value);
|
||||
return Number.isFinite(date.getTime()) ? date.toLocaleString("ru-RU") : value;
|
||||
}
|
||||
|
||||
export function FoundryCodexAgentSettings() {
|
||||
const [agents, setAgents] = useState<FoundryAgent[]>([]);
|
||||
const [newAgentName, setNewAgentName] = useState("Foundry Codex");
|
||||
const [draftNames, setDraftNames] = useState<Record<string, string>>({});
|
||||
const [setupByAgent, setSetupByAgent] = useState<Record<string, SetupCommand>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [pending, setPending] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const payload = await api<{ agents: FoundryAgent[] }>("/api/foundry-agent-api/agents");
|
||||
setAgents(payload.agents);
|
||||
setDraftNames(Object.fromEntries(payload.agents.map((agent) => [agent.id, agent.name])));
|
||||
} catch (loadError) {
|
||||
setError(loadError instanceof Error ? loadError.message : "foundry_agent_load_failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
const run = async (key: string, action: () => Promise<void>) => {
|
||||
setPending(key);
|
||||
setError("");
|
||||
setMessage("");
|
||||
try {
|
||||
await action();
|
||||
} catch (actionError) {
|
||||
setError(actionError instanceof Error ? actionError.message : "foundry_agent_operation_failed");
|
||||
} finally {
|
||||
setPending("");
|
||||
}
|
||||
};
|
||||
|
||||
const createAgent = () => run("create", async () => {
|
||||
const payload = await api<{ agent: FoundryAgent }>("/api/foundry-agent-api/agents", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name: newAgentName.trim() || "Foundry Codex" }),
|
||||
});
|
||||
setAgents((current) => [payload.agent, ...current]);
|
||||
setDraftNames((current) => ({ ...current, [payload.agent.id]: payload.agent.name }));
|
||||
setMessage("Foundry Agent создан. Теперь можно получить одноразовую setup-команду.");
|
||||
});
|
||||
|
||||
const saveAgent = (agent: FoundryAgent) => run(`save:${agent.id}`, async () => {
|
||||
const payload = await api<{ agent: FoundryAgent }>(`/api/foundry-agent-api/agents/${encodeURIComponent(agent.id)}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ name: draftNames[agent.id]?.trim() || agent.name }),
|
||||
});
|
||||
setAgents((current) => current.map((item) => item.id === agent.id ? payload.agent : item));
|
||||
setMessage("Имя агента сохранено.");
|
||||
});
|
||||
|
||||
const revokeAgent = (agent: FoundryAgent) => run(`revoke:${agent.id}`, async () => {
|
||||
const payload = await api<{ agent: FoundryAgent }>(`/api/foundry-agent-api/agents/${encodeURIComponent(agent.id)}/revoke`, {
|
||||
method: "POST",
|
||||
body: "{}",
|
||||
});
|
||||
setAgents((current) => current.map((item) => item.id === agent.id ? payload.agent : item));
|
||||
setSetupByAgent((current) => {
|
||||
const next = { ...current };
|
||||
delete next[agent.id];
|
||||
return next;
|
||||
});
|
||||
setMessage("Agent отозван. Foundry и Ontology credentials этого агента больше не принимаются.");
|
||||
});
|
||||
|
||||
const issueSetup = (agent: FoundryAgent) => run(`setup:${agent.id}`, async () => {
|
||||
const payload = await api<{ install: SetupCommand }>(`/api/foundry-agent-api/agents/${encodeURIComponent(agent.id)}/setup-code`, {
|
||||
method: "POST",
|
||||
body: "{}",
|
||||
});
|
||||
setSetupByAgent((current) => ({ ...current, [agent.id]: payload.install }));
|
||||
setMessage("Одноразовая команда выпущена. Она установит Foundry MCP и отдельный read-only Ontology MCP.");
|
||||
});
|
||||
|
||||
const copySetup = async (agentId: string) => {
|
||||
const command = setupByAgent[agentId]?.command;
|
||||
if (!command) return;
|
||||
await navigator.clipboard.writeText(command);
|
||||
setMessage("Setup-команда скопирована.");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="catalog-codex-agent-settings">
|
||||
<header className="catalog-codex-agent-settings__header">
|
||||
<div><span>NODE.DC / SOURCE-FREE DEVELOPMENT</span><h2>Codex Agent API</h2></div>
|
||||
<StatusBadge tone="success">Foundry + Ontology</StatusBadge>
|
||||
</header>
|
||||
<p className="catalog-codex-agent-settings__lead">
|
||||
Одна setup-команда добавляет в Codex два независимых MCP: полный текущий Foundry contour и отдельную read-only Ontology. Sharing, роли и межпользовательская видимость в этот срез не входят.
|
||||
</p>
|
||||
|
||||
{error ? <div className="catalog-codex-agent-settings__message" data-tone="error">{error}</div> : null}
|
||||
{message ? <div className="catalog-codex-agent-settings__message" data-tone="success">{message}</div> : null}
|
||||
|
||||
<SettingsCard eyebrow="NEW AGENT" title="Подключить Codex" description="Agent принадлежит текущему пользователю Foundry. Credentials долговечны до явного revoke.">
|
||||
<div className="catalog-codex-agent-settings__create">
|
||||
<TextField label="Имя агента" value={newAgentName} onChange={(event) => setNewAgentName(event.target.value)} />
|
||||
<Button variant="primary" shape="pill" icon={<Icon name="plus" />} disabled={pending === "create"} onClick={() => { void createAgent(); }}>
|
||||
{pending === "create" ? "Создание…" : "Создать агента"}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
<div className="catalog-codex-agent-settings__agents">
|
||||
{loading ? <span className="catalog-codex-agent-settings__empty">Загружаю agents…</span> : null}
|
||||
{!loading && agents.length === 0 ? <span className="catalog-codex-agent-settings__empty">Агенты ещё не созданы.</span> : null}
|
||||
{agents.map((agent) => {
|
||||
const setup = setupByAgent[agent.id];
|
||||
const active = agent.status === "active";
|
||||
return (
|
||||
<SettingsCard
|
||||
key={agent.id}
|
||||
eyebrow={`AGENT · ${agent.deviceCount} DEVICE`}
|
||||
title={agent.name}
|
||||
description={`Последнее использование: ${formatMoment(agent.lastUsedAt)} · doctor: ${formatMoment(agent.lastCheckAt)}`}
|
||||
actions={<StatusBadge tone={active ? "success" : "danger"}>{active ? "Активен" : "Отозван"}</StatusBadge>}
|
||||
>
|
||||
<div className="catalog-codex-agent-settings__agent-grid">
|
||||
<TextField
|
||||
label="Имя"
|
||||
value={draftNames[agent.id] ?? agent.name}
|
||||
disabled={!active}
|
||||
onChange={(event) => setDraftNames((current) => ({ ...current, [agent.id]: event.target.value }))}
|
||||
/>
|
||||
<div className="catalog-codex-agent-settings__actions">
|
||||
<Button disabled={!active || pending === `save:${agent.id}`} onClick={() => { void saveAgent(agent); }}>Сохранить</Button>
|
||||
<Button variant="primary" shape="pill" disabled={!active || pending === `setup:${agent.id}`} onClick={() => { void issueSetup(agent); }}>Получить команду</Button>
|
||||
<Button variant="danger" disabled={!active || pending === `revoke:${agent.id}`} onClick={() => { void revokeAgent(agent); }}>Revoke</Button>
|
||||
</div>
|
||||
</div>
|
||||
{setup ? (
|
||||
<div className="catalog-codex-agent-settings__setup">
|
||||
<div><strong>Одноразовая setup-команда</strong><small>Действует до {formatMoment(setup.expiresAt)}. После выполнения полностью перезапустите Codex Desktop.</small></div>
|
||||
<code>{setup.command}</code>
|
||||
<Button icon={<Icon name="copy" />} onClick={() => { void copySetup(agent.id); }}>Копировать</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</SettingsCard>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { FeatureSettingsWindow } from "@nodedc/ui-react";
|
||||
import { FoundryCodexAgentSettings } from "./FoundryCodexAgentSettings.js";
|
||||
|
||||
interface FoundrySettingsModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function FoundrySettingsModal({ open, onClose }: FoundrySettingsModalProps) {
|
||||
return (
|
||||
<FeatureSettingsWindow
|
||||
open={open}
|
||||
title="Настройки Foundry"
|
||||
subtitle="NODE DC / Codex Agent API"
|
||||
identity={{ title: "NODE.DC Foundry", subtitle: "Текущий пользователь", avatarLabel: "NF" }}
|
||||
sections={[{ id: "codex-agent-api", label: "Codex Agent API", group: "Features", icon: "network" }]}
|
||||
activeSection="codex-agent-api"
|
||||
onSectionChange={() => undefined}
|
||||
onClose={onClose}
|
||||
>
|
||||
<FoundryCodexAgentSettings />
|
||||
</FeatureSettingsWindow>
|
||||
);
|
||||
}
|
||||
@@ -270,7 +270,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
const attributes = fact.attributes;
|
||||
const label = [attributes.label, attributes.name, attributes.title, attributes.subject_id]
|
||||
.find((value) => typeof value === "string" && value.trim());
|
||||
const status = typeof attributes.status === "string" ? attributes.status : undefined;
|
||||
const status = fact.presentationStatus || (typeof attributes.status === "string" ? attributes.status : undefined);
|
||||
return {
|
||||
id: mapRuntimeEntityId(binding.bindingId, fact),
|
||||
title: typeof label === "string" ? label : fact.sourceId,
|
||||
|
||||
+122
-1
@@ -1073,6 +1073,120 @@ textarea {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.catalog-codex-agent-settings {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.catalog-codex-agent-settings__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.catalog-codex-agent-settings__header > div {
|
||||
display: grid;
|
||||
gap: 0.28rem;
|
||||
}
|
||||
|
||||
.catalog-codex-agent-settings__header span,
|
||||
.catalog-codex-agent-settings__header h2,
|
||||
.catalog-codex-agent-settings__lead {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.catalog-codex-agent-settings__header > div > span {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 850;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.catalog-codex-agent-settings__header h2 {
|
||||
font-size: clamp(1.45rem, 2.2vw, 2rem);
|
||||
}
|
||||
|
||||
.catalog-codex-agent-settings__lead {
|
||||
max-width: 58rem;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.catalog-codex-agent-settings__message {
|
||||
border-radius: var(--nodedc-radius-control-compact);
|
||||
background: rgb(var(--nodedc-success-rgb) / 0.12);
|
||||
color: color-mix(in srgb, rgb(var(--nodedc-success-rgb)) 80%, var(--nodedc-text-primary));
|
||||
padding: 0.75rem 0.9rem;
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.catalog-codex-agent-settings__message[data-tone="error"] {
|
||||
background: rgb(var(--nodedc-danger-rgb) / 0.12);
|
||||
color: color-mix(in srgb, rgb(var(--nodedc-danger-rgb)) 80%, var(--nodedc-text-primary));
|
||||
}
|
||||
|
||||
.catalog-codex-agent-settings__create,
|
||||
.catalog-codex-agent-settings__agent-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: end;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.catalog-codex-agent-settings__agents {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.catalog-codex-agent-settings__empty {
|
||||
color: var(--nodedc-text-muted);
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.catalog-codex-agent-settings__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.catalog-codex-agent-settings__setup {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 0.7rem;
|
||||
margin-top: 0.9rem;
|
||||
border-radius: var(--nodedc-radius-control);
|
||||
background: var(--nodedc-nested-bg);
|
||||
padding: 0.85rem;
|
||||
}
|
||||
|
||||
.catalog-codex-agent-settings__setup > div {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.catalog-codex-agent-settings__setup small {
|
||||
color: var(--nodedc-text-muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.catalog-codex-agent-settings__setup code {
|
||||
grid-column: 1 / -1;
|
||||
overflow: auto;
|
||||
border-radius: var(--nodedc-radius-control-compact);
|
||||
background: var(--nodedc-field-bg);
|
||||
color: var(--nodedc-text-primary);
|
||||
padding: 0.8rem;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.catalog-modal-groups {
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
@@ -1363,10 +1477,17 @@ textarea {
|
||||
}
|
||||
|
||||
.catalog-application-draft__fields,
|
||||
.catalog-application-draft__features {
|
||||
.catalog-application-draft__features,
|
||||
.catalog-codex-agent-settings__create,
|
||||
.catalog-codex-agent-settings__agent-grid,
|
||||
.catalog-codex-agent-settings__setup {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.catalog-codex-agent-settings__actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.catalog-application-draft__fields .nodedc-field-frame:last-child {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export type MapRuntimeFact = {
|
||||
receivedAt: string;
|
||||
attributes: Record<string, unknown>;
|
||||
geometry: DataProductPoint | null;
|
||||
presentationStatus: string;
|
||||
};
|
||||
|
||||
export type MapRuntimeBinding = {
|
||||
@@ -42,7 +43,16 @@ type PatchEnvelope = {
|
||||
cursor: string;
|
||||
previousCursor: string;
|
||||
emittedAt: string;
|
||||
operations: Array<{ op: "upsert"; fact: MapRuntimeFact }>;
|
||||
operations: Array<
|
||||
| { op: "upsert"; fact: MapRuntimeFact }
|
||||
| { op: "remove"; sourceId: string; semanticType: string; removedAt: string; reason: "tombstone" | "revoked" }
|
||||
>;
|
||||
};
|
||||
|
||||
type PresentationPatchEnvelope = {
|
||||
schemaVersion: "nodedc.foundry.presentation-patch/v1";
|
||||
generatedAt: string;
|
||||
operations: Array<{ op: "upsert"; sourceId: string; semanticType: string; status: string }>;
|
||||
};
|
||||
|
||||
type BindingState = {
|
||||
@@ -97,6 +107,9 @@ function asFact(value: unknown, binding: MapDataProductBinding): MapRuntimeFact
|
||||
receivedAt: candidate.receivedAt,
|
||||
attributes,
|
||||
geometry: asPoint(candidate.geometry),
|
||||
presentationStatus: typeof candidate.presentationStatus === "string" && /^[a-z0-9_-]{1,64}$/.test(candidate.presentationStatus)
|
||||
? candidate.presentationStatus
|
||||
: "active",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -121,13 +134,27 @@ function asPatch(value: unknown, binding: MapDataProductBinding): PatchEnvelope
|
||||
const product = candidate.dataProduct;
|
||||
if (candidate.schemaVersion !== "nodedc.data-product.patch/v1" || !product || typeof product !== "object" || Array.isArray(product)) return null;
|
||||
if ((product as { id?: unknown }).id !== binding.dataProductId || !cursorPattern.test(String(candidate.cursor || "")) || !cursorPattern.test(String(candidate.previousCursor || "")) || !isIsoTimestamp(candidate.emittedAt) || !Array.isArray(candidate.operations)) return null;
|
||||
const operations = candidate.operations.flatMap((operation) => {
|
||||
if (!operation || typeof operation !== "object" || Array.isArray(operation)) return [];
|
||||
const value = operation as { op?: unknown; fact?: unknown };
|
||||
if (value.op !== "upsert") return [];
|
||||
const fact = asFact(value.fact, binding);
|
||||
return fact ? [{ op: "upsert" as const, fact }] : [];
|
||||
});
|
||||
const operations: PatchEnvelope["operations"] = [];
|
||||
for (const operation of candidate.operations) {
|
||||
if (!operation || typeof operation !== "object" || Array.isArray(operation)) continue;
|
||||
const value = operation as { op?: unknown; fact?: unknown; sourceId?: unknown; semanticType?: unknown; removedAt?: unknown; reason?: unknown };
|
||||
if (value.op === "upsert") {
|
||||
const fact = asFact(value.fact, binding);
|
||||
if (fact) operations.push({ op: "upsert", fact });
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
value.op === "remove"
|
||||
&& typeof value.sourceId === "string"
|
||||
&& identifier.test(value.sourceId)
|
||||
&& typeof value.semanticType === "string"
|
||||
&& binding.semanticTypes.includes(value.semanticType)
|
||||
&& isIsoTimestamp(value.removedAt)
|
||||
&& (value.reason === "tombstone" || value.reason === "revoked")
|
||||
) {
|
||||
operations.push({ op: "remove", sourceId: value.sourceId, semanticType: value.semanticType, removedAt: value.removedAt, reason: value.reason });
|
||||
}
|
||||
}
|
||||
return {
|
||||
schemaVersion: "nodedc.data-product.patch/v1",
|
||||
dataProduct: { id: binding.dataProductId, version: String((product as { version?: unknown }).version || "") },
|
||||
@@ -138,6 +165,27 @@ function asPatch(value: unknown, binding: MapDataProductBinding): PatchEnvelope
|
||||
};
|
||||
}
|
||||
|
||||
function asPresentationPatch(value: unknown, binding: MapDataProductBinding): PresentationPatchEnvelope | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const candidate = value as Record<string, unknown>;
|
||||
if (candidate.schemaVersion !== "nodedc.foundry.presentation-patch/v1" || !isIsoTimestamp(candidate.generatedAt) || !Array.isArray(candidate.operations)) return null;
|
||||
const operations = candidate.operations.flatMap((operation) => {
|
||||
if (!operation || typeof operation !== "object" || Array.isArray(operation)) return [];
|
||||
const item = operation as { op?: unknown; sourceId?: unknown; semanticType?: unknown; status?: unknown };
|
||||
if (
|
||||
item.op !== "upsert"
|
||||
|| typeof item.sourceId !== "string"
|
||||
|| !identifier.test(item.sourceId)
|
||||
|| typeof item.semanticType !== "string"
|
||||
|| !binding.semanticTypes.includes(item.semanticType)
|
||||
|| typeof item.status !== "string"
|
||||
|| !/^[a-z0-9_-]{1,64}$/.test(item.status)
|
||||
) return [];
|
||||
return [{ op: "upsert" as const, sourceId: item.sourceId, semanticType: item.semanticType, status: item.status }];
|
||||
});
|
||||
return { schemaVersion: "nodedc.foundry.presentation-patch/v1", generatedAt: candidate.generatedAt, operations };
|
||||
}
|
||||
|
||||
function replaceSnapshot(current: BindingState, snapshot: SnapshotEnvelope): BindingState {
|
||||
return {
|
||||
...current,
|
||||
@@ -153,10 +201,22 @@ function applyPatch(current: BindingState, patch: PatchEnvelope): BindingState |
|
||||
// browser applies more updates.
|
||||
if (current.cursor !== patch.previousCursor) return null;
|
||||
const facts = { ...current.facts };
|
||||
for (const operation of patch.operations) facts[factKey(operation.fact)] = operation.fact;
|
||||
for (const operation of patch.operations) {
|
||||
if (operation.op === "upsert") facts[factKey(operation.fact)] = operation.fact;
|
||||
else delete facts[`${operation.semanticType}\u0000${operation.sourceId}`];
|
||||
}
|
||||
return { ...current, facts, cursor: patch.cursor, state: "ready" };
|
||||
}
|
||||
|
||||
function applyPresentationPatch(current: BindingState, patch: PresentationPatchEnvelope): BindingState {
|
||||
const facts = { ...current.facts };
|
||||
for (const operation of patch.operations) {
|
||||
const key = `${operation.semanticType}\u0000${operation.sourceId}`;
|
||||
if (facts[key]) facts[key] = { ...facts[key], presentationStatus: operation.status };
|
||||
}
|
||||
return { ...current, facts };
|
||||
}
|
||||
|
||||
function stateFor(binding: MapDataProductBinding, state: MapRuntimeBinding["state"] = "idle"): BindingState {
|
||||
return { binding, cursor: null, facts: {}, state };
|
||||
}
|
||||
@@ -263,6 +323,16 @@ export function useMapDataProductRuntime({
|
||||
source.addEventListener("nodedc.data-product.resync-required.v1", () => {
|
||||
void loadSnapshotAndStream(binding);
|
||||
});
|
||||
source.addEventListener("nodedc.foundry.presentation-patch.v1", (event) => {
|
||||
let patch: PresentationPatchEnvelope | null = null;
|
||||
try {
|
||||
patch = asPresentationPatch(JSON.parse((event as MessageEvent<string>).data), binding);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!patch) return;
|
||||
update(binding, (current) => applyPresentationPatch(current, patch));
|
||||
});
|
||||
source.onerror = () => {
|
||||
// EventSource will retry transient transport failures itself. A
|
||||
// permanent upstream rejection closes the stream; loading a fresh
|
||||
|
||||
Reference in New Issue
Block a user