diff --git a/.github/workflows/publish-platform-image.yml b/.github/workflows/publish-platform-image.yml index 10363226..39abdb6e 100644 --- a/.github/workflows/publish-platform-image.yml +++ b/.github/workflows/publish-platform-image.yml @@ -1,39 +1,25 @@ name: publish-platform-image -# Builds and pushes the tenant-platform Docker image to GHCR whenever a -# commit lands on main. The private molecule-controlplane provisioner sets -# TENANT_IMAGE=ghcr.io/molecule-ai/platform: to spawn tenant Fly -# Machines from this image. See molecule-controlplane README for the pairing. +# Builds and pushes the platform Docker images to GHCR whenever a commit +# lands on main. EC2 tenant instances pull the tenant image from GHCR. on: push: branches: [main] paths: - # Only rebuild when something platform-relevant changes — saves GHA - # minutes on docs-only / canvas-only / MCP-only PRs. - 'platform/**' - 'canvas/**' - 'manifest.json' - '.github/workflows/publish-platform-image.yml' - # Templates now live in standalone repos — template changes no longer - # trigger a platform rebuild. Use workflow_dispatch to manually rebuild - # if a template repo update needs to be baked into the image. - # Manual trigger for re-publishing a tag after a non-platform merge. workflow_dispatch: permissions: contents: read - packages: write # required to push to ghcr.io/${{ github.repository_owner }}/* + packages: write env: - # GHCR accepts mixed-case, but most tooling lowercases — keep us consistent. IMAGE_NAME: ghcr.io/molecule-ai/platform - # Fly registry mirror — tenant machines provisioned by the private - # `molecule-controlplane` pull from here (private GHCR image can't be - # pulled by Fly machines without auth plumbing we don't want to add). - # Fly auto-authenticates same-org machines against registry.fly.io, so - # mirroring keeps GHCR private while tenants still boot. - FLY_IMAGE_NAME: registry.fly.io/molecule-tenant + TENANT_IMAGE_NAME: ghcr.io/molecule-ai/platform-tenant jobs: build-and-push: @@ -42,83 +28,33 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Configure registry auth (write auths map; do NOT call docker login) - # `docker login` on macOS unconditionally writes credentials to the - # osxkeychain credential helper, even when DOCKER_CONFIG/config.json - # declares `credsStore: ""` and even when invoked with `--config`. - # Verified locally 2026-04-16 — after a successful login, Docker - # rewrites the same config file to: - # { "auths": { "ghcr.io": {} }, "credsStore": "osxkeychain" } - # i.e. the auth lives in the Keychain, not the config file. The - # Mac mini runner is a launchd user agent with a locked Keychain, - # so storage fails with `User interaction is not allowed (-25308)`. - # - # Six prior PRs (#273, #319, #322, #341, #484, #486) all kept calling - # `docker login` and tried to coerce credsStore — none worked. - # The only reliable fix is to skip `docker login` entirely and write - # the auth strings directly. `docker/build-push-action@v5` and the - # daemon honor the `auths` map for push without needing login. - # - # Fly registry username MUST be literal "x" (verified 2026-04-15) — - # any other value returns 401. FLY_API_TOKEN lives in GitHub Actions - # secrets AND in `fly secrets` on molecule-cp; see - # docs/runbooks/saas-secrets.md before rotating. + - name: Configure GHCR auth shell: bash env: GHCR_USER: ${{ github.actor }} GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} - FLY_TOKEN: ${{ secrets.FLY_API_TOKEN }} run: | set -eu mkdir -p "${RUNNER_TEMP}/docker-config" GHCR_AUTH=$(printf '%s:%s' "${GHCR_USER}" "${GHCR_TOKEN}" | base64) - FLY_AUTH=$(printf '%s:%s' 'x' "${FLY_TOKEN}" | base64) umask 077 - cat > "${RUNNER_TEMP}/docker-config/config.json" < "${RUNNER_TEMP}/docker-config/config.json" echo "DOCKER_CONFIG=${RUNNER_TEMP}/docker-config" >> "${GITHUB_ENV}" - # Diagnostics that don't leak the tokens. - echo "=== docker ===" - command -v docker || echo "(docker not in PATH)" - docker --version 2>&1 || true - ls -la /usr/local/bin/docker /opt/homebrew/bin/docker 2>&1 || true - echo "=== auths registries (no values) ===" - grep -o '"[a-zA-Z0-9.-]*\.io"' "${RUNNER_TEMP}/docker-config/config.json" || true - name: Set up QEMU - # Required on the Apple-silicon self-hosted runner — Fly tenant machines - # pull linux/amd64, and buildx needs binfmt handlers in Docker Desktop's - # VM to emulate amd64 during the build. uses: docker/setup-qemu-action@v3 with: platforms: linux/amd64 - name: Set up Docker Buildx - # Buildx enables cache-from/cache-to via GHA cache and multi-arch - # builds without local docker daemon wrangling. uses: docker/setup-buildx-action@v3 - name: Compute tags id: tags - # Emit two tags per build: `latest` (floating, always the main tip) - # and the short commit SHA (immutable, pin-friendly). Control plane - # can deploy `latest` today and pin to :sha in Phase H hardening. run: | echo "sha=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT" - - name: Build & push to GHCR - # Split from the Fly mirror so a registry.fly.io outage doesn't block - # GHCR (or vice versa) — each registry's failure mode is isolated. - # GHA cache is shared because both steps re-use the same Dockerfile - # context + build args. - # Explicit linux/amd64 target: the runner is Apple-silicon (arm64), - # but Fly tenant machines are amd64. QEMU handles the emulation. + - name: Build & push platform image to GHCR uses: docker/build-push-action@v5 with: context: . @@ -133,13 +69,9 @@ jobs: labels: | org.opencontainers.image.source=https://github.com/${{ github.repository }} org.opencontainers.image.revision=${{ github.sha }} - org.opencontainers.image.description=Molecule AI tenant platform (one instance per org) + org.opencontainers.image.description=Molecule AI platform (Go API server) - - name: Build & push tenant image to Fly registry - # Tenant image = Go platform + Canvas (Next.js) in one container. - # Uses Dockerfile.tenant which includes the canvas build + reverse proxy. - # Continues even if GHCR push failed. - if: always() + - name: Build & push tenant image to GHCR uses: docker/build-push-action@v5 with: context: . @@ -147,31 +79,11 @@ jobs: platforms: linux/amd64 push: true tags: | - ${{ env.FLY_IMAGE_NAME }}:latest - ${{ env.FLY_IMAGE_NAME }}:sha-${{ steps.tags.outputs.sha }} + ${{ env.TENANT_IMAGE_NAME }}:latest + ${{ env.TENANT_IMAGE_NAME }}:sha-${{ steps.tags.outputs.sha }} cache-from: type=gha + cache-to: type=gha,mode=max labels: | org.opencontainers.image.source=https://github.com/${{ github.repository }} org.opencontainers.image.revision=${{ github.sha }} - org.opencontainers.image.description=Molecule AI tenant platform + canvas (one instance per org) - - - name: Install flyctl - uses: superfly/flyctl-actions/setup-flyctl@master - - - name: Deploy to Fly tenant machines - env: - FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} - run: | - MACHINES=$(flyctl machines list -a molecule-tenant --json | jq -r '.[] | select(.state == "started" or .state == "stopped") | .id') - if [ -z "$MACHINES" ]; then - echo "No tenant machines found — skipping deploy (control plane provisions on demand)" - exit 0 - fi - for id in $MACHINES; do - echo "Updating machine $id to sha-${{ steps.tags.outputs.sha }}..." - flyctl machines update "$id" \ - --image "${{ env.FLY_IMAGE_NAME }}:sha-${{ steps.tags.outputs.sha }}" \ - -a molecule-tenant \ - --yes - done - echo "All tenant machines updated to sha-${{ steps.tags.outputs.sha }}" + org.opencontainers.image.description=Molecule AI tenant platform + canvas (one EC2 instance per org) diff --git a/.gitignore b/.gitignore index ddfa7a84..a3a4a2a1 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,10 @@ venv/ *.egg-info/ .pytest_cache/ +# Brand monitor runtime state (never commit) +brand-monitor/.surge_state.json +brand-monitor/.monitor_state.json + # Docker *.log diff --git a/canvas/src/app/page.tsx b/canvas/src/app/page.tsx index e785cb9a..74291409 100644 --- a/canvas/src/app/page.tsx +++ b/canvas/src/app/page.tsx @@ -1,15 +1,20 @@ "use client"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { Canvas } from "@/components/Canvas"; import { Legend } from "@/components/Legend"; import { CommunicationOverlay } from "@/components/CommunicationOverlay"; +import { Spinner } from "@/components/Spinner"; import { connectSocket, disconnectSocket } from "@/store/socket"; import { useCanvasStore } from "@/store/canvas"; import { api } from "@/lib/api"; import type { WorkspaceData } from "@/store/socket"; export default function Home() { + const hydrationError = useCanvasStore((s) => s.hydrationError); + const setHydrationError = useCanvasStore((s) => s.setHydrationError); + const [hydrating, setHydrating] = useState(true); + useEffect(() => { connectSocket(); @@ -23,8 +28,13 @@ export default function Home() { useCanvasStore.getState().setViewport(viewport); } }).catch((err) => { - // Initial hydration failed — socket reconnect will retry + // Initial hydration failed — show error banner to user console.error("Canvas: initial hydration failed", err); + useCanvasStore.getState().setHydrationError( + err instanceof Error && err.message ? err.message : "Failed to load canvas" + ); + }).finally(() => { + setHydrating(false); }); return () => { @@ -32,11 +42,39 @@ export default function Home() { }; }, []); + if (hydrating) { + return ( +
+
+ + Loading canvas... +
+
+ ); + } + return ( <> + {hydrationError && ( +
+

{hydrationError}

+ +
+ )} ); } diff --git a/canvas/src/components/ContextMenu.tsx b/canvas/src/components/ContextMenu.tsx index 5e1d2f4f..c03fb8fa 100644 --- a/canvas/src/components/ContextMenu.tsx +++ b/canvas/src/components/ContextMenu.tsx @@ -235,6 +235,14 @@ export function ContextMenu() { closeContextMenu(); }, [contextMenu, nestNode, closeContextMenu]); + const handleZoomToTeam = useCallback(() => { + if (!contextMenu) return; + window.dispatchEvent( + new CustomEvent("molecule:zoom-to-team", { detail: { nodeId: contextMenu.nodeId } }) + ); + closeContextMenu(); + }, [contextMenu, closeContextMenu]); + if (!contextMenu) return null; const isOfflineOrFailed = contextMenu.nodeData.status === "offline" || contextMenu.nodeData.status === "failed"; @@ -253,7 +261,10 @@ export function ContextMenu() { ? [{ label: "Extract from Team", icon: "⤴", action: handleRemoveFromTeam }] : []), ...(hasChildren - ? [{ label: "Collapse Team", icon: "◁", action: handleCollapse }] + ? [ + { label: "Collapse Team", icon: "◁", action: handleCollapse }, + { label: "Zoom to Team", icon: "⊕", action: handleZoomToTeam }, + ] : [{ label: "Expand to Team", icon: "▷", action: handleExpand }]), { label: "", icon: "", action: () => {}, divider: true }, ...(isPaused diff --git a/canvas/src/components/CreateWorkspaceDialog.tsx b/canvas/src/components/CreateWorkspaceDialog.tsx index 4b0a8065..37e1231d 100644 --- a/canvas/src/components/CreateWorkspaceDialog.tsx +++ b/canvas/src/components/CreateWorkspaceDialog.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef, useCallback, useId } from "react"; import * as Dialog from "@radix-ui/react-dialog"; import { api } from "@/lib/api"; @@ -42,6 +42,7 @@ export function CreateWorkspaceButton() { const [tier, setTier] = useState(1); const [template, setTemplate] = useState(""); const [parentId, setParentId] = useState(""); + const [budgetLimit, setBudgetLimit] = useState(""); const [creating, setCreating] = useState(false); const [error, setError] = useState(null); const [workspaces, setWorkspaces] = useState([]); @@ -50,6 +51,33 @@ export function CreateWorkspaceButton() { const [hermesProvider, setHermesProvider] = useState("anthropic"); const [hermesApiKey, setHermesApiKey] = useState(""); + // Refs for roving tabIndex on the tier radio group (WCAG 2.1 arrow-key nav) + const radioRefs = useRef>([]); + const TIERS = [ + { value: 1, label: "T1", desc: "Sandboxed" }, + { value: 2, label: "T2", desc: "Standard" }, + { value: 3, label: "T3", desc: "Full Access" }, + ]; + + const handleRadioKeyDown = useCallback( + (e: React.KeyboardEvent, currentIndex: number) => { + if (e.key === "ArrowDown" || e.key === "ArrowRight") { + e.preventDefault(); + const next = (currentIndex + 1) % TIERS.length; + setTier(TIERS[next].value); + radioRefs.current[next]?.focus(); + } else if (e.key === "ArrowUp" || e.key === "ArrowLeft") { + e.preventDefault(); + const prev = (currentIndex - 1 + TIERS.length) % TIERS.length; + setTier(TIERS[prev].value); + radioRefs.current[prev]?.focus(); + } + }, + // TIERS is stable (module-level constant pattern), setTier is stable from useState + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + const isHermes = template.trim().toLowerCase() === "hermes"; // Reset form and load workspaces whenever dialog opens @@ -60,6 +88,7 @@ export function CreateWorkspaceButton() { setTier(1); setTemplate(""); setParentId(""); + setBudgetLimit(""); setError(null); setHermesProvider("anthropic"); setHermesApiKey(""); @@ -86,12 +115,17 @@ export function CreateWorkspaceButton() { : undefined; try { + const parsedBudget = budgetLimit.trim() + ? parseFloat(budgetLimit) + : null; + await api.post("/workspaces", { name: name.trim(), role: role.trim() || undefined, template: template.trim() || undefined, tier, parent_id: parentId || undefined, + budget_limit: parsedBudget, canvas: { x: Math.random() * 400 + 100, y: Math.random() * 300 + 100 }, ...(isHermes && provider ? { secrets: { [provider.envVar]: hermesApiKey.trim() } } @@ -155,6 +189,14 @@ export function CreateWorkspaceButton() { onChange={setRole} placeholder="e.g. SEO Specialist" /> + Tier - {[ - { value: 1, label: "T1", desc: "Sandboxed" }, - { value: 2, label: "T2", desc: "Standard" }, - { value: 3, label: "T3", desc: "Full Access" }, - ].map((t) => ( + {TIERS.map((t, idx) => ( + + + ); +} diff --git a/canvas/src/components/tabs/ChannelsTab.tsx b/canvas/src/components/tabs/ChannelsTab.tsx index 5249dba1..78cb628f 100644 --- a/canvas/src/components/tabs/ChannelsTab.tsx +++ b/canvas/src/components/tabs/ChannelsTab.tsx @@ -141,19 +141,29 @@ export function ChannelsTab({ workspaceId }: Props) { } }; + const [error, setError] = useState(""); + const handleToggle = async (ch: Channel) => { - await api.patch(`/workspaces/${workspaceId}/channels/${ch.id}`, { - enabled: !ch.enabled, - }); - load(); + try { + await api.patch(`/workspaces/${workspaceId}/channels/${ch.id}`, { + enabled: !ch.enabled, + }); + load(); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : "Failed to toggle channel"); + } }; const confirmDelete = async () => { if (!pendingDelete) return; const ch = pendingDelete; setPendingDelete(null); - await api.del(`/workspaces/${workspaceId}/channels/${ch.id}`); - load(); + try { + await api.del(`/workspaces/${workspaceId}/channels/${ch.id}`); + load(); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : "Failed to delete channel"); + } }; const handleTest = async (ch: Channel) => { @@ -188,6 +198,12 @@ export function ChannelsTab({ workspaceId }: Props) { + {error && ( +
+ {error} +
+ )} + {/* Create form */} {showForm && (
diff --git a/canvas/src/components/tabs/ConfigTab.tsx b/canvas/src/components/tabs/ConfigTab.tsx index 6e600cd4..699ee27a 100644 --- a/canvas/src/components/tabs/ConfigTab.tsx +++ b/canvas/src/components/tabs/ConfigTab.tsx @@ -267,6 +267,48 @@ export function ConfigTab({ workspaceId }: Props) { updateNested("runtime_config" as keyof ConfigData, "required_env", v)} placeholder="e.g. CLAUDE_CODE_OAUTH_TOKEN" /> + {/* Claude Settings — shown for claude-code runtime or claude/anthropic model names */} + {(config.runtime === "claude-code" || + (config.runtime_config?.model || config.model || "").toLowerCase().includes("claude") || + (config.runtime_config?.model || config.model || "").toLowerCase().includes("anthropic")) && ( +
+
+ + +
+
+ + update("task_budget", parseInt(e.target.value, 10) || 0)} + placeholder="0" + className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:border-blue-500 font-mono" + data-testid="task-budget-input" + /> +
+
+ )} +
update("skills", v)} placeholder="e.g. code-review" /> update("tools", v)} placeholder="e.g. web_search, filesystem" /> diff --git a/canvas/src/components/tabs/DetailsTab.tsx b/canvas/src/components/tabs/DetailsTab.tsx index f4a53639..b9f9042f 100644 --- a/canvas/src/components/tabs/DetailsTab.tsx +++ b/canvas/src/components/tabs/DetailsTab.tsx @@ -4,6 +4,8 @@ import { useState, useEffect, useCallback } from "react"; import { api } from "@/lib/api"; import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas"; import { StatusDot } from "../StatusDot"; +import { BudgetSection } from "./BudgetSection"; +import { WorkspaceUsage } from "../WorkspaceUsage"; interface Props { workspaceId: string; @@ -59,7 +61,11 @@ export function DetailsTab({ workspaceId, data }: Props) { setSaving(true); setSaveError(null); try { - await api.patch(`/workspaces/${workspaceId}`, { name, role: role || null, tier }); + await api.patch(`/workspaces/${workspaceId}`, { + name, + role: role || null, + tier, + }); updateNodeData(workspaceId, { name, role: role || "", tier }); setEditing(false); } catch (e) { @@ -145,7 +151,13 @@ export function DetailsTab({ workspaceId, data }: Props) { {saving ? "Saving..." : "Save"}
+ {/* Budget — dedicated section with live usage stats (#541) */} + + + {/* Token usage + spend — wired to GET /workspaces/:id/metrics (#592) */} + + {/* Agent Card / Skills */} {skills.length > 0 && (
diff --git a/canvas/src/components/tabs/MemoryTab.tsx b/canvas/src/components/tabs/MemoryTab.tsx index 4502f982..fa70faa5 100644 --- a/canvas/src/components/tabs/MemoryTab.tsx +++ b/canvas/src/components/tabs/MemoryTab.tsx @@ -219,7 +219,7 @@ export function MemoryTab({ workspaceId }: Props) { Refresh