4459 lines
163 KiB
JavaScript
4459 lines
163 KiB
JavaScript
import express from "express";
|
|
import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
import { readdir, readFile, stat } from "node:fs/promises";
|
|
import { createServer } from "node:http";
|
|
import pathModule from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { gzipSync } from "node:zlib";
|
|
import { Pool } from "pg";
|
|
import { handleAssistantCallerRequest } from "../../ontology-core/src/assistant-action-caller.mjs";
|
|
import { loadCatalog } from "../../ontology-core/src/catalog.mjs";
|
|
|
|
const SUPPORTED_SURFACES = new Set(["engine", "ops", "global"]);
|
|
const SUPPORTED_EXECUTOR_TYPES = new Set(["codex-remote", "ndc-agent-core"]);
|
|
const SUPPORTED_CONNECTION_MODES = new Set(["hub", "direct"]);
|
|
const SUPPORTED_EXECUTOR_STATUSES = new Set(["unknown", "online", "offline", "checking", "error"]);
|
|
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 AI_WORKSPACE_BRIDGE_PACKAGE_NAME = "@nodedc/ai-workspace-bridge";
|
|
const AI_WORKSPACE_BRIDGE_PACKAGE_BIN = "ai-workspace-bridge";
|
|
const AI_WORKSPACE_BRIDGE_PACKAGE_PUBLIC_PATH = "/api/ai-workspace/bridge-package.tgz";
|
|
const AI_WORKSPACE_SETUP_CODE_PREFIX = "ndcaws";
|
|
const TAR_BLOCK_SIZE = 512;
|
|
let cachedBridgePackageTarball = null;
|
|
const ASSISTANT_ACTION_TOOL_PROFILE_BASE = {
|
|
schemaVersion: "ai-workspace.assistant-actions.v1",
|
|
endpoint: "/api/ai-workspace/assistant/v1/actions",
|
|
modelFlow: "interpret_user_intent_then_call_structured_action",
|
|
phases: ["preview", "execute"],
|
|
safety: {
|
|
read: "execute_after_structured_action_selection",
|
|
write: "preview_then_explicit_user_confirmation_then_execute",
|
|
destructive: "forbidden",
|
|
},
|
|
};
|
|
const ACCESS_DENIED_TEXT = "Доступ к модулю ограничен, обратитесь к администратору системы.";
|
|
const RUN_APP_ID_ALIASES = new Map([
|
|
["hub", "launcher"],
|
|
["nodedc-hub", "launcher"],
|
|
["nodedc_launcher", "launcher"],
|
|
["nodedc-launcher", "launcher"],
|
|
]);
|
|
const APP_ROUTING_CATALOG = [
|
|
{
|
|
appId: "launcher",
|
|
appTitle: "NODE.DC Launcher",
|
|
surface: "launcher",
|
|
skillId: "launcher-context",
|
|
whenToUse: [
|
|
"users",
|
|
"roles",
|
|
"admin scopes",
|
|
"application access",
|
|
"company/workspace contours",
|
|
"invites",
|
|
"entitlements",
|
|
],
|
|
appAliases: ["hub"],
|
|
actionNamespaces: ["hub.*", "launcher.*", "access.*"],
|
|
actionIdPrefixes: ["hub.", "launcher.", "access."],
|
|
mcpServerNames: [],
|
|
requiredScopes: ["launcher:access:read"],
|
|
deniedText: ACCESS_DENIED_TEXT,
|
|
},
|
|
{
|
|
appId: "engine",
|
|
appTitle: "NODE.DC Engine / InJoin",
|
|
surface: "engine",
|
|
skillId: "engine-context",
|
|
whenToUse: [
|
|
"workflow graph",
|
|
"code",
|
|
"runtime",
|
|
"agent node",
|
|
"NDC Agent Core",
|
|
"engineering automation",
|
|
],
|
|
actionNamespaces: ["engine.*", "ndc-agent-core.*"],
|
|
mcpServerNames: ["nodedc-engine", "nodedc-agent-core"],
|
|
requiredScopes: ["engine:workspace:read"],
|
|
deniedText: ACCESS_DENIED_TEXT,
|
|
},
|
|
{
|
|
appId: "ops",
|
|
appTitle: "NODE.DC Ops / Tasker",
|
|
surface: "ops",
|
|
skillId: "ops-context",
|
|
whenToUse: [
|
|
"projects",
|
|
"cards",
|
|
"comments",
|
|
"structured blocks",
|
|
"checkers",
|
|
"labels",
|
|
"operational reports",
|
|
],
|
|
actionNamespaces: ["ops.*", "tasker_*"],
|
|
mcpServerNames: ["nodedc-ops-agent", "nodedc_ops_agent"],
|
|
requiredScopes: ["ops:project:read"],
|
|
deniedText: ACCESS_DENIED_TEXT,
|
|
},
|
|
];
|
|
const ASSISTANT_ACTION_CACHE_TTL_MS = Number(process.env.AI_WORKSPACE_ASSISTANT_ACTION_CACHE_TTL_MS || 30_000);
|
|
const ASSISTANT_ACTION_RELAY_POLL_TIMEOUT_MS = Number(process.env.AI_WORKSPACE_ASSISTANT_RELAY_POLL_TIMEOUT_MS || 25_000);
|
|
const ASSISTANT_ACTION_RELAY_POLL_IDLE_MS = Number(process.env.AI_WORKSPACE_ASSISTANT_RELAY_POLL_IDLE_MS || 500);
|
|
const ASSISTANT_ACTION_RELAY_ERROR_BACKOFF_MS = Number(process.env.AI_WORKSPACE_ASSISTANT_RELAY_ERROR_BACKOFF_MS || 5_000);
|
|
const BRIDGE_RUN_MIRROR_POLL_MS = 1200;
|
|
const BRIDGE_RUN_MIRROR_MAX_MS = 12 * 60 * 60 * 1000;
|
|
let assistantActionIdsCache = { loadedAt: 0, actionIds: [] };
|
|
let assistantActionRelayStopping = false;
|
|
|
|
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" }));
|
|
|
|
app.get("/healthz", asyncRoute(async (_req, res) => {
|
|
await pool.query("select 1");
|
|
res.json({
|
|
ok: true,
|
|
service: "nodedc-ai-workspace-assistant",
|
|
database: "ready",
|
|
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.post("/api/ai-workspace/assistant/v1/run-profile", requireInternalApi, asyncRoute(async (req, res) => {
|
|
const owner = getRequestOwner(req);
|
|
const command = sanitizeRunProfileCommand(req.body);
|
|
const ownerSettings = await getOwnerSettings(owner);
|
|
const executorId = command.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 thread = {
|
|
id: command.threadId || randomUUID(),
|
|
title: command.threadTitle || "AI Workspace Bridge",
|
|
originSurface: command.originSurface || optionalString(command.context.surface) || "engine",
|
|
activeContext: {},
|
|
enabledToolPacks: command.enabledToolPacks,
|
|
};
|
|
const bridgePayload = {
|
|
context: command.context,
|
|
enabledToolPacks: command.enabledToolPacks,
|
|
workspacePath: command.workspacePath || executor.workspacePath || "",
|
|
client: command.client,
|
|
};
|
|
const runProfile = await buildRunProfile({ owner, thread, executor, ownerSettings, bridgePayload });
|
|
res.json({ ok: true, owner: publicOwner(owner), runProfile });
|
|
}));
|
|
|
|
app.post("/api/ai-workspace/assistant/v1/executors/:executorId/dispatch", 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 command = sanitizeBridgeDispatchCommand({ ...(isPlainObject(req.body) ? req.body : {}), selectedExecutorId: executorId });
|
|
if (!command.message) {
|
|
res.status(400).json({ ok: false, error: "ai_workspace_dispatch_requires_user_message" });
|
|
return;
|
|
}
|
|
|
|
const ownerSettings = await getOwnerSettings(owner);
|
|
const thread = {
|
|
id: command.threadId || randomUUID(),
|
|
title: command.threadTitle || "AI Workspace Bridge",
|
|
originSurface: command.originSurface || optionalString(command.context.surface) || "engine",
|
|
activeContext: {},
|
|
enabledToolPacks: command.enabledToolPacks,
|
|
};
|
|
const bridgePayload = {
|
|
threadId: thread.id,
|
|
threadTitle: thread.title,
|
|
workspacePath: command.workspacePath || executor.workspacePath || "",
|
|
message: command.message,
|
|
displayMessage: command.displayMessage || command.message,
|
|
publicUserMessage: command.publicUserMessage || command.message,
|
|
resume: command.resume,
|
|
history: command.history,
|
|
context: command.context,
|
|
enabledToolPacks: command.enabledToolPacks,
|
|
client: command.client,
|
|
};
|
|
bridgePayload.runProfile = await buildRunProfile({ owner, thread, executor, ownerSettings, bridgePayload });
|
|
const bridge = await dispatchExecutorMessage(executor, bridgePayload);
|
|
res.json({
|
|
ok: true,
|
|
owner: publicOwner(owner),
|
|
executorId: executor.id,
|
|
bridge,
|
|
runProfile: redactRunProfile(bridgePayload.runProfile),
|
|
});
|
|
}));
|
|
|
|
app.post("/api/ai-workspace/assistant/v1/executors/:executorId/stop", 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 command = sanitizeBridgeStopCommand(req.body);
|
|
if (!command.requestId && !command.threadId) {
|
|
res.status(400).json({ ok: false, error: "ai_workspace_stop_target_empty" });
|
|
return;
|
|
}
|
|
|
|
const bridge = await dispatchExecutorStop(executor, command);
|
|
res.json({
|
|
ok: true,
|
|
owner: publicOwner(owner),
|
|
executorId: executor.id,
|
|
bridge,
|
|
});
|
|
}));
|
|
|
|
app.post("/api/ai-workspace/assistant/v1/actions", requireInternalApi, asyncRoute(async (req, res) => {
|
|
const owner = getRequestOwner(req);
|
|
res.json(await executeAssistantActionRequest(req.body, owner));
|
|
}));
|
|
|
|
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, settings: publicOwnerSettings(settings), executors });
|
|
}));
|
|
|
|
app.post("/api/ai-workspace/assistant/v1/executors", requireInternalApi, asyncRoute(async (req, res) => {
|
|
const owner = getRequestOwner(req);
|
|
const command = sanitizeExecutorCommand(req.body, { partial: false });
|
|
const executor = await createExecutor(owner, command);
|
|
if (req.body?.select === true) {
|
|
await selectExecutor(owner, executor.id);
|
|
}
|
|
const settings = await getOwnerSettings(owner);
|
|
res.status(201).json({ ok: true, owner: publicOwner(owner), selectedExecutorId: settings.selectedExecutorId, executor });
|
|
}));
|
|
|
|
app.patch("/api/ai-workspace/assistant/v1/executors/:executorId", requireInternalApi, asyncRoute(async (req, res) => {
|
|
const owner = getRequestOwner(req);
|
|
const executorId = sanitizeUuid(req.params.executorId, "executorId");
|
|
const command = sanitizeExecutorCommand(req.body, { partial: true });
|
|
const executor = await updateExecutor(owner, executorId, command);
|
|
if (!executor) {
|
|
res.status(404).json({ ok: false, error: "ai_workspace_executor_not_found" });
|
|
return;
|
|
}
|
|
res.json({ ok: true, owner: publicOwner(owner), executor });
|
|
}));
|
|
|
|
app.delete("/api/ai-workspace/assistant/v1/executors/:executorId", requireInternalApi, asyncRoute(async (req, res) => {
|
|
const owner = getRequestOwner(req);
|
|
const executorId = sanitizeUuid(req.params.executorId, "executorId");
|
|
const deleted = await deleteExecutor(owner, executorId);
|
|
if (!deleted) {
|
|
res.status(404).json({ ok: false, error: "ai_workspace_executor_not_found" });
|
|
return;
|
|
}
|
|
res.json({ ok: true, owner: publicOwner(owner), deleted: true });
|
|
}));
|
|
|
|
app.post("/api/ai-workspace/assistant/v1/executors/:executorId/select", requireInternalApi, asyncRoute(async (req, res) => {
|
|
const owner = getRequestOwner(req);
|
|
const executorId = sanitizeUuid(req.params.executorId, "executorId");
|
|
const executor = await selectExecutor(owner, executorId);
|
|
if (!executor) {
|
|
res.status(404).json({ ok: false, error: "ai_workspace_executor_not_found" });
|
|
return;
|
|
}
|
|
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");
|
|
const executor = await getExecutor(owner, executorId);
|
|
if (!executor) {
|
|
res.status(404).send("executor_not_found");
|
|
return;
|
|
}
|
|
const installerPort = installerPortFromQuery(req.query.port);
|
|
if (!installerPort) {
|
|
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");
|
|
source = source
|
|
.replace('[string]$HubUrl = "",', `[string]$HubUrl = ${psSingle(config.hubWebSocketUrl)},`)
|
|
.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 || "")},`);
|
|
|
|
const installerHash = createHash("sha256").update(source).digest("hex");
|
|
const safeName = optionalString(executor.name)?.replace(/[^A-Za-z0-9._-]+/g, "_") || "NDC";
|
|
res.setHeader("Content-Type", "application/octet-stream");
|
|
res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate");
|
|
res.setHeader("Pragma", "no-cache");
|
|
res.setHeader("Expires", "0");
|
|
res.setHeader("Surrogate-Control", "no-store");
|
|
res.setHeader("X-AI-Workspace-Installer-SHA256", installerHash);
|
|
res.setHeader("Content-Disposition", `attachment; filename="${safeName}_BRIDGE-agent-install.ps1"`);
|
|
res.send(source);
|
|
}));
|
|
|
|
app.post("/api/ai-workspace/assistant/v1/executors/:executorId/agent/setup-command", 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;
|
|
}
|
|
if (!cleanPairingCode(executor.pairingCode || "")) {
|
|
res.status(400).json({ ok: false, error: "pairing_code_required" });
|
|
return;
|
|
}
|
|
|
|
const installerPort = installerPortFromQuery(req.body?.port || req.query?.port);
|
|
if (!installerPort) {
|
|
res.status(400).json({ ok: false, error: "installer_port_invalid" });
|
|
return;
|
|
}
|
|
|
|
const ownerSettings = await getOwnerSettings(owner);
|
|
const appMcpServers = installerMcpServersFromSettings(ownerSettings, isPlainObject(req.body) ? req.body : {});
|
|
const setupCode = await createAiWorkspaceSetupCode(owner, executor, {
|
|
port: installerPort,
|
|
appMcpServers,
|
|
});
|
|
const command = buildBridgeSetupCommand(setupCode.code);
|
|
res.status(201).json({
|
|
ok: true,
|
|
owner: publicOwner(owner),
|
|
executorId: executor.id,
|
|
install: {
|
|
command,
|
|
packageName: config.bridgePackageName,
|
|
packageSpec: config.bridgePackageSpec,
|
|
gatewayUrl: config.setupGatewayUrl,
|
|
expiresAt: setupCode.expiresAt,
|
|
},
|
|
setupCode: {
|
|
suffix: setupCode.suffix,
|
|
expiresAt: setupCode.expiresAt,
|
|
},
|
|
});
|
|
}));
|
|
|
|
app.get("/api/ai-workspace/assistant/v1/bridge-package.tgz", requireInternalApi, asyncRoute(async (_req, res) => {
|
|
const bridgePackage = await getBridgePackageTarball();
|
|
res.setHeader("Content-Type", "application/octet-stream");
|
|
res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate");
|
|
res.setHeader("Pragma", "no-cache");
|
|
res.setHeader("Expires", "0");
|
|
res.setHeader("Surrogate-Control", "no-store");
|
|
res.setHeader("X-AI-Workspace-Bridge-Package-SHA256", bridgePackage.sha256);
|
|
res.setHeader("Content-Disposition", `attachment; filename="${bridgePackage.filename}"`);
|
|
res.send(bridgePackage.buffer);
|
|
}));
|
|
|
|
app.post("/api/ai-workspace/assistant/v1/setup-codes/redeem", requireInternalApi, asyncRoute(async (req, res) => {
|
|
const setupCode = optionalString(req.body?.setupCode || req.body?.setup_code || req.query?.setupCode || req.query?.setup_code);
|
|
if (!setupCode) {
|
|
res.status(400).json({ ok: false, error: "setup_code_required" });
|
|
return;
|
|
}
|
|
const setup = await redeemAiWorkspaceSetupCode(setupCode);
|
|
res.json({ ok: true, setup });
|
|
}));
|
|
|
|
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, kind, limit, includeArchived });
|
|
res.json({ ok: true, owner: publicOwner(owner), threads });
|
|
}));
|
|
|
|
app.post("/api/ai-workspace/assistant/v1/threads", requireInternalApi, asyncRoute(async (req, res) => {
|
|
const owner = getRequestOwner(req);
|
|
const command = sanitizeThreadCommand(req.body, { partial: false });
|
|
const thread = await createThread(owner, command);
|
|
res.status(201).json({ ok: true, owner: publicOwner(owner), thread });
|
|
}));
|
|
|
|
app.get("/api/ai-workspace/assistant/v1/threads/:threadId", requireInternalApi, asyncRoute(async (req, res) => {
|
|
const owner = getRequestOwner(req);
|
|
const threadId = sanitizeUuid(req.params.threadId, "threadId");
|
|
const thread = await getThread(owner, threadId);
|
|
if (!thread) {
|
|
res.status(404).json({ ok: false, error: "ai_workspace_thread_not_found" });
|
|
return;
|
|
}
|
|
res.json({ ok: true, owner: publicOwner(owner), thread });
|
|
}));
|
|
|
|
app.patch("/api/ai-workspace/assistant/v1/threads/:threadId", requireInternalApi, asyncRoute(async (req, res) => {
|
|
const owner = getRequestOwner(req);
|
|
const threadId = sanitizeUuid(req.params.threadId, "threadId");
|
|
const command = sanitizeThreadCommand(req.body, { partial: true });
|
|
const thread = await updateThread(owner, threadId, command);
|
|
if (!thread) {
|
|
res.status(404).json({ ok: false, error: "ai_workspace_thread_not_found" });
|
|
return;
|
|
}
|
|
res.json({ ok: true, owner: publicOwner(owner), thread });
|
|
}));
|
|
|
|
app.get("/api/ai-workspace/assistant/v1/threads/:threadId/messages", requireInternalApi, asyncRoute(async (req, res) => {
|
|
const owner = getRequestOwner(req);
|
|
const threadId = sanitizeUuid(req.params.threadId, "threadId");
|
|
const limit = sanitizeLimit(req.query.limit, 200, 1000);
|
|
const cursor = optionalString(req.query.before);
|
|
const thread = await getThread(owner, threadId);
|
|
if (!thread) {
|
|
res.status(404).json({ ok: false, error: "ai_workspace_thread_not_found" });
|
|
return;
|
|
}
|
|
const messages = await listThreadMessages(owner, threadId, { limit, before: cursor });
|
|
res.json({ ok: true, owner: publicOwner(owner), threadId, messages });
|
|
}));
|
|
|
|
app.post("/api/ai-workspace/assistant/v1/threads/:threadId/messages", requireInternalApi, asyncRoute(async (req, res) => {
|
|
const owner = getRequestOwner(req);
|
|
const threadId = sanitizeUuid(req.params.threadId, "threadId");
|
|
const command = sanitizeMessageCommand(req.body);
|
|
const message = await createThreadMessage(owner, threadId, command);
|
|
if (!message) {
|
|
res.status(404).json({ ok: false, error: "ai_workspace_thread_not_found" });
|
|
return;
|
|
}
|
|
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",
|
|
};
|
|
}
|
|
const runProfile = await buildRunProfile({ owner, thread, executor, ownerSettings, bridgePayload });
|
|
bridgePayload.runProfile = runProfile;
|
|
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,
|
|
runProfile: redactRunProfile(runProfile),
|
|
bridge,
|
|
});
|
|
}));
|
|
|
|
app.use((error, _req, res, _next) => {
|
|
const status = Number(error?.status || 500);
|
|
const message = error instanceof Error ? error.message : "internal_error";
|
|
res.status(status >= 400 && status < 600 ? status : 500).json({ ok: false, error: message });
|
|
});
|
|
|
|
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}`);
|
|
startAssistantActionRelayLoop();
|
|
});
|
|
|
|
process.on("SIGTERM", shutdown);
|
|
process.on("SIGINT", shutdown);
|
|
|
|
async function listExecutors(owner) {
|
|
const result = await pool.query(
|
|
`select *
|
|
from ai_workspace_executors
|
|
where owner_key = $1
|
|
order by updated_at desc, created_at desc`,
|
|
[owner.key]
|
|
);
|
|
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) {
|
|
if (command.connectionMode === "hub" && !command.pairingCode) {
|
|
command.pairingCode = makePairingCode();
|
|
}
|
|
const result = await pool.query(
|
|
`insert into ai_workspace_executors (
|
|
id,
|
|
owner_key,
|
|
owner_user_id,
|
|
owner_email,
|
|
name,
|
|
type,
|
|
connection_mode,
|
|
endpoint,
|
|
workspace_path,
|
|
agent_port,
|
|
pairing_code,
|
|
model,
|
|
account_label,
|
|
capabilities,
|
|
status,
|
|
status_detail,
|
|
last_seen_at,
|
|
metadata
|
|
) values (
|
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
|
|
$11, $12, $13, $14::jsonb, $15, $16, $17, $18::jsonb
|
|
)
|
|
returning *`,
|
|
[
|
|
command.id || randomUUID(),
|
|
owner.key,
|
|
owner.userId,
|
|
owner.email,
|
|
command.name,
|
|
command.type,
|
|
command.connectionMode,
|
|
command.endpoint,
|
|
command.workspacePath,
|
|
command.agentPort,
|
|
command.pairingCode,
|
|
command.model,
|
|
command.accountLabel,
|
|
JSON.stringify(command.capabilities),
|
|
command.status,
|
|
command.statusDetail,
|
|
command.lastSeenAt,
|
|
JSON.stringify(command.metadata),
|
|
]
|
|
);
|
|
return toExecutor(result.rows[0]);
|
|
}
|
|
|
|
async function updateExecutor(owner, executorId, command) {
|
|
const fields = [];
|
|
const values = [owner.key, executorId];
|
|
const mappings = {
|
|
name: "name",
|
|
type: "type",
|
|
connectionMode: "connection_mode",
|
|
endpoint: "endpoint",
|
|
workspacePath: "workspace_path",
|
|
agentPort: "agent_port",
|
|
pairingCode: "pairing_code",
|
|
model: "model",
|
|
accountLabel: "account_label",
|
|
capabilities: "capabilities",
|
|
status: "status",
|
|
statusDetail: "status_detail",
|
|
lastSeenAt: "last_seen_at",
|
|
metadata: "metadata",
|
|
};
|
|
|
|
for (const [key, column] of Object.entries(mappings)) {
|
|
if (!Object.hasOwn(command, key)) continue;
|
|
const value = key === "capabilities" || key === "metadata" ? JSON.stringify(command[key]) : command[key];
|
|
values.push(value);
|
|
fields.push(`${column} = $${values.length}${key === "capabilities" || key === "metadata" ? "::jsonb" : ""}`);
|
|
}
|
|
|
|
if (fields.length === 0) {
|
|
return getExecutor(owner, executorId);
|
|
}
|
|
|
|
values.push(new Date());
|
|
const result = await pool.query(
|
|
`update ai_workspace_executors
|
|
set ${fields.join(", ")}, updated_at = $${values.length}
|
|
where owner_key = $1 and id = $2
|
|
returning *`,
|
|
values
|
|
);
|
|
return result.rows[0] ? toExecutor(result.rows[0]) : null;
|
|
}
|
|
|
|
async function deleteExecutor(owner, executorId) {
|
|
const client = await pool.connect();
|
|
try {
|
|
await client.query("begin");
|
|
await client.query(
|
|
`update ai_workspace_owner_settings
|
|
set selected_executor_id = null, updated_at = now()
|
|
where owner_key = $1 and selected_executor_id = $2`,
|
|
[owner.key, executorId]
|
|
);
|
|
const result = await client.query(
|
|
"delete from ai_workspace_executors where owner_key = $1 and id = $2",
|
|
[owner.key, executorId]
|
|
);
|
|
await client.query("commit");
|
|
return result.rowCount > 0;
|
|
} catch (error) {
|
|
await client.query("rollback");
|
|
throw error;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
async function getExecutor(owner, executorId) {
|
|
const result = await pool.query(
|
|
"select * from ai_workspace_executors where owner_key = $1 and id = $2",
|
|
[owner.key, executorId]
|
|
);
|
|
return result.rows[0] ? toExecutor(result.rows[0]) : null;
|
|
}
|
|
|
|
async function createAiWorkspaceSetupCode(owner, executor, options = {}) {
|
|
const code = makeAiWorkspaceSetupCode();
|
|
const expiresAt = new Date(Date.now() + config.setupCodeTtlSeconds * 1000);
|
|
const metadata = {
|
|
packageName: config.bridgePackageName,
|
|
setupGatewayUrl: config.setupGatewayUrl,
|
|
bridge: bridgeSetupPayload(executor, options),
|
|
};
|
|
const client = await pool.connect();
|
|
try {
|
|
await client.query("begin");
|
|
await client.query(
|
|
`update ai_workspace_setup_codes
|
|
set status = 'revoked'
|
|
where owner_key = $1 and executor_id = $2 and status = 'active'`,
|
|
[owner.key, executor.id]
|
|
);
|
|
await client.query(
|
|
`insert into ai_workspace_setup_codes (
|
|
id,
|
|
owner_key,
|
|
owner_user_id,
|
|
owner_email,
|
|
executor_id,
|
|
code_hash,
|
|
code_suffix,
|
|
expires_at,
|
|
metadata
|
|
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb)`,
|
|
[
|
|
randomUUID(),
|
|
owner.key,
|
|
owner.userId,
|
|
owner.email,
|
|
executor.id,
|
|
hashSetupCode(code),
|
|
setupCodeSuffix(code),
|
|
expiresAt,
|
|
JSON.stringify(metadata),
|
|
]
|
|
);
|
|
await client.query("commit");
|
|
} catch (error) {
|
|
await client.query("rollback");
|
|
throw error;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
return {
|
|
code,
|
|
suffix: setupCodeSuffix(code),
|
|
expiresAt: expiresAt.toISOString(),
|
|
};
|
|
}
|
|
|
|
async function redeemAiWorkspaceSetupCode(code) {
|
|
const client = await pool.connect();
|
|
let committed = false;
|
|
try {
|
|
await client.query("begin");
|
|
const result = await client.query(
|
|
`select c.*, e.name as executor_name
|
|
from ai_workspace_setup_codes c
|
|
left join ai_workspace_executors e on e.owner_key = c.owner_key and e.id = c.executor_id
|
|
where c.code_hash = $1
|
|
for update of c`,
|
|
[hashSetupCode(code)]
|
|
);
|
|
const row = result.rows[0];
|
|
if (!row) throw badRequest("setup_code_invalid");
|
|
if (row.status !== "active") throw httpError("setup_code_not_active", 410);
|
|
if (new Date(row.expires_at).getTime() <= Date.now()) {
|
|
await client.query(
|
|
"update ai_workspace_setup_codes set status = 'expired' where id = $1",
|
|
[row.id]
|
|
);
|
|
await client.query("commit");
|
|
committed = true;
|
|
throw httpError("setup_code_expired", 410);
|
|
}
|
|
|
|
await client.query(
|
|
"update ai_workspace_setup_codes set status = 'used', used_at = now() where id = $1",
|
|
[row.id]
|
|
);
|
|
await client.query("commit");
|
|
committed = true;
|
|
|
|
const metadata = isPlainObject(row.metadata) ? row.metadata : {};
|
|
const bridge = isPlainObject(metadata.bridge) ? metadata.bridge : null;
|
|
if (!bridge?.pairingCode || !Array.isArray(bridge?.hubUrls) || !bridge.hubUrls.length) {
|
|
throw httpError("setup_payload_invalid", 500);
|
|
}
|
|
return {
|
|
service: "NDC AI Workspace Bridge",
|
|
packageName: optionalString(metadata.packageName) || config.bridgePackageName,
|
|
executor: {
|
|
id: row.executor_id,
|
|
name: optionalString(row.executor_name) || optionalString(bridge.machineName) || "Codex worker",
|
|
},
|
|
expiresAt: toIso(row.expires_at),
|
|
bridge,
|
|
};
|
|
} catch (error) {
|
|
if (!committed) {
|
|
try {
|
|
await client.query("rollback");
|
|
} catch {}
|
|
}
|
|
throw error;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
function bridgeSetupPayload(executor, options = {}) {
|
|
const hubUrls = uniqueStrings([config.hubWebSocketUrl, ...(config.hubFallbackWebSocketUrls || [])]);
|
|
return {
|
|
protocol: "ai-workspace-bridge/v1",
|
|
hubUrl: hubUrls[0] || "",
|
|
hubUrls,
|
|
pairingCode: cleanPairingCode(executor.pairingCode || ""),
|
|
machineName: optionalString(executor.name) || "Codex worker",
|
|
workspace: optionalString(executor.workspacePath) || "",
|
|
port: sanitizeInteger(options.port, 8787, 1, 65535),
|
|
appMcpServers: Array.isArray(options.appMcpServers) ? options.appMcpServers : [],
|
|
};
|
|
}
|
|
|
|
function buildBridgeSetupCommand(code) {
|
|
const parts = [
|
|
"npx",
|
|
"--yes",
|
|
"--package",
|
|
config.bridgePackageSpec,
|
|
AI_WORKSPACE_BRIDGE_PACKAGE_BIN,
|
|
"setup",
|
|
code,
|
|
"--gateway",
|
|
config.setupGatewayUrl,
|
|
];
|
|
return parts.join(" ");
|
|
}
|
|
|
|
async function getBridgePackageTarball() {
|
|
if (cachedBridgePackageTarball) return cachedBridgePackageTarball;
|
|
|
|
const packageRoot = fileURLToPath(new URL("./assets/ai-workspace-npm/", import.meta.url));
|
|
const packageJson = JSON.parse(await readFile(pathModule.join(packageRoot, "package.json"), "utf8"));
|
|
const files = await collectBridgePackageFiles(packageRoot);
|
|
const tarBuffer = await buildBridgePackageTarBuffer(packageRoot, files);
|
|
const buffer = gzipSync(tarBuffer, { level: 9 });
|
|
cachedBridgePackageTarball = {
|
|
buffer,
|
|
sha256: createHash("sha256").update(buffer).digest("hex"),
|
|
filename: bridgePackageTarballFilename(packageJson.name, packageJson.version),
|
|
};
|
|
return cachedBridgePackageTarball;
|
|
}
|
|
|
|
async function collectBridgePackageFiles(root, relDir = "") {
|
|
const currentDir = pathModule.join(root, relDir);
|
|
const entries = await readdir(currentDir, { withFileTypes: true });
|
|
const files = [];
|
|
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
if (shouldSkipBridgePackageEntry(entry.name)) continue;
|
|
const relPath = relDir ? `${relDir}/${entry.name}` : entry.name;
|
|
if (entry.isDirectory()) {
|
|
files.push(...await collectBridgePackageFiles(root, relPath));
|
|
continue;
|
|
}
|
|
if (entry.isFile()) files.push(relPath);
|
|
}
|
|
return files;
|
|
}
|
|
|
|
function shouldSkipBridgePackageEntry(name) {
|
|
return (
|
|
name === ".DS_Store" ||
|
|
name === "__MACOSX" ||
|
|
name === "node_modules" ||
|
|
name.endsWith(".tgz")
|
|
);
|
|
}
|
|
|
|
async function buildBridgePackageTarBuffer(root, files) {
|
|
const chunks = [];
|
|
for (const relPath of files) {
|
|
const fullPath = pathModule.join(root, ...relPath.split("/"));
|
|
const fileStat = await stat(fullPath);
|
|
if (!fileStat.isFile()) continue;
|
|
const body = await readFile(fullPath);
|
|
const mode = relPath.startsWith("bin/") ? 0o755 : 0o644;
|
|
chunks.push(buildTarHeader({
|
|
name: `package/${relPath}`,
|
|
mode,
|
|
size: body.length,
|
|
type: "0",
|
|
}));
|
|
chunks.push(body);
|
|
const padding = (TAR_BLOCK_SIZE - (body.length % TAR_BLOCK_SIZE)) % TAR_BLOCK_SIZE;
|
|
if (padding) chunks.push(Buffer.alloc(padding));
|
|
}
|
|
chunks.push(Buffer.alloc(TAR_BLOCK_SIZE * 2));
|
|
return Buffer.concat(chunks);
|
|
}
|
|
|
|
function buildTarHeader({ name, mode, size, type }) {
|
|
const header = Buffer.alloc(TAR_BLOCK_SIZE);
|
|
const splitName = splitTarName(name);
|
|
writeTarString(header, 0, 100, splitName.name);
|
|
writeTarOctal(header, 100, 8, mode);
|
|
writeTarOctal(header, 108, 8, 0);
|
|
writeTarOctal(header, 116, 8, 0);
|
|
writeTarOctal(header, 124, 12, size);
|
|
writeTarOctal(header, 136, 12, 0);
|
|
header.fill(0x20, 148, 156);
|
|
writeTarString(header, 156, 1, type || "0");
|
|
writeTarString(header, 257, 6, "ustar");
|
|
writeTarString(header, 263, 2, "00");
|
|
writeTarString(header, 265, 32, "nodedc");
|
|
writeTarString(header, 297, 32, "nodedc");
|
|
if (splitName.prefix) writeTarString(header, 345, 155, splitName.prefix);
|
|
writeTarChecksum(header, header.reduce((sum, value) => sum + value, 0));
|
|
return header;
|
|
}
|
|
|
|
function splitTarName(name) {
|
|
if (Buffer.byteLength(name) <= 100) return { name, prefix: "" };
|
|
let slashIndex = name.lastIndexOf("/");
|
|
while (slashIndex > 0) {
|
|
const prefix = name.slice(0, slashIndex);
|
|
const entryName = name.slice(slashIndex + 1);
|
|
if (Buffer.byteLength(prefix) <= 155 && Buffer.byteLength(entryName) <= 100) {
|
|
return { name: entryName, prefix };
|
|
}
|
|
slashIndex = name.lastIndexOf("/", slashIndex - 1);
|
|
}
|
|
throw new Error(`bridge_package_path_too_long:${name}`);
|
|
}
|
|
|
|
function writeTarString(buffer, offset, length, value) {
|
|
buffer.write(String(value || ""), offset, length, "ascii");
|
|
}
|
|
|
|
function writeTarOctal(buffer, offset, length, value) {
|
|
const text = Math.trunc(Number(value) || 0).toString(8).padStart(length - 1, "0").slice(-(length - 1));
|
|
buffer.write(text, offset, length - 1, "ascii");
|
|
buffer[offset + length - 1] = 0;
|
|
}
|
|
|
|
function writeTarChecksum(buffer, checksum) {
|
|
const text = Math.trunc(Number(checksum) || 0).toString(8).padStart(6, "0").slice(-6);
|
|
buffer.write(text, 148, 6, "ascii");
|
|
buffer[154] = 0;
|
|
buffer[155] = 0x20;
|
|
}
|
|
|
|
function bridgePackageTarballFilename(packageName, version) {
|
|
const rawName = String(packageName || AI_WORKSPACE_BRIDGE_PACKAGE_NAME).replace(/^@[^/]+\//, "");
|
|
const safeName = rawName.replace(/[^A-Za-z0-9._-]+/g, "-") || "ai-workspace-bridge";
|
|
const safeVersion = String(version || "0.0.0").replace(/[^A-Za-z0-9._-]+/g, "-");
|
|
return `${safeName}-${safeVersion}.tgz`;
|
|
}
|
|
|
|
function makeAiWorkspaceSetupCode() {
|
|
return `${AI_WORKSPACE_SETUP_CODE_PREFIX}_${randomBytes(18).toString("base64url")}`;
|
|
}
|
|
|
|
function hashSetupCode(code) {
|
|
return createHash("sha256").update(String(code || ""), "utf8").digest("hex");
|
|
}
|
|
|
|
function setupCodeSuffix(code) {
|
|
return String(code || "").replace(/[^A-Za-z0-9]/g, "").slice(-6).toUpperCase();
|
|
}
|
|
|
|
async function selectExecutor(owner, executorId) {
|
|
const executor = await getExecutor(owner, executorId);
|
|
if (!executor) return null;
|
|
await pool.query(
|
|
`insert into ai_workspace_owner_settings (
|
|
owner_key,
|
|
owner_user_id,
|
|
owner_email,
|
|
selected_executor_id
|
|
) values ($1, $2, $3, $4)
|
|
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,
|
|
updated_at = now()`,
|
|
[owner.key, owner.userId, owner.email, 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 compatibility = bridgeCompatibilityStatus(health, agent);
|
|
if (!compatibility.ok) {
|
|
return executorCheckResult(
|
|
"error",
|
|
"agent_update_required",
|
|
optionalString(health.runtimeCheckedAt) || agent.lastSeenAt || checkedAt,
|
|
{
|
|
checkedAt,
|
|
mode: "hub",
|
|
pairingCode,
|
|
agent,
|
|
health: summarizeBridgeHealth(health),
|
|
bridgeCompatibility: compatibility,
|
|
}
|
|
);
|
|
}
|
|
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 compatibility = bridgeCompatibilityStatus(health, {});
|
|
if (!compatibility.ok) {
|
|
return executorCheckResult("error", "agent_update_required", optionalString(health.runtimeCheckedAt) || checkedAt, {
|
|
checkedAt,
|
|
mode: "direct",
|
|
endpoint: executor.endpoint,
|
|
health: summarizeBridgeHealth(health),
|
|
bridgeCompatibility: compatibility,
|
|
});
|
|
}
|
|
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 dispatchExecutorStop(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: "stop",
|
|
payload,
|
|
timeoutMs: 30000,
|
|
quiet: false,
|
|
},
|
|
},
|
|
10000
|
|
);
|
|
return {
|
|
ok: true,
|
|
accepted: true,
|
|
requestId: optionalString(response.requestId),
|
|
targetRequestId: payload.requestId,
|
|
threadId: payload.threadId,
|
|
mode: "hub",
|
|
};
|
|
}
|
|
|
|
const url = bridgeCommandUrl(executor.endpoint, "stop");
|
|
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),
|
|
},
|
|
10000
|
|
);
|
|
return {
|
|
ok: response?.ok !== false,
|
|
accepted: false,
|
|
response,
|
|
targetRequestId: payload.requestId,
|
|
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 runProfile = isPlainObject(payload?.runProfile) ? payload.runProfile : null;
|
|
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,
|
|
runProfile: runProfile ? redactRunProfile(runProfile) : null,
|
|
};
|
|
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(),
|
|
},
|
|
};
|
|
}
|
|
|
|
async function buildRunProfile({ owner, thread, executor, ownerSettings, bridgePayload }) {
|
|
const context = isPlainObject(bridgePayload?.context) ? bridgePayload.context : {};
|
|
const sourceSurface = optionalString(context.sourceSurface)
|
|
|| optionalString(context.surface)
|
|
|| thread.originSurface
|
|
|| "global";
|
|
const targetContexts = isPlainObject(context.contexts) ? context.contexts : {};
|
|
const enabledToolPacks = mergeToolPacks(
|
|
ownerSettings?.enabledToolPacks,
|
|
thread.enabledToolPacks,
|
|
bridgePayload?.enabledToolPacks
|
|
);
|
|
const grantResolution = await resolveRunAppGrants({ owner, context, ownerSettings });
|
|
const appGrants = summarizeRunAppGrants({ appGrants: grantResolution.appGrants });
|
|
const mcpServers = runProfileMcpServersFromAppGrants(ownerSettings, grantResolution.appGrants);
|
|
const mcpServerNames = mcpServers.map((server) => server.serverName).filter(Boolean);
|
|
const assistantActions = await assistantActionToolProfileForRun();
|
|
const appCatalog = buildRunProfileAppCatalog({
|
|
appGrants: grantResolution.appGrants,
|
|
mcpServers,
|
|
assistantActions,
|
|
});
|
|
const appAccess = summarizeRunAppAccess(appCatalog);
|
|
const requiredMcpServerNames = mcpServers
|
|
.filter((server) => server.required === true)
|
|
.map((server) => server.serverName)
|
|
.filter(Boolean);
|
|
const diagnostics = {
|
|
schemaVersion: "ai-workspace.run-profile.diagnostics.v1",
|
|
dynamicProfile: true,
|
|
contextReady: context.contextReady !== false,
|
|
accessMode: optionalString(context.accessMode) || "chat",
|
|
sourceSurface,
|
|
modeId: optionalString(context.modeId) || "ops",
|
|
targetSurfaces: Object.keys(targetContexts).map(normalizeKey).filter(Boolean).sort(),
|
|
enabledToolPacks,
|
|
appGrantIds: Object.keys(appGrants).sort(),
|
|
appCatalogIds: appCatalog.map((app) => app.appId).sort(),
|
|
grantedAppIds: appAccess.grantedAppIds,
|
|
deniedAppIds: appAccess.deniedAppIds,
|
|
notGrantedAppIds: appAccess.notGrantedAppIds,
|
|
entitlementAdapters: grantResolution.diagnostics,
|
|
mcpServerNames,
|
|
requiredMcpServerNames,
|
|
assistantActionIds: assistantActions.actionIds,
|
|
assistantActionGatewayConfigured: Boolean(assistantActions.gatewayUrl && assistantActions.gatewayToken),
|
|
missingContext: Array.isArray(context.missingContext)
|
|
? context.missingContext.map(optionalString).filter(Boolean)
|
|
: [],
|
|
};
|
|
const runProfile = {
|
|
schemaVersion: "ai-workspace.run-profile.v1",
|
|
runId: randomUUID(),
|
|
createdAt: new Date().toISOString(),
|
|
owner: publicOwner(owner),
|
|
executor: {
|
|
id: executor.id,
|
|
type: executor.type,
|
|
connectionMode: executor.connectionMode,
|
|
},
|
|
sourceSurface,
|
|
modeId: optionalString(context.modeId) || "ops",
|
|
modeTitle: optionalString(context.modeTitle) || "",
|
|
targetContexts: redactForPublicDiagnostics(targetContexts),
|
|
enabledToolPacks,
|
|
appGrants,
|
|
appCatalog,
|
|
appAccess,
|
|
toolProfile: {
|
|
schemaVersion: "ai-workspace.tool-profile.v1",
|
|
enabledToolPacks,
|
|
mcpServers,
|
|
mcpServerNames,
|
|
requiredMcpServerNames,
|
|
assistantActions,
|
|
},
|
|
policyPrompt: buildRunProfilePolicyPrompt({ context, diagnostics, assistantActions }),
|
|
diagnostics,
|
|
};
|
|
runProfile.diagnostics.profileHash = runProfileHash(runProfile);
|
|
return runProfile;
|
|
}
|
|
|
|
async function assistantActionToolProfileForRun() {
|
|
const gatewayUrl = assistantActionGatewayUrlForRun();
|
|
const gatewayToken = optionalString(
|
|
process.env.NDC_AI_WORKSPACE_ASSISTANT_ACTION_GATEWAY_TOKEN ||
|
|
config.hubInternalAccessToken ||
|
|
config.internalAccessTokens[0] ||
|
|
""
|
|
);
|
|
const actionIds = await assistantActionIdsForRun();
|
|
return {
|
|
...ASSISTANT_ACTION_TOOL_PROFILE_BASE,
|
|
endpoint: actionGatewayEndpointPath(gatewayUrl) || ASSISTANT_ACTION_TOOL_PROFILE_BASE.endpoint,
|
|
actionIds,
|
|
...(gatewayUrl ? { gatewayUrl } : {}),
|
|
...(gatewayToken ? { gatewayToken } : {}),
|
|
};
|
|
}
|
|
|
|
function assistantActionGatewayUrlForRun() {
|
|
const explicit = cleanHttpEndpoint(process.env.NDC_AI_WORKSPACE_ASSISTANT_ACTION_GATEWAY_URL);
|
|
if (explicit) return explicit;
|
|
if (config.assistantActionRelayEnabled && config.assistantActionRelayId && config.hubInternalHttpUrl) {
|
|
return cleanHttpEndpoint(
|
|
`${config.hubInternalHttpUrl.replace(/\/+$/, "")}/api/ai-workspace/hub/v1/assistant-relays/${encodeURIComponent(config.assistantActionRelayId)}/actions`
|
|
);
|
|
}
|
|
return cleanHttpEndpoint(config.hubInternalHttpUrl || httpUrlFromWebSocketUrl(config.hubWebSocketUrl));
|
|
}
|
|
|
|
function actionGatewayEndpointPath(gatewayUrl) {
|
|
try {
|
|
const url = new URL(gatewayUrl);
|
|
return `${url.pathname}${url.search || ""}`;
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
async function assistantActionIdsForRun() {
|
|
const now = Date.now();
|
|
if (
|
|
assistantActionIdsCache.actionIds.length &&
|
|
now - assistantActionIdsCache.loadedAt < ASSISTANT_ACTION_CACHE_TTL_MS
|
|
) {
|
|
return assistantActionIdsCache.actionIds;
|
|
}
|
|
|
|
try {
|
|
const catalog = await loadCatalog();
|
|
const actionIds = (Array.isArray(catalog?.assistantActions?.actions) ? catalog.assistantActions.actions : [])
|
|
.filter((action) => (
|
|
isPlainObject(action) &&
|
|
optionalString(action.id) &&
|
|
action.adapterStatus === "implemented" &&
|
|
action.confirmationMode !== "forbidden"
|
|
))
|
|
.map((action) => optionalString(action.id))
|
|
.filter(Boolean)
|
|
.sort();
|
|
assistantActionIdsCache = { loadedAt: now, actionIds };
|
|
return actionIds;
|
|
} catch {
|
|
assistantActionIdsCache = { loadedAt: now, actionIds: [] };
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function startAssistantActionRelayLoop() {
|
|
if (!config.assistantActionRelayEnabled) return;
|
|
if (!config.assistantActionRelayId || !config.hubInternalHttpUrl || !config.hubInternalAccessToken) {
|
|
console.warn("Assistant action relay disabled: relay id, hub URL, or hub token is missing.");
|
|
return;
|
|
}
|
|
void assistantActionRelayLoop();
|
|
}
|
|
|
|
async function assistantActionRelayLoop() {
|
|
while (!assistantActionRelayStopping) {
|
|
try {
|
|
const payload = await hubRequestJson(
|
|
`/api/ai-workspace/hub/v1/assistant-relays/${encodeURIComponent(config.assistantActionRelayId)}/poll`,
|
|
{
|
|
method: "POST",
|
|
body: {
|
|
limit: 5,
|
|
timeoutMs: ASSISTANT_ACTION_RELAY_POLL_TIMEOUT_MS,
|
|
},
|
|
},
|
|
ASSISTANT_ACTION_RELAY_POLL_TIMEOUT_MS + 5000
|
|
);
|
|
const calls = Array.isArray(payload?.calls) ? payload.calls : [];
|
|
for (const call of calls) {
|
|
void handleAssistantActionRelayCall(call).catch((error) => {
|
|
console.error("Assistant action relay call failed:", errorMessage(error));
|
|
});
|
|
}
|
|
await delay(ASSISTANT_ACTION_RELAY_POLL_IDLE_MS);
|
|
} catch (error) {
|
|
if (!assistantActionRelayStopping) {
|
|
console.error("Assistant action relay poll failed:", errorMessage(error));
|
|
await delay(ASSISTANT_ACTION_RELAY_ERROR_BACKOFF_MS);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async function handleAssistantActionRelayCall(call) {
|
|
const callId = optionalString(call?.callId);
|
|
if (!callId) return;
|
|
|
|
let status = 200;
|
|
let body = null;
|
|
try {
|
|
const owner = getRequestOwner({ headers: relayCallHeaders(call?.headers), query: {} });
|
|
body = await executeAssistantActionRequest(isPlainObject(call?.payload) ? call.payload : {}, owner);
|
|
} catch (error) {
|
|
status = Number(error?.status || 500);
|
|
body = { ok: false, error: errorMessage(error) };
|
|
}
|
|
|
|
await hubRequestJson(
|
|
`/api/ai-workspace/hub/v1/assistant-relays/${encodeURIComponent(config.assistantActionRelayId)}/results/${encodeURIComponent(callId)}`,
|
|
{
|
|
method: "POST",
|
|
body: {
|
|
status,
|
|
body,
|
|
},
|
|
},
|
|
10000
|
|
);
|
|
}
|
|
|
|
function relayCallHeaders(value) {
|
|
const headers = {};
|
|
if (!isPlainObject(value)) return headers;
|
|
for (const [key, rawValue] of Object.entries(value)) {
|
|
const name = optionalString(key)?.toLowerCase();
|
|
const text = optionalString(rawValue);
|
|
if (!name || !text) continue;
|
|
headers[name] = text;
|
|
}
|
|
return headers;
|
|
}
|
|
|
|
function delay(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, Math.max(0, Number(ms || 0))));
|
|
}
|
|
|
|
async function resolveRunAppGrants({ owner, context, ownerSettings }) {
|
|
const metadata = isPlainObject(ownerSettings?.metadata) ? ownerSettings.metadata : {};
|
|
const staticAppGrants = isPlainObject(metadata.appGrants) ? metadata.appGrants : {};
|
|
const appGrants = normalizeRunAppGrantsObject(staticAppGrants);
|
|
const adapterDiagnostics = [];
|
|
const adapters = Array.isArray(config.entitlementAdapters) ? config.entitlementAdapters : [];
|
|
if (!adapters.length) {
|
|
return {
|
|
appGrants,
|
|
diagnostics: {
|
|
source: "settings",
|
|
adapters: [],
|
|
},
|
|
};
|
|
}
|
|
|
|
for (const adapter of adapters) {
|
|
const result = await fetchRunEntitlementAdapter({ adapter, owner, context, ownerSettings }).catch((error) => ({
|
|
ok: false,
|
|
error: errorMessage(error),
|
|
}));
|
|
if (!result.ok) {
|
|
adapterDiagnostics.push({
|
|
appId: adapter.appId,
|
|
status: "error",
|
|
required: adapter.required === true,
|
|
error: sanitizeBridgeErrorText(result.error || "entitlement_adapter_failed"),
|
|
});
|
|
if (adapter.required === true) {
|
|
const error = new Error(`entitlement_adapter_failed:${adapter.appId}`);
|
|
error.status = 502;
|
|
throw error;
|
|
}
|
|
continue;
|
|
}
|
|
const adapterAppGrants = normalizeEntitlementAdapterAppGrants(result.payload, adapter);
|
|
for (const [rawAppId, grant] of Object.entries(adapterAppGrants)) {
|
|
if (!isPlainObject(grant)) continue;
|
|
const appId = canonicalRunAppId(grant.appId || rawAppId);
|
|
if (!appId) continue;
|
|
const existing = isPlainObject(appGrants[appId]) ? appGrants[appId] : {};
|
|
const merged = {
|
|
...existing,
|
|
...grant,
|
|
appId,
|
|
};
|
|
merged.mcpServers = isRunAppGrantDenied(merged)
|
|
? []
|
|
: Object.hasOwn(grant, "mcpServers") ? grant.mcpServers : existing.mcpServers;
|
|
appGrants[appId] = merged;
|
|
}
|
|
adapterDiagnostics.push({
|
|
appId: adapter.appId,
|
|
status: "ok",
|
|
required: adapter.required === true,
|
|
grantIds: Object.keys(adapterAppGrants).sort(),
|
|
mcpServerNames: Object.values(adapterAppGrants)
|
|
.flatMap((grant) => {
|
|
const servers = Array.isArray(grant?.mcpServers)
|
|
? grant.mcpServers
|
|
: isPlainObject(grant?.mcpServers)
|
|
? Object.values(grant.mcpServers)
|
|
: [];
|
|
return servers.map((server) => safeMcpServerName(server?.serverName || server?.server_name || server?.name));
|
|
})
|
|
.filter(Boolean),
|
|
});
|
|
}
|
|
|
|
return {
|
|
appGrants,
|
|
diagnostics: {
|
|
source: adapterDiagnostics.some((item) => item.status === "ok") ? "adapters+settings" : "settings",
|
|
adapters: adapterDiagnostics,
|
|
},
|
|
};
|
|
}
|
|
|
|
function normalizeRunAppGrantsObject(value) {
|
|
const out = {};
|
|
if (!isPlainObject(value)) return out;
|
|
for (const [key, rawGrant] of Object.entries(value)) {
|
|
if (!isPlainObject(rawGrant)) continue;
|
|
const appId = canonicalRunAppId(rawGrant.appId || rawGrant.app_id || key);
|
|
if (!appId) continue;
|
|
const existing = isPlainObject(out[appId]) ? out[appId] : {};
|
|
const grant = {
|
|
...existing,
|
|
...rawGrant,
|
|
appId,
|
|
};
|
|
grant.mcpServers = isRunAppGrantDenied(grant)
|
|
? []
|
|
: Object.hasOwn(rawGrant, "mcpServers") ? rawGrant.mcpServers : existing.mcpServers;
|
|
out[appId] = grant;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
async function fetchRunEntitlementAdapter({ adapter, owner, context, ownerSettings }) {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), adapter.timeoutMs);
|
|
const body = JSON.stringify({
|
|
schemaVersion: "ai-workspace.entitlement-request.v1",
|
|
appId: adapter.appId,
|
|
owner: publicOwner(owner),
|
|
activeContext: redactForPublicDiagnostics(isPlainObject(ownerSettings?.activeContext) ? ownerSettings.activeContext : {}),
|
|
runContext: redactForPublicDiagnostics(context),
|
|
requestedAt: new Date().toISOString(),
|
|
});
|
|
try {
|
|
const response = await fetch(adapter.url, {
|
|
method: "POST",
|
|
headers: {
|
|
Accept: "application/json",
|
|
"Content-Type": "application/json",
|
|
...(adapter.authorization ? { Authorization: adapter.authorization } : {}),
|
|
...adapter.headers,
|
|
},
|
|
body,
|
|
signal: controller.signal,
|
|
});
|
|
const text = await response.text();
|
|
let payload = {};
|
|
try {
|
|
payload = text ? JSON.parse(text) : {};
|
|
} catch {
|
|
payload = { ok: false, error: "invalid_json_response" };
|
|
}
|
|
if (!response.ok || payload?.ok === false) {
|
|
throw new Error(optionalString(payload?.error || payload?.message) || `http_${response.status}`);
|
|
}
|
|
return { ok: true, payload };
|
|
} catch (error) {
|
|
if (error?.name === "AbortError") throw new Error("entitlement_adapter_timeout");
|
|
throw error;
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
function normalizeEntitlementAdapterAppGrants(payload, adapter) {
|
|
const source = isPlainObject(payload?.appGrants)
|
|
? payload.appGrants
|
|
: Array.isArray(payload?.appGrants)
|
|
? payload.appGrants
|
|
: payload?.grant || payload?.appGrant || payload?.entitlement || payload?.entitlements || payload?.grants || payload;
|
|
const out = {};
|
|
if (Array.isArray(source)) {
|
|
for (const item of source) {
|
|
const grant = normalizeEntitlementAdapterGrant(item, adapter);
|
|
if (grant?.appId) out[grant.appId] = grant;
|
|
}
|
|
return out;
|
|
}
|
|
if (!isPlainObject(source)) return out;
|
|
if (source.mcpServers || source.scopes || source.appId || source.app_id || source.surface) {
|
|
const grant = normalizeEntitlementAdapterGrant(source, adapter);
|
|
if (grant?.appId) out[grant.appId] = grant;
|
|
return out;
|
|
}
|
|
for (const [key, value] of Object.entries(source)) {
|
|
const grant = normalizeEntitlementAdapterGrant(value, { ...adapter, appId: normalizeKey(key) || adapter.appId });
|
|
if (grant?.appId) out[grant.appId] = grant;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function normalizeEntitlementAdapterGrant(value, adapter) {
|
|
if (!isPlainObject(value)) return null;
|
|
const appId = canonicalRunAppId(value.appId || value.app_id || adapter.appId);
|
|
if (!appId) return null;
|
|
return {
|
|
...value,
|
|
appId,
|
|
appTitle: optionalString(value.appTitle || value.app_title || value.title || adapter.title),
|
|
surface: optionalString(value.surface) || appId,
|
|
source: "entitlement-adapter",
|
|
adapterId: adapter.id,
|
|
};
|
|
}
|
|
|
|
function canonicalRunAppId(value) {
|
|
const appId = normalizeKey(value);
|
|
if (!appId) return "";
|
|
return RUN_APP_ID_ALIASES.get(appId) || appId;
|
|
}
|
|
|
|
function runProfileMcpServersFromSettings(settings) {
|
|
const metadata = isPlainObject(settings?.metadata) ? settings.metadata : {};
|
|
const appGrants = normalizeRunAppGrantsObject(metadata.appGrants);
|
|
return runProfileMcpServersFromAppGrants(settings, appGrants);
|
|
}
|
|
|
|
function runProfileMcpServersFromAppGrants(settings, appGrantsInput = {}) {
|
|
const servers = [];
|
|
const metadata = isPlainObject(settings?.metadata) ? settings.metadata : {};
|
|
const hasEntitlementAdapters = Array.isArray(config.entitlementAdapters) && config.entitlementAdapters.length > 0;
|
|
if (!hasEntitlementAdapters) {
|
|
collectInstallerMcpServers(servers, metadata.mcpServers, {});
|
|
}
|
|
const appGrants = normalizeRunAppGrantsObject(appGrantsInput);
|
|
for (const [appId, grant] of Object.entries(appGrants)) {
|
|
if (!isPlainObject(grant)) continue;
|
|
if (isRunAppGrantDenied(grant)) continue;
|
|
collectInstallerMcpServers(servers, grant.mcpServers, {
|
|
appId: optionalString(grant.appId) || normalizeKey(appId),
|
|
appTitle: optionalString(grant.appTitle || grant.title),
|
|
});
|
|
}
|
|
|
|
const byServerName = new Map();
|
|
for (const server of servers.map(sanitizeInstallerMcpServer).filter(Boolean)) {
|
|
if (server.enabled === false) continue;
|
|
byServerName.set(server.serverName, server);
|
|
}
|
|
return Array.from(byServerName.values());
|
|
}
|
|
|
|
function summarizeRunAppGrants(metadata) {
|
|
const appGrants = normalizeRunAppGrantsObject(metadata?.appGrants);
|
|
const out = {};
|
|
for (const [key, value] of Object.entries(appGrants)) {
|
|
if (!isPlainObject(value)) continue;
|
|
const appId = canonicalRunAppId(value.appId || key);
|
|
if (!appId) continue;
|
|
const denied = isRunAppGrantDenied(value);
|
|
const mcpServers = Array.isArray(value.mcpServers)
|
|
? value.mcpServers
|
|
: isPlainObject(value.mcpServers)
|
|
? Object.values(value.mcpServers)
|
|
: [];
|
|
out[appId] = {
|
|
appId,
|
|
appTitle: optionalString(value.appTitle || value.title),
|
|
surface: optionalString(value.surface) || appId,
|
|
status: denied ? "denied" : "granted",
|
|
granted: !denied,
|
|
denied,
|
|
deniedReason: denied ? optionalString(value.reason || value.deniedReason || value.denied_reason || value.status) : null,
|
|
deniedText: denied ? optionalString(value.deniedText || value.denied_text) || ACCESS_DENIED_TEXT : null,
|
|
updatedAt: optionalString(value.updatedAt || value.updated_at),
|
|
context: redactForPublicDiagnostics(isPlainObject(value.context) ? value.context : {}),
|
|
scopes: uniqueStrings(Array.isArray(value.scopes) ? value.scopes : []),
|
|
hasMcpServers: !denied && mcpServers.length > 0,
|
|
mcpServerNames: mcpServers
|
|
.map((server) => safeMcpServerName(server?.serverName || server?.server_name || server?.name))
|
|
.filter(Boolean),
|
|
};
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function buildRunProfileAppCatalog({ appGrants, mcpServers, assistantActions }) {
|
|
const grants = normalizeRunAppGrantsObject(appGrants);
|
|
const dynamicAppIds = Object.keys(grants)
|
|
.map(canonicalRunAppId)
|
|
.filter(Boolean)
|
|
.filter((appId) => !APP_ROUTING_CATALOG.some((entry) => entry.appId === appId));
|
|
const catalog = [
|
|
...APP_ROUTING_CATALOG,
|
|
...dynamicAppIds.map((appId) => ({
|
|
appId,
|
|
appTitle: appId,
|
|
surface: appId,
|
|
skillId: `${appId}-context`,
|
|
whenToUse: [],
|
|
actionNamespaces: [`${appId}.*`],
|
|
actionIdPrefixes: [`${appId}.`],
|
|
mcpServerNames: [],
|
|
requiredScopes: [],
|
|
deniedText: ACCESS_DENIED_TEXT,
|
|
})),
|
|
];
|
|
const mcpByAppId = new Map();
|
|
for (const server of Array.isArray(mcpServers) ? mcpServers : []) {
|
|
const appId = normalizeKey(server?.appId);
|
|
if (!appId) continue;
|
|
const list = mcpByAppId.get(appId) || [];
|
|
if (server?.serverName && !list.includes(server.serverName)) list.push(server.serverName);
|
|
mcpByAppId.set(appId, list);
|
|
}
|
|
const actionIds = Array.isArray(assistantActions?.actionIds) ? assistantActions.actionIds : [];
|
|
const allMcpServerNames = uniqueStrings((Array.isArray(mcpServers) ? mcpServers : [])
|
|
.map((server) => server?.serverName)
|
|
.filter(Boolean));
|
|
return catalog.map((entry) => {
|
|
const grant = isPlainObject(grants[entry.appId]) ? grants[entry.appId] : null;
|
|
const grantedByLegacyMcp = !grant && entry.mcpServerNames.some((name) => allMcpServerNames.includes(name));
|
|
const denied = grant ? isRunAppGrantDenied(grant) : !grantedByLegacyMcp;
|
|
const status = grant ? (denied ? "denied" : "granted") : grantedByLegacyMcp ? "granted" : "not-granted";
|
|
const grantMcpServers = grant
|
|
? Array.isArray(grant.mcpServers)
|
|
? grant.mcpServers
|
|
: isPlainObject(grant.mcpServers)
|
|
? Object.values(grant.mcpServers)
|
|
: []
|
|
: [];
|
|
const grantMcpServerNames = grantMcpServers
|
|
.map((server) => safeMcpServerName(server?.serverName || server?.server_name || server?.name))
|
|
.filter(Boolean);
|
|
const advertisedMcpServerNames = uniqueStrings([
|
|
...entry.mcpServerNames,
|
|
...grantMcpServerNames,
|
|
...(mcpByAppId.get(entry.appId) || []),
|
|
]);
|
|
const actionIdPrefixes = uniqueStrings([
|
|
...(Array.isArray(entry.actionIdPrefixes) ? entry.actionIdPrefixes : []),
|
|
`${entry.appId}.`,
|
|
...(Array.isArray(entry.appAliases) ? entry.appAliases.map((alias) => `${normalizeKey(alias)}.`) : []),
|
|
].filter(Boolean));
|
|
return {
|
|
schemaVersion: "ai-workspace.app-route.v1",
|
|
appId: entry.appId,
|
|
appTitle: entry.appTitle,
|
|
surface: entry.surface,
|
|
skillId: entry.skillId,
|
|
status,
|
|
granted: status === "granted",
|
|
denied: status === "denied",
|
|
notGranted: status === "not-granted",
|
|
deniedReason: status === "denied"
|
|
? optionalString(grant?.reason || grant?.deniedReason || grant?.denied_reason || grant?.status) || status
|
|
: null,
|
|
deniedText: status === "denied"
|
|
? optionalString(grant?.deniedText || grant?.denied_text) || entry.deniedText || ACCESS_DENIED_TEXT
|
|
: null,
|
|
whenToUse: uniqueStrings(entry.whenToUse),
|
|
actionNamespaces: uniqueStrings(entry.actionNamespaces),
|
|
actionIds: actionIds.filter((actionId) => actionIdPrefixes.some((prefix) => String(actionId || "").startsWith(prefix))),
|
|
mcpServerNames: status === "granted" ? advertisedMcpServerNames : [],
|
|
requiredScopes: uniqueStrings(grant?.requiredScopes || grant?.required_scopes || entry.requiredScopes),
|
|
scopes: uniqueStrings(Array.isArray(grant?.scopes) ? grant.scopes : []),
|
|
};
|
|
});
|
|
}
|
|
|
|
function summarizeRunAppAccess(appCatalog) {
|
|
const apps = Array.isArray(appCatalog) ? appCatalog : [];
|
|
return {
|
|
schemaVersion: "ai-workspace.app-access.v1",
|
|
grantedAppIds: apps.filter((app) => app?.granted === true).map((app) => app.appId).sort(),
|
|
deniedAppIds: apps.filter((app) => app?.status === "denied" || app?.denied === true).map((app) => app.appId).sort(),
|
|
notGrantedAppIds: apps.filter((app) => app?.status === "not-granted").map((app) => app.appId).sort(),
|
|
availableSkillIds: apps.filter((app) => app?.granted === true).map((app) => app.skillId).filter(Boolean).sort(),
|
|
};
|
|
}
|
|
|
|
function isRunAppGrantDenied(grant) {
|
|
if (!isPlainObject(grant)) return false;
|
|
const status = normalizeKey(grant.status || grant.state || grant.accessStatus || grant.access_status);
|
|
return grant.enabled === false ||
|
|
grant.allowed === false ||
|
|
grant.granted === false ||
|
|
grant.denied === true ||
|
|
["denied", "disabled", "blocked", "revoked", "not-granted", "not_granted", "forbidden"].includes(status);
|
|
}
|
|
|
|
function buildRunProfilePolicyPrompt({ context, diagnostics, assistantActions }) {
|
|
const lines = [
|
|
"AI Workspace dynamic run profile:",
|
|
`- source surface: ${diagnostics.sourceSurface}`,
|
|
`- mode: ${diagnostics.modeId}`,
|
|
`- access mode: ${diagnostics.accessMode}`,
|
|
`- context ready: ${diagnostics.contextReady ? "yes" : "no"}`,
|
|
`- entitlement source: ${diagnostics.entitlementAdapters?.source || "settings"}`,
|
|
`- enabled tool packs: ${diagnostics.enabledToolPacks.length ? diagnostics.enabledToolPacks.join(", ") : "none"}`,
|
|
`- app routes granted: ${diagnostics.grantedAppIds.length ? diagnostics.grantedAppIds.join(", ") : "none"}`,
|
|
`- MCP servers available in this run: ${diagnostics.mcpServerNames.length ? diagnostics.mcpServerNames.join(", ") : "none"}`,
|
|
`- assistant action ids available: ${Array.isArray(assistantActions?.actionIds) ? assistantActions.actionIds.join(", ") : "none"}`,
|
|
"- Interpret the user's natural-language request first; call assistant actions only after selecting a structured action id.",
|
|
"- Read assistant actions may execute after structured action selection. Privileged/write assistant actions require preview, explicit user confirmation, then execute.",
|
|
"- Ops card actions advertised in this run are valid assistant actions: use ops.card.list_recent for reading cards, ops.card.create for creating cards, and ops.card.add_comment for comments instead of refusing because direct Ops MCP tools are absent.",
|
|
"- Destructive assistant actions are forbidden; offer safe alternatives such as block/disable instead of delete.",
|
|
"- MCP tokens and headers are runtime secrets and must never be printed in public answers.",
|
|
];
|
|
if (diagnostics.deniedAppIds.length) {
|
|
lines.push(
|
|
`- App routes explicitly denied by access control: ${diagnostics.deniedAppIds.join(", ")}.`,
|
|
`- If the user requests one of those explicitly denied app capabilities, do not bypass it. Say exactly: ${ACCESS_DENIED_TEXT}`,
|
|
);
|
|
} else {
|
|
lines.push("- No app route is explicitly denied in this run; do not claim module access is restricted.");
|
|
}
|
|
const opsContext = isPlainObject(context?.contexts?.ops) ? context.contexts.ops : {};
|
|
if (opsContext.opsWorkspaceSlug || opsContext.opsProjectId) {
|
|
lines.push(
|
|
`- Ops target workspace: ${opsContext.opsWorkspaceSlug || opsContext.opsWorkspaceId || "unknown"}`,
|
|
`- Ops target project: ${opsContext.opsProjectId || "unknown"}`
|
|
);
|
|
}
|
|
const engineContext = isPlainObject(context?.contexts?.engine) ? context.contexts.engine : {};
|
|
if (engineContext.workflowId || engineContext.agentNodeId) {
|
|
lines.push(
|
|
`- Engine target workflow: ${engineContext.workflowId || "unknown"}`,
|
|
`- Engine target agent node: ${engineContext.agentNodeId || "unknown"}`
|
|
);
|
|
}
|
|
return lines.join("\n");
|
|
}
|
|
|
|
function runProfileHash(runProfile) {
|
|
const publicProfile = redactRunProfile(runProfile);
|
|
const stableProfile = {
|
|
...publicProfile,
|
|
runId: undefined,
|
|
createdAt: undefined,
|
|
diagnostics: {
|
|
...(isPlainObject(publicProfile?.diagnostics) ? publicProfile.diagnostics : {}),
|
|
profileHash: undefined,
|
|
},
|
|
};
|
|
return createHash("sha256").update(JSON.stringify(stableProfile)).digest("hex").slice(0, 16);
|
|
}
|
|
|
|
function redactRunProfile(runProfile) {
|
|
if (!isPlainObject(runProfile)) return null;
|
|
const toolProfile = isPlainObject(runProfile.toolProfile) ? runProfile.toolProfile : {};
|
|
return {
|
|
...runProfile,
|
|
targetContexts: redactForPublicDiagnostics(runProfile.targetContexts),
|
|
appGrants: redactForPublicDiagnostics(runProfile.appGrants),
|
|
toolProfile: {
|
|
...toolProfile,
|
|
assistantActions: redactAssistantActions(toolProfile.assistantActions),
|
|
mcpServers: Array.isArray(toolProfile.mcpServers)
|
|
? toolProfile.mcpServers.map(redactMcpServer)
|
|
: [],
|
|
},
|
|
};
|
|
}
|
|
|
|
function redactAssistantActions(value) {
|
|
if (!isPlainObject(value)) return value;
|
|
return {
|
|
...value,
|
|
...(value.gatewayToken ? { gatewayToken: "<redacted>" } : {}),
|
|
};
|
|
}
|
|
|
|
function redactMcpServer(server) {
|
|
if (!isPlainObject(server)) return {};
|
|
const headers = isPlainObject(server.httpHeaders) ? server.httpHeaders : {};
|
|
return {
|
|
...server,
|
|
httpHeaders: Object.fromEntries(Object.keys(headers).map((key) => [key, "<redacted>"])),
|
|
headers: undefined,
|
|
};
|
|
}
|
|
|
|
function redactForPublicDiagnostics(value, depth = 0) {
|
|
if (depth > 6) return "[max-depth]";
|
|
if (Array.isArray(value)) return value.map((item) => redactForPublicDiagnostics(item, depth + 1));
|
|
if (!isPlainObject(value)) return value;
|
|
const out = {};
|
|
for (const [key, item] of Object.entries(value)) {
|
|
if (/token|secret|password|authorization|cookie|api[_-]?key/i.test(key)) {
|
|
out[key] = "<redacted>";
|
|
continue;
|
|
}
|
|
out[key] = redactForPublicDiagnostics(item, depth + 1);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
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),
|
|
agentVersion: optionalString(health.agentVersion),
|
|
protocolVersion: optionalString(health.protocolVersion),
|
|
capabilities: Array.isArray(health.capabilities) ? uniqueStrings(health.capabilities).slice(0, 80) : [],
|
|
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 bridgeCompatibilityStatus(health, agent) {
|
|
const agentVersion = optionalString(health?.agentVersion || agent?.agentVersion);
|
|
const protocolVersion = optionalString(health?.protocolVersion || agent?.protocolVersion);
|
|
const capabilities = uniqueStrings([
|
|
...(Array.isArray(agent?.capabilities) ? agent.capabilities : []),
|
|
...(Array.isArray(health?.capabilities) ? health.capabilities : []),
|
|
]);
|
|
const requiredCapabilities = Array.isArray(config.requiredBridgeCapabilities)
|
|
? config.requiredBridgeCapabilities
|
|
: [];
|
|
const missingCapabilities = requiredCapabilities.filter((item) => !capabilities.includes(item));
|
|
const minAgentVersion = optionalString(config.minimumBridgeAgentVersion);
|
|
const requiredProtocolVersion = optionalString(config.requiredBridgeProtocolVersion);
|
|
|
|
if (requiredProtocolVersion && protocolVersion !== requiredProtocolVersion) {
|
|
return {
|
|
ok: false,
|
|
reason: "protocol_version_required",
|
|
agentVersion,
|
|
protocolVersion,
|
|
requiredProtocolVersion,
|
|
minimumBridgeAgentVersion: minAgentVersion,
|
|
missingCapabilities,
|
|
};
|
|
}
|
|
if (minAgentVersion && compareSemanticVersion(agentVersion, minAgentVersion) < 0) {
|
|
return {
|
|
ok: false,
|
|
reason: "agent_version_too_old",
|
|
agentVersion,
|
|
protocolVersion,
|
|
requiredProtocolVersion,
|
|
minimumBridgeAgentVersion: minAgentVersion,
|
|
missingCapabilities,
|
|
};
|
|
}
|
|
if (missingCapabilities.length) {
|
|
return {
|
|
ok: false,
|
|
reason: "capability_required",
|
|
agentVersion,
|
|
protocolVersion,
|
|
requiredProtocolVersion,
|
|
minimumBridgeAgentVersion: minAgentVersion,
|
|
missingCapabilities,
|
|
};
|
|
}
|
|
return {
|
|
ok: true,
|
|
agentVersion,
|
|
protocolVersion,
|
|
requiredProtocolVersion,
|
|
minimumBridgeAgentVersion: minAgentVersion,
|
|
missingCapabilities: [],
|
|
};
|
|
}
|
|
|
|
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, activeContext: {}, enabledToolPacks: [], metadata: {} };
|
|
}
|
|
|
|
async function listThreads(owner, { surface, kind = "all", limit, includeArchived = false }) {
|
|
const values = [owner.key, limit];
|
|
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 ${clauses.join("\n and ")}
|
|
order by updated_at desc, created_at desc
|
|
limit $2`,
|
|
values
|
|
);
|
|
return result.rows.map(toThread);
|
|
}
|
|
|
|
async function createThread(owner, command) {
|
|
const result = await pool.query(
|
|
`insert into ai_workspace_threads (
|
|
id,
|
|
owner_key,
|
|
owner_user_id,
|
|
owner_email,
|
|
title,
|
|
origin_surface,
|
|
active_context,
|
|
enabled_tool_packs,
|
|
linked_artifacts,
|
|
selected_executor_id,
|
|
lifecycle_state,
|
|
metadata
|
|
) values (
|
|
$1, $2, $3, $4, $5, $6, $7::jsonb, $8::text[], $9::jsonb, $10, $11, $12::jsonb
|
|
)
|
|
returning *`,
|
|
[
|
|
randomUUID(),
|
|
owner.key,
|
|
owner.userId,
|
|
owner.email,
|
|
command.title,
|
|
command.originSurface,
|
|
JSON.stringify(command.activeContext),
|
|
command.enabledToolPacks,
|
|
JSON.stringify(command.linkedArtifacts),
|
|
command.selectedExecutorId,
|
|
command.lifecycleState,
|
|
JSON.stringify(command.metadata),
|
|
]
|
|
);
|
|
return toThread(result.rows[0]);
|
|
}
|
|
|
|
async function getThread(owner, threadId) {
|
|
const result = await pool.query(
|
|
"select * from ai_workspace_threads where owner_key = $1 and id = $2",
|
|
[owner.key, threadId]
|
|
);
|
|
return result.rows[0] ? toThread(result.rows[0]) : null;
|
|
}
|
|
|
|
async function updateThread(owner, threadId, command) {
|
|
const fields = [];
|
|
const values = [owner.key, threadId];
|
|
const mappings = {
|
|
title: "title",
|
|
originSurface: "origin_surface",
|
|
activeContext: "active_context",
|
|
enabledToolPacks: "enabled_tool_packs",
|
|
linkedArtifacts: "linked_artifacts",
|
|
selectedExecutorId: "selected_executor_id",
|
|
lifecycleState: "lifecycle_state",
|
|
metadata: "metadata",
|
|
};
|
|
|
|
for (const [key, column] of Object.entries(mappings)) {
|
|
if (!Object.hasOwn(command, key)) continue;
|
|
const isJson = key === "activeContext" || key === "linkedArtifacts" || key === "metadata";
|
|
const isArray = key === "enabledToolPacks";
|
|
const value = isJson ? JSON.stringify(command[key]) : command[key];
|
|
values.push(value);
|
|
fields.push(`${column} = $${values.length}${isJson ? "::jsonb" : isArray ? "::text[]" : ""}`);
|
|
}
|
|
|
|
if (fields.length === 0) {
|
|
return getThread(owner, threadId);
|
|
}
|
|
|
|
values.push(new Date());
|
|
const result = await pool.query(
|
|
`update ai_workspace_threads
|
|
set ${fields.join(", ")}, updated_at = $${values.length}
|
|
where owner_key = $1 and id = $2
|
|
returning *`,
|
|
values
|
|
);
|
|
return result.rows[0] ? toThread(result.rows[0]) : null;
|
|
}
|
|
|
|
async function listThreadMessages(owner, threadId, { limit, before }) {
|
|
const values = [owner.key, threadId, limit];
|
|
const beforeSql = before ? "and m.created_at < $4::timestamptz" : "";
|
|
if (before) values.push(before);
|
|
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
|
|
${beforeSql}
|
|
order by m.created_at desc, m.sequence desc
|
|
limit $3`,
|
|
values
|
|
);
|
|
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 {
|
|
await client.query("begin");
|
|
const thread = await client.query(
|
|
"select id from ai_workspace_threads where owner_key = $1 and id = $2 for update",
|
|
[owner.key, threadId]
|
|
);
|
|
if (thread.rowCount === 0) {
|
|
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]
|
|
);
|
|
const sequence = Number(sequenceResult.rows[0]?.next_sequence || 1);
|
|
const inserted = await client.query(
|
|
`insert into ai_workspace_thread_messages (
|
|
id,
|
|
thread_id,
|
|
sequence,
|
|
role,
|
|
content,
|
|
payload
|
|
) values ($1, $2, $3, $4, $5, $6::jsonb)
|
|
returning *`,
|
|
[randomUUID(), threadId, sequence, command.role, command.content, JSON.stringify(command.payload)]
|
|
);
|
|
await client.query("update ai_workspace_threads set updated_at = now() where id = $1", [threadId]);
|
|
await client.query("commit");
|
|
return toThreadMessage(inserted.rows[0]);
|
|
} catch (error) {
|
|
await client.query("rollback");
|
|
throw error;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
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 (
|
|
id uuid primary key,
|
|
owner_key text not null,
|
|
owner_user_id text,
|
|
owner_email text,
|
|
name text not null,
|
|
type text not null default 'codex-remote',
|
|
connection_mode text not null default 'hub',
|
|
endpoint text,
|
|
workspace_path text,
|
|
agent_port integer,
|
|
pairing_code text,
|
|
model text,
|
|
account_label text,
|
|
capabilities jsonb not null default '[]'::jsonb,
|
|
status text not null default 'unknown',
|
|
status_detail text,
|
|
last_seen_at timestamptz,
|
|
metadata jsonb not null default '{}'::jsonb,
|
|
created_at timestamptz not null default now(),
|
|
updated_at timestamptz not null default now(),
|
|
constraint ai_workspace_executors_owner_check
|
|
check (owner_user_id is not null or owner_email is not null),
|
|
constraint ai_workspace_executors_type_check
|
|
check (type in ('codex-remote', 'ndc-agent-core')),
|
|
constraint ai_workspace_executors_connection_mode_check
|
|
check (connection_mode in ('hub', 'direct')),
|
|
constraint ai_workspace_executors_status_check
|
|
check (status in ('unknown', 'online', 'offline', 'checking', 'error'))
|
|
)
|
|
`);
|
|
|
|
await pool.query(`
|
|
create table if not exists ai_workspace_owner_settings (
|
|
owner_key text primary key,
|
|
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(),
|
|
constraint ai_workspace_owner_settings_owner_check
|
|
check (owner_user_id is not null or owner_email is not null)
|
|
)
|
|
`);
|
|
|
|
await pool.query(`
|
|
create table if not exists ai_workspace_threads (
|
|
id uuid primary key,
|
|
owner_key text not null,
|
|
owner_user_id text,
|
|
owner_email text,
|
|
title text not null,
|
|
origin_surface text not null default 'global',
|
|
active_context jsonb not null default '{}'::jsonb,
|
|
enabled_tool_packs text[] not null default '{}'::text[],
|
|
linked_artifacts jsonb not null default '[]'::jsonb,
|
|
selected_executor_id uuid references ai_workspace_executors(id) on delete set null,
|
|
lifecycle_state text not null default 'active',
|
|
metadata jsonb not null default '{}'::jsonb,
|
|
created_at timestamptz not null default now(),
|
|
updated_at timestamptz not null default now(),
|
|
constraint ai_workspace_threads_owner_check
|
|
check (owner_user_id is not null or owner_email is not null),
|
|
constraint ai_workspace_threads_surface_check
|
|
check (origin_surface in ('engine', 'ops', 'global')),
|
|
constraint ai_workspace_threads_lifecycle_check
|
|
check (lifecycle_state in ('active', 'archived'))
|
|
)
|
|
`);
|
|
|
|
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,
|
|
thread_id uuid not null references ai_workspace_threads(id) on delete cascade,
|
|
sequence integer not null,
|
|
role text not null,
|
|
content text not null default '',
|
|
payload jsonb not null default '{}'::jsonb,
|
|
created_at timestamptz not null default now(),
|
|
constraint ai_workspace_thread_messages_role_check
|
|
check (role in ('user', 'assistant', 'system', 'tool')),
|
|
unique (thread_id, sequence)
|
|
)
|
|
`);
|
|
|
|
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 table if not exists ai_workspace_setup_codes (
|
|
id uuid primary key,
|
|
owner_key text not null,
|
|
owner_user_id text,
|
|
owner_email text,
|
|
executor_id uuid not null references ai_workspace_executors(id) on delete cascade,
|
|
code_hash text not null unique,
|
|
code_suffix text not null,
|
|
status text not null default 'active',
|
|
expires_at timestamptz not null,
|
|
used_at timestamptz,
|
|
metadata jsonb not null default '{}'::jsonb,
|
|
created_at timestamptz not null default now(),
|
|
constraint ai_workspace_setup_codes_owner_check
|
|
check (owner_user_id is not null or owner_email is not null),
|
|
constraint ai_workspace_setup_codes_status_check
|
|
check (status in ('active', 'used', 'expired', 'revoked'))
|
|
)
|
|
`);
|
|
|
|
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");
|
|
await pool.query("create index if not exists ai_workspace_setup_codes_owner_executor_idx on ai_workspace_setup_codes(owner_key, executor_id, created_at desc)");
|
|
await pool.query("create index if not exists ai_workspace_setup_codes_expires_idx on ai_workspace_setup_codes(status, expires_at)");
|
|
}
|
|
|
|
function sanitizeExecutorCommand(payload, { partial }) {
|
|
const source = isPlainObject(payload) ? payload : {};
|
|
const command = {};
|
|
|
|
if (Object.hasOwn(source, "id")) {
|
|
command.id = sanitizeUuid(source.id, "id");
|
|
}
|
|
if (!partial || Object.hasOwn(source, "name")) {
|
|
command.name = requireNonEmptyString(source.name, "name");
|
|
}
|
|
if (!partial || Object.hasOwn(source, "type")) {
|
|
command.type = sanitizeEnum(source.type || "codex-remote", SUPPORTED_EXECUTOR_TYPES, "unsupported_executor_type");
|
|
}
|
|
if (!partial || Object.hasOwn(source, "connectionMode") || Object.hasOwn(source, "connection_mode")) {
|
|
command.connectionMode = sanitizeEnum(source.connectionMode || source.connection_mode || "hub", SUPPORTED_CONNECTION_MODES, "unsupported_connection_mode");
|
|
}
|
|
copyOptionalString(command, "endpoint", source.endpoint);
|
|
copyOptionalString(command, "workspacePath", source.workspacePath || source.workspace_path);
|
|
copyOptionalInteger(command, "agentPort", source.agentPort || source.agent_port, 1, 65535);
|
|
copyOptionalString(command, "pairingCode", source.pairingCode || source.pairing_code);
|
|
copyOptionalString(command, "model", source.model);
|
|
copyOptionalString(command, "accountLabel", source.accountLabel || source.account_label);
|
|
if (!partial || Object.hasOwn(source, "capabilities")) {
|
|
command.capabilities = sanitizeCapabilities(source.capabilities);
|
|
}
|
|
if (!partial || Object.hasOwn(source, "status")) {
|
|
command.status = sanitizeEnum(source.status || "unknown", SUPPORTED_EXECUTOR_STATUSES, "unsupported_executor_status");
|
|
}
|
|
copyOptionalString(command, "statusDetail", source.statusDetail || source.status_detail);
|
|
if (Object.hasOwn(source, "lastSeenAt") || Object.hasOwn(source, "last_seen_at")) {
|
|
command.lastSeenAt = sanitizeOptionalIso(source.lastSeenAt || source.last_seen_at, "lastSeenAt");
|
|
}
|
|
if (!partial || Object.hasOwn(source, "metadata")) {
|
|
command.metadata = isPlainObject(source.metadata) ? source.metadata : {};
|
|
}
|
|
|
|
return command;
|
|
}
|
|
|
|
function sanitizeThreadCommand(payload, { partial }) {
|
|
const source = isPlainObject(payload) ? payload : {};
|
|
const command = {};
|
|
|
|
if (!partial || Object.hasOwn(source, "title")) {
|
|
command.title = requireNonEmptyString(source.title || "AI Workspace Thread", "title");
|
|
}
|
|
if (!partial || Object.hasOwn(source, "originSurface") || Object.hasOwn(source, "origin_surface")) {
|
|
command.originSurface = sanitizeSurface(source.originSurface || source.origin_surface || "global");
|
|
}
|
|
if (!partial || Object.hasOwn(source, "activeContext") || Object.hasOwn(source, "active_context")) {
|
|
const value = source.activeContext || source.active_context;
|
|
command.activeContext = isPlainObject(value) ? value : {};
|
|
}
|
|
if (!partial || Object.hasOwn(source, "enabledToolPacks") || Object.hasOwn(source, "enabled_tool_packs")) {
|
|
command.enabledToolPacks = sanitizeToolPacks(source.enabledToolPacks || source.enabled_tool_packs || []);
|
|
}
|
|
if (!partial || Object.hasOwn(source, "linkedArtifacts") || Object.hasOwn(source, "linked_artifacts")) {
|
|
const value = source.linkedArtifacts || source.linked_artifacts;
|
|
command.linkedArtifacts = Array.isArray(value) ? value.filter(isPlainObject) : [];
|
|
}
|
|
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 (!partial || Object.hasOwn(source, "lifecycleState") || Object.hasOwn(source, "lifecycle_state")) {
|
|
command.lifecycleState = sanitizeEnum(source.lifecycleState || source.lifecycle_state || "active", SUPPORTED_THREAD_STATES, "unsupported_thread_state");
|
|
}
|
|
if (!partial || Object.hasOwn(source, "metadata")) {
|
|
command.metadata = isPlainObject(source.metadata) ? source.metadata : {};
|
|
}
|
|
|
|
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 sanitizeRunProfileCommand(payload) {
|
|
const source = isPlainObject(payload) ? payload : {};
|
|
const context = isPlainObject(source.context) ? source.context : {};
|
|
const client = isPlainObject(source.client) ? source.client : {};
|
|
const selectedExecutorId = source.selectedExecutorId || source.selected_executor_id || null;
|
|
const toolPacks = mergeToolPacks(
|
|
source.enabledToolPacks,
|
|
source.enabled_tool_packs,
|
|
context.enabledToolPacks,
|
|
);
|
|
return {
|
|
selectedExecutorId: selectedExecutorId ? sanitizeUuid(selectedExecutorId, "selectedExecutorId") : null,
|
|
threadId: optionalString(source.threadId || source.thread_id),
|
|
threadTitle: optionalString(source.threadTitle || source.thread_title),
|
|
workspacePath: optionalString(source.workspacePath || source.workspace_path),
|
|
originSurface: sanitizeSurface(source.originSurface || source.origin_surface || context.sourceSurface || context.surface || client.surface || "engine"),
|
|
context,
|
|
client,
|
|
enabledToolPacks: toolPacks,
|
|
};
|
|
}
|
|
|
|
function sanitizeBridgeDispatchCommand(payload) {
|
|
const source = isPlainObject(payload) ? payload : {};
|
|
const command = sanitizeRunProfileCommand(source);
|
|
return {
|
|
...command,
|
|
message: optionalString(source.message || source.content),
|
|
displayMessage: optionalString(source.displayMessage || source.display_message),
|
|
publicUserMessage: optionalString(source.publicUserMessage || source.public_user_message),
|
|
resume: source.resume === true,
|
|
history: Array.isArray(source.history)
|
|
? source.history.slice(-40).map((item) => ({
|
|
role: optionalString(item?.role),
|
|
text: optionalString(item?.text || item?.content),
|
|
})).filter((item) => item.role && item.text)
|
|
: [],
|
|
};
|
|
}
|
|
|
|
function sanitizeThreadKind(value) {
|
|
const text = normalizeKey(value || "all");
|
|
if (text === "shared" || text === "remote" || text === "all") return text;
|
|
return "all";
|
|
}
|
|
|
|
function sanitizeBridgeStopCommand(payload) {
|
|
const source = isPlainObject(payload) ? payload : {};
|
|
return {
|
|
requestId: optionalString(source.requestId || source.request_id),
|
|
threadId: optionalString(source.threadId || source.thread_id),
|
|
reason: optionalString(source.reason) || "user_stop",
|
|
client: isPlainObject(source.client) ? source.client : {},
|
|
};
|
|
}
|
|
|
|
function sanitizeMessageCommand(payload) {
|
|
const source = isPlainObject(payload) ? payload : {};
|
|
return {
|
|
role: sanitizeEnum(source.role || "user", SUPPORTED_MESSAGE_ROLES, "unsupported_message_role"),
|
|
content: optionalString(source.content) || "",
|
|
payload: isPlainObject(source.payload) ? source.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);
|
|
const role = normalizeKey(req.headers["x-nodedc-user-role"] || req.query.role);
|
|
const groups = uniqueStrings(
|
|
String(req.headers["x-nodedc-user-groups"] || req.query.groups || "")
|
|
.split(/[\n,;]+/)
|
|
.map((item) => item.trim())
|
|
.filter(Boolean)
|
|
);
|
|
if (!userId && !email) {
|
|
throw badRequest("ai_workspace_owner_required");
|
|
}
|
|
return {
|
|
key: email ? `email:${email}` : `user:${userId}`,
|
|
userId,
|
|
email,
|
|
role: role || "",
|
|
groups,
|
|
};
|
|
}
|
|
|
|
function sanitizeAssistantActionCommand(body, owner) {
|
|
const source = isPlainObject(body) ? body : {};
|
|
const phase = normalizeKey(source.phase || source.mode || "preview");
|
|
if (!["preview", "dry-run", "execute"].includes(phase)) {
|
|
throw badRequest("assistant_action_phase_invalid");
|
|
}
|
|
|
|
const sourceInput = isPlainObject(source.input) ? source.input : source;
|
|
const input = { ...sourceInput };
|
|
delete input.phase;
|
|
delete input.mode;
|
|
delete input.input;
|
|
|
|
const ownerContext = assistantOwnerContext(owner, sourceInput);
|
|
input.actorUserId = owner.userId || null;
|
|
input.actorEmail = owner.email || null;
|
|
input.actorSubject = owner.key;
|
|
input.groups = owner.groups;
|
|
input.launcherGlobalRole = ownerContext.launcherGlobalRole;
|
|
input.membershipRole = ownerContext.membershipRole;
|
|
input.membershipStatus = ownerContext.membershipStatus;
|
|
input.launcherUserStatus = ownerContext.launcherUserStatus;
|
|
input.assistantRole = ownerContext.assistantRole;
|
|
|
|
const confirmationToken = optionalString(source.confirmationToken || sourceInput.confirmationToken || source.confirmation?.token);
|
|
if (confirmationToken) input.confirmationToken = confirmationToken;
|
|
|
|
return { phase, input };
|
|
}
|
|
|
|
async function executeAssistantActionRequest(body, owner) {
|
|
const command = sanitizeAssistantActionCommand(body, owner);
|
|
let opsGatewayToken = "";
|
|
if (command.phase === "execute") {
|
|
const ownerSettings = await getOwnerSettings(owner);
|
|
const grantResolution = await resolveRunAppGrants({
|
|
owner,
|
|
context: isPlainObject(ownerSettings.activeContext) ? ownerSettings.activeContext : {},
|
|
ownerSettings,
|
|
});
|
|
opsGatewayToken = opsGatewayTokenFromAppGrants(ownerSettings, grantResolution.appGrants, config.opsGatewayBaseUrl);
|
|
}
|
|
const action = await handleAssistantCallerRequest(command, {
|
|
baseUrl: config.ontologyLauncherBaseUrl,
|
|
launcherInternalToken: config.launcherInternalAccessToken,
|
|
opsGatewayBaseUrl: config.opsGatewayBaseUrl,
|
|
opsGatewayToken,
|
|
opsEntitlementUrl: config.opsEntitlementUrl,
|
|
opsEntitlementAuthorization: config.opsEntitlementAuthorization,
|
|
defaultOpsWorkspaceSlug: config.defaultOpsWorkspaceSlug,
|
|
defaultOpsProjectId: config.defaultOpsProjectId,
|
|
});
|
|
return { ok: true, owner: publicOwner(owner), action: publicAssistantCallerResult(action) };
|
|
}
|
|
|
|
function urlOrigin(value) {
|
|
try {
|
|
return new URL(value).origin;
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function opsGatewayTokenFromAppGrants(ownerSettings, appGrants, opsGatewayBaseUrl) {
|
|
const servers = runProfileMcpServersFromAppGrants(ownerSettings, appGrants);
|
|
const targetOrigin = urlOrigin(opsGatewayBaseUrl);
|
|
const preferred = servers.find((server) => (
|
|
server.appId === "ops" &&
|
|
(!targetOrigin || urlOrigin(server.url) === targetOrigin)
|
|
)) || servers.find((server) => server.appId === "ops");
|
|
return bearerTokenFromHeaders(preferred?.httpHeaders);
|
|
}
|
|
|
|
function assistantOwnerContext(owner, sourceInput = {}) {
|
|
const groups = new Set(owner.groups || []);
|
|
const ownerRole = normalizeKey(owner.role);
|
|
const isRoot = ownerRole === "root-admin" || ownerRole === "root_admin" || groups.has("nodedc:superadmin") || groups.has("nodedc:launcher:admin");
|
|
const membershipRole = launcherMembershipRoleFromOwner(ownerRole, sourceInput);
|
|
const assistantRole = assistantRoleFromOwner({ isRoot, sourceInput });
|
|
|
|
return {
|
|
assistantRole,
|
|
launcherGlobalRole: isRoot ? "root_admin" : ownerRole.replace(/-/g, "_"),
|
|
membershipRole,
|
|
membershipStatus: optionalString(sourceInput.membershipStatus) || "active",
|
|
launcherUserStatus: optionalString(sourceInput.launcherUserStatus || sourceInput.globalStatus) || "active",
|
|
};
|
|
}
|
|
|
|
function assistantRoleFromOwner({ isRoot, sourceInput }) {
|
|
if (isRoot) return "admin";
|
|
const value = normalizeKey(sourceInput.assistantRole || sourceInput.coreAssistantRole);
|
|
if (value === "assistant-admin" || value === "admin") return "admin";
|
|
if (value === "assistant-blocked" || value === "blocked") return "blocked";
|
|
return "member";
|
|
}
|
|
|
|
function launcherMembershipRoleFromOwner(ownerRole, sourceInput = {}) {
|
|
const role = ownerRole.replace(/-/g, "_");
|
|
if (["client_owner", "client_admin", "member"].includes(role)) return role;
|
|
const sourceRole = normalizeKey(sourceInput.membershipRole).replace(/-/g, "_");
|
|
if (["client_owner", "client_admin", "member"].includes(sourceRole)) return sourceRole;
|
|
return "member";
|
|
}
|
|
|
|
function publicOwner(owner) {
|
|
return {
|
|
ownerKey: owner.key,
|
|
userId: owner.userId,
|
|
email: owner.email,
|
|
role: owner.role || "",
|
|
groups: Array.isArray(owner.groups) ? owner.groups : [],
|
|
};
|
|
}
|
|
|
|
function publicAssistantCallerResult(result) {
|
|
if (!isPlainObject(result)) return result;
|
|
const { raw: _raw, ...publicResult } = result;
|
|
return publicResult;
|
|
}
|
|
|
|
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,
|
|
ownerKey: row.owner_key,
|
|
ownerUserId: row.owner_user_id,
|
|
ownerEmail: row.owner_email,
|
|
name: row.name,
|
|
type: row.type,
|
|
connectionMode: row.connection_mode,
|
|
endpoint: row.endpoint,
|
|
workspacePath: row.workspace_path,
|
|
agentPort: row.agent_port,
|
|
pairingCode: row.pairing_code,
|
|
model: row.model,
|
|
accountLabel: row.account_label,
|
|
capabilities: sanitizeCapabilities(row.capabilities),
|
|
status: row.status,
|
|
statusDetail: row.status_detail,
|
|
lastSeenAt: toIso(row.last_seen_at),
|
|
metadata: row.metadata || {},
|
|
createdAt: toIso(row.created_at),
|
|
updatedAt: toIso(row.updated_at),
|
|
};
|
|
}
|
|
|
|
function toOwnerSettings(row) {
|
|
return {
|
|
ownerKey: row.owner_key,
|
|
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),
|
|
};
|
|
}
|
|
|
|
function toThread(row) {
|
|
return {
|
|
id: row.id,
|
|
ownerKey: row.owner_key,
|
|
ownerUserId: row.owner_user_id,
|
|
ownerEmail: row.owner_email,
|
|
title: row.title,
|
|
originSurface: row.origin_surface,
|
|
activeContext: row.active_context || {},
|
|
enabledToolPacks: row.enabled_tool_packs || [],
|
|
linkedArtifacts: row.linked_artifacts || [],
|
|
selectedExecutorId: row.selected_executor_id,
|
|
lifecycleState: row.lifecycle_state,
|
|
metadata: row.metadata || {},
|
|
createdAt: toIso(row.created_at),
|
|
updatedAt: toIso(row.updated_at),
|
|
};
|
|
}
|
|
|
|
function toThreadMessage(row) {
|
|
return {
|
|
id: row.id,
|
|
threadId: row.thread_id,
|
|
sequence: row.sequence,
|
|
role: row.role,
|
|
content: row.content,
|
|
payload: row.payload || {},
|
|
createdAt: toIso(row.created_at),
|
|
};
|
|
}
|
|
|
|
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.internalAccessTokens.length) {
|
|
res.status(503).json({ ok: false, error: "ai_workspace_assistant_token_not_configured" });
|
|
return;
|
|
}
|
|
|
|
const authorization = typeof req.headers.authorization === "string" ? req.headers.authorization : "";
|
|
const bearerToken = authorization.match(/^Bearer\s+(.+)$/i)?.[1] || "";
|
|
const headerToken = typeof req.headers["x-nodedc-internal-token"] === "string" ? req.headers["x-nodedc-internal-token"] : "";
|
|
const requestToken = bearerToken || headerToken;
|
|
|
|
if (!config.internalAccessTokens.some((token) => safeTokenEquals(requestToken, token))) {
|
|
res.status(401).json({ ok: false, error: "ai_workspace_assistant_unauthorized" });
|
|
return;
|
|
}
|
|
|
|
next();
|
|
}
|
|
|
|
function safeTokenEquals(actual, expected) {
|
|
if (!actual || !expected) return false;
|
|
const actualBuffer = Buffer.from(actual);
|
|
const expectedBuffer = Buffer.from(expected);
|
|
if (actualBuffer.length !== expectedBuffer.length) return false;
|
|
return timingSafeEqual(actualBuffer, expectedBuffer);
|
|
}
|
|
|
|
function sanitizeSurface(value) {
|
|
return sanitizeEnum(value || "global", SUPPORTED_SURFACES, "unsupported_surface");
|
|
}
|
|
|
|
function sanitizeToolPacks(value) {
|
|
if (!Array.isArray(value)) return [];
|
|
return Array.from(new Set(value.map(normalizeKey).filter(Boolean))).filter((toolPack) => {
|
|
if (!SUPPORTED_TOOL_PACKS.has(toolPack)) throw badRequest("unsupported_tool_pack");
|
|
return true;
|
|
});
|
|
}
|
|
|
|
function sanitizeCapabilities(value) {
|
|
const items = Array.isArray(value)
|
|
? value
|
|
: Array.isArray(value?.items)
|
|
? value.items
|
|
: isPlainObject(value)
|
|
? Object.entries(value).filter(([, enabled]) => enabled === true).map(([key]) => key)
|
|
: [];
|
|
return Array.from(new Set(items.map(normalizeKey).filter(Boolean)));
|
|
}
|
|
|
|
function sanitizeEnum(value, supported, errorMessage) {
|
|
const key = normalizeKey(value);
|
|
if (!supported.has(key)) {
|
|
throw badRequest(errorMessage);
|
|
}
|
|
return key;
|
|
}
|
|
|
|
function sanitizeLimit(value, fallback, max) {
|
|
const limit = Number(value || fallback);
|
|
if (!Number.isFinite(limit)) return fallback;
|
|
return Math.min(Math.max(Math.trunc(limit), 1), max);
|
|
}
|
|
|
|
function normalizePairingCode(value) {
|
|
return String(value || "").replace(/[^A-Za-z0-9]/g, "").toUpperCase().slice(0, 32);
|
|
}
|
|
|
|
function cleanPairingCode(value) {
|
|
const normalized = normalizePairingCode(value);
|
|
if (!normalized) return "";
|
|
return normalized.replace(/(.{4})(?=.)/g, "$1-");
|
|
}
|
|
|
|
function makePairingCode() {
|
|
return cleanPairingCode(randomUUID().replace(/-/g, "").slice(0, 12));
|
|
}
|
|
|
|
function installerPortFromQuery(raw) {
|
|
const value = optionalString(raw);
|
|
if (!value) return 8787;
|
|
if (!/^\d{1,5}$/.test(value)) return null;
|
|
const port = Number(value);
|
|
return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : null;
|
|
}
|
|
|
|
function psSingle(value) {
|
|
return `'${String(value || "").replace(/'/g, "''")}'`;
|
|
}
|
|
|
|
function sanitizeUuid(value, name) {
|
|
const text = optionalString(value);
|
|
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(text || "")) {
|
|
throw badRequest(`${name}_invalid`);
|
|
}
|
|
return text;
|
|
}
|
|
|
|
function sanitizeOptionalIso(value, name) {
|
|
const text = optionalString(value);
|
|
if (!text) return null;
|
|
const date = new Date(text);
|
|
if (Number.isNaN(date.getTime())) {
|
|
throw badRequest(`${name}_invalid`);
|
|
}
|
|
return date.toISOString();
|
|
}
|
|
|
|
function copyOptionalString(target, key, value) {
|
|
if (value === undefined) return;
|
|
target[key] = key === "pairingCode" ? cleanPairingCode(value) : optionalString(value);
|
|
}
|
|
|
|
function copyOptionalInteger(target, key, value, min, max) {
|
|
if (value === undefined) return;
|
|
if (value === null || value === "") {
|
|
target[key] = null;
|
|
return;
|
|
}
|
|
const number = Number(value);
|
|
if (!Number.isInteger(number) || number < min || number > max) {
|
|
throw badRequest(`${key}_invalid`);
|
|
}
|
|
target[key] = number;
|
|
}
|
|
|
|
function requireNonEmptyString(value, name) {
|
|
const text = optionalString(value);
|
|
if (!text) throw badRequest(`${name}_required`);
|
|
return text;
|
|
}
|
|
|
|
function optionalString(value) {
|
|
if (typeof value !== "string") return null;
|
|
const text = value.trim();
|
|
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 isFalsy(value) {
|
|
const text = optionalString(value)?.toLowerCase() || "";
|
|
return text === "0" || text === "false" || text === "no" || text === "off";
|
|
}
|
|
|
|
function normalizeEmail(value) {
|
|
const text = optionalString(value);
|
|
return text ? text.toLowerCase() : null;
|
|
}
|
|
|
|
function normalizeKey(value) {
|
|
return String(value || "").trim().toLowerCase().replace(/_/g, "-");
|
|
}
|
|
|
|
function compareSemanticVersion(actual, minimum) {
|
|
const actualText = optionalString(actual);
|
|
const minimumText = optionalString(minimum);
|
|
if (!minimumText) return 0;
|
|
if (!actualText) return -1;
|
|
const parse = (value) => String(value || "")
|
|
.split(/[.-]/)
|
|
.slice(0, 4)
|
|
.map((part) => {
|
|
const match = part.match(/^\d+/);
|
|
return match ? Number(match[0]) : 0;
|
|
});
|
|
const left = parse(actualText);
|
|
const right = parse(minimumText);
|
|
const length = Math.max(left.length, right.length, 3);
|
|
for (let i = 0; i < length; i += 1) {
|
|
const delta = Number(left[i] || 0) - Number(right[i] || 0);
|
|
if (delta !== 0) return delta > 0 ? 1 : -1;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function isPlainObject(value) {
|
|
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
}
|
|
|
|
function toIso(value) {
|
|
if (!value) return null;
|
|
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
|
|
}
|
|
|
|
function badRequest(message) {
|
|
return httpError(message, 400);
|
|
}
|
|
|
|
function httpError(message, status = 500) {
|
|
const error = new Error(message);
|
|
error.status = status;
|
|
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 parseEntitlementAdapters() {
|
|
const adapters = [];
|
|
collectEntitlementAdapters(adapters, parseJsonEnv(
|
|
process.env.AI_WORKSPACE_ENTITLEMENT_ADAPTERS_JSON ||
|
|
process.env.NDC_AI_WORKSPACE_ENTITLEMENT_ADAPTERS_JSON ||
|
|
""
|
|
));
|
|
collectEntitlementAdapter(adapters, "ops", {
|
|
url: process.env.AI_WORKSPACE_OPS_ENTITLEMENT_URL || process.env.NDC_AI_WORKSPACE_OPS_ENTITLEMENT_URL,
|
|
token: process.env.AI_WORKSPACE_OPS_ENTITLEMENT_TOKEN || process.env.NDC_AI_WORKSPACE_OPS_ENTITLEMENT_TOKEN,
|
|
required: process.env.AI_WORKSPACE_OPS_ENTITLEMENT_REQUIRED || process.env.NDC_AI_WORKSPACE_OPS_ENTITLEMENT_REQUIRED,
|
|
});
|
|
collectEntitlementAdapter(adapters, "engine", {
|
|
url: process.env.AI_WORKSPACE_ENGINE_ENTITLEMENT_URL || process.env.NDC_AI_WORKSPACE_ENGINE_ENTITLEMENT_URL,
|
|
token: process.env.AI_WORKSPACE_ENGINE_ENTITLEMENT_TOKEN || process.env.NDC_AI_WORKSPACE_ENGINE_ENTITLEMENT_TOKEN,
|
|
required: process.env.AI_WORKSPACE_ENGINE_ENTITLEMENT_REQUIRED || process.env.NDC_AI_WORKSPACE_ENGINE_ENTITLEMENT_REQUIRED,
|
|
});
|
|
collectEntitlementAdapter(adapters, "launcher", {
|
|
url:
|
|
process.env.AI_WORKSPACE_LAUNCHER_ENTITLEMENT_URL ||
|
|
process.env.NDC_AI_WORKSPACE_LAUNCHER_ENTITLEMENT_URL ||
|
|
process.env.AI_WORKSPACE_HUB_ENTITLEMENT_URL ||
|
|
process.env.NDC_AI_WORKSPACE_HUB_ENTITLEMENT_URL,
|
|
token:
|
|
process.env.AI_WORKSPACE_LAUNCHER_ENTITLEMENT_TOKEN ||
|
|
process.env.NDC_AI_WORKSPACE_LAUNCHER_ENTITLEMENT_TOKEN ||
|
|
process.env.AI_WORKSPACE_HUB_ENTITLEMENT_TOKEN ||
|
|
process.env.NDC_AI_WORKSPACE_HUB_ENTITLEMENT_TOKEN,
|
|
required:
|
|
process.env.AI_WORKSPACE_LAUNCHER_ENTITLEMENT_REQUIRED ||
|
|
process.env.NDC_AI_WORKSPACE_LAUNCHER_ENTITLEMENT_REQUIRED ||
|
|
process.env.AI_WORKSPACE_HUB_ENTITLEMENT_REQUIRED ||
|
|
process.env.NDC_AI_WORKSPACE_HUB_ENTITLEMENT_REQUIRED,
|
|
});
|
|
|
|
const byAppId = new Map();
|
|
for (const adapter of adapters) {
|
|
byAppId.set(adapter.appId, adapter);
|
|
}
|
|
const order = new Map([
|
|
["ops", 10],
|
|
["engine", 20],
|
|
["launcher", 90],
|
|
]);
|
|
return Array.from(byAppId.values())
|
|
.sort((left, right) => (order.get(left.appId) || 50) - (order.get(right.appId) || 50));
|
|
}
|
|
|
|
function parseJsonEnv(value) {
|
|
const text = optionalString(value);
|
|
if (!text) return null;
|
|
try {
|
|
return JSON.parse(text);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function collectEntitlementAdapters(target, value) {
|
|
if (Array.isArray(value)) {
|
|
for (const item of value) collectEntitlementAdapter(target, "", item);
|
|
return;
|
|
}
|
|
if (!isPlainObject(value)) return;
|
|
for (const [appId, adapter] of Object.entries(value)) {
|
|
collectEntitlementAdapter(target, appId, adapter);
|
|
}
|
|
}
|
|
|
|
function collectEntitlementAdapter(target, defaultAppId, value) {
|
|
const adapter = sanitizeEntitlementAdapter(value, defaultAppId);
|
|
if (adapter) target.push(adapter);
|
|
}
|
|
|
|
function sanitizeEntitlementAdapter(value, defaultAppId = "") {
|
|
if (!isPlainObject(value)) return null;
|
|
const appId = canonicalRunAppId(value.appId || value.app_id || defaultAppId);
|
|
const url = cleanHttpEndpoint(value.url || value.endpoint);
|
|
if (!appId || !url) return null;
|
|
const tokenEnv = optionalString(value.tokenEnv || value.token_env);
|
|
const rawToken = optionalString(value.token || value.bearerToken || value.bearer_token)
|
|
|| (tokenEnv ? optionalString(process.env[tokenEnv]) : null)
|
|
|| optionalString(process.env.NODEDC_INTERNAL_ACCESS_TOKEN)
|
|
|| optionalString(process.env.NODEDC_PLATFORM_SERVICE_TOKEN)
|
|
|| "";
|
|
const authorization = optionalString(value.authorization || value.Authorization)
|
|
|| (rawToken
|
|
? rawToken.match(/^Bearer\s+/i) ? rawToken : `Bearer ${rawToken}`
|
|
: "");
|
|
return {
|
|
id: optionalString(value.id) || appId,
|
|
appId,
|
|
title: optionalString(value.title || value.appTitle || value.app_title),
|
|
url,
|
|
authorization,
|
|
headers: sanitizeAdapterHeaders(value.headers),
|
|
required: value.required === true || isTruthy(value.required),
|
|
timeoutMs: sanitizeInteger(value.timeoutMs || value.timeout_ms, 5000, 500, 30000),
|
|
};
|
|
}
|
|
|
|
function sanitizeAdapterHeaders(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 || /[\r\n\0]/.test(headerName) || /[\r\n\0]/.test(headerValue)) continue;
|
|
if (/^authorization$/i.test(headerName)) continue;
|
|
headers[headerName] = headerValue;
|
|
}
|
|
return headers;
|
|
}
|
|
|
|
function cleanHttpEndpoint(value) {
|
|
const text = optionalString(value);
|
|
if (!text) return "";
|
|
try {
|
|
const url = new URL(text);
|
|
if (url.protocol !== "http:" && url.protocol !== "https:") return "";
|
|
url.username = "";
|
|
url.password = "";
|
|
return url.toString().replace(/\/+$/, "");
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function readConfig() {
|
|
const nodedcEnv = normalizeKey(process.env.NODEDC_ENV || process.env.NDC_ENV || process.env.NODE_ENV || "");
|
|
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 setupGatewayUrl =
|
|
optionalString(process.env.AI_WORKSPACE_SETUP_GATEWAY_URL) ||
|
|
optionalString(process.env.NDC_AI_WORKSPACE_SETUP_GATEWAY_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) || "";
|
|
const defaultOntologyLauncherBaseUrl =
|
|
process.env.NODE_ENV === 'production' ? 'http://launcher:5173' : 'http://launcher.local.nodedc'
|
|
const ontologyLauncherBaseUrl =
|
|
optionalString(process.env.NDC_ONTOLOGY_LAUNCHER_BASE_URL) ||
|
|
optionalString(process.env.NDC_LAUNCHER_BASE_URL) ||
|
|
optionalString(process.env.NDC_LAUNCHER_INTERNAL_URL) ||
|
|
optionalString(process.env.LAUNCHER_INTERNAL_URL) ||
|
|
optionalString(process.env.LAUNCHER_BASE_URL) ||
|
|
defaultOntologyLauncherBaseUrl;
|
|
const launcherInternalAccessToken =
|
|
optionalString(process.env.NDC_LAUNCHER_INTERNAL_ACCESS_TOKEN) ||
|
|
optionalString(process.env.LAUNCHER_INTERNAL_TOKEN) ||
|
|
sharedInternalAccessToken;
|
|
const assistantActionRelayId =
|
|
optionalString(process.env.AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ID) ||
|
|
optionalString(process.env.NDC_AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ID) ||
|
|
(nodedcEnv === "local" || nodedcEnv === "tunnel-local-e2e" ? "local-dev" : "");
|
|
const assistantActionRelayEnabledRaw = optionalString(
|
|
process.env.AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ENABLED ||
|
|
process.env.NDC_AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ENABLED
|
|
);
|
|
const assistantActionRelayDefaultEnabled = Boolean(
|
|
assistantActionRelayId &&
|
|
(nodedcEnv === "local" || nodedcEnv === "tunnel-local-e2e") &&
|
|
isDeployedPublicHubUrl(hubInternalHttpUrl)
|
|
);
|
|
const assistantActionRelayEnabled = assistantActionRelayEnabledRaw
|
|
? isTruthy(assistantActionRelayEnabledRaw) && !isFalsy(assistantActionRelayEnabledRaw)
|
|
: assistantActionRelayDefaultEnabled;
|
|
const entitlementAdapters = parseEntitlementAdapters();
|
|
const opsEntitlementAdapter = entitlementAdapters.find((adapter) => adapter.appId === "ops") || null;
|
|
const opsGatewayBaseUrl =
|
|
optionalString(process.env.AI_WORKSPACE_OPS_GATEWAY_BASE_URL) ||
|
|
optionalString(process.env.NDC_AI_WORKSPACE_OPS_GATEWAY_BASE_URL) ||
|
|
"";
|
|
const normalizedSetupGatewayUrl = setupGatewayUrl.replace(/\/+$/, "");
|
|
const bridgePackageSpec =
|
|
optionalString(process.env.AI_WORKSPACE_BRIDGE_PACKAGE_SPEC) ||
|
|
optionalString(process.env.NDC_AI_WORKSPACE_BRIDGE_PACKAGE_SPEC) ||
|
|
`${normalizedSetupGatewayUrl}${AI_WORKSPACE_BRIDGE_PACKAGE_PUBLIC_PATH}`;
|
|
|
|
return {
|
|
nodedcEnv,
|
|
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,
|
|
hubInternalHttpUrl: hubInternalHttpUrl.replace(/\/+$/, ""),
|
|
setupGatewayUrl: normalizedSetupGatewayUrl,
|
|
bridgePackageSpec,
|
|
bridgePackageName:
|
|
optionalString(process.env.AI_WORKSPACE_BRIDGE_PACKAGE_NAME) ||
|
|
optionalString(process.env.NDC_AI_WORKSPACE_BRIDGE_PACKAGE_NAME) ||
|
|
AI_WORKSPACE_BRIDGE_PACKAGE_NAME,
|
|
minimumBridgeAgentVersion:
|
|
optionalString(process.env.AI_WORKSPACE_MIN_BRIDGE_AGENT_VERSION) ||
|
|
optionalString(process.env.NDC_AI_WORKSPACE_MIN_BRIDGE_AGENT_VERSION) ||
|
|
"",
|
|
requiredBridgeProtocolVersion:
|
|
optionalString(process.env.AI_WORKSPACE_REQUIRED_BRIDGE_PROTOCOL_VERSION) ||
|
|
optionalString(process.env.NDC_AI_WORKSPACE_REQUIRED_BRIDGE_PROTOCOL_VERSION) ||
|
|
"",
|
|
requiredBridgeCapabilities: uniqueStrings(String(
|
|
process.env.AI_WORKSPACE_REQUIRED_BRIDGE_CAPABILITIES ||
|
|
process.env.NDC_AI_WORKSPACE_REQUIRED_BRIDGE_CAPABILITIES ||
|
|
""
|
|
).split(/[\n,;]+/)),
|
|
setupCodeTtlSeconds: sanitizeInteger(
|
|
process.env.AI_WORKSPACE_SETUP_CODE_TTL_SECONDS ||
|
|
process.env.NDC_AI_WORKSPACE_SETUP_CODE_TTL_SECONDS,
|
|
15 * 60,
|
|
60,
|
|
24 * 60 * 60
|
|
),
|
|
hubInternalAccessToken:
|
|
explicitHubAccessToken || (isDeployedPublicHubUrl(hubInternalHttpUrl) ? "" : sharedInternalAccessToken),
|
|
ontologyLauncherBaseUrl: ontologyLauncherBaseUrl.replace(/\/+$/, ""),
|
|
launcherInternalAccessToken,
|
|
opsGatewayBaseUrl: opsGatewayBaseUrl.replace(/\/+$/, ""),
|
|
opsEntitlementUrl: opsEntitlementAdapter?.url || "",
|
|
opsEntitlementAuthorization: opsEntitlementAdapter?.authorization || "",
|
|
defaultOpsWorkspaceSlug:
|
|
optionalString(process.env.AI_WORKSPACE_OPS_DEFAULT_WORKSPACE_SLUG) ||
|
|
optionalString(process.env.NDC_AI_WORKSPACE_OPS_DEFAULT_WORKSPACE_SLUG) ||
|
|
"",
|
|
defaultOpsProjectId:
|
|
optionalString(process.env.AI_WORKSPACE_OPS_DEFAULT_PROJECT_ID) ||
|
|
optionalString(process.env.NDC_AI_WORKSPACE_OPS_DEFAULT_PROJECT_ID) ||
|
|
"",
|
|
assistantActionRelayEnabled,
|
|
assistantActionRelayId,
|
|
hubFallbackWebSocketUrls: String(
|
|
process.env.AI_WORKSPACE_HUB_FALLBACK_URLS ||
|
|
process.env.NDC_AI_WORKSPACE_HUB_FALLBACK_URLS ||
|
|
""
|
|
)
|
|
.split(/[\n,;]+/)
|
|
.map((item) => item.trim())
|
|
.filter(Boolean),
|
|
internalAccessTokens: uniqueStrings([
|
|
process.env.AI_WORKSPACE_ASSISTANT_TOKEN,
|
|
process.env.NODEDC_INTERNAL_ACCESS_TOKEN,
|
|
process.env.NODEDC_PLATFORM_SERVICE_TOKEN,
|
|
]),
|
|
entitlementAdapters,
|
|
};
|
|
}
|
|
|
|
async function shutdown() {
|
|
assistantActionRelayStopping = true;
|
|
try {
|
|
httpServer.close();
|
|
await pool.end();
|
|
} finally {
|
|
process.exit(0);
|
|
}
|
|
}
|