diff --git a/.env.example b/.env.example index 3888db48..32fac03a 100644 --- a/.env.example +++ b/.env.example @@ -34,7 +34,7 @@ PLUGINS_DIR= # Path to plugins/ directory (default: /plugins i # MOLECULE_MCP_ALLOW_SEND_MESSAGE= # Set to "true" to include send_message_to_user in the MCP bridge tool list (issue #810). Excluded by default to prevent unintended WebSocket pushes from CLI sessions. # MOLECULE_MCP_URL=http://localhost:8080 # Platform URL for opencode MCP config (opencode.json). Same as PLATFORM_URL; separate var so opencode configs can reference it without ambiguity. # WORKSPACE_DIR= # Optional global host path bind-mounted to /workspace in every container. Per-workspace workspace_dir column overrides this; if neither is set each workspace gets an isolated Docker named volume. -# MOLECULE_ENV=development # Environment label (development/staging/production). Used for log tagging and conditional behaviour. +MOLECULE_ENV=development # Environment label (development/staging/production). Used for log tagging and for the AdminAuth dev-mode escape hatch (lets the Canvas dashboard keep working after the first workspace is created, when ADMIN_TOKEN is unset). SaaS deployments MUST set MOLECULE_ENV=production. # MOLECULE_ENABLE_TEST_TOKENS= # Set to 1 to expose GET /admin/workspaces/:id/test-token (mints a fresh bearer token for E2E scripts). The route is auto-enabled when MOLECULE_ENV != production; this flag is the explicit override. Leave unset/0 in prod — the route 404s unless enabled. # MOLECULE_ORG_ID= # SaaS only: org UUID set by control plane on tenant machines. When set, workspace provisioning auto-routes through the control plane API instead of Docker. # CP_PROVISION_URL= # Override control plane URL for workspace provisioning (default: https://api.moleculesai.app). Only needed for testing against a non-production control plane. diff --git a/.github/workflows/block-internal-paths.yml b/.github/workflows/block-internal-paths.yml new file mode 100644 index 00000000..da4679b2 --- /dev/null +++ b/.github/workflows/block-internal-paths.yml @@ -0,0 +1,95 @@ +name: Block internal-flavored paths + +# Hard CI gate. Internal content (positioning, competitive briefs, sales +# playbooks, PMM/press drip, draft campaigns) lives in Molecule-AI/internal — +# this public monorepo must never re-acquire those paths. CEO directive +# 2026-04-23 after a fleet-wide audit found 79 internal files leaked here. +# +# Failure mode without this gate: agents (PMM, Research, DevRel, Sales) drop +# briefs into the easiest path their cwd resolves to (root /research, +# /marketing, /docs/marketing) and gitignore alone won't catch a `git add -f` +# or a stale gitignore line. This workflow is the mechanical backstop. + +on: + pull_request: + types: [opened, synchronize, reopened] + push: + branches: [main, staging] + +jobs: + check: + name: Block forbidden paths + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 2 # need previous commit to diff against on push events + + - name: Refuse if forbidden paths appear + run: | + # Paths that must NEVER live in the public monorepo. Add to this + # list narrowly — broader patterns belong in .gitignore so day-to-day + # docs work isn't accidentally blocked. + FORBIDDEN_PATTERNS=( + "^research/" + "^marketing/" + "^docs/marketing/" + "^comment-[0-9]+\.json$" + "^test-pmm.*\.(txt|md)$" + "^tick-reflections.*\.(txt|md)$" + ".*-temp\.(md|txt)$" + ) + + # Determine the diff base. + if [ "${{ github.event_name }}" = "pull_request" ]; then + BASE="${{ github.event.pull_request.base.sha }}" + HEAD="${{ github.event.pull_request.head.sha }}" + else + BASE="${{ github.event.before }}" + HEAD="${{ github.event.after }}" + fi + + # Files added or modified in this change. + if [ -z "$BASE" ] || echo "$BASE" | grep -qE '^0+$'; then + # New branch / no previous SHA — check entire tree. + CHANGED=$(git ls-tree -r --name-only HEAD) + else + CHANGED=$(git diff --name-only --diff-filter=AM "$BASE" "$HEAD") + fi + + if [ -z "$CHANGED" ]; then + echo "No changed files to inspect." + exit 0 + fi + + OFFENDING="" + for path in $CHANGED; do + for pattern in "${FORBIDDEN_PATTERNS[@]}"; do + if echo "$path" | grep -qE "$pattern"; then + OFFENDING="${OFFENDING}${path} (matched: ${pattern})\n" + break + fi + done + done + + if [ -n "$OFFENDING" ]; then + echo "::error::Forbidden internal-flavored paths detected:" + printf "$OFFENDING" + echo "" + echo "These paths belong in Molecule-AI/internal, not this public repo." + echo "See docs/internal-content-policy.md for canonical locations." + echo "" + echo "If your file is genuinely public-facing (e.g. a blog post" + echo "ready to ship), use one of these alternatives instead:" + echo " • Public-bound blog posts: docs/blog/.md" + echo " • Public-bound tutorials: docs/tutorials/.md" + echo " • Public devrel content: docs/devrel/.md" + echo "" + echo "If you legitimately need to add a new top-level path that" + echo "happens to match a forbidden pattern, edit" + echo ".github/workflows/block-internal-paths.yml and update the" + echo "FORBIDDEN_PATTERNS list with reviewer signoff." + exit 1 + fi + + echo "✓ No forbidden paths in this change." diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1350f68c..2ee5fe5b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,9 +5,17 @@ on: branches: [main, staging] pull_request: branches: [main, staging] + # GitHub merge queue fires `merge_group` for the queue's pre-merge CI run. + # Required so the queue gets a real check result instead of a false-green + # from the absence of a triggered workflow. Safe to add unconditionally — + # the event simply doesn't fire until the queue is enabled on the branch. + merge_group: + types: [checks_requested] # Cancel in-progress CI runs when a new commit arrives on the same ref. -# This prevents stale runs from queuing behind each other. +# This prevents stale runs from queuing behind each other. The merge_group +# refs (refs/heads/gh-readonly-queue/...) get their own concurrency group +# automatically because github.ref differs from the PR ref. concurrency: group: ci-${{ github.ref }} cancel-in-progress: true @@ -142,7 +150,8 @@ jobs: # Strip the package-import prefix so we can match .coverage-allowlist.txt # entries written as paths relative to workspace-server/. - rel=$(echo "$file" | sed 's|^github.com/Molecule-AI/molecule-monorepo/platform/||') + # Handle both module paths: platform/workspace-server/... and platform/... + rel=$(echo "$file" | sed 's|^github.com/Molecule-AI/molecule-monorepo/platform/workspace-server/||; s|^github.com/Molecule-AI/molecule-monorepo/platform/||') if echo "$ALLOWLIST" | grep -qxF "$rel"; then echo "::warning file=workspace-server/$rel::Critical file at ${pct}% coverage (allowlisted, #1823) — fix before expiry." diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e1661304..22d095b4 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -18,6 +18,12 @@ on: branches: [main, staging] pull_request: branches: [main, staging] + # GitHub merge queue fires `merge_group` for the queue's pre-merge CI run. + # Required so CodeQL Analyze checks get a real result on the queued + # commit instead of a false-green. Event only fires once merge queue is + # enabled on the target branch — safe to add unconditionally. + merge_group: + types: [checks_requested] schedule: # Weekly run picks up findings in code that hasn't been touched. - cron: '30 1 * * 0' diff --git a/.github/workflows/e2e-api.yml b/.github/workflows/e2e-api.yml index 43f1004c..a0238dcd 100644 --- a/.github/workflows/e2e-api.yml +++ b/.github/workflows/e2e-api.yml @@ -1,35 +1,21 @@ name: E2E API Smoke Test # Extracted from ci.yml so workflow-level concurrency can protect this job # from run-level cancellation (issue #458). -# -# Problem: the job-level `concurrency.cancel-in-progress: false` in ci.yml -# prevented *sibling* E2E jobs from killing each other, but GitHub still -# cancelled the parent *workflow run* when a new push arrived. Since the job -# lived inside that run, it got cancelled too. -# -# Fix: a dedicated workflow gets its own concurrency group at the workflow -# level. New pushes to the same branch queue here instead of cancelling. -# Fast jobs (platform-build, canvas-build, etc.) stay in ci.yml and continue -# to benefit from run-level cancellation for quick feedback. on: push: - branches: [main] + branches: [main, staging] paths: - 'workspace-server/**' - 'tests/e2e/**' - '.github/workflows/e2e-api.yml' pull_request: - branches: [main] + branches: [main, staging] paths: - 'workspace-server/**' - 'tests/e2e/**' - '.github/workflows/e2e-api.yml' -# Workflow-level concurrency: new runs queue rather than cancel. -# `cancel-in-progress: false` is load-bearing — without it GitHub would still -# cancel this run when the next push arrives, defeating the whole fix. -# The group key includes github.ref so PRs don't compete with main. concurrency: group: e2e-api-${{ github.ref }} cancel-in-progress: false @@ -39,12 +25,6 @@ jobs: name: E2E API Smoke Test runs-on: ubuntu-latest timeout-minutes: 15 - # Postgres + Redis run as sibling containers via `docker run`. Could - # switch to a `services:` block now that we're on Linux, but the - # explicit start-and-wait gives us pg_isready / PING readiness checks - # that match the 30-tick timeouts the rest of the job expects. Ports - # 15432/16379 avoid collision with anything the host may already have - # on the standard ports. env: DATABASE_URL: postgres://dev:dev@localhost:15432/molecule?sslmode=disable REDIS_URL: redis://localhost:16379 @@ -61,12 +41,7 @@ jobs: - name: Start Postgres (docker) run: | docker rm -f "$PG_CONTAINER" 2>/dev/null || true - docker run -d --name "$PG_CONTAINER" \ - -e POSTGRES_USER=dev \ - -e POSTGRES_PASSWORD=dev \ - -e POSTGRES_DB=molecule \ - -p 15432:5432 \ - postgres:16 + docker run -d --name "$PG_CONTAINER" -e POSTGRES_USER=dev -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=molecule -p 15432:5432 postgres:16 for i in $(seq 1 30); do if docker exec "$PG_CONTAINER" pg_isready -U dev >/dev/null 2>&1; then echo "Postgres ready after ${i}s" @@ -89,6 +64,7 @@ jobs: sleep 1 done echo "::error::Redis did not become ready in 15s" + docker logs "$REDIS_CONTAINER" || true exit 1 - name: Build platform working-directory: workspace-server @@ -111,16 +87,14 @@ jobs: cat workspace-server/platform.log || true exit 1 - name: Assert migrations applied - # Migrations auto-run at platform boot. Fail fast if they silently - # didn't — catches future migration-author mistakes before the E2E run. run: | tables=$(docker exec "$PG_CONTAINER" psql -U dev -d molecule -tAc "SELECT count(*) FROM information_schema.tables WHERE table_schema='public' AND table_name='workspaces'") if [ "$tables" != "1" ]; then - echo "::error::Migrations did not apply — 'workspaces' table missing" + echo "::error::Migrations did not apply" cat workspace-server/platform.log || true exit 1 fi - echo "Migrations OK (workspaces table present)" + echo "Migrations OK" - name: Run E2E API tests run: bash tests/e2e/test_api.sh - name: Dump platform log on failure diff --git a/.gitignore b/.gitignore index 98430d60..05da25ee 100644 --- a/.gitignore +++ b/.gitignore @@ -117,14 +117,32 @@ backups/ # Cloned-via-manifest dirs — populated locally by scripts/clone-manifest.sh, # tracked in their own standalone repos. Never commit to core. -# org-templates live in Molecule-AI/molecule-ai-org-template-* repos. +# org-templates live in Molecule-AI/molecule-ai-org-template-* repos +# (including molecule-dev — no checkin exception). # plugins live in Molecule-AI/molecule-ai-plugin-* repos. -# Exception: molecule-dev is checked in so it doubles as the internal-team -# seed template (not fetched via clone-manifest). -/org-templates/* -!/org-templates/molecule-dev/ +# All three directories are populated by scripts/clone-manifest.sh +# (now auto-run by infra/scripts/setup.sh). The in-tree exception for +# molecule-dev was removed because the checked-in copy drifted from +# the standalone repo and shipped with broken !include references to +# role files that never existed in the snapshot. +/org-templates/ /plugins/ /workspace-configs-templates/ # Cloned by publish-workspace-server-image.yml so the Dockerfile's # replace-directive path resolves. Lives in its own repo. /molecule-ai-plugin-github-app-auth/ + +# Internal-flavored content lives in Molecule-AI/internal — NEVER in this +# public monorepo. Migrated 2026-04-23 (CEO directive). The CI workflow +# .github/workflows/block-internal-paths.yml enforces this; this gitignore +# is the second line of defence so accidental local writes don't reach a +# commit. See docs/internal-content-policy.md for the full rationale. +/research/ +/marketing/ +/docs/marketing/ +# Common temp/scratch patterns agents have produced +/comment-*.json +*-temp.md +*-temp.txt +/test-pmm-*.txt +/tick-reflections-*.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e7cf4d45..8eaea59e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,6 +12,11 @@ development workflow, conventions, and how to get your changes merged. - **Python 3.11+** — workspace runtime - **Docker** — infrastructure services (Postgres, Redis) - **Git** — with hooks path set to `.githooks` +- **jq** — parses `manifest.json` during `setup.sh` to clone the + template/plugin registry. Install via `brew install jq` (macOS) or + `apt install jq` (Debian). Without it, setup.sh prints a note and + leaves the registry dirs empty (recoverable by installing jq and + re-running). ### Setup diff --git a/README.md b/README.md index a845b6d0..3e3e0fb4 100644 --- a/README.md +++ b/README.md @@ -261,6 +261,12 @@ cp .env.example .env # and Temporal (:7233 gRPC, :8233 UI) on the shared # `molecule-monorepo-net` Docker network. Temporal runs with # no auth on localhost — dev-only; production must gate it. +# +# Also populates the template/plugin registry by cloning every repo +# listed in manifest.json into workspace-configs-templates/, +# org-templates/, and plugins/. Requires jq — install via +# `brew install jq` (macOS) or `apt install jq` (Debian). Idempotent: +# re-runs skip any target dir that's already populated. cd workspace-server go run ./cmd/server # applies pending migrations on first boot diff --git a/README.zh-CN.md b/README.zh-CN.md index 7538c5c9..20df5685 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -260,6 +260,11 @@ cp .env.example .env # 以及 Temporal (:7233 gRPC, :8233 UI),全部挂在共享的 # `molecule-monorepo-net` Docker 网络上。Temporal 默认无鉴权, # 仅用于本地开发;生产环境必须加 mTLS / API Key。 +# +# 同时会根据 manifest.json 拉取所有模板/插件仓库到 +# workspace-configs-templates/、org-templates/、plugins/ 三个目录。 +# 需要安装 jq:`brew install jq`(macOS)或 `apt install jq`(Debian)。 +# 脚本幂等:已经存在内容的目录会被跳过,可以安全重跑。 cd workspace-server go run ./cmd/server # 首次启动会自动跑 schema_migrations 里未应用的迁移 diff --git a/canvas/src/components/Canvas.tsx b/canvas/src/components/Canvas.tsx index 0cb3c3de..16c299cb 100644 --- a/canvas/src/components/Canvas.tsx +++ b/canvas/src/components/Canvas.tsx @@ -1,21 +1,18 @@ "use client"; -import { useCallback, useRef, useMemo, useEffect, useState } from "react"; +import { useCallback, useMemo } from "react"; import { ReactFlow, ReactFlowProvider, Background, Controls, MiniMap, - useReactFlow, - type OnNodeDrag, - type Node, type Edge, BackgroundVariant, } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; -import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas"; +import { useCanvasStore } from "@/store/canvas"; import { A2ATopologyOverlay } from "./A2ATopologyOverlay"; import { WorkspaceNode } from "./WorkspaceNode"; import { SidePanel } from "./SidePanel"; @@ -27,17 +24,19 @@ import { BundleDropZone } from "./BundleDropZone"; import { EmptyState } from "./EmptyState"; import { OnboardingWizard } from "./OnboardingWizard"; import { SearchDialog } from "./SearchDialog"; -import { Toaster } from "./Toaster"; +import { Toaster, showToast } from "./Toaster"; import { Toolbar } from "./Toolbar"; import { ConfirmDialog } from "./ConfirmDialog"; import { api } from "@/lib/api"; -import { showToast } from "./Toaster"; -// Phase 20 components import { SettingsPanel, DeleteConfirmDialog } from "./settings"; -// Phase 20.3 batch operations import { BatchActionBar } from "./BatchActionBar"; import { ProvisioningTimeout } from "./ProvisioningTimeout"; +import { DropTargetBadge } from "./canvas/DropTargetBadge"; +import { useDragHandlers } from "./canvas/useDragHandlers"; +import { useKeyboardShortcuts } from "./canvas/useKeyboardShortcuts"; +import { useCanvasViewport } from "./canvas/useCanvasViewport"; + const nodeTypes = { workspaceNode: WorkspaceNode, }; @@ -63,57 +62,33 @@ function CanvasInner() { const edges = useCanvasStore((s) => s.edges); const a2aEdges = useCanvasStore((s) => s.a2aEdges); const showA2AEdges = useCanvasStore((s) => s.showA2AEdges); - // Merge topology edges with A2A overlay edges via useMemo (no new object in selector) const allEdges = useMemo( () => (showA2AEdges ? [...edges, ...a2aEdges] : edges), - [edges, a2aEdges, showA2AEdges] + [edges, a2aEdges, showA2AEdges], ); const onNodesChange = useCanvasStore((s) => s.onNodesChange); - const savePosition = useCanvasStore((s) => s.savePosition); const selectNode = useCanvasStore((s) => s.selectNode); const selectedNodeId = useCanvasStore((s) => s.selectedNodeId); - const setDragOverNode = useCanvasStore((s) => s.setDragOverNode); - const nestNode = useCanvasStore((s) => s.nestNode); - const isDescendant = useCanvasStore((s) => s.isDescendant); - const dragStartParentRef = useRef(null); - const { getIntersectingNodes } = useReactFlow(); - const onNodeDragStart: OnNodeDrag> = useCallback( - (_event, node) => { - dragStartParentRef.current = (node.data as WorkspaceNodeData).parentId; - }, - [] - ); + // Drag / nest lifecycle — handlers, pending-nest state, confirm/cancel. + const { + onNodeDragStart, + onNodeDrag, + onNodeDragStop, + pendingNest, + confirmNest, + cancelNest, + } = useDragHandlers(); - const onNodeDrag: OnNodeDrag> = useCallback( - (_event, node) => { - // Only consider nodes within a proximity threshold as nest targets. - // Without this check, getIntersectingNodes returns any node whose bounding - // boxes overlap — which can be hundreds of pixels away on a sparse canvas, - // causing accidental nesting when the user drags a node across the board. - const thresholdPx = 100; - const threshold = thresholdPx * thresholdPx; // compare squared distances - let nearest: { id: string; dist: number } | null = null; - for (const candidate of getIntersectingNodes(node)) { - if (candidate.id === node.id || isDescendant(node.id, candidate.id)) continue; - const dx = candidate.position.x - node.position.x; - const dy = candidate.position.y - node.position.y; - const dist2 = dx * dx + dy * dy; - if (dist2 <= threshold && (!nearest || dist2 < nearest.dist)) { - nearest = { id: candidate.id, dist: dist2 }; - } - } - setDragOverNode(nearest?.id ?? null); - }, - [getIntersectingNodes, isDescendant, setDragOverNode] - ); + // Window-level keyboard shortcuts (Esc, Enter, Shift+Enter, Cmd+]/[, Z). + useKeyboardShortcuts(); + + // Pan-to-node / zoom-to-team CustomEvent listeners + viewport save. + const { onMoveEnd } = useCanvasViewport(); - // Confirmation dialog state for structure changes - const [pendingNest, setPendingNest] = useState<{ nodeId: string; targetId: string | null; nodeName: string; targetName: string } | null>(null); // Delete-confirmation lives in the store so the dialog survives ContextMenu // unmounting — the prior local-in-ContextMenu state raced with the menu's - // outside-click handler (the portal-rendered Confirm button counted as - // "outside" and closed the menu, killing the dialog mid-click). + // outside-click handler. const pendingDelete = useCanvasStore((s) => s.pendingDelete); const setPendingDelete = useCanvasStore((s) => s.setPendingDelete); const removeNode = useCanvasStore((s) => s.removeNode); @@ -129,48 +104,6 @@ function CanvasInner() { } }, [pendingDelete, setPendingDelete, removeNode]); - // Cascade guard: include child count in the warning message when the workspace - // has children, so the user understands the blast radius before clicking Delete All. - const cascadeMessage = pendingDelete?.hasChildren - ? `⚠️ Deleting "${pendingDelete.name}" will permanently delete all child workspaces and their data. This cannot be undone.` - : null; - - const onNodeDragStop: OnNodeDrag> = useCallback( - (_event, node) => { - const { dragOverNodeId, nodes: allNodes } = useCanvasStore.getState(); - setDragOverNode(null); - - const nodeName = (node.data as WorkspaceNodeData).name; - - if (dragOverNodeId) { - const targetNode = allNodes.find((n) => n.id === dragOverNodeId); - const targetName = targetNode?.data.name || "Unknown"; - setPendingNest({ nodeId: node.id, targetId: dragOverNodeId, nodeName, targetName }); - } else { - const currentParentId = (node.data as WorkspaceNodeData).parentId; - if (currentParentId) { - const parentNode = allNodes.find((n) => n.id === currentParentId); - const parentName = parentNode?.data.name || "Unknown"; - setPendingNest({ nodeId: node.id, targetId: null, nodeName, targetName: parentName }); - } - } - - savePosition(node.id, node.position.x, node.position.y); - }, - [savePosition, setDragOverNode] - ); - - const confirmNest = useCallback(() => { - if (pendingNest) { - nestNode(pendingNest.nodeId, pendingNest.targetId); - setPendingNest(null); - } - }, [pendingNest, nestNode]); - - const cancelNest = useCallback(() => { - setPendingNest(null); - }, []); - const onPaneClick = useCallback(() => { selectNode(null); const state = useCanvasStore.getState(); @@ -178,123 +111,14 @@ function CanvasInner() { state.clearSelection(); }, [selectNode]); - // Team zoom-in: double-click a team node to zoom to its children - const { fitBounds, fitView } = useReactFlow(); - - // Pan to newly deployed workspace. - // Uses fitView({ nodes }) so the viewport adapts to any current zoom level - // instead of forcing zoom=1 (which was jarring when the user was zoomed out). - const panTimerRef = useRef>(undefined); - useEffect(() => { - const handler = (e: Event) => { - const { nodeId } = (e as CustomEvent<{ nodeId: string }>).detail; - // Small delay so ReactFlow has time to measure the newly rendered node - clearTimeout(panTimerRef.current); - panTimerRef.current = setTimeout(() => { - fitView({ nodes: [{ id: nodeId }], duration: 400, padding: 0.3 }); - }, 100); - }; - window.addEventListener("molecule:pan-to-node", handler); - return () => { - window.removeEventListener("molecule:pan-to-node", handler); - clearTimeout(panTimerRef.current); - }; - }, [fitView]); - useEffect(() => { - const handler = (e: Event) => { - const { nodeId } = (e as CustomEvent).detail; - const state = useCanvasStore.getState(); - const children = state.nodes.filter((n) => n.data.parentId === nodeId); - if (children.length === 0) return; - - const parent = state.nodes.find((n) => n.id === nodeId); - const allNodes = parent ? [parent, ...children] : children; - - let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; - for (const n of allNodes) { - minX = Math.min(minX, n.position.x); - minY = Math.min(minY, n.position.y); - maxX = Math.max(maxX, n.position.x + 260); - maxY = Math.max(maxY, n.position.y + 120); - } - - fitBounds( - { x: minX - 50, y: minY - 50, width: maxX - minX + 100, height: maxY - minY + 100 }, - { padding: 0.2, duration: 500 } - ); - }; - window.addEventListener("molecule:zoom-to-team", handler); - return () => window.removeEventListener("molecule:zoom-to-team", handler); - }, [fitBounds]); - - // Keyboard shortcuts - useEffect(() => { - const handler = (e: KeyboardEvent) => { - if (e.key === "Escape") { - const state = useCanvasStore.getState(); - if (state.contextMenu) { - state.closeContextMenu(); - } else if (state.selectedNodeIds.size > 0) { - state.clearSelection(); - } else if (state.selectedNodeId) { - state.selectNode(null); - } - } - - // Z — keyboard equivalent for double-click zoom-to-team (WCAG 2.1.1) - if (e.key === "z" || e.key === "Z") { - const tag = (e.target as HTMLElement).tagName; - if ( - tag === "INPUT" || - tag === "TEXTAREA" || - tag === "SELECT" || - (e.target as HTMLElement).isContentEditable - ) - return; - const state = useCanvasStore.getState(); - const selectedId = state.selectedNodeId; - if (!selectedId) return; - const hasChildren = state.nodes.some((n) => n.data.parentId === selectedId); - if (hasChildren) { - window.dispatchEvent( - new CustomEvent("molecule:zoom-to-team", { detail: { nodeId: selectedId } }) - ); - } - } - }; - window.addEventListener("keydown", handler); - return () => window.removeEventListener("keydown", handler); - }, []); - - const saveViewport = useCanvasStore((s) => s.saveViewport); const viewport = useCanvasStore((s) => s.viewport); - const saveTimerRef = useRef>(undefined); - - // Cleanup debounced save timer on unmount - useEffect(() => { - return () => clearTimeout(saveTimerRef.current); - }, []); - - const onMoveEnd = useCallback( - (_event: unknown, vp: { x: number; y: number; zoom: number }) => { - // Debounce viewport saves to avoid spamming the API - clearTimeout(saveTimerRef.current); - saveTimerRef.current = setTimeout(() => { - saveViewport(vp.x, vp.y, vp.zoom); - }, 1000); - }, - [saveViewport] - ); - const defaultViewport = useMemo( () => ({ x: viewport.x, y: viewport.y, zoom: viewport.zoom }), // Only use the initial viewport — don't re-render on every save // eslint-disable-next-line react-hooks/exhaustive-deps - [] + [], ); - // Determine which workspace ID to use for global settings. - // Fall back to "global" when no specific node is selected. const settingsWorkspaceId = selectedNodeId ?? "global"; return ( @@ -306,112 +130,118 @@ function CanvasInner() { Skip to canvas
- - + + + { + // Parents show as a filled region — hierarchy visible at + // a glance in the minimap without needing to zoom. + const hasChildren = nodes.some((n) => n.parentId === node.id); + if (hasChildren) return "#3b82f6"; + const status = (node.data as Record)?.status; + switch (status) { + case "online": + return "#34d399"; + case "offline": + return "#52525b"; + case "degraded": + return "#fbbf24"; + case "failed": + return "#f87171"; + case "provisioning": + return "#38bdf8"; + default: + return "#3f3f46"; + } + }} + nodeStrokeColor={(node) => { + const hasChildren = nodes.some((n) => n.parentId === node.id); + return hasChildren ? "#60a5fa" : "transparent"; + }} + nodeStrokeWidth={2} + nodeBorderRadius={4} + /> + + + + {/* Screen-reader live region: announces workspace count on canvas load or change */} +
+ {nodes.filter((n) => !n.parentId).length === 0 + ? "No workspaces on canvas" + : `${nodes.filter((n) => !n.parentId).length} workspace${nodes.filter((n) => !n.parentId).length !== 1 ? "s" : ""} on canvas`} +
+ + {nodes.length === 0 && } + + + + + + + + + + + + {!selectedNodeId && } + + + - setPendingDelete(null)} /> - { - const status = (node.data as Record)?.status; - switch (status) { - case "online": - return "#34d399"; - case "offline": - return "#52525b"; - case "degraded": - return "#fbbf24"; - case "failed": - return "#f87171"; - case "provisioning": - return "#38bdf8"; - default: - return "#3f3f46"; - } - }} - nodeStrokeWidth={0} - nodeBorderRadius={4} - /> - - {/* Screen-reader live region: announces workspace count when canvas loads or changes */} -
- {nodes.filter((n) => !n.data.parentId).length === 0 - ? "No workspaces on canvas" - : `${nodes.filter((n) => !n.data.parentId).length} workspace${nodes.filter((n) => !n.data.parentId).length !== 1 ? "s" : ""} on canvas`} -
- - {nodes.length === 0 && } - - - - - - - - - - - - {!selectedNodeId && } - - - {/* Confirmation dialog for structure changes */} - - - {/* Confirmation dialog for workspace delete — driven by store */} - setPendingDelete(null)} - /> - - {/* Settings Panel — global secrets management drawer */} - - + +
); diff --git a/canvas/src/components/ContextMenu.tsx b/canvas/src/components/ContextMenu.tsx index d87e62b3..475e8319 100644 --- a/canvas/src/components/ContextMenu.tsx +++ b/canvas/src/components/ContextMenu.tsx @@ -202,15 +202,22 @@ export function ContextMenu() { closeContextMenu(); }, [contextMenu, closeContextMenu]); + const setCollapsed = useCanvasStore((s) => s.setCollapsed); const handleCollapse = useCallback(async () => { if (!contextMenu) return; + const nodeId = contextMenu.nodeId; + const wasCollapsed = !!contextMenu.nodeData.collapsed; + // Optimistic local flip so the card shrinks/expands immediately. + // Descendants' hidden flags are toggled atomically by the store. + setCollapsed(nodeId, !wasCollapsed); try { - await api.post(`/workspaces/${contextMenu.nodeId}/collapse`, {}); + await api.patch(`/workspaces/${nodeId}`, { collapsed: !wasCollapsed }); } catch (e) { + setCollapsed(nodeId, wasCollapsed); showToast("Collapse failed", "error"); } closeContextMenu(); - }, [contextMenu, closeContextMenu]); + }, [contextMenu, setCollapsed, closeContextMenu]); const handleRemoveFromTeam = useCallback(async () => { if (!contextMenu) return; @@ -223,6 +230,13 @@ export function ContextMenu() { closeContextMenu(); }, [contextMenu, nestNode, closeContextMenu]); + const arrangeChildren = useCanvasStore((s) => s.arrangeChildren); + const handleArrangeChildren = useCallback(() => { + if (!contextMenu) return; + arrangeChildren(contextMenu.nodeId); + closeContextMenu(); + }, [contextMenu, arrangeChildren, closeContextMenu]); + const handleZoomToTeam = useCallback(() => { if (!contextMenu) return; window.dispatchEvent( @@ -250,7 +264,12 @@ export function ContextMenu() { : []), ...(hasChildren ? [ - { label: "Collapse Team", icon: "◁", action: handleCollapse }, + { label: "Arrange Children", icon: "▦", action: handleArrangeChildren }, + { + label: contextMenu.nodeData.collapsed ? "Expand Team" : "Collapse Team", + icon: contextMenu.nodeData.collapsed ? "▽" : "◁", + action: handleCollapse, + }, { label: "Zoom to Team", icon: "⊕", action: handleZoomToTeam }, ] : [{ label: "Expand to Team", icon: "▷", action: handleExpand }]), diff --git a/canvas/src/components/CookieConsent.tsx b/canvas/src/components/CookieConsent.tsx index 5ea0dc57..2f04df39 100644 --- a/canvas/src/components/CookieConsent.tsx +++ b/canvas/src/components/CookieConsent.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useState } from "react"; +import { isSaaSTenant } from "@/lib/tenant"; const STORAGE_KEY = "molecule_cookie_consent"; @@ -74,7 +75,18 @@ export function CookieConsent() { // Read persisted decision on mount. useState's initialState can't run // on first render because localStorage is SSR-unsafe — defer to // useEffect so the initial HTML is identical to the server snapshot. + // + // The banner is SaaS-only: it carries a link to the hosted + // privacy policy (moleculesai.app/legal/privacy) and presumes + // GDPR/ePrivacy obligations that only apply to the hosted offering. + // Self-hosted / local-dev / Vercel-preview hosts get no banner — + // matches the `isSaaSTenant()` convention used by AuthGate and + // the tier picker. useEffect(() => { + if (!isSaaSTenant()) { + setVisible(false); + return; + } setVisible(getStoredConsent() === null); }, []); diff --git a/canvas/src/components/CreateWorkspaceDialog.tsx b/canvas/src/components/CreateWorkspaceDialog.tsx index 6318d0ae..344f0e46 100644 --- a/canvas/src/components/CreateWorkspaceDialog.tsx +++ b/canvas/src/components/CreateWorkspaceDialog.tsx @@ -89,7 +89,13 @@ export function CreateWorkspaceButton() { ], [isSaaS], ); - const defaultTier = isSaaS ? 4 : 1; + // T3 ("Privileged") is the self-hosted default — gives agents the + // read_write workspace mount + Docker daemon access most templates + // expect to do real work. T1 sandboxed and T2 standard are kept as + // explicit opt-ins for low-trust agents. SaaS still defaults to T4 + // because every SaaS workspace gets its own EC2 (sibling VMs, no + // shared blast radius — see isSaaSTenant() / tier picker hide logic). + const defaultTier = isSaaS ? 4 : 3; const [tier, setTier] = useState(defaultTier); // Refs for roving tabIndex on the tier radio group (WCAG 2.1 arrow-key nav) diff --git a/canvas/src/components/Legend.tsx b/canvas/src/components/Legend.tsx index ad7ec8fa..10964fd3 100644 --- a/canvas/src/components/Legend.tsx +++ b/canvas/src/components/Legend.tsx @@ -1,12 +1,18 @@ "use client"; import { STATUS_CONFIG } from "@/lib/design-tokens"; +import { useCanvasStore } from "@/store/canvas"; const LEGEND_STATUSES = ["online", "provisioning", "degraded", "failed", "paused", "offline"] as const; export function Legend() { + // TemplatePalette (when open) is fixed top-0 left-0 w-[280px] — the + // default bottom-6 left-4 position of this legend would sit under it. + // Shift past the 280 px palette + a 16 px gap when the palette is open. + const paletteOpen = useCanvasStore((s) => s.templatePaletteOpen); + const leftClass = paletteOpen ? "left-[296px]" : "left-4"; return ( -
+
Legend
{/* Status */} diff --git a/canvas/src/components/MissingKeysModal.tsx b/canvas/src/components/MissingKeysModal.tsx index 91346776..e6712177 100644 --- a/canvas/src/components/MissingKeysModal.tsx +++ b/canvas/src/components/MissingKeysModal.tsx @@ -1,33 +1,374 @@ "use client"; -import { useState, useEffect, useCallback, useRef } from "react"; +import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { api } from "@/lib/api"; -import { getKeyLabel } from "@/lib/deploy-preflight"; +import { getKeyLabel, type ProviderChoice } from "@/lib/deploy-preflight"; interface Props { open: boolean; + /** Flat list of every candidate env var. Used as the fallback input + * set when `providers` is empty (or length 1). */ missingKeys: string[]; + /** Grouped provider options derived from the template's models[] / + * required_env. When length ≥ 2 the modal shows a radio picker. */ + providers?: ProviderChoice[]; + /** Runtime slug — used only for the "The runtime …" + * headline; behavior is driven by providers/missingKeys. */ runtime: string; - /** Called when user adds all keys and wants to proceed with deploy. */ + /** Called when all required keys for the chosen provider are saved. */ onKeysAdded: () => void; - /** Called when user cancels the deploy. */ + /** Called when the user cancels the deploy. */ onCancel: () => void; - /** Called when user wants to open the Settings Panel (Config tab → Secrets). */ + /** Optional — open the Settings Panel (Config tab → Secrets). */ onOpenSettings?: () => void; - /** Optional workspace ID — if provided, secrets are saved at workspace scope. */ + /** If provided, secrets save at workspace scope instead of global. */ workspaceId?: string; } interface KeyEntry { key: string; - label: string; value: string; saved: boolean; saving: boolean; error: string | null; } +/** + * MissingKeysModal + * ---------------- + * Dispatches between two modes based on what the template declares: + * + * 1. PROVIDER PICKER — when the preflight returned ≥2 `providers` (e.g. + * a Hermes template whose models[].required_env enumerate OpenRouter, + * Anthropic, Nous-native, etc.). Radio list of options, saving the + * chosen option's env vars satisfies the deploy. + * + * 2. ALL-KEYS — every entry in `missingKeys` rendered as its own input, + * all must save before Deploy. Used when the template has a single + * provider option or no declared alternatives. + * + * The modal never hardcodes per-runtime provider lists; the upstream + * preflight derives that from the template config.yaml. + */ export function MissingKeysModal({ + open, + missingKeys, + providers, + runtime, + onKeysAdded, + onCancel, + onOpenSettings, + workspaceId, +}: Props) { + const pickerProviders = providers ?? []; + const pickerMode = pickerProviders.length > 1; + + if (pickerMode) { + return ( + + ); + } + + // Prefer the (single) provider's envVars over the raw missingKeys when + // we have one — the provider list is already de-duped and ordered. + const keys = + pickerProviders.length === 1 ? pickerProviders[0].envVars : missingKeys; + + return ( + + ); +} + +// ----------------------------------------------------------------------------- +// Provider-picker mode — choose one option, save its env var(s), deploy. +// ----------------------------------------------------------------------------- + +function ProviderPickerModal({ + open, + providers, + runtime, + onKeysAdded, + onCancel, + onOpenSettings, + workspaceId, +}: { + open: boolean; + providers: ProviderChoice[]; + runtime: string; + onKeysAdded: () => void; + onCancel: () => void; + onOpenSettings?: () => void; + workspaceId?: string; +}) { + const [selectedId, setSelectedId] = useState(providers[0].id); + const [entries, setEntries] = useState([]); + const firstInputRef = useRef(null); + + const selected = useMemo( + () => providers.find((p) => p.id === selectedId) ?? providers[0], + [providers, selectedId], + ); + + useEffect(() => { + if (!open) return; + setSelectedId(providers[0].id); + }, [open, providers]); + + useEffect(() => { + if (!open) return; + setEntries( + selected.envVars.map((key) => ({ + key, + value: "", + saved: false, + saving: false, + error: null, + })), + ); + }, [open, selected]); + + useEffect(() => { + if (!open) return; + const raf = requestAnimationFrame(() => firstInputRef.current?.focus()); + return () => cancelAnimationFrame(raf); + }, [open, selectedId]); + + useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") onCancel(); + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [open, onCancel]); + + const updateEntry = useCallback( + (index: number, updates: Partial) => { + setEntries((prev) => + prev.map((e, i) => (i === index ? { ...e, ...updates } : e)), + ); + }, + [], + ); + + const handleSaveKey = useCallback( + async (index: number) => { + const entry = entries[index]; + if (!entry.value.trim()) return; + updateEntry(index, { saving: true, error: null }); + try { + if (workspaceId) { + await api.put(`/workspaces/${workspaceId}/secrets`, { + key: entry.key, + value: entry.value.trim(), + }); + } else { + await api.put("/settings/secrets", { + key: entry.key, + value: entry.value.trim(), + }); + } + updateEntry(index, { saved: true, saving: false }); + } catch (e) { + updateEntry(index, { + saving: false, + error: e instanceof Error ? e.message : "Failed to save", + }); + } + }, + [entries, updateEntry, workspaceId], + ); + + if (!open) return null; + + const allSaved = entries.length > 0 && entries.every((e) => e.saved); + const anySaving = entries.some((e) => e.saving); + const runtimeLabel = runtime + .replace(/[-_]/g, " ") + .replace(/\b\w/g, (c) => c.toUpperCase()); + + return ( +
+ + ); +} + +// ----------------------------------------------------------------------------- +// All-keys mode — every missingKey rendered as its own input, all required. +// ----------------------------------------------------------------------------- + +function AllKeysModal({ open, missingKeys, runtime, @@ -35,18 +376,23 @@ export function MissingKeysModal({ onCancel, onOpenSettings, workspaceId, -}: Props) { +}: { + open: boolean; + missingKeys: string[]; + runtime: string; + onKeysAdded: () => void; + onCancel: () => void; + onOpenSettings?: () => void; + workspaceId?: string; +}) { const [entries, setEntries] = useState([]); const [globalError, setGlobalError] = useState(null); - const firstInputRef = useRef(null); - // Initialize entries when modal opens or missingKeys change useEffect(() => { if (!open) return; setEntries( missingKeys.map((key) => ({ key, - label: getKeyLabel(key), value: "", saved: false, saving: false, @@ -56,14 +402,6 @@ export function MissingKeysModal({ setGlobalError(null); }, [open, missingKeys]); - // Focus first input when modal opens - useEffect(() => { - if (!open) return; - const raf = requestAnimationFrame(() => { - firstInputRef.current?.focus(); - }); - return () => cancelAnimationFrame(raf); - }, [open]); useEffect(() => { if (!open) return; const handler = (e: KeyboardEvent) => { @@ -90,7 +428,6 @@ export function MissingKeysModal({ updateEntry(index, { saving: true, error: null }); try { - // Save to global scope by default (available to all workspaces) if (workspaceId) { await api.put(`/workspaces/${workspaceId}/secrets`, { key: entry.key, @@ -127,39 +464,45 @@ export function MissingKeysModal({ onKeysAdded(); }, [entries, onKeysAdded]); + // Focus trap: auto-focus first input when modal opens + useEffect(() => { + if (!open) return; + const timer = requestAnimationFrame(() => { + document.getElementById("missing-keys-title")?.focus(); + }); + return () => cancelAnimationFrame(timer); + }, [open]); + if (!open) return null; - const allSaved = entries.every((e) => e.saved); + const allSaved = entries.length > 0 && entries.every((e) => e.saved); const anySaving = entries.some((e) => e.saving); - const runtimeLabel = runtime.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); + const runtimeLabel = runtime + .replace(/[-_]/g, " ") + .replace(/\b\w/g, (c) => c.toUpperCase()); return (
- {/* Backdrop */}