diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 9726f63..4a79eae 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -19,11 +19,13 @@ import { RemoteEnvironmentGateway } from "./remote/remoteEnvironmentGateway.ts"; import { createAgentBundlesRouter } from "./routes/agentBundles.ts"; import { createGlobalMemoryRouter } from "./routes/globalMemory.ts"; import { createInternalAgentsRouter } from "./routes/internalAgents.ts"; +import { createInternalCsomRouter } from "./routes/internalCsom.ts"; import { createInternalEgressRouter } from "./routes/internalEgress.ts"; import { createInternalMemoryRouter } from "./routes/internalMemory.ts"; import { createInternalSessionRouter } from "./routes/internalSession.ts"; import { createInternalTriggersRouter } from "./routes/internalTriggers.ts"; import { createMeRouter } from "./routes/me.ts"; +import { createRemoteEnvRouter } from "./routes/remoteEnv.ts"; import { createSessionsRouter } from "./routes/sessions/index.ts"; import { createAgentEventHandler, @@ -120,6 +122,9 @@ app.use("/api/agent-bundles", createAgentBundlesRouter(agentBundleStore)); app.use("/api/global-memory", createGlobalMemoryRouter(memory)); // Returns the current user, derived from the Oktasso JWT cookie. app.use("/api/me", createMeRouter()); +// Hands the SPA the `/remote-env` token so the Pipeline Editor tab can connect +// as its session's CSOM executor (only when remote hosting is enabled). +app.use("/api/remote-env", createRemoteEnvRouter()); // Internal API for the orchestrator extension running inside each Pi process. app.use( "/internal/agents", @@ -127,6 +132,9 @@ app.use( ); // Internal egress proxy for bundle tool extensions (e.g. the Tangle API tool). app.use("/internal/egress", createInternalEgressRouter()); +// Internal CSOM relay: Prime's pipeline-editor tools drive the browser's +// embedded Tangle editor through the remote-environment gateway. +app.use("/internal/csom", createInternalCsomRouter(remoteGateway)); // Internal API for the triggers extension running inside each Pi process. app.use( "/internal/triggers", diff --git a/apps/server/src/remote/remoteEnvironmentGateway.ts b/apps/server/src/remote/remoteEnvironmentGateway.ts index 5c8031c..69399c6 100644 --- a/apps/server/src/remote/remoteEnvironmentGateway.ts +++ b/apps/server/src/remote/remoteEnvironmentGateway.ts @@ -11,6 +11,8 @@ import { type RemoteAgentEvent, type RemoteAgentEventPayload, type RemoteAgentMessagePayload, + type RemoteCsomCallRequest, + type RemoteCsomCallResponse, RemoteEnvEvents, type RemoteEnvHandshake, type RemoteKillCommand, @@ -35,6 +37,9 @@ import type { SessionStore } from "../store/sessionStore.ts"; const DEFAULT_ROOM_LIMIT = 30; const MAX_ROOM_LIMIT = 200; +/** How long a CSOM invocation waits for the environment's ack before failing. */ +const CSOM_CALL_TIMEOUT_MS = 20_000; + /** * Relays a remote sub-agent's reply/report into the session's Prime process. * Wired in `index.ts` to `pi.sendToAgent(sessionId, PRIME_AGENT_ID, text)`, so @@ -46,6 +51,8 @@ export type DeliverToPrime = (sessionId: string, text: string) => void; interface RemoteEnvConnection { environmentId: string; socket: Socket; + /** Session this environment is bound to as the CSOM executor, if any. */ + sessionId?: string; } /** A sub-agent hosted in a remote environment, tracked in the gateway roster. */ @@ -107,6 +114,8 @@ export class RemoteEnvironmentGateway { private readonly environments = new Map(); /** Per-session remote sub-agent rosters, keyed by sessionId then agentId. */ private readonly sessions = new Map>(); + /** CSOM executor binding: sessionId -> environmentId (e.g. a browser tab). */ + private readonly csomBindings = new Map(); constructor( io: SocketIOServer, @@ -138,6 +147,54 @@ export class RemoteEnvironmentGateway { return [...roster.values()].map(toInfo); } + /** True when a session has a connected CSOM executor (pipeline editor). */ + hasCsomEditor(sessionId: string): boolean { + const environmentId = this.csomBindings.get(sessionId); + return environmentId !== undefined && this.environments.has(environmentId); + } + + /** + * Invokes a CSOM editing method on the session's bound editor environment and + * resolves with its ack. Returns a structured `{ ok: false, error }` when no + * editor is connected or the environment times out, so callers (Prime's CSOM + * tools) can surface a helpful message rather than throw. + */ + invokeCsom( + sessionId: string, + method: string, + args: unknown[], + ): Promise { + const environmentId = this.csomBindings.get(sessionId); + const environment = environmentId + ? this.environments.get(environmentId) + : undefined; + if (!environment) { + return Promise.resolve({ + ok: false, + error: + "No pipeline editor is connected for this session. Ask the user to " + + "open the Pipeline Editor tab first.", + }); + } + + const request: RemoteCsomCallRequest = { sessionId, method, args }; + return new Promise((resolve) => { + environment.socket + .timeout(CSOM_CALL_TIMEOUT_MS) + .emit( + RemoteEnvEvents.CsomCall, + request, + (err: Error | null, response: RemoteCsomCallResponse) => { + if (err) { + resolve({ ok: false, error: `CSOM call timed out: ${method}` }); + return; + } + resolve(response); + }, + ); + }); + } + /** * Spawns a sub-agent on a connected remote environment. Resolves the * effective config from the global templates/defaults (remote environments @@ -300,8 +357,10 @@ export class RemoteEnvironmentGateway { /** Registers a connected environment and wires its inbound listeners. */ private onConnection(_namespace: Namespace, socket: Socket): void { - const { environmentId } = socket.handshake.auth as RemoteEnvHandshake; - this.environments.set(environmentId, { environmentId, socket }); + const { environmentId, sessionId } = socket.handshake + .auth as RemoteEnvHandshake; + this.environments.set(environmentId, { environmentId, socket, sessionId }); + if (sessionId) this.csomBindings.set(sessionId, environmentId); console.log(`[remote-env] connected: ${environmentId}`); socket.on(RemoteEnvEvents.AgentEvent, (payload: RemoteAgentEventPayload) => @@ -398,6 +457,9 @@ export class RemoteEnvironmentGateway { /** Drops a disconnected environment and fails its still-live sub-agents. */ private onDisconnect(environmentId: string): void { this.environments.delete(environmentId); + for (const [sessionId, boundId] of this.csomBindings) { + if (boundId === environmentId) this.csomBindings.delete(sessionId); + } for (const [sessionId, roster] of this.sessions) { this.failEnvironmentAgents(sessionId, roster, environmentId); } diff --git a/apps/server/src/routes/internalCsom.ts b/apps/server/src/routes/internalCsom.ts new file mode 100644 index 0000000..a361660 --- /dev/null +++ b/apps/server/src/routes/internalCsom.ts @@ -0,0 +1,55 @@ +import { type Response, Router } from "express"; +import { z } from "zod"; + +import { requireInternalToken } from "../middleware/requireInternalToken.ts"; +import { getValidated, validate } from "../middleware/validate.ts"; +import type { RemoteEnvironmentGateway } from "../remote/remoteEnvironmentGateway.ts"; + +/** + * A CSOM invocation: the camelCase editor method (e.g. `addTask`, + * `connectNodes`, `getSpecYaml`) and its positional args, scoped to a session. + * `args` is permissive — it is forwarded verbatim to the editor bridge. + */ +const csomInvokeSchema = z.object({ + sessionId: z.string().min(1), + method: z.string().min(1), + args: z.array(z.unknown()).optional(), +}); +type CsomInvokeBody = z.infer; + +/** Forwards a CSOM call to the session's bound editor and returns its ack. */ +async function handleInvoke( + remoteGateway: RemoteEnvironmentGateway, + body: CsomInvokeBody, + res: Response, +): Promise { + const result = await remoteGateway.invokeCsom( + body.sessionId, + body.method, + body.args ?? [], + ); + res.json(result); +} + +/** + * Internal API used by the CSOM tool extension running inside each Pi process. + * It lets Prime drive the embedded Tangle pipeline editor: each tool call is + * relayed to the browser tab that opened the editor (the session's bound remote + * environment) and the CSOM result is returned. + * + * Guarded by the same `INTERNAL_TOKEN` the other internal APIs use, so only the + * spawned Pi processes (not arbitrary local callers) can drive the editor. + */ +export function createInternalCsomRouter( + remoteGateway: RemoteEnvironmentGateway, +): Router { + const router = Router(); + + router.use(requireInternalToken); + + router.post("/invoke", validate({ body: csomInvokeSchema }), (req, res) => + handleInvoke(remoteGateway, getValidated(req).body, res), + ); + + return router; +} diff --git a/apps/server/src/routes/remoteEnv.ts b/apps/server/src/routes/remoteEnv.ts new file mode 100644 index 0000000..a04095a --- /dev/null +++ b/apps/server/src/routes/remoteEnv.ts @@ -0,0 +1,30 @@ +import { type Request, type Response, Router } from "express"; + +import { REMOTE_ENV_TOKEN } from "../config.ts"; + +/** + * Tells the SPA whether remote CSOM hosting is enabled and, if so, hands it the + * shared `/remote-env` token so the Pipeline Editor tab can connect as its + * session's CSOM executor. + * + * The token is the same shared secret external remote environments use. Remote + * hosting is opt-in (empty by default), so this only exposes a token an operator + * has explicitly configured. It is a same-origin dev convenience; production + * deployments that gate the app behind auth should front this route with it. + */ +function handleGetToken(_req: Request, res: Response): void { + if (!REMOTE_ENV_TOKEN) { + res.json({ enabled: false }); + return; + } + res.json({ enabled: true, token: REMOTE_ENV_TOKEN }); +} + +/** Public REST router exposing the remote-env connection token to the SPA. */ +export function createRemoteEnvRouter(): Router { + const router = Router(); + router.get("/token", (req: Request, res: Response) => + handleGetToken(req, res), + ); + return router; +} diff --git a/apps/web/package.json b/apps/web/package.json index 7e98840..3bbba80 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -21,6 +21,7 @@ "@remote-dom/core": "^1.11.1", "@remote-dom/polyfill": "^1.5.1", "@remote-dom/react": "^1.2.2", + "@tangent/remote-subagent": "workspace:*", "@tangent/shared": "workspace:*", "@tangent/ui-extensions-sdk": "workspace:*", "@tangent/ui-primitives": "workspace:*", diff --git a/apps/web/src/features/bundle-ui/BundleUiHost.tsx b/apps/web/src/features/bundle-ui/BundleUiHost.tsx index 605129a..b587438 100644 --- a/apps/web/src/features/bundle-ui/BundleUiHost.tsx +++ b/apps/web/src/features/bundle-ui/BundleUiHost.tsx @@ -101,6 +101,8 @@ interface BundleUiHostProps { stateNamespace?: string; /** Collapses the message this component is rendered in (message surface). */ onCollapse?: () => void; + /** Opens a full-screen in-app tab requested via `execUICommand({ type: "openTab" })`. */ + onOpenTab?: (command: Extract) => void; } function Placeholder() { @@ -118,6 +120,7 @@ export function BundleUiHost({ onSendPrompt, stateNamespace, onCollapse, + onOpenTab, }: BundleUiHostProps) { const receiver = useMemo(() => new RemoteReceiver(), []); const [failed, setFailed] = useState(false); @@ -128,11 +131,15 @@ export function BundleUiHost({ const sendPromptRef = useRef<((text: string) => void) | undefined>(undefined); const stateNamespaceRef = useRef(undefined); const collapseRef = useRef<(() => void) | undefined>(undefined); + const openTabRef = useRef< + ((command: Extract) => void) | undefined + >(undefined); useEffect(() => { propsRef.current = props ?? {}; sendPromptRef.current = onSendPrompt; stateNamespaceRef.current = stateNamespace; collapseRef.current = onCollapse; + openTabRef.current = onOpenTab; }); useEffect(() => { @@ -169,6 +176,10 @@ export function BundleUiHost({ openExternalUrl(command.url); return; } + if (command.type === "openTab") { + openTabRef.current?.(command); + return; + } await openTargetUrl(command); }, }); diff --git a/apps/web/src/features/chat/components/PrimeChatPanel.tsx b/apps/web/src/features/chat/components/PrimeChatPanel.tsx index 417b410..cf92cc7 100644 --- a/apps/web/src/features/chat/components/PrimeChatPanel.tsx +++ b/apps/web/src/features/chat/components/PrimeChatPanel.tsx @@ -7,6 +7,7 @@ import { type SubagentInfo, type Trigger, } from "@tangent/shared/contracts"; +import type { UICommand } from "@tangent/ui-extensions-sdk/types"; import { Box } from "@tangent/ui-primitives/box"; import { BlockStack, InlineStack } from "@tangent/ui-primitives/layout"; @@ -55,6 +56,7 @@ interface PrimeChatPanelProps { openArtifactTab: (url: string, title: string) => void; pinnedPaths: Set; togglePinArtifact: (path: string, title: string) => void; + onOpenTab: (command: Extract) => void; } export function PrimeChatPanel({ @@ -82,6 +84,7 @@ export function PrimeChatPanel({ openArtifactTab, pinnedPaths, togglePinArtifact, + onOpenTab, }: PrimeChatPanelProps) { return ( @@ -95,6 +98,7 @@ export function PrimeChatPanel({ onOpenArtifact={openArtifactTab} pinnedPaths={pinnedPaths} onTogglePinArtifact={togglePinArtifact} + onOpenTab={onOpenTab} isMessageStreaming={isMessageStreaming} /> {memorySuggestions.length > 0 && ( @@ -112,7 +116,11 @@ export function PrimeChatPanel({ )} {bundleId && ( - + )} { + if (command.tab === "pipeline-editor") openPipelineEditor(command.title); + }; + // Pin an artifact if it isn't already pinned, else unpin it. The chip's // pinned state and the sidebar list both update via the `artifacts.update` // directive once the server confirms. @@ -227,6 +244,7 @@ export function SessionChat({ sessionId }: SessionChatProps) { openArtifactTab={openArtifactTab} pinnedPaths={pinnedPaths} togglePinArtifact={togglePinArtifact} + onOpenTab={handleOpenTab} /> diff --git a/apps/web/src/features/chat/components/composer/BundlePanelLauncher.tsx b/apps/web/src/features/chat/components/composer/BundlePanelLauncher.tsx index 12a12d1..b0debb0 100644 --- a/apps/web/src/features/chat/components/composer/BundlePanelLauncher.tsx +++ b/apps/web/src/features/chat/components/composer/BundlePanelLauncher.tsx @@ -1,3 +1,4 @@ +import type { UICommand } from "@tangent/ui-extensions-sdk/types"; import { Box } from "@tangent/ui-primitives/box"; import { Button } from "@tangent/ui-primitives/button"; import { BlockStack, InlineStack } from "@tangent/ui-primitives/layout"; @@ -14,6 +15,8 @@ interface BundlePanelLauncherProps { bundleId: string; /** Composes the panel's prompt and sends it to Prime. */ onSendPrompt: (text: string) => void; + /** Opens a full-screen in-app tab requested by a panel via `execUICommand`. */ + onOpenTab?: (command: Extract) => void; } /** @@ -24,6 +27,7 @@ interface BundlePanelLauncherProps { export function BundlePanelLauncher({ bundleId, onSendPrompt, + onOpenTab, }: BundlePanelLauncherProps) { const { data: bundle } = useAgentBundle(bundleId); const [selected, setSelected] = useState(null); @@ -81,6 +85,7 @@ export function BundlePanelLauncher({ onSendPrompt(text); setSelected(null); }} + onOpenTab={onOpenTab} /> diff --git a/apps/web/src/features/chat/components/message/ChatMessage.tsx b/apps/web/src/features/chat/components/message/ChatMessage.tsx index e0d0185..b415fc2 100644 --- a/apps/web/src/features/chat/components/message/ChatMessage.tsx +++ b/apps/web/src/features/chat/components/message/ChatMessage.tsx @@ -1,3 +1,4 @@ +import type { UICommand } from "@tangent/ui-extensions-sdk/types"; import { BlockStack } from "@tangent/ui-primitives/layout"; import { Paragraph } from "@tangent/ui-primitives/typography"; import { useState } from "react"; @@ -39,6 +40,8 @@ interface ChatMessageProps { onTogglePinArtifact?: (path: string, title: string) => void; /** Collapses this message into the hidden state; omitted disables collapse. */ onCollapse?: () => void; + /** Opens a full-screen in-app tab requested by a `tangent-ui:` component. */ + onOpenTab?: (command: Extract) => void; } function ChatMessageContent({ @@ -52,6 +55,7 @@ function ChatMessageContent({ pinnedPaths, onTogglePinArtifact, onCollapse, + onOpenTab, }: ChatMessageProps) { if (message.memory) return ( @@ -121,6 +125,7 @@ function ChatMessageContent({ sessionId={sessionId} messageId={message.id} onCollapse={onCollapse} + onOpenTab={onOpenTab} > {message.content} diff --git a/apps/web/src/features/chat/components/message/ChatMessageList.tsx b/apps/web/src/features/chat/components/message/ChatMessageList.tsx index 022274f..9743d9d 100644 --- a/apps/web/src/features/chat/components/message/ChatMessageList.tsx +++ b/apps/web/src/features/chat/components/message/ChatMessageList.tsx @@ -1,4 +1,5 @@ import type { AgentActivity } from "@tangent/shared/contracts"; +import type { UICommand } from "@tangent/ui-extensions-sdk/types"; import { Box } from "@tangent/ui-primitives/box"; import { BlockStack } from "@tangent/ui-primitives/layout"; import { Paragraph } from "@tangent/ui-primitives/typography"; @@ -31,6 +32,8 @@ interface ChatMessageListProps { pinnedPaths?: Set; /** Toggles an artifact's pinned state from its chip. */ onTogglePinArtifact?: (path: string, title: string) => void; + /** Opens a full-screen in-app tab requested by a `tangent-ui:` component. */ + onOpenTab?: (command: Extract) => void; /** Whether a given message id is still receiving streamed deltas. */ isMessageStreaming: (messageId: string) => boolean; } @@ -54,6 +57,7 @@ interface RowContentProps { onOpenArtifact?: (url: string, title: string) => void; pinnedPaths?: Set; onTogglePinArtifact?: (path: string, title: string) => void; + onOpenTab?: (command: Extract) => void; isMessageStreaming: (messageId: string) => boolean; onCollapse: (id: string) => void; onExpand: (ids: string[]) => void; @@ -68,6 +72,7 @@ function RowContent({ onOpenArtifact, pinnedPaths, onTogglePinArtifact, + onOpenTab, isMessageStreaming, onCollapse, onExpand, @@ -84,6 +89,7 @@ function RowContent({ onOpenArtifact={onOpenArtifact} pinnedPaths={pinnedPaths} onTogglePinArtifact={onTogglePinArtifact} + onOpenTab={onOpenTab} isStreaming={isMessageStreaming(row.message.id)} onCollapse={() => onCollapse(row.message.id)} /> @@ -110,6 +116,7 @@ export function ChatMessageList({ onOpenArtifact, pinnedPaths, onTogglePinArtifact, + onOpenTab, isMessageStreaming, }: ChatMessageListProps) { // Collapse state is ephemeral per view (not URL or server). Thinking-only @@ -214,6 +221,7 @@ export function ChatMessageList({ onOpenArtifact={onOpenArtifact} pinnedPaths={pinnedPaths} onTogglePinArtifact={onTogglePinArtifact} + onOpenTab={onOpenTab} isMessageStreaming={isMessageStreaming} onCollapse={collapse} onExpand={expand} diff --git a/apps/web/src/features/chat/components/tabs/AssetTabContent.tsx b/apps/web/src/features/chat/components/tabs/AssetTabContent.tsx index 951ec37..1794cd8 100644 --- a/apps/web/src/features/chat/components/tabs/AssetTabContent.tsx +++ b/apps/web/src/features/chat/components/tabs/AssetTabContent.tsx @@ -11,6 +11,7 @@ import type { AgentModelSelection } from "@/features/chat/hooks/useSessionChat"; import type { ChatMessage } from "@/features/chat/model/types"; import { ArtifactTabView } from "./ArtifactTabView"; +import { PipelineEditorTabView } from "./PipelineEditorTabView"; import { SubagentTabView } from "./SubagentTabView"; import { TriggerTabPanel } from "./TriggerTabPanel"; @@ -118,6 +119,8 @@ export function AssetTabContent({ onClose={() => closeAsset(tab.id)} /> ); + case "pipeline-editor": + return ; default: return ( { + try { + const res = await fetch(apiUrl("/api/remote-env/token")); + if (!res.ok) return { enabled: false }; + return (await res.json()) as RemoteEnvConfig; + } catch { + return { enabled: false }; + } +} + +/** Builds the embed URL with the origin gate and (optional) backend base. */ +function buildEmbedUrl(base: string): string { + const url = new URL("/embed/editor", base); + url.searchParams.set("parentOrigin", window.location.origin); + if (env.tangleBackendUrl) { + url.searchParams.set("backendUrl", env.tangleBackendUrl); + } + return url.toString(); +} + +const STATUS_LABEL: Record = { + disabled: "Prime control off", + connecting: "Connecting Prime...", + connected: "Prime can edit live", + error: "Prime control unavailable", +}; + +const STATUS_TONE: Record = { + disabled: "subdued", + connecting: "subdued", + connected: "success", + error: "critical", +}; + +/** + * Full-screen tab that embeds the Tangle pipeline editor and lets Prime drive + * it live. The iframe speaks the CSOM postMessage bridge; this tab also connects + * to the server's `/remote-env` gateway as the session's CSOM executor, so each + * CSOM tool Prime calls is relayed here and run against the editor. + * + * The parent is the source of truth for the embed, but here Prime edits the + * in-memory spec directly through CSOM, so the graph updates in real time. + */ +export function PipelineEditorTabView({ + sessionId, +}: PipelineEditorTabViewProps) { + const iframeRef = useRef(null); + const [remoteStatus, setRemoteStatus] = useState("connecting"); + + const embedBase = env.tangleEmbedUrl; + + useEffect(() => { + const iframe = iframeRef.current; + if (!iframe || !embedBase) return undefined; + + const embedOrigin = new URL(embedBase).origin; + const client = new TangleEmbedClient(iframe, embedOrigin); + client.init({ + parentOrigin: window.location.origin, + backendUrl: env.tangleBackendUrl || undefined, + }); + + let remote: RemoteEnvironmentClient | undefined; + let cancelled = false; + + void fetchRemoteEnvConfig().then((config) => { + if (cancelled) return; + if (!config.enabled || !config.token) { + setRemoteStatus("disabled"); + return; + } + remote = connectRemoteEnvironment({ + url: window.location.origin, + token: config.token, + environmentId: `browser-csom:${sessionId}`, + sessionId, + handlers: { + onCsomCall: (request) => client.call(request.method, ...request.args), + }, + }); + remote.socket.on("connect", () => setRemoteStatus("connected")); + remote.socket.on("disconnect", () => setRemoteStatus("connecting")); + remote.socket.on("connect_error", () => setRemoteStatus("error")); + }); + + return () => { + cancelled = true; + remote?.disconnect(); + client.dispose(); + }; + }, [embedBase, sessionId]); + + if (!embedBase) { + return ( + + + + ); + } + + return ( + + + + + + {STATUS_LABEL[remoteStatus]} + + + +
+