diff --git a/infra/.env.example b/infra/.env.example index 30da4fc..af88641 100644 --- a/infra/.env.example +++ b/infra/.env.example @@ -65,4 +65,5 @@ NODEDC_AI_WORKSPACE_ASSISTANT_URL=http://ai-workspace-assistant:18082 AI_WORKSPACE_HUB_TOKEN=change-me-generate-with-infra-scripts-init-dev-env AI_WORKSPACE_HUB_HOST_BIND=127.0.0.1:18081 AI_WORKSPACE_HUB_PUBLIC_URL=wss://ai-hub.nodedc.ru/api/ai-workspace/hub +AI_WORKSPACE_HUB_INTERNAL_URL=https://ai-hub.nodedc.ru AI_WORKSPACE_HUB_FALLBACK_URLS= diff --git a/infra/docker-compose.dev.yml b/infra/docker-compose.dev.yml index 1dac0f3..913cbd5 100644 --- a/infra/docker-compose.dev.yml +++ b/infra/docker-compose.dev.yml @@ -163,6 +163,8 @@ services: DATABASE_URL: postgres://${AI_WORKSPACE_PG_USER:-nodedc_ai_workspace}:${AI_WORKSPACE_PG_PASS:-nodedc_ai_workspace}@ai-workspace-postgres:5432/${AI_WORKSPACE_PG_DB:-nodedc_ai_workspace} AI_WORKSPACE_ASSISTANT_TOKEN: ${AI_WORKSPACE_ASSISTANT_TOKEN:-dev-ai-workspace-assistant-token} AI_WORKSPACE_HUB_PUBLIC_URL: ${AI_WORKSPACE_HUB_PUBLIC_URL:-wss://ai-hub.nodedc.ru/api/ai-workspace/hub} + AI_WORKSPACE_HUB_INTERNAL_URL: ${AI_WORKSPACE_HUB_INTERNAL_URL:-https://ai-hub.nodedc.ru} + AI_WORKSPACE_HUB_TOKEN: ${AI_WORKSPACE_HUB_TOKEN:-} AI_WORKSPACE_HUB_FALLBACK_URLS: ${AI_WORKSPACE_HUB_FALLBACK_URLS:-} expose: - "18082" diff --git a/infra/synology/.env.synology.example b/infra/synology/.env.synology.example index 71aba0d..c77550e 100644 --- a/infra/synology/.env.synology.example +++ b/infra/synology/.env.synology.example @@ -66,4 +66,5 @@ NODEDC_AI_WORKSPACE_ASSISTANT_URL=http://ai-workspace-assistant:18082 AI_WORKSPACE_HUB_TOKEN=replace-with-random-synology-secret AI_WORKSPACE_HUB_HOST_BIND=0.0.0.0:18081 AI_WORKSPACE_HUB_PUBLIC_URL=wss://ai-hub.nodedc.ru/api/ai-workspace/hub +AI_WORKSPACE_HUB_INTERNAL_URL=https://ai-hub.nodedc.ru AI_WORKSPACE_HUB_FALLBACK_URLS= diff --git a/infra/synology/README.md b/infra/synology/README.md index 54b13ba..70abe08 100644 --- a/infra/synology/README.md +++ b/infra/synology/README.md @@ -41,10 +41,11 @@ Installer generation must always point remote Codex workers to the deployed Hub: ```env AI_WORKSPACE_HUB_PUBLIC_URL=wss://ai-hub.nodedc.ru/api/ai-workspace/hub +AI_WORKSPACE_HUB_INTERNAL_URL=https://ai-hub.nodedc.ru AI_WORKSPACE_HUB_FALLBACK_URLS= ``` -Server-side Hub API calls require a token accepted by Hub: `AI_WORKSPACE_HUB_TOKEN`, `NDC_AI_WORKSPACE_HUB_TOKEN`, or the shared `NODEDC_INTERNAL_ACCESS_TOKEN` where that token is intentionally common across platform services. +Server-side Hub API calls, including executor status checks, use `AI_WORKSPACE_HUB_INTERNAL_URL` and require a token accepted by Hub: `AI_WORKSPACE_HUB_TOKEN`, `NDC_AI_WORKSPACE_HUB_TOKEN`, or the shared `NODEDC_INTERNAL_ACCESS_TOKEN` where that token is intentionally common across platform services. ## Локальные домены для первичной проверки diff --git a/infra/synology/docker-compose.platform-http.yml b/infra/synology/docker-compose.platform-http.yml index 2b922d6..f07b1a0 100644 --- a/infra/synology/docker-compose.platform-http.yml +++ b/infra/synology/docker-compose.platform-http.yml @@ -139,6 +139,8 @@ services: AI_WORKSPACE_ASSISTANT_TOKEN: ${AI_WORKSPACE_ASSISTANT_TOKEN:?ai workspace assistant token required} NODEDC_INTERNAL_ACCESS_TOKEN: ${NODEDC_INTERNAL_ACCESS_TOKEN:-} AI_WORKSPACE_HUB_PUBLIC_URL: ${AI_WORKSPACE_HUB_PUBLIC_URL:-wss://ai-hub.nodedc.ru/api/ai-workspace/hub} + AI_WORKSPACE_HUB_INTERNAL_URL: ${AI_WORKSPACE_HUB_INTERNAL_URL:-https://ai-hub.nodedc.ru} + AI_WORKSPACE_HUB_TOKEN: ${AI_WORKSPACE_HUB_TOKEN:?ai workspace hub token required} AI_WORKSPACE_HUB_FALLBACK_URLS: ${AI_WORKSPACE_HUB_FALLBACK_URLS:-} expose: - "18082" diff --git a/services/ai-workspace-assistant/src/server.mjs b/services/ai-workspace-assistant/src/server.mjs index ee23d62..65c13ff 100644 --- a/services/ai-workspace-assistant/src/server.mjs +++ b/services/ai-workspace-assistant/src/server.mjs @@ -11,11 +11,15 @@ const SUPPORTED_EXECUTOR_STATUSES = new Set(["unknown", "online", "offline", "ch const SUPPORTED_THREAD_STATES = new Set(["active", "archived"]); const SUPPORTED_MESSAGE_ROLES = new Set(["user", "assistant", "system", "tool"]); const SUPPORTED_TOOL_PACKS = new Set(["engine", "ops", "ndc-agent-core", "deploy", "docs"]); +const SUPPORTED_RUN_STATUSES = new Set(["running", "completed", "failed", "timeout"]); +const BRIDGE_RUN_MIRROR_POLL_MS = 1200; +const BRIDGE_RUN_MIRROR_MAX_MS = 12 * 60 * 60 * 1000; const config = readConfig(); const pool = new Pool({ connectionString: config.databaseUrl, max: config.databasePoolSize }); const app = express(); const httpServer = createServer(app); +const activeBridgeRunMirrors = new Map(); app.disable("x-powered-by"); app.use(express.json({ limit: "2mb" })); @@ -26,17 +30,30 @@ app.get("/healthz", asyncRoute(async (_req, res) => { ok: true, service: "nodedc-ai-workspace-assistant", database: "ready", - internalApiConfigured: Boolean(config.internalAccessToken), + internalApiConfigured: config.internalAccessTokens.length > 0, }); })); +app.get("/api/ai-workspace/assistant/v1/settings", requireInternalApi, asyncRoute(async (req, res) => { + const owner = getRequestOwner(req); + const settings = await getOwnerSettings(owner); + res.json({ ok: true, owner: publicOwner(owner), settings: publicOwnerSettings(settings) }); +})); + +app.patch("/api/ai-workspace/assistant/v1/settings", requireInternalApi, asyncRoute(async (req, res) => { + const owner = getRequestOwner(req); + const command = sanitizeOwnerSettingsCommand(req.body); + const settings = await updateOwnerSettings(owner, command); + res.json({ ok: true, owner: publicOwner(owner), settings: publicOwnerSettings(settings) }); +})); + app.get("/api/ai-workspace/assistant/v1/executors", requireInternalApi, asyncRoute(async (req, res) => { const owner = getRequestOwner(req); const [executors, settings] = await Promise.all([ listExecutors(owner), getOwnerSettings(owner), ]); - res.json({ ok: true, owner: publicOwner(owner), selectedExecutorId: settings.selectedExecutorId, executors }); + res.json({ ok: true, owner: publicOwner(owner), selectedExecutorId: settings.selectedExecutorId, settings: publicOwnerSettings(settings), executors }); })); app.post("/api/ai-workspace/assistant/v1/executors", requireInternalApi, asyncRoute(async (req, res) => { @@ -84,6 +101,51 @@ app.post("/api/ai-workspace/assistant/v1/executors/:executorId/select", requireI res.json({ ok: true, owner: publicOwner(owner), selectedExecutorId: executor.id, executor }); })); +app.post("/api/ai-workspace/assistant/v1/executors/:executorId/check", requireInternalApi, asyncRoute(async (req, res) => { + const owner = getRequestOwner(req); + const executorId = sanitizeUuid(req.params.executorId, "executorId"); + const executor = await getExecutor(owner, executorId); + if (!executor) { + res.status(404).json({ ok: false, error: "ai_workspace_executor_not_found" }); + return; + } + + const check = await checkExecutor(executor); + const updated = await updateExecutor(owner, executor.id, { + status: check.status, + statusDetail: check.statusDetail, + lastSeenAt: check.lastSeenAt, + metadata: { + ...(executor.metadata || {}), + lastCheck: check.details, + }, + }); + res.json({ ok: true, owner: publicOwner(owner), executor: updated || executor, check: check.details }); +})); + +app.get("/api/ai-workspace/assistant/v1/executors/:executorId/events", requireInternalApi, asyncRoute(async (req, res) => { + const owner = getRequestOwner(req); + const executorId = sanitizeUuid(req.params.executorId, "executorId"); + const executor = await getExecutor(owner, executorId); + if (!executor) { + res.status(404).json({ ok: false, error: "ai_workspace_executor_not_found" }); + return; + } + const latestOnly = String(req.query.since || "") === "latest"; + const payload = await readExecutorEvents(executor, { + since: latestOnly ? 0 : Number(req.query.since || 0), + limit: sanitizeLimit(req.query.limit, 100, 500), + }); + res.json({ + ok: true, + owner: publicOwner(owner), + executorId: executor.id, + online: payload.online, + latestEventId: payload.latestEventId, + events: latestOnly ? [] : payload.events, + }); +})); + app.get("/api/ai-workspace/assistant/v1/executors/:executorId/agent/windows.ps1", requireInternalApi, asyncRoute(async (req, res) => { const owner = getRequestOwner(req); const executorId = sanitizeUuid(req.params.executorId, "executorId"); @@ -97,6 +159,13 @@ app.get("/api/ai-workspace/assistant/v1/executors/:executorId/agent/windows.ps1" res.status(400).send("installer_port_invalid"); return; } + const ownerSettings = await getOwnerSettings(owner); + const appMcpServers = installerMcpServersFromSettings(ownerSettings, req.query); + const opsMcpServer = appMcpServers.find((server) => server.appId === "ops") || null; + const opsMcpUrl = optionalString(opsMcpServer?.url) || ""; + const opsMcpToken = bearerTokenFromHeaders(opsMcpServer?.httpHeaders) || ""; + const opsMcpServerName = optionalString(opsMcpServer?.serverName) || "nodedc_tasker"; + const appMcpServersJson = appMcpServers.length ? JSON.stringify(appMcpServers) : ""; const templateUrl = new URL("./templates/install-windows.ps1", import.meta.url); let source = await readFile(templateUrl, "utf8"); @@ -105,6 +174,10 @@ app.get("/api/ai-workspace/assistant/v1/executors/:executorId/agent/windows.ps1" .replace('[string]$HubFallbackUrls = "",', `[string]$HubFallbackUrls = ${psSingle(config.hubFallbackWebSocketUrls.join(","))},`) .replace('[string]$PairingCode = "",', `[string]$PairingCode = ${psSingle(executor.pairingCode || "")},`) .replace('[string]$MachineName = "",', `[string]$MachineName = ${psSingle(executor.name)},`) + .replace('[string]$OpsMcpUrl = "",', `[string]$OpsMcpUrl = ${psSingle(opsMcpUrl)},`) + .replace('[string]$OpsMcpToken = "",', `[string]$OpsMcpToken = ${psSingle(opsMcpToken)},`) + .replace('[string]$OpsMcpServerName = "nodedc_tasker",', `[string]$OpsMcpServerName = ${psSingle(opsMcpServerName)},`) + .replace('[string]$AppMcpServersJson = "",', `[string]$AppMcpServersJson = ${psSingle(appMcpServersJson)},`) .replace('[int]$Port = 8787,', `[int]$Port = ${installerPort},`) .replace('[string]$Workspace = "",', `[string]$Workspace = ${psSingle(executor.workspacePath || "")},`); @@ -123,8 +196,10 @@ app.get("/api/ai-workspace/assistant/v1/executors/:executorId/agent/windows.ps1" app.get("/api/ai-workspace/assistant/v1/threads", requireInternalApi, asyncRoute(async (req, res) => { const owner = getRequestOwner(req); const surface = req.query.surface ? sanitizeSurface(req.query.surface) : null; + const kind = sanitizeThreadKind(req.query.kind || req.query.threadKind || req.query.thread_kind || "shared"); + const includeArchived = isTruthy(req.query.includeArchived || req.query.include_archived); const limit = sanitizeLimit(req.query.limit, 100, 200); - const threads = await listThreads(owner, { surface, limit }); + const threads = await listThreads(owner, { surface, kind, limit, includeArchived }); res.json({ ok: true, owner: publicOwner(owner), threads }); })); @@ -184,6 +259,130 @@ app.post("/api/ai-workspace/assistant/v1/threads/:threadId/messages", requireInt res.status(201).json({ ok: true, owner: publicOwner(owner), message }); })); +app.post("/api/ai-workspace/assistant/v1/threads/:threadId/dispatch", requireInternalApi, asyncRoute(async (req, res) => { + const owner = getRequestOwner(req); + const threadId = sanitizeUuid(req.params.threadId, "threadId"); + const command = sanitizeDispatchCommand(req.body); + const thread = await getThread(owner, threadId); + if (!thread) { + res.status(404).json({ ok: false, error: "ai_workspace_thread_not_found" }); + return; + } + const message = await getThreadMessage(owner, threadId, command.messageId); + if (!message) { + res.status(404).json({ ok: false, error: "ai_workspace_thread_message_not_found" }); + return; + } + if (message.role !== "user") { + res.status(400).json({ ok: false, error: "ai_workspace_dispatch_requires_user_message" }); + return; + } + + const ownerSettings = await getOwnerSettings(owner); + const executorId = command.selectedExecutorId || thread.selectedExecutorId || ownerSettings.selectedExecutorId; + if (!executorId) { + res.status(400).json({ ok: false, error: "ai_workspace_executor_required" }); + return; + } + const executor = await getExecutor(owner, executorId); + if (!executor) { + res.status(404).json({ ok: false, error: "ai_workspace_executor_not_found" }); + return; + } + + const history = await listThreadMessages(owner, threadId, { limit: 40, before: null }); + const bridgePayload = buildBridgeMessagePayload({ thread, message, history, executor, command, ownerSettings }); + const missingContext = missingNdcAgentCoreContext(bridgePayload.context); + const missingOpsContext = missingOpsContextFields(bridgePayload.context); + if (missingContext.length > 0) { + const guardInstruction = ndcMissingContextInstruction(bridgePayload.context, missingContext); + bridgePayload.context = { + ...bridgePayload.context, + contextReady: false, + missingContext, + accessMode: "chat-readonly", + guardInstruction, + }; + } else if (normalizeKey(bridgePayload.context?.modeId) === "ndc-agent-core") { + bridgePayload.context = { + ...bridgePayload.context, + contextReady: true, + missingContext: [], + accessMode: "agent-write", + }; + } else if (missingOpsContext.length > 0) { + bridgePayload.context = { + ...bridgePayload.context, + contextReady: false, + missingContext: missingOpsContext, + accessMode: "chat-readonly", + guardInstruction: opsMissingContextInstruction(bridgePayload.context, missingOpsContext), + }; + } else if (normalizeKey(bridgePayload.context?.modeId) === "ops") { + bridgePayload.context = { + ...bridgePayload.context, + contextReady: true, + missingContext: [], + accessMode: "ops-write", + }; + } + let bridge = null; + try { + bridge = await dispatchExecutorMessage(executor, bridgePayload); + } catch (error) { + const failureText = bridgeFailureText({ message: errorMessage(error) }); + const assistantMessage = await createThreadMessage(owner, threadId, { + role: "assistant", + content: failureText, + payload: { + surface: optionalString(bridgePayload.context?.surface) || thread.originSurface || "global", + kind: "bridge_failure", + executorId: executor.id, + modeId: bridgePayload.context?.modeId, + }, + }); + await updateExecutor(owner, executor.id, { + status: "offline", + statusDetail: sanitizeBridgeErrorText(errorMessage(error)), + lastSeenAt: executor.lastSeenAt, + metadata: { + ...(executor.metadata || {}), + lastDispatchError: { + at: new Date().toISOString(), + message: sanitizeBridgeErrorText(errorMessage(error)), + }, + }, + }).catch(() => null); + res.json({ + ok: true, + owner: publicOwner(owner), + threadId, + executor, + message, + assistantMessage, + bridge: { + ok: false, + accepted: false, + mode: executor.connectionMode === "direct" ? "direct" : "hub", + error: sanitizeBridgeErrorText(errorMessage(error)), + }, + }); + return; + } + const run = await createBridgeRun({ owner, thread, executor, bridge, payload: bridgePayload }); + startBridgeRunMirror({ owner, thread, executor, bridge, run }); + + res.json({ + ok: true, + owner: publicOwner(owner), + threadId, + executor, + message, + assistantMessage: null, + bridge, + }); +})); + app.use((error, _req, res, _next) => { const status = Number(error?.status || 500); const message = error instanceof Error ? error.message : "internal_error"; @@ -191,6 +390,7 @@ app.use((error, _req, res, _next) => { }); await migrate(); +await recoverBridgeRunMirrors(); httpServer.listen(config.port, "0.0.0.0", () => { console.log(`NODE.DC AI Workspace Assistant listening on http://0.0.0.0:${config.port}`); @@ -207,7 +407,47 @@ async function listExecutors(owner) { order by updated_at desc, created_at desc`, [owner.key] ); - return result.rows.map(toExecutor); + return Promise.all(result.rows.map((row) => decorateExecutorLiveStatus(toExecutor(row)))); +} + +async function decorateExecutorLiveStatus(executor) { + if (!(executor?.connectionMode === "hub" || executor?.pairingCode)) return executor; + const pairingCode = cleanPairingCode(executor.pairingCode); + if (!pairingCode) { + return { ...executor, status: "offline", statusDetail: "pairing_code_required" }; + } + if (!config.hubInternalHttpUrl || !config.hubInternalAccessToken) return executor; + + try { + const payload = await hubRequestJson( + `/api/ai-workspace/hub/v1/agents/${encodeURIComponent(pairingCode)}`, + { method: "GET" }, + 1800 + ); + const agent = isPlainObject(payload.agent) ? payload.agent : null; + if (agent?.online && agent.runtimeStatus !== "error") { + return { + ...executor, + status: "online", + statusDetail: "", + lastSeenAt: optionalString(agent.lastSeenAt) || executor.lastSeenAt, + }; + } + return { + ...executor, + status: "offline", + statusDetail: agent?.runtimeStatus === "error" + ? optionalString(agent.runtimeError) || "bridge_runtime_error" + : "bridge_agent_offline", + lastSeenAt: optionalString(agent?.lastSeenAt) || executor.lastSeenAt, + }; + } catch (error) { + return { + ...executor, + status: "offline", + statusDetail: errorMessage(error) || "bridge_agent_offline", + }; + } } async function createExecutor(owner, command) { @@ -357,23 +597,1135 @@ async function selectExecutor(owner, executorId) { return executor; } +async function updateOwnerSettings(owner, command) { + const current = await getOwnerSettings(owner); + const selectedExecutorId = Object.hasOwn(command, "selectedExecutorId") + ? command.selectedExecutorId + : current.selectedExecutorId; + if (selectedExecutorId && !(await getExecutor(owner, selectedExecutorId))) { + const error = new Error("ai_workspace_executor_not_found"); + error.status = 404; + throw error; + } + const activeContext = Object.hasOwn(command, "activeContext") + ? mergeOwnerActiveContext(current.activeContext || {}, command.activeContext) + : current.activeContext || {}; + const enabledToolPacks = Object.hasOwn(command, "enabledToolPacks") + ? mergeToolPacks(current.enabledToolPacks, command.enabledToolPacks) + : current.enabledToolPacks || []; + const metadata = Object.hasOwn(command, "metadata") + ? mergeOwnerMetadata(current.metadata || {}, command.metadata) + : current.metadata || {}; + const result = await pool.query( + `insert into ai_workspace_owner_settings ( + owner_key, + owner_user_id, + owner_email, + selected_executor_id, + active_context, + enabled_tool_packs, + metadata + ) values ($1, $2, $3, $4, $5::jsonb, $6::text[], $7::jsonb) + on conflict (owner_key) do update set + owner_user_id = excluded.owner_user_id, + owner_email = excluded.owner_email, + selected_executor_id = excluded.selected_executor_id, + active_context = excluded.active_context, + enabled_tool_packs = excluded.enabled_tool_packs, + metadata = excluded.metadata, + updated_at = now() + returning *`, + [ + owner.key, + owner.userId, + owner.email, + selectedExecutorId, + JSON.stringify(activeContext), + enabledToolPacks, + JSON.stringify(metadata), + ] + ); + return toOwnerSettings(result.rows[0]); +} + +function mergeOwnerActiveContext(current, incoming) { + const currentContext = isPlainObject(current) ? current : {}; + const incomingContext = isPlainObject(incoming) ? incoming : {}; + const currentContexts = isPlainObject(currentContext.contexts) ? currentContext.contexts : {}; + const incomingContexts = isPlainObject(incomingContext.contexts) ? incomingContext.contexts : {}; + const surface = normalizeKey(incomingContext.surface); + const nextContexts = { ...currentContexts }; + + for (const [key, value] of Object.entries(incomingContexts)) { + const normalizedKey = normalizeKey(key); + if (!normalizedKey || !isPlainObject(value)) continue; + const { contexts: _nestedContexts, ...surfaceContext } = value; + nextContexts[normalizedKey] = { + ...(isPlainObject(nextContexts[normalizedKey]) ? nextContexts[normalizedKey] : {}), + ...surfaceContext, + }; + } + + if ((surface === "engine" || surface === "ops") && !isPlainObject(incomingContexts[surface])) { + const { contexts: _contexts, ...surfaceContext } = incomingContext; + nextContexts[surface] = { + ...(isPlainObject(nextContexts[surface]) ? nextContexts[surface] : {}), + ...surfaceContext, + }; + } + + return { + ...currentContext, + ...incomingContext, + contexts: nextContexts, + }; +} + +function mergeOwnerMetadata(current, incoming) { + const currentMetadata = isPlainObject(current) ? current : {}; + const incomingMetadata = isPlainObject(incoming) ? incoming : {}; + const currentAppGrants = isPlainObject(currentMetadata.appGrants) ? currentMetadata.appGrants : {}; + const incomingAppGrants = isPlainObject(incomingMetadata.appGrants) ? incomingMetadata.appGrants : {}; + const nextAppGrants = { ...currentAppGrants }; + + for (const [key, value] of Object.entries(incomingAppGrants)) { + const appId = normalizeKey(key); + if (!appId) continue; + if (value === null || value === false) { + delete nextAppGrants[appId]; + continue; + } + if (!isPlainObject(value)) continue; + nextAppGrants[appId] = { + ...(isPlainObject(nextAppGrants[appId]) ? nextAppGrants[appId] : {}), + ...value, + appId: optionalString(value.appId) || appId, + }; + } + + return { + ...currentMetadata, + ...incomingMetadata, + appGrants: nextAppGrants, + }; +} + +function installerMcpServersFromSettings(settings, query = {}) { + const servers = []; + const metadata = isPlainObject(settings?.metadata) ? settings.metadata : {}; + collectInstallerMcpServers(servers, metadata.mcpServers, {}); + const appGrants = isPlainObject(metadata.appGrants) ? metadata.appGrants : {}; + for (const [appId, grant] of Object.entries(appGrants)) { + if (!isPlainObject(grant)) continue; + collectInstallerMcpServers(servers, grant.mcpServers, { + appId: optionalString(grant.appId) || normalizeKey(appId), + appTitle: optionalString(grant.appTitle || grant.title), + }); + } + + const legacyOpsUrl = optionalString(query.opsMcpUrl || query.ops_mcp_url); + const legacyOpsToken = optionalString(query.opsMcpToken || query.ops_mcp_token); + if (legacyOpsUrl && legacyOpsToken) { + servers.push({ + appId: "ops", + appTitle: "NODE.DC Ops", + serverName: optionalString(query.opsMcpServerName || query.ops_mcp_server_name) || "nodedc_tasker", + url: legacyOpsUrl, + enabled: true, + required: false, + startupTimeoutSec: 20, + toolTimeoutSec: 60, + httpHeaders: { + Authorization: `Bearer ${legacyOpsToken}`, + Accept: "application/json", + "MCP-Protocol-Version": "2025-06-18", + }, + }); + } + + const byServerName = new Map(); + for (const server of servers.map(sanitizeInstallerMcpServer).filter(Boolean)) { + byServerName.set(server.serverName, server); + } + return Array.from(byServerName.values()); +} + +function collectInstallerMcpServers(target, value, defaults = {}) { + const items = Array.isArray(value) + ? value + : isPlainObject(value) + ? Object.values(value) + : []; + for (const item of items) { + if (!isPlainObject(item)) continue; + target.push({ ...defaults, ...item }); + } +} + +function sanitizeInstallerMcpServer(raw) { + if (!isPlainObject(raw)) return null; + const serverName = safeMcpServerName(raw.serverName || raw.server_name || raw.name); + const url = optionalString(raw.url); + if (!serverName || !url) return null; + const httpHeaders = sanitizeInstallerMcpHeaders(raw.httpHeaders || raw.http_headers || raw.headers); + return { + appId: normalizeKey(raw.appId || raw.app_id) || "global", + appTitle: optionalString(raw.appTitle || raw.app_title || raw.title), + serverName, + url, + enabled: raw.enabled !== false, + required: raw.required === true, + startupTimeoutSec: sanitizeInteger(raw.startupTimeoutSec || raw.startup_timeout_sec, 20, 1, 600), + toolTimeoutSec: sanitizeInteger(raw.toolTimeoutSec || raw.tool_timeout_sec, 60, 1, 3600), + httpHeaders, + }; +} + +function sanitizeInstallerMcpHeaders(value) { + if (!isPlainObject(value)) return {}; + const headers = {}; + for (const [key, rawValue] of Object.entries(value)) { + const headerName = optionalString(key); + const headerValue = optionalString(rawValue); + if (!headerName || !headerValue || headerName.length > 120 || headerValue.length > 4000) continue; + headers[headerName] = headerValue; + } + return headers; +} + +function bearerTokenFromHeaders(headers) { + if (!isPlainObject(headers)) return ""; + const authorization = optionalString(headers.Authorization || headers.authorization); + const match = authorization?.match(/^Bearer\s+(.+)$/i); + return match ? match[1].trim() : ""; +} + +function safeMcpServerName(value) { + const text = optionalString(value); + if (!text) return ""; + return text.replace(/[^A-Za-z0-9_-]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 80); +} + +function sanitizeInteger(value, fallback, min, max) { + const number = Number(value || fallback); + if (!Number.isFinite(number)) return fallback; + return Math.min(Math.max(Math.trunc(number), min), max); +} + +async function checkExecutor(executor) { + const checkedAt = new Date().toISOString(); + if (executor.connectionMode === "direct") { + return checkDirectExecutor(executor, checkedAt); + } + return checkHubExecutor(executor, checkedAt); +} + +async function checkHubExecutor(executor, checkedAt) { + const pairingCode = cleanPairingCode(executor.pairingCode); + if (!pairingCode) { + return executorCheckResult("error", "pairing_code_required", checkedAt, { checkedAt, mode: "hub" }); + } + if (!config.hubInternalHttpUrl) { + return executorCheckResult("unknown", "ai_workspace_hub_url_not_configured", checkedAt, { checkedAt, mode: "hub", pairingCode }); + } + if (!config.hubInternalAccessToken) { + return executorCheckResult("unknown", "ai_workspace_hub_token_not_configured", checkedAt, { checkedAt, mode: "hub", pairingCode }); + } + + try { + const agentPayload = await hubRequestJson( + `/api/ai-workspace/hub/v1/agents/${encodeURIComponent(pairingCode)}`, + { method: "GET" }, + 5000 + ); + const agent = isPlainObject(agentPayload.agent) ? agentPayload.agent : null; + if (!agent?.online) { + return executorCheckResult("offline", "bridge_agent_offline", optionalString(agent?.lastSeenAt) || optionalString(executor.lastSeenAt) || checkedAt, { + checkedAt, + mode: "hub", + pairingCode, + agent, + }); + } + + try { + const healthPayload = await hubRequestJson( + `/api/ai-workspace/hub/v1/agents/${encodeURIComponent(pairingCode)}/request`, + { + method: "POST", + body: { + command: "health", + payload: {}, + timeoutMs: 7000, + quiet: true, + }, + }, + 9000 + ); + const health = isPlainObject(healthPayload.payload) ? healthPayload.payload : {}; + const runtimeOk = health.ok !== false && health.runtimeStatus !== "error"; + return executorCheckResult( + runtimeOk ? "online" : "error", + runtimeOk ? "bridge_health_ok" : optionalString(health.runtimeError || health.error) || "bridge_health_failed", + optionalString(health.runtimeCheckedAt) || agent.lastSeenAt || checkedAt, + { + checkedAt, + mode: "hub", + pairingCode, + agent, + health: summarizeBridgeHealth(health), + } + ); + } catch (error) { + const status = Number(error?.status || 0); + const statusDetail = errorMessage(error); + return executorCheckResult( + status === 503 ? "offline" : executorStatusForHubError(statusDetail), + statusDetail, + agent.lastSeenAt || checkedAt, + { + checkedAt, + mode: "hub", + pairingCode, + agent, + error: statusDetail, + status, + } + ); + } + } catch (error) { + const statusDetail = errorMessage(error); + return executorCheckResult(executorStatusForHubError(statusDetail), statusDetail, checkedAt, { + checkedAt, + mode: "hub", + pairingCode, + error: statusDetail, + status: Number(error?.status || 0) || null, + }); + } +} + +async function checkDirectExecutor(executor, checkedAt) { + const url = bridgeHealthUrl(executor.endpoint); + if (!url) { + return executorCheckResult("error", "bridge_endpoint_required", checkedAt, { checkedAt, mode: "direct" }); + } + + try { + const health = await fetchJson(url, { method: "GET", headers: { Accept: "application/json" } }, 7000); + const runtimeOk = health.ok !== false && health.runtimeStatus !== "error"; + return executorCheckResult( + runtimeOk ? "online" : "error", + runtimeOk ? "bridge_health_ok" : optionalString(health.runtimeError || health.error) || "bridge_health_failed", + optionalString(health.runtimeCheckedAt) || checkedAt, + { + checkedAt, + mode: "direct", + endpoint: executor.endpoint, + health: summarizeBridgeHealth(health), + } + ); + } catch (error) { + const statusDetail = errorMessage(error); + return executorCheckResult("offline", statusDetail || "bridge_direct_unavailable", checkedAt, { + checkedAt, + mode: "direct", + endpoint: executor.endpoint, + error: statusDetail, + status: Number(error?.status || 0) || null, + }); + } +} + +async function readExecutorEvents(executor, options = {}) { + if (executor.connectionMode !== "hub" && !executor.pairingCode) { + return { events: [], latestEventId: 0, online: false }; + } + const pairingCode = cleanPairingCode(executor.pairingCode); + if (!pairingCode) { + return { events: [], latestEventId: 0, online: false }; + } + const params = new URLSearchParams(); + params.set("since", String(Number(options.since || 0))); + params.set("limit", String(sanitizeLimit(options.limit, 100, 500))); + const payload = await hubRequestJson( + `/api/ai-workspace/hub/v1/agents/${encodeURIComponent(pairingCode)}/events?${params.toString()}`, + { method: "GET" }, + 10000 + ); + return { + events: Array.isArray(payload.events) ? payload.events.map(cleanBridgeEventForUi) : [], + latestEventId: Number(payload.latestEventId || 0), + online: payload.online === true, + }; +} + +async function dispatchExecutorMessage(executor, payload) { + if (executor.connectionMode === "hub" || executor.pairingCode) { + const pairingCode = cleanPairingCode(executor.pairingCode); + if (!pairingCode) { + const error = new Error("bridge_pairing_code_empty"); + error.status = 400; + throw error; + } + const response = await hubRequestJson( + `/api/ai-workspace/hub/v1/agents/${encodeURIComponent(pairingCode)}/dispatch`, + { + method: "POST", + body: { + command: "message", + payload, + timeoutMs: 12 * 60 * 60 * 1000, + quiet: false, + }, + }, + 10000 + ); + return { + ok: true, + accepted: true, + requestId: optionalString(response.requestId), + threadId: payload.threadId, + mode: "hub", + }; + } + + const url = bridgeCommandUrl(executor.endpoint, "message"); + if (!url) { + const error = new Error("bridge_endpoint_required"); + error.status = 400; + throw error; + } + const response = await fetchJson( + url, + { + method: "POST", + headers: { Accept: "application/json", "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }, + 120000 + ); + return { + ok: response?.ok !== false, + accepted: false, + response, + threadId: payload.threadId, + mode: "direct", + }; +} + +async function createBridgeRun({ owner, thread, executor, bridge, payload }) { + const requestId = optionalString(bridge?.requestId); + if (!requestId || bridge?.accepted !== true || bridge?.mode !== "hub") return null; + const context = isPlainObject(payload?.context) ? payload.context : {}; + const metadata = { + modeId: optionalString(context.modeId), + modeTitle: optionalString(context.modeTitle), + workspacePath: optionalString(payload?.workspacePath), + threadTitle: optionalString(payload?.threadTitle) || thread.title, + executorName: executor.name, + executorType: executor.type, + }; + const result = await pool.query( + `insert into ai_workspace_runs ( + id, + owner_key, + owner_user_id, + owner_email, + thread_id, + executor_id, + request_id, + origin_surface, + status, + latest_event_id, + metadata + ) values ($1, $2, $3, $4, $5, $6, $7, $8, 'running', 0, $9::jsonb) + on conflict (owner_key, thread_id, request_id) do update set + owner_user_id = excluded.owner_user_id, + owner_email = excluded.owner_email, + executor_id = excluded.executor_id, + origin_surface = excluded.origin_surface, + status = 'running', + completed_at = null, + metadata = ai_workspace_runs.metadata || excluded.metadata, + updated_at = now() + returning *`, + [ + randomUUID(), + owner.key, + owner.userId, + owner.email, + thread.id, + executor.id, + requestId, + thread.originSurface || "global", + JSON.stringify(metadata), + ] + ); + return toBridgeRun(result.rows[0]); +} + +async function updateBridgeRunState(run, patch) { + if (!run?.id) return null; + const fields = []; + const values = [run.id]; + if (Object.hasOwn(patch, "status")) { + values.push(sanitizeEnum(patch.status, SUPPORTED_RUN_STATUSES, "unsupported_run_status")); + fields.push(`status = $${values.length}`); + } + if (Object.hasOwn(patch, "latestEventId")) { + const rawLatestEventId = Number(patch.latestEventId || 0); + const latestEventId = Number.isFinite(rawLatestEventId) + ? Math.max(0, Math.trunc(rawLatestEventId)) + : 0; + values.push(latestEventId); + fields.push(`latest_event_id = greatest(latest_event_id, $${values.length})`); + } + if (Object.hasOwn(patch, "completedAt")) { + values.push(patch.completedAt ? new Date(patch.completedAt).toISOString() : null); + fields.push(`completed_at = $${values.length}::timestamptz`); + } + if (Object.hasOwn(patch, "metadata")) { + values.push(JSON.stringify(isPlainObject(patch.metadata) ? patch.metadata : {})); + fields.push(`metadata = metadata || $${values.length}::jsonb`); + } + if (fields.length === 0) return run; + const result = await pool.query( + `update ai_workspace_runs + set ${fields.join(", ")}, updated_at = now() + where id = $1 + returning *`, + values + ); + return result.rows[0] ? toBridgeRun(result.rows[0]) : null; +} + +async function persistBridgeRunEvents(run, events) { + if (!run?.id || !Array.isArray(events) || events.length === 0) return; + for (const event of events) { + const hubEventId = bridgeEventId(event); + const occurredAt = eventOccurredAt(event) || new Date().toISOString(); + await pool.query( + `insert into ai_workspace_run_events ( + id, + run_id, + owner_key, + thread_id, + request_id, + hub_event_id, + kind, + payload, + occurred_at + ) values ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9::timestamptz) + on conflict do nothing`, + [ + randomUUID(), + run.id, + run.ownerKey, + run.threadId, + run.requestId, + hubEventId, + optionalString(event?.kind) || "event", + JSON.stringify(isPlainObject(event) ? event : { value: event }), + occurredAt, + ] + ); + } +} + +async function listBridgeRunEvents(run, limit = 1000) { + if (!run?.id) return []; + const result = await pool.query( + `select payload + from ( + select payload, occurred_at, created_at + from ai_workspace_run_events + where run_id = $1 + order by occurred_at desc, created_at desc + limit $2 + ) e + order by occurred_at asc, created_at asc`, + [run.id, sanitizeLimit(limit, 1000, 5000)] + ); + return result.rows.map((row) => row.payload).filter(Boolean); +} + +async function recoverBridgeRunMirrors() { + if (!config.hubInternalHttpUrl || !config.hubInternalAccessToken) return; + const result = await pool.query( + `select r.*, to_jsonb(e) as executor_row + from ai_workspace_runs r + join ai_workspace_executors e on e.id = r.executor_id and e.owner_key = r.owner_key + where r.status = 'running' + and r.started_at > now() - interval '12 hours' + order by r.started_at asc + limit 200` + ); + for (const row of result.rows) { + const run = toBridgeRun(row); + const executor = row.executor_row ? toExecutor(row.executor_row) : null; + if (!executor) continue; + const owner = { key: run.ownerKey, userId: run.ownerUserId, email: run.ownerEmail }; + const thread = { id: run.threadId, originSurface: run.originSurface }; + const bridge = { accepted: true, mode: "hub", requestId: run.requestId }; + const events = await listBridgeRunEvents(run); + startBridgeRunMirror({ owner, thread, executor, bridge, run, events }); + } +} + +function startBridgeRunMirror({ owner, thread, executor, bridge, run = null, events = [] }) { + const requestId = optionalString(bridge?.requestId || run?.requestId); + if (!requestId || bridge?.accepted !== true || bridge?.mode !== "hub") return; + const threadId = thread?.id || run?.threadId; + if (!owner?.key || !threadId || !executor) return; + const key = `${owner.key}:${threadId}:${requestId}`; + if (activeBridgeRunMirrors.has(key)) return; + const restoredStartedAt = run?.startedAt ? new Date(run.startedAt).getTime() : 0; + const state = { + key, + owner, + threadId, + originSurface: thread?.originSurface || run?.originSurface || "global", + executorId: executor.id, + executor, + requestId, + run, + latestEventId: Math.max(0, Number(run?.latestEventId || 0)), + events: Array.isArray(events) ? events.slice(-1000) : [], + startedAt: Number.isFinite(restoredStartedAt) && restoredStartedAt > 0 ? restoredStartedAt : Date.now(), + }; + activeBridgeRunMirrors.set(key, state); + void mirrorBridgeRun(state); +} + +async function mirrorBridgeRun(state) { + try { + const done = await mirrorBridgeRunOnce(state); + if (done) { + activeBridgeRunMirrors.delete(state.key); + return; + } + if (Date.now() - state.startedAt > BRIDGE_RUN_MIRROR_MAX_MS) { + await createThreadMessage(state.owner, state.threadId, { + role: "assistant", + content: "Bridge request timed out before a final assistant response was persisted.", + payload: { + surface: state.originSurface, + kind: "bridge_failure", + requestId: state.requestId, + executorId: state.executorId, + }, + }); + if (state.run) { + state.run = await updateBridgeRunState(state.run, { + status: "timeout", + latestEventId: state.latestEventId, + completedAt: new Date().toISOString(), + }); + } + activeBridgeRunMirrors.delete(state.key); + return; + } + } catch { + // Transient Hub/DB failures are retried while the request remains within the mirror window. + } + setTimeout(() => void mirrorBridgeRun(state), BRIDGE_RUN_MIRROR_POLL_MS); +} + +async function mirrorBridgeRunOnce(state) { + const previousLatestEventId = state.latestEventId; + const payload = await readExecutorEvents(state.executor, { + since: state.latestEventId, + limit: 500, + }); + state.latestEventId = Math.max(state.latestEventId, Number(payload.latestEventId || 0)); + const nextEvents = (Array.isArray(payload.events) ? payload.events : []) + .filter((event) => optionalString(event?.requestId) === state.requestId); + if (nextEvents.length) { + if (state.run) await persistBridgeRunEvents(state.run, nextEvents); + state.events = [...state.events, ...nextEvents].slice(-1000); + } + if (state.run && state.latestEventId !== previousLatestEventId) { + state.run = await updateBridgeRunState(state.run, { latestEventId: state.latestEventId }); + } + + const failure = latestBridgeFailureEvent(state.events); + if (failure) { + await createThreadMessage(state.owner, state.threadId, { + role: "assistant", + content: bridgeFailureText(failure), + payload: { + surface: state.originSurface, + kind: "bridge_failure", + requestId: state.requestId, + executorId: state.executorId, + }, + }); + if (state.run) { + state.run = await updateBridgeRunState(state.run, { + status: bridgeRunStatusFromFailure(failure), + latestEventId: state.latestEventId, + completedAt: new Date().toISOString(), + }); + } + return true; + } + + const hasDone = state.events.some((event) => optionalString(event?.kind) === "done"); + if (!hasDone) return false; + const finalText = latestBridgeAssistantText(state.events); + if (!finalText) return false; + await createThreadMessage(state.owner, state.threadId, { + role: "assistant", + content: finalText, + payload: { + surface: state.originSurface, + kind: "bridge_final", + requestId: state.requestId, + executorId: state.executorId, + }, + }); + if (state.run) { + state.run = await updateBridgeRunState(state.run, { + status: "completed", + latestEventId: state.latestEventId, + completedAt: new Date().toISOString(), + }); + } + return true; +} + +function latestBridgeFailureEvent(events) { + return [...(Array.isArray(events) ? events : [])] + .reverse() + .find((event) => { + const kind = optionalString(event?.kind); + return kind === "error" || kind === "timeout"; + }) || null; +} + +function bridgeFailureText(event) { + const text = sanitizeBridgeErrorText(event?.text || event?.message || "bridge_agent_failed"); + return text.startsWith("Bridge request failed:") ? text : `Bridge request failed: ${text}`; +} + +function latestBridgeAssistantText(events) { + const event = [...(Array.isArray(events) ? events : [])] + .reverse() + .find((item) => optionalString(item?.kind) === "codex_message" && optionalString(item?.text)); + if (!event) return ""; + const text = optionalString(event.text); + return text.replace(/^Codex emitted assistant output:\s*/i, "").trim(); +} + +function bridgeRunStatusFromFailure(event) { + return optionalString(event?.kind) === "timeout" ? "timeout" : "failed"; +} + +function bridgeEventId(event) { + const value = Number(event?.id || event?.eventId || event?.event_id || 0); + return Number.isFinite(value) && value > 0 ? Math.trunc(value) : null; +} + +function eventOccurredAt(event) { + const text = optionalString(event?.at || event?.createdAt || event?.created_at || event?.time); + if (!text) return null; + const date = new Date(text); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); +} + +function buildBridgeMessagePayload({ thread, message, history, executor, command, ownerSettings }) { + const ownerContext = isPlainObject(ownerSettings?.activeContext) ? ownerSettings.activeContext : {}; + const threadContext = isPlainObject(thread.activeContext) ? thread.activeContext : {}; + const commandContext = isPlainObject(command.context) ? command.context : {}; + const surfaceContexts = mergeSurfaceContexts(threadContext, ownerContext, commandContext); + const engineContext = surfaceContexts.engine || {}; + const opsContext = surfaceContexts.ops || {}; + const commandModeId = optionalString(command.modeId); + const commandModeKey = normalizeKey(commandModeId); + const ownerContextModeKey = normalizeKey(ownerContext.modeId); + const commandContextModeKey = normalizeKey(commandContext.modeId); + const requestFromOpsMode = commandModeKey === "ops" + || commandContextModeKey === "ops" + || normalizeKey(commandContext.surface) === "ops"; + const useUnifiedAgentCore = requestFromOpsMode && isNdcAgentCoreContextReady(engineContext); + const commandProvidesNdcContext = commandModeKey === "ndc-agent-core" || commandContextModeKey === "ndc-agent-core"; + const preferOwnerContext = ownerContextModeKey === "ndc-agent-core" + && !commandProvidesNdcContext + && !commandModeId; + const effectiveThreadContext = preferOwnerContext ? {} : threadContext; + const ownerContextReady = isNdcAgentCoreContextReady(ownerContext); + const requestContextReady = isNdcAgentCoreContextReady(effectiveThreadContext) || isNdcAgentCoreContextReady(commandContext); + const promoteOwnerContext = !preferOwnerContext && ownerContextReady && !requestContextReady && !commandModeId; + const enabledToolPacks = mergeToolPacks( + ownerSettings?.enabledToolPacks, + thread.enabledToolPacks, + (preferOwnerContext || promoteOwnerContext || useUnifiedAgentCore) ? ["ndc-agent-core", "engine", "ops"] : [] + ); + const mergedContext = { + ...(preferOwnerContext || promoteOwnerContext ? ownerContext : {}), + ...effectiveThreadContext, + ...commandContext, + ...(useUnifiedAgentCore ? engineContext : {}), + }; + const mergedContextModeId = optionalString(mergedContext.modeId); + const preferContextMode = requestContextReady && (!commandModeId || commandModeKey === "ops") && mergedContextModeId; + const modeId = preferOwnerContext + ? optionalString(ownerContext.modeId) || "ndc-agent-core" + : promoteOwnerContext + ? optionalString(ownerContext.modeId) || "ndc-agent-core" + : useUnifiedAgentCore + ? optionalString(engineContext.modeId) || "ndc-agent-core" + : preferContextMode + ? mergedContextModeId + : commandModeId || mergedContextModeId || "ops"; + const commandModeTitle = optionalString(command.modeTitle); + const mergedContextModeTitle = optionalString(mergedContext.modeTitle); + const modeTitle = preferOwnerContext + ? optionalString(ownerContext.modeTitle) || "NDC Agent Core" + : promoteOwnerContext + ? optionalString(ownerContext.modeTitle) || "NDC Agent Core" + : useUnifiedAgentCore + ? optionalString(engineContext.modeTitle) || "NDC Agent Core" + : preferContextMode + ? mergedContextModeTitle || mergedContextModeId + : commandModeTitle || mergedContextModeTitle || "Ops"; + const context = { + ...mergedContext, + surface: optionalString(commandContext.surface) || optionalString(threadContext.surface) || thread.originSurface || "ops", + contexts: surfaceContexts, + engineContext, + opsContext, + enabledToolPacks, + remoteControlMode: optionalString(command.remoteControlMode || mergedContext.remoteControlMode) || "remote", + modeId, + modeTitle, + ...(useUnifiedAgentCore ? { sourceSurface: optionalString(commandContext.surface) || optionalString(threadContext.surface) || thread.originSurface || "ops" } : {}), + }; + return { + threadId: thread.id, + threadTitle: thread.title, + workspacePath: optionalString(command.workspacePath) || optionalString(executor.workspacePath) || "", + message: message.content, + displayMessage: message.content, + publicUserMessage: message.content, + resume: command.resume === true, + enabledToolPacks, + history: Array.isArray(history) + ? history.slice(-40).map((item) => ({ + role: optionalString(item.role), + text: optionalString(item.content), + })).filter((item) => item.role && item.text) + : [], + context, + client: { + surface: optionalString(context.surface) || thread.originSurface || "ops", + protocol: "ai-workspace-bridge/v1", + sentAt: new Date().toISOString(), + }, + }; +} + +function mergeSurfaceContexts(...contexts) { + const out = {}; + for (const context of contexts) { + if (!isPlainObject(context)) continue; + const bank = isPlainObject(context.contexts) ? context.contexts : {}; + for (const [key, value] of Object.entries(bank)) { + const normalizedKey = normalizeKey(key); + if (!normalizedKey || !isPlainObject(value)) continue; + out[normalizedKey] = mergeBridgeContextObject(out[normalizedKey], value); + } + const surface = normalizeKey(context.surface); + const mode = normalizeKey(context.modeId); + const inferredSurface = surface === "engine" || surface === "ops" + ? surface + : mode === "ndc-agent-core" + ? "engine" + : mode === "ops" + ? "ops" + : ""; + if (inferredSurface) { + const { contexts: _contexts, engineContext: _engineContext, opsContext: _opsContext, ...plain } = context; + out[inferredSurface] = mergeBridgeContextObject(out[inferredSurface], plain); + } + } + return out; +} + +function mergeBridgeContextObject(current, incoming) { + const out = isPlainObject(current) ? { ...current } : {}; + if (!isPlainObject(incoming)) return out; + for (const [key, value] of Object.entries(incoming)) { + if (key === "contexts") continue; + if (isBridgeEmptyContextValue(value) && !isBridgeEmptyContextValue(out[key])) continue; + if (isPlainObject(value) && isPlainObject(out[key])) { + out[key] = mergeBridgeContextObject(out[key], value); + } else { + out[key] = value; + } + } + return out; +} + +function isBridgeEmptyContextValue(value) { + if (value === null || value === undefined) return true; + if (typeof value === "string") return value.trim() === ""; + if (Array.isArray(value)) return value.length === 0; + if (isPlainObject(value)) return Object.keys(value).length === 0; + return false; +} + +function isNdcAgentCoreContextReady(context) { + if (!isPlainObject(context)) return false; + return Boolean(optionalString(context.workflowId) && optionalString(context.agentNodeId)); +} + +function missingNdcAgentCoreContext(context) { + if (!isPlainObject(context) || normalizeKey(context.modeId) !== "ndc-agent-core") return []; + const missing = []; + if (!optionalString(context.workflowId)) missing.push("workflow"); + if (!optionalString(context.agentNodeId)) missing.push("agent node"); + if (!optionalString(context.roleId)) missing.push("роль"); + return missing; +} + +function missingOpsContextFields(context) { + if (!isPlainObject(context) || normalizeKey(context.modeId) !== "ops") return []; + const opsContext = isPlainObject(context.opsContext) ? context.opsContext : context; + const missing = []; + if (!optionalString(opsContext.opsWorkspaceSlug || opsContext.opsWorkspaceId)) missing.push("Ops workspace"); + if (!optionalString(opsContext.opsProjectId || opsContext.opsProjectIdentifier || opsContext.opsProjectSlug)) missing.push("Ops project"); + return missing; +} + +function ndcMissingContextInstruction(context, missingContext) { + const missing = Array.isArray(missingContext) ? missingContext.join(", ") : "context"; + const workflow = optionalString(context?.workflowTitle || context?.workflowId) || "не выбран"; + const role = optionalString(context?.roleTitle || context?.roleId) || "не выбрана"; + return [ + "System context guard:", + "The current NODE.DC Engine context is incomplete.", + `Missing: ${missing}.`, + `Selected workflow: ${workflow}.`, + `Selected role: ${role}.`, + "You still have full chat access: answer the user's question normally, in the user's language, and describe the current connection if asked.", + "Do not claim that an agent node is selected when it is missing.", + "Do not create, edit, patch, deploy, or write workflow graph changes until the missing agent node is selected.", + "If the user asks for development or graph edits, explain briefly that selecting an agent node is required for that specific write action.", + ].join("\n"); +} + +function opsMissingContextInstruction(context, missingContext) { + const missing = Array.isArray(missingContext) ? missingContext.join(", ") : "Ops context"; + const opsContext = isPlainObject(context?.opsContext) ? context.opsContext : {}; + const workspace = optionalString(opsContext.opsWorkspaceTitle || opsContext.opsWorkspaceSlug || opsContext.opsWorkspaceId) || "не выбран"; + const project = optionalString(opsContext.opsProjectTitle || opsContext.opsProjectIdentifier || opsContext.opsProjectId) || "не выбран"; + return [ + "Ops context guard:", + "The current NODE.DC Ops context is incomplete.", + `Missing: ${missing}.`, + `Selected workspace: ${workspace}.`, + `Selected project: ${project}.`, + "You still have full chat access: answer the user's question normally in the user's language.", + "Do not create, edit, move, or delete Ops tasks until workspace and project are selected.", + "If the user asks for Ops task changes, explain briefly that selecting Ops workspace and project is required for that write action.", + "Do not claim that an Engine agent node is required for Ops task writes.", + ].join("\n"); +} + +function mergeToolPacks(...values) { + const out = []; + for (const value of values) { + const items = Array.isArray(value) ? value : []; + for (const item of items) { + const normalized = optionalString(item); + if (!SUPPORTED_TOOL_PACKS.has(normalized) || out.includes(normalized)) continue; + out.push(normalized); + } + } + return out; +} + +function executorCheckResult(status, statusDetail, lastSeenAt, details = {}) { + return { + status: SUPPORTED_EXECUTOR_STATUSES.has(status) ? status : "error", + statusDetail: optionalString(statusDetail) || "bridge_check_failed", + lastSeenAt: lastSeenAt || new Date().toISOString(), + details, + }; +} + +async function hubRequestJson(path, options = {}, timeoutMs = 5000) { + const target = `${config.hubInternalHttpUrl.replace(/\/+$/, "")}${path}`; + const body = options.body === undefined ? undefined : JSON.stringify(options.body); + return fetchJson( + target, + { + method: options.method || "GET", + headers: { + Accept: "application/json", + Authorization: `Bearer ${config.hubInternalAccessToken}`, + ...(body ? { "Content-Type": "application/json" } : {}), + }, + body, + }, + timeoutMs + ); +} + +async function fetchJson(url, init = {}, timeoutMs = 5000) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(url, { ...init, redirect: "manual", signal: controller.signal }); + const text = await response.text(); + let data = null; + try { + data = text ? JSON.parse(text) : {}; + } catch { + data = { ok: false, error: "invalid_json_response", body: text.slice(0, 500) }; + } + if (!response.ok) { + const error = new Error(optionalString(data?.error || data?.message) || `http_${response.status}`); + error.status = response.status; + error.payload = data; + throw error; + } + return data; + } catch (error) { + if (error?.name === "AbortError") { + const timeoutError = new Error("bridge_check_timeout"); + timeoutError.status = 504; + throw timeoutError; + } + throw error; + } finally { + clearTimeout(timer); + } +} + +function bridgeHealthUrl(endpoint) { + const text = optionalString(endpoint); + if (!text) return ""; + try { + const url = new URL(text); + const path = url.pathname.replace(/\/+$/, ""); + if (path.endsWith("/api/ai-workspace/bridge/v1/health")) { + return url.toString(); + } + if (path.endsWith("/api/ai-workspace/bridge/v1")) { + url.pathname = `${path}/health`; + return url.toString(); + } + url.pathname = `${path}/api/ai-workspace/bridge/v1/health`.replace(/\/+/g, "/"); + return url.toString(); + } catch { + return ""; + } +} + +function bridgeCommandUrl(endpoint, command) { + const text = optionalString(endpoint); + if (!text) return ""; + try { + const url = new URL(text); + const path = url.pathname.replace(/\/+$/, ""); + const suffix = String(command || "").replace(/^\/+/, ""); + if (path.endsWith(`/api/ai-workspace/bridge/v1/${suffix}`)) { + return url.toString(); + } + if (path.endsWith("/api/ai-workspace/bridge/v1")) { + url.pathname = `${path}/${suffix}`; + return url.toString(); + } + url.pathname = `${path}/api/ai-workspace/bridge/v1/${suffix}`.replace(/\/+/g, "/"); + return url.toString(); + } catch { + return ""; + } +} + +function summarizeBridgeHealth(health) { + if (!isPlainObject(health)) return {}; + return { + ok: health.ok !== false, + service: optionalString(health.service), + host: optionalString(health.host), + machineName: optionalString(health.machineName), + runtimeStatus: optionalString(health.runtimeStatus), + runtimeError: optionalString(health.runtimeError), + runtimeCheckedAt: optionalString(health.runtimeCheckedAt), + hubConnected: health.hubConnected === true, + hubMode: optionalString(health.hubMode), + }; +} + +function cleanBridgeEventForUi(event) { + if (!isPlainObject(event)) return event; + const kind = optionalString(event.kind) || ""; + const text = optionalString(event.text || event.message) || ""; + if (kind === "error") { + return { + ...event, + text: sanitizeBridgeErrorText(text), + message: "", + }; + } + if (kind !== "stdout" && kind !== "stderr") return event; + const cleaned = summarizeLegacyCodexEventText(text); + if (!cleaned || cleaned === text) return event; + return { + ...event, + kind: cleaned.startsWith("Codex emitted assistant output:") ? "codex_message" : "codex_event", + text: cleaned, + message: "", + }; +} + +function summarizeLegacyCodexEventText(value) { + const text = optionalString(value) || ""; + if (!text) return ""; + if (/^codex\s*\n/i.test(text)) { + const answer = text.replace(/^codex\s*\n/i, "").trim(); + return answer ? `Codex emitted assistant output: ${answer.slice(0, 1200)}` : "Codex emitted assistant output."; + } + if (/^tokens used/im.test(text)) return "Tokens reported."; + return text.length > 1200 ? `${text.slice(0, 1200).trim()}...` : text; +} + +function sanitizeBridgeErrorText(value) { + const text = optionalString(value) || "bridge_agent_failed"; + return text.length > 1200 ? `${text.slice(0, 1200).trim()}...` : text; +} + async function getOwnerSettings(owner) { const result = await pool.query( "select * from ai_workspace_owner_settings where owner_key = $1", [owner.key] ); - return result.rows[0] ? toOwnerSettings(result.rows[0]) : { ownerKey: owner.key, selectedExecutorId: null }; + return result.rows[0] + ? toOwnerSettings(result.rows[0]) + : { ownerKey: owner.key, selectedExecutorId: null, activeContext: {}, enabledToolPacks: [], metadata: {} }; } -async function listThreads(owner, { surface, limit }) { +async function listThreads(owner, { surface, kind = "all", limit, includeArchived = false }) { const values = [owner.key, limit]; - const surfaceSql = surface ? "and origin_surface = $3" : ""; - if (surface) values.push(surface); + const clauses = ["owner_key = $1"]; + if (!includeArchived) clauses.push("lifecycle_state = 'active'"); + if (surface) { + values.push(surface); + clauses.push(`origin_surface = $${values.length}`); + } + if (kind === "shared") { + clauses.push("coalesce(active_context->>'modeId', '') <> 'codex-remote'"); + } else if (kind === "remote") { + clauses.push("active_context->>'modeId' = 'codex-remote'"); + } const result = await pool.query( `select * from ai_workspace_threads - where owner_key = $1 - ${surfaceSql} + where ${clauses.join("\n and ")} order by updated_at desc, created_at desc limit $2`, values @@ -482,6 +1834,19 @@ async function listThreadMessages(owner, threadId, { limit, before }) { return result.rows.reverse().map(toThreadMessage); } +async function getThreadMessage(owner, threadId, messageId) { + const result = await pool.query( + `select m.* + from ai_workspace_thread_messages m + join ai_workspace_threads t on t.id = m.thread_id + where t.owner_key = $1 + and m.thread_id = $2 + and m.id = $3`, + [owner.key, threadId, messageId] + ); + return result.rows[0] ? toThreadMessage(result.rows[0]) : null; +} + async function createThreadMessage(owner, threadId, command) { const client = await pool.connect(); try { @@ -494,6 +1859,24 @@ async function createThreadMessage(owner, threadId, command) { await client.query("rollback"); return null; } + const bridgeRequestId = bridgeResultRequestId(command); + if (bridgeRequestId) { + const existing = await client.query( + `select * + from ai_workspace_thread_messages + where thread_id = $1 + and role = $2 + and payload->>'kind' in ('bridge_final', 'bridge_failure') + and payload->>'requestId' = $3 + order by sequence asc + limit 1`, + [threadId, command.role, bridgeRequestId] + ); + if (existing.rowCount > 0) { + await client.query("commit"); + return toThreadMessage(existing.rows[0]); + } + } const sequenceResult = await client.query( "select coalesce(max(sequence), 0) + 1 as next_sequence from ai_workspace_thread_messages where thread_id = $1", [threadId] @@ -522,6 +1905,13 @@ async function createThreadMessage(owner, threadId, command) { } } +function bridgeResultRequestId(command) { + if (!command || command.role !== "assistant" || !isPlainObject(command.payload)) return ""; + const kind = optionalString(command.payload.kind); + if (kind !== "bridge_final" && kind !== "bridge_failure") return ""; + return optionalString(command.payload.requestId || command.payload.request_id) || ""; +} + async function migrate() { await pool.query(` create table if not exists ai_workspace_executors ( @@ -562,6 +1952,8 @@ async function migrate() { owner_user_id text, owner_email text, selected_executor_id uuid references ai_workspace_executors(id) on delete set null, + active_context jsonb not null default '{}'::jsonb, + enabled_tool_packs text[] not null default '{}'::text[], metadata jsonb not null default '{}'::jsonb, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), @@ -595,6 +1987,12 @@ async function migrate() { ) `); + await pool.query(` + alter table ai_workspace_owner_settings + add column if not exists active_context jsonb not null default '{}'::jsonb, + add column if not exists enabled_tool_packs text[] not null default '{}'::text[] + `); + await pool.query(` create table if not exists ai_workspace_thread_messages ( id uuid primary key, @@ -610,10 +2008,56 @@ async function migrate() { ) `); + await pool.query(` + create table if not exists ai_workspace_runs ( + id uuid primary key, + owner_key text not null, + owner_user_id text, + owner_email text, + thread_id uuid not null references ai_workspace_threads(id) on delete cascade, + executor_id uuid references ai_workspace_executors(id) on delete set null, + request_id text not null, + origin_surface text not null default 'global', + status text not null default 'running', + latest_event_id bigint not null default 0, + metadata jsonb not null default '{}'::jsonb, + started_at timestamptz not null default now(), + completed_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint ai_workspace_runs_owner_check + check (owner_user_id is not null or owner_email is not null), + constraint ai_workspace_runs_surface_check + check (origin_surface in ('engine', 'ops', 'global')), + constraint ai_workspace_runs_status_check + check (status in ('running', 'completed', 'failed', 'timeout')), + unique (owner_key, thread_id, request_id) + ) + `); + + await pool.query(` + create table if not exists ai_workspace_run_events ( + id uuid primary key, + run_id uuid not null references ai_workspace_runs(id) on delete cascade, + owner_key text not null, + thread_id uuid not null references ai_workspace_threads(id) on delete cascade, + request_id text not null, + hub_event_id bigint, + kind text not null default 'event', + payload jsonb not null default '{}'::jsonb, + occurred_at timestamptz not null default now(), + created_at timestamptz not null default now() + ) + `); + await pool.query("create index if not exists ai_workspace_executors_owner_idx on ai_workspace_executors(owner_key, updated_at desc)"); await pool.query("create index if not exists ai_workspace_executors_pairing_code_idx on ai_workspace_executors(pairing_code) where pairing_code is not null"); await pool.query("create index if not exists ai_workspace_threads_owner_surface_idx on ai_workspace_threads(owner_key, origin_surface, updated_at desc)"); await pool.query("create index if not exists ai_workspace_thread_messages_thread_idx on ai_workspace_thread_messages(thread_id, sequence)"); + await pool.query("create index if not exists ai_workspace_runs_owner_thread_idx on ai_workspace_runs(owner_key, thread_id, updated_at desc)"); + await pool.query("create index if not exists ai_workspace_runs_request_idx on ai_workspace_runs(owner_key, request_id)"); + await pool.query("create index if not exists ai_workspace_run_events_run_idx on ai_workspace_run_events(run_id, occurred_at asc, created_at asc)"); + await pool.query("create unique index if not exists ai_workspace_run_events_hub_event_idx on ai_workspace_run_events(run_id, hub_event_id) where hub_event_id is not null"); } function sanitizeExecutorCommand(payload, { partial }) { @@ -690,6 +2134,32 @@ function sanitizeThreadCommand(payload, { partial }) { return command; } +function sanitizeOwnerSettingsCommand(payload) { + const source = isPlainObject(payload) ? payload : {}; + const command = {}; + if (Object.hasOwn(source, "selectedExecutorId") || Object.hasOwn(source, "selected_executor_id")) { + const selectedExecutorId = source.selectedExecutorId || source.selected_executor_id; + command.selectedExecutorId = selectedExecutorId ? sanitizeUuid(selectedExecutorId, "selectedExecutorId") : null; + } + if (Object.hasOwn(source, "activeContext") || Object.hasOwn(source, "active_context")) { + const activeContext = source.activeContext || source.active_context; + command.activeContext = isPlainObject(activeContext) ? activeContext : {}; + } + if (Object.hasOwn(source, "enabledToolPacks") || Object.hasOwn(source, "enabled_tool_packs")) { + command.enabledToolPacks = sanitizeToolPacks(source.enabledToolPacks || source.enabled_tool_packs || []); + } + if (Object.hasOwn(source, "metadata")) { + command.metadata = isPlainObject(source.metadata) ? source.metadata : {}; + } + return command; +} + +function sanitizeThreadKind(value) { + const text = normalizeKey(value || "all"); + if (text === "shared" || text === "remote" || text === "all") return text; + return "all"; +} + function sanitizeMessageCommand(payload) { const source = isPlainObject(payload) ? payload : {}; return { @@ -699,6 +2169,22 @@ function sanitizeMessageCommand(payload) { }; } +function sanitizeDispatchCommand(payload) { + const source = isPlainObject(payload) ? payload : {}; + return { + messageId: sanitizeUuid(source.messageId || source.message_id, "messageId"), + selectedExecutorId: source.selectedExecutorId || source.selected_executor_id + ? sanitizeUuid(source.selectedExecutorId || source.selected_executor_id, "selectedExecutorId") + : null, + context: isPlainObject(source.context) ? source.context : {}, + workspacePath: optionalString(source.workspacePath || source.workspace_path), + remoteControlMode: optionalString(source.remoteControlMode || source.remote_control_mode), + modeId: optionalString(source.modeId || source.mode_id), + modeTitle: optionalString(source.modeTitle || source.mode_title), + resume: source.resume === true, + }; +} + function getRequestOwner(req) { const userId = optionalString(req.headers["x-nodedc-user-id"] || req.query.userId); const email = normalizeEmail(req.headers["x-nodedc-user-email"] || req.query.email); @@ -720,6 +2206,52 @@ function publicOwner(owner) { }; } +function publicOwnerSettings(settings) { + if (!settings || !isPlainObject(settings)) return settings; + return { + ...settings, + metadata: redactOwnerSettingsMetadata(settings.metadata), + }; +} + +function redactOwnerSettingsMetadata(metadata) { + if (!isPlainObject(metadata)) return {}; + const next = { ...metadata }; + if (isPlainObject(next.appGrants)) { + const appGrants = {}; + for (const [appId, grant] of Object.entries(next.appGrants)) { + if (!isPlainObject(grant)) continue; + appGrants[appId] = { + ...grant, + mcpServers: redactMcpServers(grant.mcpServers), + }; + } + next.appGrants = appGrants; + } + if (Object.hasOwn(next, "mcpServers")) { + next.mcpServers = redactMcpServers(next.mcpServers); + } + return next; +} + +function redactMcpServers(value) { + const items = Array.isArray(value) + ? value + : isPlainObject(value) + ? Object.values(value) + : []; + return items.map((item) => { + if (!isPlainObject(item)) return null; + const redacted = { ...item }; + if (isPlainObject(redacted.httpHeaders) || isPlainObject(redacted.http_headers) || isPlainObject(redacted.headers)) { + redacted.httpHeaders = { configured: true }; + delete redacted.http_headers; + delete redacted.headers; + } + return redacted; + }).filter(Boolean); +} + function toExecutor(row) { return { id: row.id, @@ -751,6 +2283,8 @@ function toOwnerSettings(row) { ownerUserId: row.owner_user_id, ownerEmail: row.owner_email, selectedExecutorId: row.selected_executor_id, + activeContext: row.active_context || {}, + enabledToolPacks: row.enabled_tool_packs || [], metadata: row.metadata || {}, createdAt: toIso(row.created_at), updatedAt: toIso(row.updated_at), @@ -788,8 +2322,28 @@ function toThreadMessage(row) { }; } +function toBridgeRun(row) { + return { + id: row.id, + ownerKey: row.owner_key, + ownerUserId: row.owner_user_id, + ownerEmail: row.owner_email, + threadId: row.thread_id, + executorId: row.executor_id, + requestId: row.request_id, + originSurface: row.origin_surface, + status: row.status, + latestEventId: Number(row.latest_event_id || 0), + metadata: row.metadata || {}, + startedAt: toIso(row.started_at), + completedAt: toIso(row.completed_at), + createdAt: toIso(row.created_at), + updatedAt: toIso(row.updated_at), + }; +} + function requireInternalApi(req, res, next) { - if (!config.internalAccessToken) { + if (!config.internalAccessTokens.length) { res.status(503).json({ ok: false, error: "ai_workspace_assistant_token_not_configured" }); return; } @@ -799,7 +2353,7 @@ function requireInternalApi(req, res, next) { const headerToken = typeof req.headers["x-nodedc-internal-token"] === "string" ? req.headers["x-nodedc-internal-token"] : ""; const requestToken = bearerToken || headerToken; - if (!safeTokenEquals(requestToken, config.internalAccessToken)) { + if (!config.internalAccessTokens.some((token) => safeTokenEquals(requestToken, token))) { res.status(401).json({ ok: false, error: "ai_workspace_assistant_unauthorized" }); return; } @@ -926,6 +2480,23 @@ function optionalString(value) { return text ? text : null; } +function uniqueStrings(values) { + const result = []; + const seen = new Set(); + for (const value of values || []) { + const text = optionalString(value); + if (!text || seen.has(text)) continue; + seen.add(text); + result.push(text); + } + return result; +} + +function isTruthy(value) { + const text = optionalString(value)?.toLowerCase() || ""; + return text === "1" || text === "true" || text === "yes" || text === "on"; +} + function normalizeEmail(value) { const text = optionalString(value); return text ? text.toLowerCase() : null; @@ -950,27 +2521,82 @@ function badRequest(message) { return error; } +function errorMessage(error) { + return String(error?.message || error?.error || error || "bridge_check_failed"); +} + +function executorStatusForHubError(message) { + const text = String(message || ""); + if ( + text === "ai_workspace_hub_token_not_configured" || + text === "ai_workspace_hub_unauthorized" || + text === "ai_workspace_hub_url_not_configured" + ) { + return "unknown"; + } + return "error"; +} + function asyncRoute(handler) { return (req, res, next) => { Promise.resolve(handler(req, res, next)).catch(next); }; } +function httpUrlFromWebSocketUrl(value) { + const text = optionalString(value); + if (!text) return ""; + try { + const url = new URL(text); + if (url.protocol === "wss:") url.protocol = "https:"; + else if (url.protocol === "ws:") url.protocol = "http:"; + else if (url.protocol !== "http:" && url.protocol !== "https:") return ""; + url.search = ""; + url.hash = ""; + url.pathname = url.pathname.replace(/\/api\/ai-workspace\/hub\/?$/i, "") || "/"; + return url.toString().replace(/\/+$/, ""); + } catch { + return ""; + } +} + +function isDeployedPublicHubUrl(value) { + try { + return new URL(value).hostname === "ai-hub.nodedc.ru"; + } catch { + return false; + } +} + function readConfig() { const databaseUrl = process.env.DATABASE_URL || process.env.AI_WORKSPACE_ASSISTANT_DATABASE_URL || "postgres://nodedc_ai_workspace:nodedc_ai_workspace@localhost:5432/nodedc_ai_workspace"; + const hubWebSocketUrl = + optionalString(process.env.AI_WORKSPACE_HUB_PUBLIC_URL) || + optionalString(process.env.NDC_AI_WORKSPACE_HUB_URL) || + optionalString(process.env.AI_WORKSPACE_HUB_URL) || + "wss://ai-hub.nodedc.ru/api/ai-workspace/hub"; + const hubInternalHttpUrl = + optionalString(process.env.AI_WORKSPACE_HUB_INTERNAL_URL) || + optionalString(process.env.NDC_AI_WORKSPACE_HUB_HTTP_URL) || + optionalString(process.env.AI_WORKSPACE_HUB_HTTP_URL) || + httpUrlFromWebSocketUrl(hubWebSocketUrl); + const explicitHubAccessToken = + optionalString(process.env.AI_WORKSPACE_HUB_TOKEN) || + optionalString(process.env.NDC_AI_WORKSPACE_HUB_TOKEN) || + ""; + const sharedInternalAccessToken = optionalString(process.env.NODEDC_INTERNAL_ACCESS_TOKEN) || ""; return { port: Number(process.env.PORT || process.env.AI_WORKSPACE_ASSISTANT_PORT || "18082"), databaseUrl, databasePoolSize: Number(process.env.AI_WORKSPACE_ASSISTANT_DATABASE_POOL_SIZE || "10"), - hubWebSocketUrl: - process.env.AI_WORKSPACE_HUB_PUBLIC_URL || - process.env.NDC_AI_WORKSPACE_HUB_URL || - process.env.AI_WORKSPACE_HUB_URL || - "wss://ai-hub.nodedc.ru/api/ai-workspace/hub", + hubWebSocketUrl, + hubInternalHttpUrl: hubInternalHttpUrl.replace(/\/+$/, ""), + hubInternalAccessToken: + explicitHubAccessToken || (isDeployedPublicHubUrl(hubInternalHttpUrl) ? "" : sharedInternalAccessToken), hubFallbackWebSocketUrls: String( process.env.AI_WORKSPACE_HUB_FALLBACK_URLS || process.env.NDC_AI_WORKSPACE_HUB_FALLBACK_URLS || @@ -979,11 +2605,11 @@ function readConfig() { .split(/[\n,;]+/) .map((item) => item.trim()) .filter(Boolean), - internalAccessToken: - process.env.AI_WORKSPACE_ASSISTANT_TOKEN || - process.env.NODEDC_INTERNAL_ACCESS_TOKEN || - process.env.NODEDC_PLATFORM_SERVICE_TOKEN || - "", + internalAccessTokens: uniqueStrings([ + process.env.AI_WORKSPACE_ASSISTANT_TOKEN, + process.env.NODEDC_INTERNAL_ACCESS_TOKEN, + process.env.NODEDC_PLATFORM_SERVICE_TOKEN, + ]), }; } diff --git a/services/ai-workspace-assistant/src/templates/install-windows.ps1 b/services/ai-workspace-assistant/src/templates/install-windows.ps1 index 81ec5ce..485507c 100644 --- a/services/ai-workspace-assistant/src/templates/install-windows.ps1 +++ b/services/ai-workspace-assistant/src/templates/install-windows.ps1 @@ -11,10 +11,14 @@ param( [string]$HubFallbackUrls = "", [string]$PairingCode = "", [string]$MachineName = "", + [string]$OpsMcpUrl = "", + [string]$OpsMcpToken = "", + [string]$OpsMcpServerName = "nodedc_tasker", + [string]$AppMcpServersJson = "", [switch]$NoAutostart, [switch]$NoFirewall, [switch]$NoCodexLogin, - [switch]$EnableWatchdog, + [switch]$EnableWatchdog = $true, [switch]$Confirmed ) @@ -215,6 +219,10 @@ function Request-Elevation { if ($HubFallbackUrls) { $args += @('-HubFallbackUrls', "`"$HubFallbackUrls`"") } if ($PairingCode) { $args += @('-PairingCode', "`"$PairingCode`"") } if ($MachineName) { $args += @('-MachineName', "`"$MachineName`"") } + if ($OpsMcpUrl) { $args += @('-OpsMcpUrl', "`"$OpsMcpUrl`"") } + if ($OpsMcpToken) { $args += @('-OpsMcpToken', "`"$OpsMcpToken`"") } + if ($OpsMcpServerName) { $args += @('-OpsMcpServerName', "`"$OpsMcpServerName`"") } + if ($AppMcpServersJson) { $args += @('-AppMcpServersJson', (ConvertTo-PsSingleQuoted $AppMcpServersJson)) } Write-Host 'Opening an Administrator PowerShell window. Approve the Windows UAC prompt and continue there.' -ForegroundColor Yellow Start-Process -FilePath 'powershell.exe' -ArgumentList ($args -join ' ') -Verb RunAs -Wait @@ -506,6 +514,185 @@ function Repair-CodexMcpConfig { } } +function ConvertTo-TomlString { + param([string]$Value) + $escaped = ([string]$Value).Replace('\', '\\').Replace('"', '\"').Replace("`r", '\r').Replace("`n", '\n') + return '"' + $escaped + '"' +} + +function ConvertTo-TomlKey { + param([string]$Key) + $text = ([string]$Key).Trim() + if ($text -match '^[A-Za-z0-9_-]+$') { return $text } + return ConvertTo-TomlString $text +} + +function Get-SafeMcpServerName { + param([string]$ServerName) + $candidate = ([string]$ServerName).Trim() + if ($candidate -match '^[A-Za-z0-9_-]+$') { return $candidate } + return 'nodedc_tasker' +} + +function Remove-CodexMcpServerSections { + param( + [string]$Raw, + [string]$ServerName + ) + if (-not $Raw) { return "" } + + $mainHeader = "[mcp_servers.$ServerName]" + $headersHeader = "[mcp_servers.$ServerName.headers]" + $httpHeadersHeader = "[mcp_servers.$ServerName.http_headers]" + $lines = $Raw -split '\r?\n' + $result = New-Object System.Collections.Generic.List[string] + $skip = $false + $inMcpServers = $false + + foreach ($line in $lines) { + $headerKey = Normalize-TomlHeader $line + if ($headerKey.StartsWith('[')) { + $skip = ($headerKey -eq $mainHeader -or $headerKey -eq $headersHeader -or $headerKey -eq $httpHeadersHeader) + $inMcpServers = ($headerKey -eq '[mcp_servers]') + if ($skip) { continue } + } + + $activeLine = Remove-TomlComment $line + if ($inMcpServers -and $activeLine -match ("^\s*" + [regex]::Escape($ServerName) + "\s*=")) { + continue + } + if (-not $skip) { + $result.Add($line) | Out-Null + } + } + + return ($result -join ([Environment]::NewLine)).TrimEnd() +} + +function Ensure-McpServerConfig { + param( + [string]$ServerName, + [string]$McpUrl, + [object]$Headers, + [bool]$Required = $false, + [int]$StartupTimeoutSec = 20, + [int]$ToolTimeoutSec = 60, + [string]$Label = "AI Workspace" + ) + + $serverName = Get-SafeMcpServerName $ServerName + $mcpUrl = ([string]$McpUrl).Trim() + if (-not $serverName -or -not $mcpUrl) { return } + + $configDir = Join-Path $env:USERPROFILE '.codex' + $configPath = Join-Path $configDir 'config.toml' + New-Item -ItemType Directory -Force -Path $configDir | Out-Null + + $raw = "" + if (Test-Path $configPath) { + try { + $raw = Get-Content -Path $configPath -Raw -Encoding UTF8 + } catch { + Write-Warn "Could not read Codex config for MCP setup: $($_.Exception.Message)" + return + } + } + + $next = Remove-CodexMcpServerSections -Raw $raw -ServerName $serverName + $requiredValue = if ($Required) { "true" } else { "false" } + $startupTimeout = [Math]::Max(1, [int]$StartupTimeoutSec) + $toolTimeout = [Math]::Max(1, [int]$ToolTimeoutSec) + $sectionLines = New-Object System.Collections.Generic.List[string] + $sectionLines.Add("") | Out-Null + $sectionLines.Add("[mcp_servers.$serverName]") | Out-Null + $sectionLines.Add("url = $(ConvertTo-TomlString $mcpUrl)") | Out-Null + $sectionLines.Add("enabled = true") | Out-Null + $sectionLines.Add("required = $requiredValue") | Out-Null + $sectionLines.Add("startup_timeout_sec = $startupTimeout") | Out-Null + $sectionLines.Add("tool_timeout_sec = $toolTimeout") | Out-Null + + $headerLines = New-Object System.Collections.Generic.List[string] + if ($Headers) { + foreach ($property in $Headers.PSObject.Properties) { + $headerName = ([string]$property.Name).Trim() + $headerValue = ([string]$property.Value).Trim() + if (-not $headerName -or -not $headerValue) { continue } + $headerLines.Add("$(ConvertTo-TomlKey $headerName) = $(ConvertTo-TomlString $headerValue)") | Out-Null + } + } + if ($headerLines.Count -gt 0) { + $sectionLines.Add("") | Out-Null + $sectionLines.Add("[mcp_servers.$serverName.http_headers]") | Out-Null + foreach ($line in $headerLines) { $sectionLines.Add($line) | Out-Null } + } + + $section = $sectionLines -join ([Environment]::NewLine) + $next = ($next + $section).TrimStart() + + try { + if (Test-Path $configPath) { + $backupPath = "$configPath.bak-$(Get-Date -Format 'yyyyMMdd-HHmmss')" + Copy-Item -Path $configPath -Destination $backupPath -Force + Write-Ok "Codex config backup created: $backupPath" + } + Set-Content -Path $configPath -Value $next -Encoding UTF8 + Write-Ok "Codex MCP configured for $Label ($serverName)." + } catch { + Write-Warn "Could not update Codex config for MCP server $serverName`: $($_.Exception.Message)" + } +} + +function Ensure-AppMcpConfigs { + $rawJson = ([string]$AppMcpServersJson).Trim() + if (-not $rawJson) { return } + try { + $servers = $rawJson | ConvertFrom-Json + } catch { + Write-Warn "Could not parse AI Workspace app MCP manifest: $($_.Exception.Message)" + return + } + if ($null -eq $servers) { return } + if ($servers -isnot [System.Array]) { $servers = @($servers) } + + foreach ($server in $servers) { + if ($null -eq $server) { continue } + $serverName = [string]$server.serverName + $mcpUrl = [string]$server.url + $headers = $server.httpHeaders + if ($null -eq $headers) { $headers = $server.headers } + $label = [string]$server.appTitle + if (-not $label) { $label = [string]$server.appId } + if (-not $label) { $label = "AI Workspace" } + $startupTimeout = 20 + $toolTimeout = 60 + try { if ($server.startupTimeoutSec) { $startupTimeout = [int]$server.startupTimeoutSec } } catch {} + try { if ($server.toolTimeoutSec) { $toolTimeout = [int]$server.toolTimeoutSec } } catch {} + Ensure-McpServerConfig ` + -ServerName $serverName ` + -McpUrl $mcpUrl ` + -Headers $headers ` + -Required ($server.required -eq $true) ` + -StartupTimeoutSec $startupTimeout ` + -ToolTimeoutSec $toolTimeout ` + -Label $label + } +} + +function Ensure-OpsMcpConfig { + $mcpUrl = ([string]$OpsMcpUrl).Trim() + $mcpToken = ([string]$OpsMcpToken).Trim() + if (-not $mcpUrl -or -not $mcpToken) { return } + + $serverName = Get-SafeMcpServerName $OpsMcpServerName + $authorization = "Bearer $mcpToken" + $headers = [pscustomobject]@{ + Authorization = $authorization + Accept = "application/json" + "MCP-Protocol-Version" = "2025-06-18" + } + Ensure-McpServerConfig -ServerName $serverName -McpUrl $mcpUrl -Headers $headers -Required $false -StartupTimeoutSec 20 -ToolTimeoutSec 60 -Label "AI Workspace Ops" +} + function Get-WorkspacePath { $workspacePath = Get-DefaultWorkspaceCandidate Write-Ok "Codex workspace: $workspacePath" @@ -863,37 +1050,97 @@ function buildPrompt(payload) { const userMessage = String(payload?.message || '').trim() const history = payload?.resume ? [] : normalizeHistory(payload?.history || []) const isNdcAgentCore = String(context.modeId || '').trim() === 'ndc-agent-core' + const isOpsMode = String(context.modeId || '').trim() === 'ops' + const guardInstruction = String(context.guardInstruction || payload?.guardInstruction || '').trim() + const missingContext = Array.isArray(context.missingContext) + ? context.missingContext.map((item) => String(item || '').trim()).filter(Boolean) + : [] + const contextReady = context.contextReady !== false && missingContext.length === 0 + const engineContext = context.engineContext && typeof context.engineContext === 'object' + ? context.engineContext + : context.contexts?.engine && typeof context.contexts.engine === 'object' + ? context.contexts.engine + : {} + const opsContext = context.opsContext && typeof context.opsContext === 'object' + ? context.opsContext + : context.contexts?.ops && typeof context.contexts.ops === 'object' + ? context.contexts.ops + : {} const ndcAgentMcpApiBaseUrl = deriveNdcAgentMcpApiBaseUrl(context) const lines = [ 'You are connected to NODE.DC AI Workspace through AI Workspace Bridge.', '', 'Context:', + `- active surface: ${context.surface || 'unknown'}`, + `- source surface: ${context.sourceSurface || context.surface || 'unknown'}`, `- mode: ${context.modeTitle || context.modeId || 'unknown'}`, `- workflow: ${context.workflowTitle || context.workflowId || 'unknown'}`, `- agent node: ${context.agentNodeTitle || context.agentNodeId || 'unknown'}`, `- role: ${context.roleTitle || context.roleId || 'unknown'}`, `- workspace: ${context.workspacePath || payload?.workspacePath || CODEX_CWD}`, + `- context ready: ${contextReady ? 'yes' : 'no'}`, + ...missingContext.length ? [`- missing context: ${missingContext.join(', ')}`] : [], + `- access mode: ${context.accessMode || 'chat'}`, + '', + 'Cross-platform contexts:', + `- Engine workflow: ${engineContext.workflowTitle || engineContext.workflowId || 'not selected'}`, + `- Engine agent node: ${engineContext.agentNodeTitle || engineContext.agentNodeId || 'not selected'}`, + `- Engine role: ${engineContext.roleTitle || engineContext.roleId || 'not selected'}`, + `- Ops workspace: ${opsContext.opsWorkspaceTitle || opsContext.opsWorkspaceSlug || opsContext.opsWorkspaceId || 'not selected'}`, + `- Ops project: ${opsContext.opsProjectTitle || opsContext.opsProjectIdentifier || opsContext.opsProjectId || 'not selected'}`, + '- the active/source surface is where the user opened the assistant; it is not a write boundary by itself', + '- choose the write target from the selected per-app contexts and the user request; cross-app work is allowed when the relevant context is selected', + '- do not refuse Engine work only because the source surface is Ops, and do not refuse Ops work only because the mode is NDC Agent Core', '- use the user language for the final answer and public progress/reasoning summaries; if the user writes in Russian, write them in Russian', + '- never mention internal n8n/N8N names, endpoints, environment variables, schemas, or runtime identifiers in public answers; call the product and workflow runtime NDC', '- while working, provide concise public progress/reasoning summaries when the Codex runtime exposes them', '- public progress/reasoning summaries should say what you are checking, reading, comparing, or deciding', '- keep public progress/reasoning summaries user-facing; do not include raw command lines, JSON, hidden chain-of-thought, or bridge transport details', ...isNdcAgentCore ? [ '', 'NDC Agent Core mode contract:', - '- Treat the selected agent node as the only writable target.', - '- Do not write directly to the NDC core runtime and do not modify deployed runtime workflows by hand.', - '- The Engine second-level workflow file is the source of truth for edits.', - `- Engine API base for this target: ${ndcAgentMcpApiBaseUrl}`, - `- Selected workflowId: ${context.workflowId || 'unknown'}`, - `- Selected agentNodeId: ${context.agentNodeId || 'unknown'}`, - '- Read the second-level graph with GET /subworkflow?workflowId=&nodeId=.', - '- Apply graph edits with POST /subworkflow/patch using JSON: { workflowId, nodeId, intent, operations }.', - '- Prefer the MCP tools exposed by the ndc_agent_core server: ndc_get_context, ndc_get_subworkflow, ndc_search_nodes, ndc_get_node_definition, ndc_apply_subworkflow_patch, ndc_validate_subworkflow.', - '- Do not use direct n8n/NDC core MCP servers, local workflow files, or shell probes for graph edits in this mode.', - '- Use only ndc_agent_core MCP tools for the selected second-level workflow and stop after a cancellation, fetch, or policy error with the exact tool name and error.', - '- Supported operations: addNode, upsertNode, updateNode, removeNode, moveNode, addConnection, removeConnection.', - '- Prefer small patches and preserve existing node ids, node names, positions, parameters, and connections unless the user asks to change them.', - '- After saving through the Engine API, the open Engine canvas refreshes from dc.subworkflow.json; never edit local files or use shell fallbacks for graph changes in this mode.', + ...!contextReady ? [ + '- The NDC Agent Core context is incomplete. Chat, explanations, reading, and Ops work are still allowed when the required target for that work is selected.', + '- Do not claim that an Engine agent node is selected when it is missing.', + '- Do not create, edit, patch, deploy, or write Engine workflow graph changes until the missing Engine agent node is selected.', + '- If the user asks for Engine development or graph edits, explain briefly that selecting an Engine agent node is required for that specific write action.', + ] : [ + '- Treat the selected Engine agent node as the only writable Engine workflow target.', + '- Do not write directly to the NDC core runtime and do not modify deployed runtime workflows by hand.', + '- The Engine second-level workflow file is the source of truth for edits.', + `- Engine API base for this target: ${ndcAgentMcpApiBaseUrl}`, + `- Selected workflowId: ${context.workflowId || 'unknown'}`, + `- Selected agentNodeId: ${context.agentNodeId || 'unknown'}`, + '- Read the second-level graph with GET /subworkflow?workflowId=&nodeId=.', + '- Apply graph edits with POST /subworkflow/patch using JSON: { workflowId, nodeId, intent, operations }.', + '- Prefer the MCP tools exposed by the ndc_agent_core server: ndc_get_context, ndc_get_subworkflow, ndc_search_nodes, ndc_get_node_definition, ndc_apply_subworkflow_patch, ndc_validate_subworkflow.', + '- Do not use direct NDC core runtime MCP servers, local workflow files, or shell probes for graph edits in this mode.', + '- Use only ndc_agent_core MCP tools for the selected second-level workflow and stop after a cancellation, fetch, or policy error with the exact tool name and error.', + '- In public answers, use only NDC labels: NDC workflow, NDC node, NDC node type, NDC nodebase, and NDC Agent Core.', + '- Do not expose internal vendor names, raw implementation field names, backend schema routes, or internal node class names in public answers.', + '- Supported operations: addNode, upsertNode, updateNode, removeNode, moveNode, addConnection, removeConnection.', + '- Prefer small patches and preserve existing node ids, node names, positions, parameters, and connections unless the user asks to change them.', + '- After saving through the Engine API, the open Engine canvas refreshes from dc.subworkflow.json; never edit local files or use shell fallbacks for graph changes in this mode.', + ], + ] : [], + ...(isOpsMode || opsContext.opsWorkspaceSlug || opsContext.opsProjectId ? [ + '', + 'NODE.DC Ops mode contract:', + '- Use Ops only inside the selected Ops workspace and project.', + `- Selected Ops workspace slug: ${opsContext.opsWorkspaceSlug || opsContext.opsWorkspaceId || 'unknown'}`, + `- Selected Ops project id: ${opsContext.opsProjectId || 'unknown'}`, + `- Selected Ops project identifier: ${opsContext.opsProjectIdentifier || 'unknown'}`, + '- Prefer the MCP tools exposed for NODE.DC Ops when creating, updating, moving, or reading tasks.', + '- Before writing Ops tasks, use the Ops MCP project/context tools when available and include a unique idempotency key for write tools.', + '- If Ops MCP tools are unavailable in the Codex session, say that the Ops context is selected but the Ops MCP tools are unavailable.', + '- Do not claim that an Engine workflow or Engine agent node is required for Ops task writes.', + '- If the current request is about Ops tasks and Ops workspace/project are selected, treat that as the writable Ops target.', + '- In public answers, use NODE.DC/Ops labels and never expose internal vendor names.', + ] : []), + ...guardInstruction ? [ + '', + 'System context guard:', + guardInstruction, ] : [], '', ...history.length ? [ @@ -1354,6 +1601,99 @@ function stripTopLevelTomlKeys(raw, keyNames) { return result.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() } +function extractTomlSection(raw, sectionName) { + const section = String(sectionName || '').trim() + if (!section) return '' + const headerKey = `[${section}]` + const lines = String(raw || '').split(/\r?\n/) + const result = [] + let collecting = false + for (const line of lines) { + const header = normalizedTomlHeader(line) + if (header) { + if (header === headerKey) { + collecting = true + result.push(line) + continue + } + if (collecting) break + } + if (collecting) result.push(line) + } + return result.join('\n').trim() +} + +function normalizeMcpServerSection(section, serverName) { + const lines = String(section || '').split(/\r?\n/) + const result = [] + let hasEnabled = false + let hasRequired = false + for (const line of lines) { + const header = normalizedTomlHeader(line) + if (header) { + result.push(`[mcp_servers.${serverName}]`) + continue + } + const activeLine = stripTomlComment(line) + if (/^\s*transport\s*=/.test(activeLine)) continue + if (/^\s*required\s*=/.test(activeLine)) { + result.push('required = false') + hasRequired = true + continue + } + if (/^\s*enabled\s*=/.test(activeLine)) { + result.push('enabled = true') + hasEnabled = true + continue + } + result.push(line) + } + if (!hasEnabled) result.push('enabled = true') + if (!hasRequired) result.push('required = false') + return result.join('\n').trim() +} + +function normalizeMcpHeadersSection(section, serverName) { + if (!section) return '' + const lines = String(section || '').split(/\r?\n/) + const result = [] + for (const line of lines) { + const header = normalizedTomlHeader(line) + if (header) { + result.push(`[mcp_servers.${serverName}.http_headers]`) + continue + } + result.push(line) + } + return result.join('\n').trim() +} + +function extractOptionalOpsMcpConfig(raw) { + const serverNames = ['nodedc-ops-agent', 'nodedc_tasker'] + const configs = [] + for (const serverName of serverNames) { + const baseSection = extractTomlSection(raw, `mcp_servers.${serverName}`) + if (!baseSection) continue + const headersSection = extractTomlSection(raw, `mcp_servers.${serverName}.http_headers`) + || extractTomlSection(raw, `mcp_servers.${serverName}.headers`) + const normalized = [ + normalizeMcpServerSection(baseSection, serverName), + normalizeMcpHeadersSection(headersSection, serverName), + ].filter(Boolean).join('\n\n').trim() + if (normalized) configs.push(normalized) + } + return configs.join('\n\n').trim() +} + +async function readOptionalOpsMcpConfig() { + try { + const raw = await fs.readFile(path.join(CODEX_HOME, 'config.toml'), 'utf8') + return extractOptionalOpsMcpConfig(raw) + } catch { + return '' + } +} + async function copyIfExists(from, to) { try { await fs.copyFile(from, to) @@ -1380,6 +1720,7 @@ function ndcAgentMcpEnvConfig(mcpContext, cwd) { async function prepareNdcAgentCodexHome(mcpContext = {}, cwd = CODEX_CWD) { await fs.mkdir(NDC_AGENT_CODEX_HOME, { recursive: true }) await copyIfExists(path.join(CODEX_HOME, 'auth.json'), path.join(NDC_AGENT_CODEX_HOME, 'auth.json')) + const opsMcpConfig = await readOptionalOpsMcpConfig() const runtimeConfig = [ 'approval_policy = "never"', 'sandbox_mode = "danger-full-access"', @@ -1391,7 +1732,12 @@ async function prepareNdcAgentCodexHome(mcpContext = {}, cwd = CODEX_CWD) { 'startup_timeout_sec = 20', 'tool_timeout_sec = 120', ].join('\n') - await fs.writeFile(path.join(NDC_AGENT_CODEX_HOME, 'config.toml'), `${runtimeConfig}\n\n${ndcConfig}\n${ndcAgentMcpEnvConfig(mcpContext, cwd)}\n`, 'utf8') + const config = [ + runtimeConfig, + `${ndcConfig}\n${ndcAgentMcpEnvConfig(mcpContext, cwd)}`, + opsMcpConfig, + ].filter(Boolean).join('\n\n') + await fs.writeFile(path.join(NDC_AGENT_CODEX_HOME, 'config.toml'), `${config}\n`, 'utf8') return NDC_AGENT_CODEX_HOME } @@ -1600,13 +1946,14 @@ function createActiveMirror(command, payload = {}, meta = {}) { const context = payload?.context && typeof payload.context === 'object' ? payload.context : {} if (!ACTIVE_MIRROR_ENABLED || command !== 'message' || context.remoteControlMode !== 'active') return null const cwd = String(meta.cwd || CODEX_CWD) + const displayMessage = String(payload.displayMessage || payload.publicUserMessage || payload.message || '') const { mirrorDir, currentFile, eventsFile } = activeMirrorPathsForCwd(cwd) const startedAt = new Date().toISOString() const state = { requestId: String(meta.requestId || payload.threadId || `local-${Date.now()}`), threadId: String(payload.threadId || ''), threadTitle: String(payload.threadTitle || ''), - userMessage: String(payload.message || ''), + userMessage: displayMessage, modeId: String(context.modeId || ''), modeTitle: String(context.modeTitle || ''), machineName: MACHINE_NAME, @@ -2620,10 +2967,16 @@ function engineApiBaseCandidates() { ...splitList(process.env.AI_BRIDGE_HUB_URLS).map(hubOriginToApiBase), ] const envBase = cleanString(process.env.NDC_AGENT_MCP_API_BASE_URL, 1000).replace(/\/+$/, '') - const ordered = isLoopbackUrl(explicit) && hubBases.some(Boolean) - ? [...hubBases, envBase, explicit, DEFAULT_API_BASE] - : [explicit, envBase, ...hubBases, DEFAULT_API_BASE] - return uniqueStrings(ordered) + if (explicit && !isLoopbackUrl(explicit)) { + return uniqueStrings([explicit, envBase]) + } + if (envBase && !isLoopbackUrl(envBase)) { + return uniqueStrings([envBase, explicit]) + } + if (hubBases.some(Boolean)) { + return uniqueStrings([...hubBases, envBase, explicit]) + } + return uniqueStrings([explicit, envBase, DEFAULT_API_BASE]) } function engineApiUrls(pathname) { @@ -2886,20 +3239,11 @@ async function handleGetContext() { async function handleGetSubworkflow(args) { const target = targetFromArgs(args) const q = new URLSearchParams(target) - try { - const result = await engineFetch(`/subworkflow?${q.toString()}`) - return { - ok: true, - ...(result && typeof result === 'object' ? result : {}), - graph: normalizeGraphResult(result?.graph, target.workflowId, target.nodeId), - } - } catch (error) { - if (Number(error?.status || 0) !== 404 && !/not_found/.test(String(error?.message || ''))) throw error - return { - ok: true, - graph: normalizeGraphResult(null, target.workflowId, target.nodeId), - missing: true, - } + const result = await engineFetch(`/subworkflow?${q.toString()}`) + return { + ok: true, + ...(result && typeof result === 'object' ? result : {}), + graph: normalizeGraphResult(result?.graph, target.workflowId, target.nodeId), } } @@ -3767,6 +4111,8 @@ function Main { Ensure-Codex Ensure-CodexLogin Repair-CodexMcpConfig + Ensure-AppMcpConfigs + if (-not $AppMcpServersJson) { Ensure-OpsMcpConfig } Stop-BridgeNow $script:Port = Resolve-BridgePort $files = Write-WorkerFiles -Root $InstallRoot -WorkspacePath $workspacePath diff --git a/services/ai-workspace-hub/src/server.mjs b/services/ai-workspace-hub/src/server.mjs index 624528e..c1e819c 100644 --- a/services/ai-workspace-hub/src/server.mjs +++ b/services/ai-workspace-hub/src/server.mjs @@ -307,11 +307,12 @@ function readAgentEvents(pairingCodeRaw, options = {}) { function requestMetaFromPayload(command, payload = {}) { const context = payload?.context && typeof payload.context === "object" ? payload.context : {}; + const publicUserMessage = payload?.displayMessage || payload?.publicUserMessage || payload?.message; return { command: cleanString(command, 80), threadId: cleanString(payload?.threadId, 160), threadTitle: cleanString(payload?.threadTitle, 240), - userMessage: cleanString(payload?.message, 12000), + userMessage: cleanString(publicUserMessage, 12000), remoteControlMode: cleanString(context.remoteControlMode, 40), }; }