feat(ai-workspace): add local relay profiles

This commit is contained in:
Codex
2026-06-20 12:54:19 +03:00
parent 2d5fef3948
commit 3526351b1b
28 changed files with 2959 additions and 77 deletions
+6 -3
View File
@@ -1,4 +1,5 @@
# domains
NODEDC_ENV=local
AUTH_DOMAIN=auth.local.nodedc
AUTH_ADMIN_DOMAIN=auth-admin.local.nodedc
LAUNCHER_DOMAIN=launcher.local.nodedc
@@ -62,11 +63,13 @@ NODEDC_AI_WORKSPACE_ASSISTANT_URL=http://ai-workspace-assistant:18082
AI_WORKSPACE_OPS_ENTITLEMENT_URL=http://host.docker.internal:4100/api/internal/v1/ai-workspace/entitlements
AI_WORKSPACE_OPS_ENTITLEMENT_TOKEN=replace-with-ops-agent-gateway-internal-token
AI_WORKSPACE_OPS_ENTITLEMENT_REQUIRED=false
AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ENABLED=true
AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ID=local-dev
# AI Workspace Hub for downloaded Codex workers.
# Default local development uses the deployed relay and is a hybrid-prod-bridge topology:
# local UI/services can be tested, but live Codex worker write-path e2e is not proven.
# For true local/tunnel e2e, change both Hub URLs and Ops Gateway public URL deliberately.
# Default local development may use the deployed AI Hub only as a relay for remote Codex workers.
# Launcher/Engine/Ops/Auth downstream URLs must remain local.
# For Tailscale/ngrok-style relay URLs, set NODEDC_ENV=tunnel-local-e2e deliberately.
AI_WORKSPACE_HUB_TOKEN=change-me-generate-with-infra-scripts-init-dev-env
AI_WORKSPACE_HUB_HOST_BIND=127.0.0.1:18081
AI_WORKSPACE_HUB_PUBLIC_URL=wss://ai-hub.nodedc.ru/api/ai-workspace/hub
+9
View File
@@ -32,6 +32,15 @@ infra/
## Local start
0. Check the local testing contract:
```bash
cd /Users/dcconstructions/Downloads/mnt/NODEDC/platform
sh infra/scripts/check-local-test-system.sh
```
The local testing canon is documented in `docs/LOCAL_TESTING_SYSTEM.md`. It keeps product apps local and allows the deployed AI Hub only as a remote Codex worker relay.
1. Add local domains to `/etc/hosts`:
```text
+19 -28
View File
@@ -4,9 +4,6 @@ services:
reverse-proxy:
image: ${PLATFORM_PROXY_IMAGE:-nodedc/plane-proxy:ru}
restart: unless-stopped
env_file:
- path: .env
required: false
ports:
- "${PLATFORM_HTTP_PORT:-80}:80"
volumes:
@@ -28,9 +25,6 @@ services:
postgresql-authentik:
image: docker.io/library/postgres:16-alpine
restart: unless-stopped
env_file:
- path: .env
required: false
environment:
POSTGRES_DB: ${PG_DB:-authentik}
POSTGRES_PASSWORD: ${PG_PASS:?database password required}
@@ -48,9 +42,6 @@ services:
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2026.2.2}
command: server
restart: unless-stopped
env_file:
- path: .env
required: false
environment:
AUTHENTIK_POSTGRESQL__HOST: postgresql-authentik
AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik}
@@ -58,6 +49,10 @@ services:
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS: ${AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS:-127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,::1/128}
AUTHENTIK_ERROR_REPORTING__ENABLED: ${AUTHENTIK_ERROR_REPORTING__ENABLED:-false}
AUTHENTIK_BOOTSTRAP_EMAIL: ${AUTHENTIK_BOOTSTRAP_EMAIL:-}
AUTHENTIK_BOOTSTRAP_PASSWORD: ${AUTHENTIK_BOOTSTRAP_PASSWORD:-}
AUTHENTIK_BOOTSTRAP_TOKEN: ${AUTHENTIK_BOOTSTRAP_TOKEN:-}
depends_on:
postgresql-authentik:
condition: service_healthy
@@ -74,15 +69,13 @@ services:
command: worker
restart: unless-stopped
user: root
env_file:
- path: .env
required: false
environment:
AUTHENTIK_POSTGRESQL__HOST: postgresql-authentik
AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik}
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS:?database password required}
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
AUTHENTIK_ERROR_REPORTING__ENABLED: ${AUTHENTIK_ERROR_REPORTING__ENABLED:-false}
depends_on:
postgresql-authentik:
condition: service_healthy
@@ -96,9 +89,6 @@ services:
notification-postgres:
image: docker.io/library/postgres:16-alpine
restart: unless-stopped
env_file:
- path: .env
required: false
environment:
POSTGRES_DB: ${NOTIFICATION_PG_DB:-nodedc_notifications}
POSTGRES_PASSWORD: ${NOTIFICATION_PG_PASS:-nodedc_notifications}
@@ -117,9 +107,6 @@ services:
build:
context: ../services/notification-core
restart: unless-stopped
env_file:
- path: .env
required: false
environment:
NODE_ENV: production
PORT: 5185
@@ -133,9 +120,6 @@ services:
ai-workspace-postgres:
image: docker.io/library/postgres:16-alpine
restart: unless-stopped
env_file:
- path: .env
required: false
environment:
POSTGRES_DB: ${AI_WORKSPACE_PG_DB:-nodedc_ai_workspace}
POSTGRES_PASSWORD: ${AI_WORKSPACE_PG_PASS:-nodedc_ai_workspace}
@@ -152,11 +136,9 @@ services:
ai-workspace-assistant:
image: nodedc/ai-workspace-assistant:local
build:
context: ../services/ai-workspace-assistant
context: ../services
dockerfile: ai-workspace-assistant/Dockerfile.dev
restart: unless-stopped
env_file:
- path: .env
required: false
environment:
NODE_ENV: production
PORT: 18082
@@ -166,6 +148,16 @@ services:
AI_WORKSPACE_HUB_INTERNAL_URL: ${AI_WORKSPACE_HUB_INTERNAL_URL:-https://ai-hub.nodedc.ru}
AI_WORKSPACE_HUB_TOKEN: ${AI_WORKSPACE_HUB_TOKEN:-}
AI_WORKSPACE_HUB_FALLBACK_URLS: ${AI_WORKSPACE_HUB_FALLBACK_URLS:-}
AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ENABLED: ${AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ENABLED:-true}
AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ID: ${AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ID:-local-dev}
AI_WORKSPACE_OPS_ENTITLEMENT_URL: ${AI_WORKSPACE_OPS_ENTITLEMENT_URL:-}
AI_WORKSPACE_OPS_ENTITLEMENT_TOKEN: ${AI_WORKSPACE_OPS_ENTITLEMENT_TOKEN:-}
AI_WORKSPACE_OPS_ENTITLEMENT_REQUIRED: ${AI_WORKSPACE_OPS_ENTITLEMENT_REQUIRED:-false}
AI_WORKSPACE_OPS_GATEWAY_BASE_URL: ${AI_WORKSPACE_OPS_GATEWAY_BASE_URL:-}
AI_WORKSPACE_OPS_DEFAULT_WORKSPACE_SLUG: ${AI_WORKSPACE_OPS_DEFAULT_WORKSPACE_SLUG:-}
AI_WORKSPACE_OPS_DEFAULT_PROJECT_ID: ${AI_WORKSPACE_OPS_DEFAULT_PROJECT_ID:-}
NODEDC_INTERNAL_ACCESS_TOKEN: ${NODEDC_INTERNAL_ACCESS_TOKEN:-}
NDC_LAUNCHER_INTERNAL_URL: ${NDC_LAUNCHER_INTERNAL_URL:-http://host.docker.internal:5173}
expose:
- "18082"
ports:
@@ -179,14 +171,13 @@ services:
build:
context: ../services/ai-workspace-hub
restart: unless-stopped
env_file:
- path: .env
required: false
environment:
NODE_ENV: production
PORT: 18081
AI_WORKSPACE_HUB_TOKEN: ${AI_WORKSPACE_HUB_TOKEN:-dev-ai-workspace-hub-token}
AI_WORKSPACE_HUB_WS_PATH: /api/ai-workspace/hub
NODEDC_INTERNAL_ACCESS_TOKEN: ${NODEDC_INTERNAL_ACCESS_TOKEN:-}
NODEDC_AI_WORKSPACE_ASSISTANT_URL: http://ai-workspace-assistant:18082
expose:
- "18081"
ports:
@@ -55,6 +55,21 @@ require_contains() {
fi
}
require_not_contains() {
file="$1"
needle="$2"
label="$3"
if [ ! -f "$file" ]; then
fail "$label (missing file: $file)"
return 0
fi
if grep -Fq "$needle" "$file"; then
fail "$label (forbidden: $needle)"
else
pass "$label"
fi
}
read_env() {
file="$1"
key="$2"
@@ -88,9 +103,12 @@ require_env_value() {
section "Required files"
require_file "$PLATFORM_ROOT/infra/.env.example" "Platform local env example exists"
require_file "$PLATFORM_ROOT/infra/scripts/check-local-environment-safety.mjs" "Local environment safety checker exists"
require_file "$PLATFORM_ROOT/infra/synology/.env.synology.example" "Platform Synology env example exists"
require_file "$PLATFORM_ROOT/infra/docker-compose.dev.yml" "Platform local compose exists"
require_file "$PLATFORM_ROOT/infra/synology/docker-compose.platform-http.yml" "Platform Synology compose exists"
require_file "$PLATFORM_ROOT/infra/synology/sync-ai-hub-relay.sh" "Synology AI Hub relay sync script exists"
require_file "$PLATFORM_ROOT/infra/synology/apply-ai-hub-relay.sh" "Synology AI Hub relay apply script exists"
require_file "$OPS_GATEWAY_REPO/.env.example" "Ops Gateway local env example exists"
require_file "$OPS_GATEWAY_REPO/.env.synology.example" "Ops Gateway Synology env example exists"
require_file "$OPS_GATEWAY_REPO/docker-compose.local.yml" "Ops Gateway local compose exists"
@@ -98,11 +116,28 @@ require_file "$OPS_GATEWAY_REPO/docker-compose.synology.yml" "Ops Gateway Synolo
require_file "$ENGINE_REPO/docker-compose.yml" "Engine compose exists"
section "Platform local contract"
require_env_value "$PLATFORM_ROOT/infra/.env.example" "NODEDC_ENV" "local" "Local Platform example declares NODEDC_ENV"
require_env_value "$PLATFORM_ROOT/infra/.env.example" "AI_WORKSPACE_OPS_ENTITLEMENT_URL" "http://host.docker.internal:4100/api/internal/v1/ai-workspace/entitlements" "Local Platform example points Ops entitlement to local Gateway"
require_env_value "$PLATFORM_ROOT/infra/.env.example" "AI_WORKSPACE_HUB_PUBLIC_URL" "wss://ai-hub.nodedc.ru/api/ai-workspace/hub" "Local Platform example keeps deployed Hub as hybrid worker relay by default"
require_env_value "$PLATFORM_ROOT/infra/.env.example" "AI_WORKSPACE_HUB_INTERNAL_URL" "https://ai-hub.nodedc.ru" "Local Platform example has explicit Hub internal URL"
require_env_value "$PLATFORM_ROOT/infra/.env.example" "AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ENABLED" "true" "Local Platform example enables Assistant action relay"
require_env_value "$PLATFORM_ROOT/infra/.env.example" "AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ID" "local-dev" "Local Platform example declares stable local Assistant action relay id"
require_env_value "$PLATFORM_ROOT/infra/.env.example" "AI_WORKSPACE_HUB_PUBLIC_URL" "wss://ai-hub.nodedc.ru/api/ai-workspace/hub" "Local Platform example keeps deployed AI Hub only as worker relay"
require_env_value "$PLATFORM_ROOT/infra/.env.example" "AI_WORKSPACE_HUB_INTERNAL_URL" "https://ai-hub.nodedc.ru" "Local Platform example has explicit relay control URL"
require_contains "$PLATFORM_ROOT/infra/scripts/init-dev-env.sh" "NODEDC_ENV=local" "Local env generator emits NODEDC_ENV"
require_contains "$PLATFORM_ROOT/infra/scripts/init-dev-env.sh" "AI_WORKSPACE_OPS_ENTITLEMENT_URL=http://host.docker.internal:4100/api/internal/v1/ai-workspace/entitlements" "Local env generator emits local Ops entitlement URL"
require_contains "$PLATFORM_ROOT/infra/scripts/init-dev-env.sh" "AI_WORKSPACE_HUB_INTERNAL_URL=https://ai-hub.nodedc.ru" "Local env generator emits explicit Hub internal URL"
require_contains "$PLATFORM_ROOT/infra/scripts/init-dev-env.sh" "AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ENABLED=true" "Local env generator enables Assistant action relay"
require_contains "$PLATFORM_ROOT/infra/scripts/init-dev-env.sh" "AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ID=local-dev" "Local env generator emits stable Assistant action relay id"
require_contains "$PLATFORM_ROOT/infra/scripts/init-dev-env.sh" "AI_WORKSPACE_HUB_INTERNAL_URL=https://ai-hub.nodedc.ru" "Local env generator emits explicit relay control URL"
require_not_contains "$PLATFORM_ROOT/infra/docker-compose.dev.yml" "env_file:" "Local compose does not inject full .env into containers"
require_contains "$PLATFORM_ROOT/infra/docker-compose.dev.yml" 'NODEDC_INTERNAL_ACCESS_TOKEN: ${NODEDC_INTERNAL_ACCESS_TOKEN:-}' "Local compose explicitly passes internal token only to AI services"
require_contains "$PLATFORM_ROOT/infra/docker-compose.dev.yml" 'AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ENABLED: ${AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ENABLED:-true}' "Local compose explicitly passes Assistant action relay enabled flag"
require_contains "$PLATFORM_ROOT/infra/docker-compose.dev.yml" 'AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ID: ${AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ID:-local-dev}' "Local compose explicitly passes Assistant action relay id"
section "Local safety guardrail"
if node "$PLATFORM_ROOT/infra/scripts/check-local-environment-safety.mjs"; then
pass "Local environment safety checker passes"
else
fail "Local environment safety checker failed"
fi
section "Platform Synology contract"
require_env_value "$PLATFORM_ROOT/infra/synology/.env.synology.example" "NODEDC_AI_WORKSPACE_ASSISTANT_URL" "http://ai-workspace-assistant:18082" "Synology Platform exposes Assistant to app services on compose network"
@@ -110,6 +145,11 @@ require_env_value "$PLATFORM_ROOT/infra/synology/.env.synology.example" "AI_WORK
require_env_value "$PLATFORM_ROOT/infra/synology/.env.synology.example" "AI_WORKSPACE_HUB_PUBLIC_URL" "wss://ai-hub.nodedc.ru/api/ai-workspace/hub" "Synology Platform worker-facing Hub URL is public relay"
require_env_value "$PLATFORM_ROOT/infra/synology/.env.synology.example" "AI_WORKSPACE_HUB_INTERNAL_URL" "https://ai-hub.nodedc.ru" "Synology Platform backend Hub URL is explicit"
require_contains "$PLATFORM_ROOT/infra/synology/verify-current-runtime.sh" 'AI_WORKSPACE_OPS_ENTITLEMENT_URL" = "http://172.22.0.222:18190/api/internal/v1/ai-workspace/entitlements"' "Synology verify checks Ops entitlement URL"
require_contains "$PLATFORM_ROOT/infra/synology/sync-ai-hub-relay.sh" 'services/ai-workspace-hub/' "Hub-only sync copies AI Workspace Hub source"
require_contains "$PLATFORM_ROOT/infra/synology/apply-ai-hub-relay.sh" 'up -d --no-deps --force-recreate ai-workspace-hub' "Hub-only apply recreates only AI Workspace Hub without dependencies"
require_contains "$PLATFORM_ROOT/infra/synology/apply-ai-hub-relay.sh" '/api/ai-workspace/hub/v1/assistant-relays/' "Hub-only apply verifies Assistant relay endpoint"
require_not_contains "$PLATFORM_ROOT/infra/synology/apply-ai-hub-relay.sh" ' launcher' "Hub-only apply does not target Launcher"
require_not_contains "$PLATFORM_ROOT/infra/synology/apply-ai-hub-relay.sh" 'authentik' "Hub-only apply does not target Authentik"
section "Ops Gateway contract"
require_env_value "$OPS_GATEWAY_REPO/.env.example" "NODEDC_AGENT_GATEWAY_PUBLIC_URL" "https://ops-agents.nodedc.ru" "Ops Gateway local example keeps worker-facing MCP URL explicit"
@@ -134,6 +174,13 @@ if [ -f "$PLATFORM_ENV" ]; then
else
pass "Current Platform env has AI_WORKSPACE_OPS_ENTITLEMENT_URL"
fi
local_action_relay_enabled=$(read_env "$PLATFORM_ENV" AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ENABLED || true)
local_action_relay_id=$(read_env "$PLATFORM_ENV" AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ID || true)
if [ "$local_action_relay_enabled" = "true" ] && [ -n "$local_action_relay_id" ]; then
pass "Current Platform env has Assistant action relay enabled"
else
warn "Current Platform env has no enabled Assistant action relay; remote worker action calls may route incorrectly."
fi
else
warn "Current Platform env not found: $PLATFORM_ENV"
fi
@@ -65,9 +65,12 @@ run_in "$PLATFORM_ROOT" \
"infra/scripts/check-ai-workspace-topology.sh"
section "Platform static and contract gates"
run_in "$PLATFORM_ROOT" \
"Local test system contract gate" \
"sh infra/scripts/check-local-test-system.sh"
run_in "$PLATFORM_ROOT" \
"Platform shell syntax" \
"sh -n infra/scripts/init-dev-env.sh infra/scripts/check-ai-workspace-topology.sh infra/scripts/check-ai-workspace-config-contract.sh infra/scripts/check-ai-workspace-release-gates.sh infra/synology/check-ai-workspace-apply-plan.sh infra/synology/prepare-ai-workspace-env.sh infra/synology/deploy-current.sh infra/synology/verify-current-runtime.sh infra/synology/apply-current-runtime.sh infra/synology/backup-current.sh"
"sh -n infra/scripts/init-dev-env.sh infra/scripts/check-local-test-system.sh infra/scripts/check-ai-workspace-topology.sh infra/scripts/check-ai-workspace-config-contract.sh infra/scripts/check-ai-workspace-release-gates.sh infra/synology/check-ai-workspace-apply-plan.sh infra/synology/prepare-ai-workspace-env.sh infra/synology/deploy-current.sh infra/synology/verify-current-runtime.sh infra/synology/apply-current-runtime.sh infra/synology/backup-current.sh"
run_in "$PLATFORM_ROOT" \
"AI Workspace config contract" \
"infra/scripts/check-ai-workspace-config-contract.sh"
+55 -13
View File
@@ -90,40 +90,63 @@ bool_and() {
}
assistant_code=$(http_status "$ASSISTANT_URL/healthz")
hub_body=$(http_body "$HUB_URL/healthz")
hub_code=000
if [ -n "$hub_body" ]; then
hub_code=$(http_status "$HUB_URL/healthz")
fi
gateway_code=$(http_status "$GATEWAY_URL/readyz")
task_code=$(http_status "$TASK_URL")
engine_code=$(http_status "$ENGINE_URL")
assistant_ok=$(ok_status "$assistant_code")
hub_ok=$(ok_status "$hub_code")
gateway_ok=$(ok_status "$gateway_code")
task_ok=$(ok_status "$task_code")
engine_ok=$(ok_status "$engine_code")
agents_online=$(printf "%s" "$hub_body" | json_number agentsOnline)
case "$agents_online" in
""|*[!0-9]*) agents_online=0 ;;
profile=$(read_env "$PLATFORM_ENV" NODEDC_ENV || true)
case "$profile" in
"") profile=local ;;
esac
hub_public=$(read_env "$PLATFORM_ENV" AI_WORKSPACE_HUB_PUBLIC_URL || true)
hub_internal=$(read_env "$PLATFORM_ENV" AI_WORKSPACE_HUB_INTERNAL_URL || true)
ops_entitlement=$(read_env "$PLATFORM_ENV" AI_WORKSPACE_OPS_ENTITLEMENT_URL || true)
action_relay_enabled=$(read_env "$PLATFORM_ENV" AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ENABLED || true)
action_relay_id=$(read_env "$PLATFORM_ENV" AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ID || true)
case "$action_relay_enabled" in
1|true|TRUE|yes|YES|on|ON) action_relay_enabled=true ;;
*) action_relay_enabled=false ;;
esac
hub_public_kind=$(url_kind "$hub_public")
hub_internal_kind=$(url_kind "$hub_internal")
ops_entitlement_kind=$(url_kind "$ops_entitlement")
local_services_ok=$(bool_and "$(bool_and "$assistant_ok" "$hub_ok")" "$(bool_and "$gateway_ok" "$task_ok")")
local_hub_body=$(http_body "$HUB_URL/healthz")
local_hub_code=000
if [ -n "$local_hub_body" ]; then
local_hub_code=$(http_status "$HUB_URL/healthz")
fi
local_hub_ok=$(ok_status "$local_hub_code")
relay_health_code=000
relay_health_ok=false
if [ -n "$hub_internal" ]; then
relay_health_code=$(http_status "$(printf "%s" "$hub_internal" | sed 's#/*$##')/healthz")
relay_health_ok=$(ok_status "$relay_health_code")
fi
agents_online=$(printf "%s" "$local_hub_body" | json_number agentsOnline)
case "$agents_online" in
""|*[!0-9]*) agents_online=0 ;;
esac
local_services_ok=$(bool_and "$assistant_ok" "$(bool_and "$gateway_ok" "$task_ok")")
worker_on_local_hub=false
if [ "$agents_online" -gt 0 ]; then
worker_on_local_hub=true
fi
action_relay_configured=false
if [ "$action_relay_enabled" = true ] && [ -n "$action_relay_id" ]; then
action_relay_configured=true
fi
hub_runtime_kind=custom
case "$hub_public_kind:$hub_internal_kind" in
local:local|local:missing|missing:local) hub_runtime_kind=local ;;
@@ -135,6 +158,10 @@ esac
classification=contract-only
if [ "$local_services_ok" = true ]; then
if [ "$engine_ok" = true ] \
&& [ "$action_relay_configured" = true ] \
&& [ "$hub_runtime_kind" = prod-public ]; then
classification=local-remote-worker-relay
elif [ "$engine_ok" = true ] \
&& [ "$worker_on_local_hub" = true ] \
&& [ "$ops_entitlement_kind" = local ] \
&& [ "$hub_runtime_kind" = local ]; then
@@ -156,9 +183,11 @@ fi
cat <<EOF
classification=$classification
profile=$profile
platform_env=$PLATFORM_ENV
assistant_health=$assistant_ok status=$assistant_code url=$ASSISTANT_URL/healthz
hub_health=$hub_ok status=$hub_code url=$HUB_URL/healthz agents_online=$agents_online
local_hub_health=$local_hub_ok status=$local_hub_code url=$HUB_URL/healthz agents_online=$agents_online
relay_health=$relay_health_ok status=$relay_health_code url=${hub_internal%/}/healthz
gateway_health=$gateway_ok status=$gateway_code url=$GATEWAY_URL/readyz
task_health=$task_ok status=$task_code url=$TASK_URL
engine_health=$engine_ok status=$engine_code url=$ENGINE_URL
@@ -166,14 +195,20 @@ hub_public_kind=$hub_public_kind
hub_internal_kind=$hub_internal_kind
hub_runtime_kind=$hub_runtime_kind
ops_entitlement_kind=$ops_entitlement_kind
assistant_action_relay_enabled=${action_relay_enabled:-missing}
assistant_action_relay_id=${action_relay_id:-missing}
assistant_action_relay_configured=$action_relay_configured
EOF
case "$classification" in
true-local-e2e|tunnel-local-e2e)
echo "meaning=local write-path e2e can be interpreted if the worker can reach returned MCP URLs"
;;
local-remote-worker-relay)
echo "meaning=local apps are the downstream target; deployed AI Hub is used only as remote worker relay and assistant actions route back to local Assistant"
;;
hybrid-prod-bridge)
echo "meaning=do not treat UI dialog as local write-path proof; worker/HUB/MCP topology is mixed"
echo "meaning=do not treat UI dialog as local write-path proof; topology is mixed or assistant action relay is not configured"
;;
local-ops-vertical)
echo "meaning=Gateway and Tasker local vertical checks are valid, but no live Codex worker e2e is proven"
@@ -192,3 +227,10 @@ if [ "${REQUIRE_TRUE_E2E:-0}" = "1" ]; then
*) exit 2 ;;
esac
fi
if [ "${REQUIRE_LOCAL_REMOTE_WORKER_RELAY:-0}" = "1" ]; then
case "$classification" in
local-remote-worker-relay) exit 0 ;;
*) exit 2 ;;
esac
fi
@@ -0,0 +1,147 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const platformRoot = path.resolve(scriptDir, "../..");
const monorepoRoot = path.resolve(platformRoot, "..");
const prodHosts = new Set([
"ai-hub.nodedc.ru",
"hub.nodedc.ru",
"engine.nodedc.ru",
"ops.nodedc.ru",
"id.nodedc.ru",
"ops-agents.nodedc.ru",
]);
const relayKeysAllowedInLocal = new Set([
"AI_WORKSPACE_HUB_PUBLIC_URL",
"AI_WORKSPACE_HUB_INTERNAL_URL",
"NDC_AI_WORKSPACE_HUB_URL",
"NDC_AI_WORKSPACE_HUB_HTTP_URL",
"AI_WORKSPACE_ASSISTANT_ACTION_GATEWAY_URL",
"NDC_AI_WORKSPACE_ASSISTANT_ACTION_GATEWAY_URL",
]);
const localOnlyFiles = [
{
file: path.join(platformRoot, "infra", ".env.example"),
required: true,
requiredKeys: ["NODEDC_ENV", "NODEDC_INTERNAL_ACCESS_TOKEN", "AI_WORKSPACE_HUB_PUBLIC_URL", "AI_WORKSPACE_HUB_INTERNAL_URL", "AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ENABLED", "AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ID"],
},
{
file: path.join(platformRoot, "infra", ".env"),
required: false,
requiredKeys: ["NODEDC_ENV", "NODEDC_INTERNAL_ACCESS_TOKEN", "AI_WORKSPACE_HUB_PUBLIC_URL", "AI_WORKSPACE_HUB_INTERNAL_URL", "AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ENABLED", "AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ID"],
},
{
file: path.join(monorepoRoot, "NODEDC_ENGINE_INFRA", "nodedc-source", "server", ".env.local"),
required: false,
requiredKeys: [
"NODEDC_ENV",
"NODEDC_LAUNCHER_INTERNAL_URL",
"NODEDC_LAUNCHER_ORIGIN",
"AUTHENTIK_PUBLIC_BASE_URL",
"NODEDC_AI_WORKSPACE_ASSISTANT_URL",
],
},
];
const failures = [];
const warnings = [];
const passes = [];
function parseEnvFile(file) {
const env = new Map();
const raw = fs.readFileSync(file, "utf8");
raw.split(/\r?\n/).forEach((line, index) => {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) return;
const match = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(trimmed);
if (!match) return;
let value = match[2].trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
env.set(match[1], { value, line: index + 1 });
});
return env;
}
function urlHost(value) {
try {
return new URL(value).hostname.toLowerCase();
} catch {
return "";
}
}
function isTunnelOrPrivateRelay(value) {
const host = urlHost(value);
return (
host.endsWith(".ts.net") ||
host.startsWith("100.") ||
host.startsWith("172.22.") ||
host.startsWith("192.168.") ||
host.startsWith("10.")
);
}
function checkLocalEnvFile({ file, required, requiredKeys }) {
if (!fs.existsSync(file)) {
if (required) failures.push(`${file}: missing required local env file`);
else warnings.push(`${file}: not present, skipped`);
return;
}
const env = parseEnvFile(file);
const profile = env.get("NODEDC_ENV")?.value || "local";
if (profile !== "local" && profile !== "tunnel-local-e2e") {
failures.push(`${file}: expected NODEDC_ENV=local or tunnel-local-e2e, got ${profile}`);
}
requiredKeys.forEach((key) => {
if (!env.get(key)?.value) failures.push(`${file}: missing required ${key}`);
});
for (const [key, entry] of env.entries()) {
const value = entry.value;
if (!/^https?:\/\//i.test(value) && !/^wss?:\/\//i.test(value)) continue;
const host = urlHost(value);
if (!host) continue;
if (prodHosts.has(host) && !relayKeysAllowedInLocal.has(key)) {
failures.push(`${file}:${entry.line}: ${key} points local profile at prod host ${host}`);
}
if (isTunnelOrPrivateRelay(value) && profile !== "tunnel-local-e2e") {
failures.push(`${file}:${entry.line}: ${key} uses tunnel/private host ${host}; set NODEDC_ENV=tunnel-local-e2e explicitly or use a normal local/public-relay URL`);
}
}
const hubInternal = env.get("AI_WORKSPACE_HUB_INTERNAL_URL")?.value || "";
if (hubInternal && profile === "local") {
const host = urlHost(hubInternal);
const allowed = host === "ai-hub.nodedc.ru" || host === "127.0.0.1" || host === "localhost";
if (!allowed) {
failures.push(`${file}: AI_WORKSPACE_HUB_INTERNAL_URL must be loopback or deployed relay in local profile, got ${hubInternal}`);
}
}
passes.push(`${file}: checked`);
}
localOnlyFiles.forEach(checkLocalEnvFile);
passes.forEach((item) => console.log(`PASS ${item}`));
warnings.forEach((item) => console.warn(`WARN ${item}`));
failures.forEach((item) => console.error(`FAIL ${item}`));
console.log(`summary: passed=${passes.length} warnings=${warnings.length} failed=${failures.length}`);
if (failures.length) process.exit(1);
+183
View File
@@ -0,0 +1,183 @@
#!/usr/bin/env sh
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
PLATFORM_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)
INFRA_DIR="$PLATFORM_ROOT/infra"
ASSISTANT_DIR="$PLATFORM_ROOT/services/ai-workspace-assistant"
HUB_DIR="$PLATFORM_ROOT/services/ai-workspace-hub"
ONTOLOGY_DIR="$PLATFORM_ROOT/services/ontology-core"
COMPOSE_ENV="${COMPOSE_ENV:-$INFRA_DIR/.env}"
if [ ! -f "$COMPOSE_ENV" ]; then
COMPOSE_ENV="$INFRA_DIR/.env.example"
fi
REQUIRE_RUNTIME="${REQUIRE_RUNTIME:-0}"
REQUIRE_REMOTE_WORKER_RELAY="${REQUIRE_REMOTE_WORKER_RELAY:-0}"
passed=0
failed=0
skipped=0
section() {
printf '\n== %s ==\n' "$1"
}
pass() {
passed=$((passed + 1))
printf 'PASS %s\n' "$1"
}
fail() {
failed=$((failed + 1))
printf 'FAIL %s\n' "$1" >&2
}
skip() {
skipped=$((skipped + 1))
printf 'SKIP %s\n' "$1"
}
run_cmd() {
label="$1"
shift
printf '\n$ %s\n' "$label"
set +e
"$@"
status=$?
set -e
if [ "$status" -eq 0 ]; then
pass "$label"
else
fail "$label (exit $status)"
fi
}
run_sh() {
label="$1"
dir="$2"
command="$3"
printf '\n$ %s\n' "$label"
set +e
(cd "$dir" && sh -lc "$command")
status=$?
set -e
if [ "$status" -eq 0 ]; then
pass "$label"
else
fail "$label (exit $status)"
fi
}
run_sh_quiet() {
label="$1"
dir="$2"
command="$3"
output_file=$(mktemp "${TMPDIR:-/tmp}/nodedc-check.XXXXXX")
printf '\n$ %s\n' "$label"
set +e
(cd "$dir" && sh -lc "$command") > "$output_file" 2>&1
status=$?
set -e
if [ "$status" -eq 0 ]; then
pass "$label"
rm -f "$output_file"
else
cat "$output_file"
rm -f "$output_file"
fail "$label (exit $status)"
fi
}
section "Static syntax"
run_cmd "Hub server syntax" node --check "$HUB_DIR/src/server.mjs"
run_cmd "Assistant server syntax" node --check "$ASSISTANT_DIR/src/server.mjs"
run_cmd "Local environment safety checker syntax" node --check "$INFRA_DIR/scripts/check-local-environment-safety.mjs"
run_sh "Shell scripts syntax" "$PLATFORM_ROOT" \
"sh -n infra/scripts/init-dev-env.sh infra/scripts/check-local-test-system.sh infra/scripts/check-ai-workspace-topology.sh infra/scripts/check-ai-workspace-config-contract.sh infra/scripts/check-ai-workspace-release-gates.sh"
if command -v bash >/dev/null 2>&1; then
run_sh "Synology hub-only deploy scripts syntax" "$PLATFORM_ROOT" \
"bash -n infra/synology/sync-ai-hub-relay.sh infra/synology/apply-ai-hub-relay.sh"
else
skip "Synology hub-only deploy scripts syntax (bash not installed)"
fi
section "Environment contracts"
run_cmd "Local environment safety" node "$INFRA_DIR/scripts/check-local-environment-safety.mjs"
run_cmd "AI Workspace config contract" "$INFRA_DIR/scripts/check-ai-workspace-config-contract.sh"
section "Compose boundary"
if command -v docker >/dev/null 2>&1; then
compose_tmp=$(mktemp "${TMPDIR:-/tmp}/nodedc-local-compose.XXXXXX")
if docker compose --env-file "$COMPOSE_ENV" -f "$INFRA_DIR/docker-compose.dev.yml" config > "$compose_tmp"; then
pass "Local compose config renders"
else
fail "Local compose config renders"
fi
rm -f "$compose_tmp"
if grep -Fq "env_file:" "$INFRA_DIR/docker-compose.dev.yml"; then
fail "Local compose must not inject the full .env into services"
else
pass "Local compose uses explicit service environment only"
fi
else
skip "Docker compose config render (docker not installed)"
fi
section "AI Workspace smokes"
run_sh_quiet "Assistant run-profile smoke" "$ASSISTANT_DIR" "npm run smoke:run-profile"
if [ -d "$ONTOLOGY_DIR" ]; then
run_sh_quiet "Ontology assistant caller smoke" "$ONTOLOGY_DIR" "npm run smoke:assistant-caller"
else
skip "Ontology assistant caller smoke (missing $ONTOLOGY_DIR)"
fi
section "Runtime classification"
topology_tmp=$(mktemp "${TMPDIR:-/tmp}/nodedc-topology.XXXXXX")
set +e
(cd "$PLATFORM_ROOT" && infra/scripts/check-ai-workspace-topology.sh) > "$topology_tmp"
topology_status=$?
set -e
cat "$topology_tmp"
classification=$(awk -F= '/^classification=/{print $2; exit}' "$topology_tmp")
rm -f "$topology_tmp"
if [ "$topology_status" -eq 0 ]; then
pass "Runtime topology classified as ${classification:-unknown}"
else
fail "Runtime topology classification failed (exit $topology_status)"
fi
if [ "$REQUIRE_RUNTIME" = "1" ]; then
case "$classification" in
true-local-e2e|tunnel-local-e2e|local-remote-worker-relay)
pass "Runtime requirement satisfied"
;;
*)
fail "Runtime requirement not satisfied (classification=${classification:-unknown})"
;;
esac
fi
if [ "$REQUIRE_REMOTE_WORKER_RELAY" = "1" ]; then
case "$classification" in
local-remote-worker-relay)
pass "Remote worker relay requirement satisfied"
;;
*)
fail "Remote worker relay requirement not satisfied (classification=${classification:-unknown})"
;;
esac
fi
section "Git hygiene"
run_sh "Platform diff hygiene" "$PLATFORM_ROOT" "git diff --check"
section "Summary"
printf 'passed=%s failed=%s skipped=%s\n' "$passed" "$failed" "$skipped"
if [ "$failed" -ne 0 ]; then
exit 1
fi
+6 -2
View File
@@ -16,6 +16,7 @@ rand() {
cat > "$ENV_FILE" <<EOF
# domains
NODEDC_ENV=local
AUTH_DOMAIN=auth.local.nodedc
LAUNCHER_DOMAIN=launcher.local.nodedc
TASK_DOMAIN=task.local.nodedc
@@ -61,8 +62,9 @@ COOKIE_DOMAIN=.local.nodedc
COOKIE_SECURE=false
# AI Workspace shared registry and Hub.
# Generated local env uses the deployed relay by default. This is suitable for
# UI/contract smoke, not proof of local live-worker write-path e2e.
# Generated local env may use the deployed AI Hub only as a relay for remote
# Codex workers. Launcher/Engine/Ops/Auth downstream URLs must remain local.
# For Tailscale/ngrok-style relay URLs, set NODEDC_ENV=tunnel-local-e2e deliberately.
AI_WORKSPACE_PG_DB=nodedc_ai_workspace
AI_WORKSPACE_PG_USER=nodedc_ai_workspace
AI_WORKSPACE_PG_PASS=$(rand 36)
@@ -70,6 +72,8 @@ AI_WORKSPACE_ASSISTANT_TOKEN=$(openssl rand -hex 48 | tr -d '\n')
AI_WORKSPACE_OPS_ENTITLEMENT_URL=http://host.docker.internal:4100/api/internal/v1/ai-workspace/entitlements
AI_WORKSPACE_OPS_ENTITLEMENT_TOKEN=replace-with-ops-agent-gateway-internal-token
AI_WORKSPACE_OPS_ENTITLEMENT_REQUIRED=false
AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ENABLED=true
AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ID=local-dev
AI_WORKSPACE_HUB_TOKEN=$(openssl rand -hex 48 | tr -d '\n')
AI_WORKSPACE_HUB_HOST_BIND=127.0.0.1:18081
AI_WORKSPACE_HUB_PUBLIC_URL=wss://ai-hub.nodedc.ru/api/ai-workspace/hub
+38
View File
@@ -171,6 +171,44 @@ TASKER_SYNC_SOURCE=1 ./infra/synology/deploy-current.sh
Если emergency-fix был сделан прямо на Synology в этих env-файлах, перенести sanitized-значение в `.env.synology.example`/docs, а секрет оставить только в live env.
## AI Hub relay-only deploy
Для локального тестирования с внешней Codex-машиной нельзя деплоить Launcher, Engine, Ops, Authentik или Tasker. Единственная допустимая удалённая точка в этой схеме — `ai-workspace-hub`, потому что удалённый worker должен иметь публичный WebSocket/HTTP relay.
С Mac, при смонтированном `/Volumes/docker`, синхронизировать только Hub source, compose и узкий apply-script:
```bash
cd /Users/dcconstructions/Downloads/mnt/NODEDC/platform
NAS_ROOT=/Volumes/docker/nodedc-platform ./infra/synology/sync-ai-hub-relay.sh
```
На Synology применить только `ai-workspace-hub`:
```bash
ssh dctouch@100.109.216.21 'cd /volume1/docker/nodedc-platform/platform && sudo bash apply-ai-hub-relay.sh'
```
Этот путь намеренно делает только:
- build `nodedc/ai-workspace-hub:local`;
- `docker compose up -d --no-deps --force-recreate ai-workspace-hub`;
- container health check;
- проверку `/api/ai-workspace/hub/v1/assistant-relays/<relayId>/poll`;
- public route check через `https://ai-hub.nodedc.ru`.
Он не меняет `.env.synology`, не синхронизирует Launcher/Tasker/Ops source, не пересоздаёт Authentik, reverse-proxy, базы, storage или product containers.
После успешного relay-only deploy локальная цепочка для теста должна быть такой:
```text
local Launcher/Engine/Ops/Auth/Assistant
-> deployed AI Hub relay: https://ai-hub.nodedc.ru
-> remote Codex worker
-> deployed AI Hub relay
-> local Assistant relay poll
-> local product services
```
## Лёгкое обновление Hub / Launcher
Для правок только в Launcher/BFF не пересоздавать Authentik, reverse-proxy, Tasker и Ops Agents:
+218
View File
@@ -0,0 +1,218 @@
#!/usr/bin/env bash
set -euo pipefail
DOCKER_BIN="${DOCKER_BIN:-/usr/local/bin/docker}"
PLATFORM_DIR="${PLATFORM_DIR:-/volume1/docker/nodedc-platform/platform}"
ENV_FILE="${ENV_FILE:-${PLATFORM_DIR}/${NODEDC_SYNOLOGY_ENV_FILE:-.env.synology}}"
COMPOSE_FILE="${COMPOSE_FILE:-${PLATFORM_DIR}/docker-compose.platform-http.yml}"
HUB_DIR="${HUB_DIR:-${PLATFORM_DIR}/ai-workspace-hub}"
RELAY_ID="${AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ID:-local-dev}"
PUBLIC_VERIFY_BASE_URL="${AI_WORKSPACE_HUB_VERIFY_BASE_URL:-https://ai-hub.nodedc.ru}"
VERIFY_PUBLIC="${VERIFY_PUBLIC:-1}"
if [[ ! -x "${DOCKER_BIN}" ]]; then
DOCKER_BIN="$(command -v docker)"
fi
read_env_value() {
local file="$1"
local key="$2"
awk -F= -v key="$key" '
$0 ~ "^[[:space:]]*#" { next }
$1 == key {
value = substr($0, index($0, "=") + 1)
gsub(/^[[:space:]]+|[[:space:]]+$/, "", value)
gsub(/^"|"$/, "", value)
print value
exit
}
' "$file"
}
require_file() {
local path="$1"
local label="$2"
if [[ ! -f "${path}" ]]; then
echo "${label} missing: ${path}" >&2
exit 1
fi
}
require_dir() {
local path="$1"
local label="$2"
if [[ ! -d "${path}" ]]; then
echo "${label} missing: ${path}" >&2
exit 1
fi
}
show_container_debug() {
local container_id="$1"
echo "== ai-workspace-hub debug =="
"${DOCKER_BIN}" ps --filter "id=${container_id}" --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' || true
echo "-- recent logs --"
"${DOCKER_BIN}" logs --tail 120 "${container_id}" || true
}
check_container_health() {
local container_id="$1"
"${DOCKER_BIN}" exec "${container_id}" node -e '
fetch("http://127.0.0.1:18081/healthz")
.then(async (response) => {
const body = await response.text();
console.log(body);
process.exit(response.ok ? 0 : 1);
})
.catch(() => process.exit(2));
'
}
check_container_relay_route() {
local container_id="$1"
"${DOCKER_BIN}" exec \
-e RELAY_ID="${RELAY_ID}" \
-e RELAY_TOKEN="${relay_token}" \
"${container_id}" \
node -e '
const relayId = encodeURIComponent(process.env.RELAY_ID || "local-dev");
const token = process.env.RELAY_TOKEN || "";
fetch(`http://127.0.0.1:18081/api/ai-workspace/hub/v1/assistant-relays/${relayId}/poll`, {
method: "POST",
headers: {
authorization: `Bearer ${token}`,
accept: "application/json",
"content-type": "application/json",
},
body: JSON.stringify({ limit: 1, timeoutMs: 1 }),
})
.then(async (response) => {
const text = await response.text();
if (response.status !== 200) {
console.error(`relay route failed: status=${response.status} body=${text.slice(0, 300)}`);
process.exit(1);
}
let payload = {};
try {
payload = JSON.parse(text);
} catch {}
console.log(JSON.stringify({
ok: payload.ok === true,
relayId: payload.relayId,
calls: Array.isArray(payload.calls) ? payload.calls.length : null,
}));
})
.catch((error) => {
console.error(error);
process.exit(2);
});
'
}
echo "== ai hub relay preflight =="
require_dir "${PLATFORM_DIR}" "Platform directory"
require_dir "${HUB_DIR}" "AI Workspace Hub source directory"
require_file "${ENV_FILE}" "Synology env file"
require_file "${COMPOSE_FILE}" "Synology compose file"
require_file "${HUB_DIR}/Dockerfile" "AI Workspace Hub Dockerfile"
require_file "${HUB_DIR}/package.json" "AI Workspace Hub package.json"
require_file "${HUB_DIR}/src/server.mjs" "AI Workspace Hub server"
hub_token="${AI_WORKSPACE_HUB_TOKEN:-$(read_env_value "${ENV_FILE}" AI_WORKSPACE_HUB_TOKEN || true)}"
internal_token="${NODEDC_INTERNAL_ACCESS_TOKEN:-$(read_env_value "${ENV_FILE}" NODEDC_INTERNAL_ACCESS_TOKEN || true)}"
relay_token="${hub_token:-${internal_token}}"
if [[ -z "${relay_token}" ]]; then
echo "No AI_WORKSPACE_HUB_TOKEN or NODEDC_INTERNAL_ACCESS_TOKEN found for relay verification." >&2
exit 1
fi
cd "${PLATFORM_DIR}"
if ! "${DOCKER_BIN}" compose --env-file "${ENV_FILE}" -f "${COMPOSE_FILE}" config --services \
| grep -qx 'ai-workspace-hub'; then
echo "Compose service ai-workspace-hub is not available." >&2
exit 1
fi
echo "== build ai-workspace-hub only =="
"${DOCKER_BIN}" compose \
--env-file "${ENV_FILE}" \
-f "${COMPOSE_FILE}" \
build ai-workspace-hub
echo "== recreate ai-workspace-hub only =="
"${DOCKER_BIN}" compose \
--env-file "${ENV_FILE}" \
-f "${COMPOSE_FILE}" \
up -d --no-deps --force-recreate ai-workspace-hub
container_id="$(
"${DOCKER_BIN}" compose \
--env-file "${ENV_FILE}" \
-f "${COMPOSE_FILE}" \
ps -q ai-workspace-hub
)"
if [[ -z "${container_id}" ]]; then
echo "Cannot resolve ai-workspace-hub container id after apply." >&2
exit 1
fi
echo "== container health =="
health_ok=0
for attempt in $(seq 1 30); do
if check_container_health "${container_id}"; then
health_ok=1
break
fi
echo "ai-workspace-hub-not-ready attempt=${attempt}/30"
sleep 2
done
if [[ "${health_ok}" != "1" ]]; then
show_container_debug "${container_id}"
exit 1
fi
echo "== container relay route =="
relay_ok=0
for attempt in $(seq 1 10); do
if check_container_relay_route "${container_id}"; then
relay_ok=1
break
fi
echo "ai-workspace-hub-relay-not-ready attempt=${attempt}/10"
sleep 2
done
if [[ "${relay_ok}" != "1" ]]; then
show_container_debug "${container_id}"
exit 1
fi
if [[ "${VERIFY_PUBLIC}" == "1" ]]; then
if command -v curl >/dev/null 2>&1; then
echo "== public relay route =="
public_body="$(mktemp -t ai-hub-relay-public.XXXXXX)"
public_status="$(
curl -k -sS -m 20 \
-o "${public_body}" \
-w '%{http_code}' \
-X POST "${PUBLIC_VERIFY_BASE_URL%/}/api/ai-workspace/hub/v1/assistant-relays/${RELAY_ID}/poll" \
-H "Authorization: Bearer ${relay_token}" \
-H 'Accept: application/json' \
-H 'Content-Type: application/json' \
--data '{"limit":1,"timeoutMs":1}'
)"
if [[ "${public_status}" != "200" ]]; then
echo "public relay route failed: status=${public_status}" >&2
head -c 300 "${public_body}" >&2 || true
echo >&2
rm -f "${public_body}"
exit 1
fi
rm -f "${public_body}"
echo "public-relay-route-ok"
else
echo "curl is not available; skipped public relay route check."
fi
fi
echo "ai-hub-relay-apply-ok"
+8
View File
@@ -64,6 +64,14 @@ rsync -av "${RSYNC_METADATA_ARGS[@]}" --delete \
"${PLATFORM_REPO}/services/ai-workspace-assistant/" \
"${NAS_ROOT}/platform/ai-workspace-assistant/"
mkdir -p "${NAS_ROOT}/platform/ai-workspace-assistant/ontology-core"
rsync -av "${RSYNC_METADATA_ARGS[@]}" --delete \
--exclude='node_modules/' \
--exclude='.env' \
--exclude='.env.*' \
"${PLATFORM_REPO}/services/ontology-core/" \
"${NAS_ROOT}/platform/ai-workspace-assistant/ontology-core/"
if [[ "${SYNC_AUTHENTIK_TEMPLATES}" == "1" ]]; then
mkdir -p "${NAS_ROOT}/authentik/custom-templates"
rsync -av "${RSYNC_METADATA_ARGS[@]}" --delete \
@@ -142,6 +142,7 @@ services:
AI_WORKSPACE_HUB_INTERNAL_URL: ${AI_WORKSPACE_HUB_INTERNAL_URL:-https://ai-hub.nodedc.ru}
AI_WORKSPACE_HUB_TOKEN: ${AI_WORKSPACE_HUB_TOKEN:?ai workspace hub token required}
AI_WORKSPACE_HUB_FALLBACK_URLS: ${AI_WORKSPACE_HUB_FALLBACK_URLS:-}
NDC_ONTOLOGY_LAUNCHER_BASE_URL: http://launcher:5173
expose:
- "18082"
ports:
@@ -165,6 +166,7 @@ services:
PORT: 18081
AI_WORKSPACE_HUB_TOKEN: ${AI_WORKSPACE_HUB_TOKEN:?ai workspace hub token required}
NODEDC_INTERNAL_ACCESS_TOKEN: ${NODEDC_INTERNAL_ACCESS_TOKEN:-}
NODEDC_AI_WORKSPACE_ASSISTANT_URL: http://ai-workspace-assistant:18082
AI_WORKSPACE_HUB_WS_PATH: /api/ai-workspace/hub
expose:
- "18081"
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
PLATFORM_REPO="$(cd -- "${SCRIPT_DIR}/../.." && pwd)"
NAS_ROOT="${NAS_ROOT:-/Volumes/docker/nodedc-platform}"
SSH_TARGET="${SSH_TARGET:-dctouch@100.109.216.21}"
RSYNC_METADATA_ARGS=()
if [[ "${RSYNC_PRESERVE_METADATA:-0}" != "1" ]]; then
RSYNC_METADATA_ARGS=(--no-times --no-perms --no-owner --no-group)
fi
if [[ ! -d "${NAS_ROOT}" ]]; then
echo "NAS_ROOT not found: ${NAS_ROOT}" >&2
echo "Set NAS_ROOT=/path/to/nodedc-platform when the Synology share is mounted elsewhere." >&2
exit 1
fi
if [[ ! -f "${PLATFORM_REPO}/infra/synology/docker-compose.platform-http.yml" ]]; then
echo "Synology compose is missing in local repo." >&2
exit 1
fi
if [[ ! -d "${PLATFORM_REPO}/services/ai-workspace-hub" ]]; then
echo "AI Workspace Hub source is missing in local repo." >&2
exit 1
fi
mkdir -p "${NAS_ROOT}/platform"
rsync -av "${RSYNC_METADATA_ARGS[@]}" \
"${PLATFORM_REPO}/infra/synology/docker-compose.platform-http.yml" \
"${NAS_ROOT}/platform/docker-compose.platform-http.yml"
rsync -av "${RSYNC_METADATA_ARGS[@]}" \
"${PLATFORM_REPO}/infra/synology/apply-ai-hub-relay.sh" \
"${NAS_ROOT}/platform/apply-ai-hub-relay.sh"
mkdir -p "${NAS_ROOT}/platform/ai-workspace-hub"
rsync -av "${RSYNC_METADATA_ARGS[@]}" --delete \
--exclude='node_modules/' \
--exclude='.env' \
--exclude='.env.*' \
"${PLATFORM_REPO}/services/ai-workspace-hub/" \
"${NAS_ROOT}/platform/ai-workspace-hub/"
cat <<EOF
AI Hub relay files synced to NAS mount.
Apply only AI Workspace Hub on Synology:
ssh ${SSH_TARGET} 'cd /volume1/docker/nodedc-platform/platform && sudo bash apply-ai-hub-relay.sh'
EOF