Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 68 additions & 7 deletions canvas/src/components/ProvisioningTimeout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,27 @@ import { api } from "@/lib/api";
import { showToast } from "./Toaster";
import { ConsoleModal } from "./ConsoleModal";

/** Default provisioning timeout in milliseconds (2 minutes). */
/** Base provisioning timeout in milliseconds (2 minutes). Used as the
* floor; the effective threshold scales with the number of workspaces
* concurrently provisioning (see effectiveTimeoutMs below). */
export const DEFAULT_PROVISION_TIMEOUT_MS = 120_000;

/** The server provisions up to `PROVISION_CONCURRENCY` containers at
* once and paces the rest in a queue (`workspaceCreatePacingMs` =
* 2s). Mirrors the Go constants — if those change, bump these. */
const PROVISION_CONCURRENCY = 3;
const PER_QUEUE_SLOT_EXTRA_MS = 45_000; // ~45s head-room per queued workspace

/** Scale the base timeout by how many workspaces are provisioning at
* once. A 30-workspace org import has tail items that legitimately
* wait minutes before Docker even starts on them — flagging each as
* "stuck" after 2m creates a wall of 27 yellow banners that buries
* the canvas. */
function effectiveTimeoutMs(base: number, concurrentCount: number): number {
const overflow = Math.max(0, concurrentCount - PROVISION_CONCURRENCY);
return base + overflow * PER_QUEUE_SLOT_EXTRA_MS;
}

interface TimeoutEntry {
workspaceId: string;
workspaceName: string;
Expand All @@ -33,6 +51,10 @@ export function ProvisioningTimeout({
const [retrying, setRetrying] = useState<Set<string>>(new Set());
const [cancelling, setCancelling] = useState<Set<string>>(new Set());
const trackingRef = useRef<Map<string, number>>(new Map());
// Workspaces the user explicitly dismissed — don't re-show their
// banner even if they stay in provisioning. Cleared when the
// workspace leaves provisioning (status changes).
const [dismissed, setDismissed] = useState<Set<string>>(new Set());

// Subscribe to provisioning nodes — use shallow compare to avoid infinite re-render
// (filter+map creates new array reference on every store update)
Expand Down Expand Up @@ -71,17 +93,34 @@ export function ProvisioningTimeout({
}
}

// Also remove from timedOut list if no longer provisioning
// Also remove from timedOut list if no longer provisioning, and
// clear `dismissed` entries for workspaces that finished so a
// re-provision (e.g. retry) can surface a fresh banner.
setTimedOut((prev) => prev.filter((e) => activeIds.has(e.workspaceId)));
setDismissed((prev) => {
let changed = false;
const next = new Set(prev);
for (const id of prev) {
if (!activeIds.has(id)) {
next.delete(id);
changed = true;
}
}
return changed ? next : prev;
});

// Interval to check for timeouts
const interval = setInterval(() => {
const now = Date.now();
const newTimedOut: TimeoutEntry[] = [];
const effective = effectiveTimeoutMs(
timeoutMs,
parsedProvisioningNodes.length,
);

for (const node of parsedProvisioningNodes) {
const startedAt = tracking.get(node.id);
if (startedAt && now - startedAt >= timeoutMs) {
if (startedAt && now - startedAt >= effective) {
newTimedOut.push({
workspaceId: node.id,
workspaceName: node.name,
Expand All @@ -104,6 +143,11 @@ export function ProvisioningTimeout({
return () => clearInterval(interval);
}, [parsedProvisioningNodes, timeoutMs]);

const handleDismiss = useCallback((workspaceId: string) => {
setDismissed((prev) => new Set(prev).add(workspaceId));
setTimedOut((prev) => prev.filter((e) => e.workspaceId !== workspaceId));
}, []);

const RETRY_COOLDOWN_MS = 5_000;
const [retryCooldown, setRetryCooldown] = useState<Set<string>>(new Set());

Expand Down Expand Up @@ -180,11 +224,16 @@ export function ProvisioningTimeout({
setConsoleFor(workspaceId);
}, []);

if (timedOut.length === 0) return null;
const visibleTimedOut = useMemo(
() => timedOut.filter((e) => !dismissed.has(e.workspaceId)),
[timedOut, dismissed],
);

if (visibleTimedOut.length === 0) return null;

return (
<div role="alert" aria-live="assertive" className="fixed top-14 left-1/2 -translate-x-1/2 z-40 flex flex-col gap-2 max-w-[480px] w-full px-4">
{timedOut.map((entry) => {
{visibleTimedOut.map((entry) => {
const elapsed = Math.round((Date.now() - entry.startedAt) / 1000);
const isRetrying = retrying.has(entry.workspaceId);
const isCancelling = cancelling.has(entry.workspaceId);
Expand All @@ -210,8 +259,20 @@ export function ProvisioningTimeout({
</div>

<div className="flex-1 min-w-0">
<div className="text-[12px] font-semibold text-amber-200 mb-0.5">
Provisioning Timeout
<div className="flex items-center justify-between mb-0.5 gap-2">
<div className="text-[12px] font-semibold text-amber-200">
Provisioning Timeout
</div>
<button
onClick={() => handleDismiss(entry.workspaceId)}
aria-label="Dismiss provisioning timeout warning"
title="Dismiss — keep this workspace running without the warning"
className="shrink-0 text-amber-400/60 hover:text-amber-200 transition-colors -mr-1"
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
</button>
</div>
<div className="text-[11px] text-amber-300/80 leading-relaxed">
<span className="font-medium text-amber-200">{entry.workspaceName}</span>{" "}
Expand Down
57 changes: 52 additions & 5 deletions canvas/src/components/TemplatePalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { api } from "@/lib/api";
import { useCanvasStore } from "@/store/canvas";
import type { WorkspaceData } from "@/store/socket";
import { checkDeploySecrets, type PreflightResult, type ModelSpec } from "@/lib/deploy-preflight";
import { MissingKeysModal } from "./MissingKeysModal";
import { ConfirmDialog } from "./ConfirmDialog";
import { Spinner } from "./Spinner";
import { showToast } from "./Toaster";
import { TIER_CONFIG } from "@/lib/design-tokens";

interface Template {
Expand Down Expand Up @@ -40,10 +42,41 @@ export async function fetchOrgTemplates(): Promise<OrgTemplate[]> {
}
}

/** Import an org template by directory name. Throws on platform error so the
* caller can surface the message in its error state. */
export async function importOrgTemplate(dir: string): Promise<void> {
await api.post("/org/import", { dir });
/** Server response from POST /org/import. The handler returns 207
* (StatusMultiStatus) with a populated `error` field when only some of
* the workspaces in the tree could be created — the HTTP status alone
* isn't enough to detect a partial failure. */
interface OrgImportResponse {
org: string;
workspaces: Array<{ id: string; name: string }>;
count: number;
error?: string;
}

/** Import an org template by directory name. Throws on platform error
* so the caller can surface the message in its error state. Also throws
* on 2xx-with-error-body (StatusMultiStatus) — without this check a
* partial failure (e.g. first workspace INSERT fails, 0 created)
* appears as a green success toast and the user sees no canvas update.
*
* Uses a long timeout because createWorkspaceTree paces sibling DB
* inserts by `workspaceCreatePacingMs` (2s) to avoid overwhelming
* Docker — a 15-workspace tree sleeps ~28s in the handler alone,
* which blows past the default 15s and makes the client report a
* spurious "signal timed out" error even though the server finished
* successfully. 2min covers trees up to ~60 workspaces. */
const ORG_IMPORT_TIMEOUT_MS = 120_000;

export async function importOrgTemplate(dir: string): Promise<OrgImportResponse> {
const resp = await api.post<OrgImportResponse>(
"/org/import",
{ dir },
{ timeoutMs: ORG_IMPORT_TIMEOUT_MS },
);
if (resp && resp.error) {
throw new Error(`${resp.error} (created ${resp.count ?? 0} workspaces)`);
}
return resp;
}

/**
Expand Down Expand Up @@ -81,8 +114,22 @@ export function OrgTemplatesSection() {
setError(null);
try {
await importOrgTemplate(org.dir);
// Refresh canvas inline — the WebSocket may be offline, in which case
// WORKSPACE_PROVISIONING broadcasts never arrive and the user sees
// no change from clicking "Import org". A direct fetch guarantees
// the new workspaces land on canvas regardless of WS state.
try {
const workspaces = await api.get<WorkspaceData[]>("/workspaces");
useCanvasStore.getState().hydrate(workspaces);
} catch {
// Rehydrate failure is non-fatal; WS (if alive) or the next
// health-check cycle will eventually pick the new workspaces up.
}
showToast(`Imported "${org.name || org.dir}" (${org.workspaces} workspaces)`, "success");
} catch (e) {
setError(e instanceof Error ? e.message : "Import failed");
const msg = e instanceof Error ? e.message : "Import failed";
setError(msg);
showToast(`Import failed: ${msg}`, "error");
} finally {
setImporting(null);
}
Expand Down
48 changes: 26 additions & 22 deletions canvas/src/components/Toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,11 @@ export function Toolbar() {
<span className="text-[11px] font-semibold text-zinc-300 tracking-wide">Molecule AI</span>
</div>

{/* Status counts */}
{/* Status pills + workspace total in one segment — previously two
separate border-delimited cells; merged to drop a redundant
divider and keep the count compact. `whitespace-nowrap` prevents
"+ N sub" from wrapping onto a second line when the toolbar
gets tight. */}
<div className="flex items-center gap-2.5">
<StatusPill color={statusDotClass("online")} count={counts.online} label="online" />
{counts.offline > 0 && (
Expand All @@ -149,11 +153,8 @@ export function Toolbar() {
{counts.failed > 0 && (
<StatusPill color={statusDotClass("failed")} count={counts.failed} label="failed" />
)}
</div>

{/* Total */}
<div className="pl-3 border-l border-zinc-800/60">
<span className="text-[10px] text-zinc-500">
<span className="text-zinc-700" aria-hidden="true">·</span>
<span className="text-[10px] text-zinc-500 whitespace-nowrap">
{counts.roots} workspace{counts.roots !== 1 ? "s" : ""}
{counts.children > 0 && <span className="text-zinc-600"> + {counts.children} sub</span>}
</span>
Expand Down Expand Up @@ -200,22 +201,27 @@ export function Toolbar() {
</button>
)}

{/* Secondary tools below are icon-only (Figma/Linear pattern) — text
label is exposed via title + aria-label for hover/screen-reader
users. The primary Stop All / Restart Pending buttons above keep
their text because they are urgent + conditional. */}

{/* A2A topology overlay toggle */}
<button
onClick={() => setShowA2AEdges(!showA2AEdges)}
aria-pressed={showA2AEdges}
aria-label={showA2AEdges ? "Hide A2A edges" : "Show A2A edges"}
title={showA2AEdges ? "Hide A2A delegation edges" : "Show A2A delegation edges (last 60 min)"}
className={`flex items-center gap-1.5 px-2.5 py-1 border rounded-lg transition-colors ${
className={`flex items-center justify-center w-7 h-7 border rounded-lg transition-colors ${
showA2AEdges
? "bg-blue-950/50 hover:bg-blue-900/50 border-blue-800/40 text-blue-300"
: "bg-zinc-800/50 hover:bg-zinc-700/50 border-zinc-700/40 text-zinc-500 hover:text-zinc-300"
}`}
>
{/* Mesh / network icon */}
<svg
width="12"
height="12"
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
className="shrink-0"
Expand All @@ -231,7 +237,6 @@ export function Toolbar() {
strokeLinecap="round"
/>
</svg>
<span className="text-[10px] font-medium">A2A</span>
</button>

{/* Audit trail shortcut — switches selected workspace's panel to the Audit tab */}
Expand All @@ -244,13 +249,13 @@ export function Toolbar() {
}
}}
aria-label="Open audit trail for selected workspace"
title="View audit ledger for the selected workspace"
className="flex items-center gap-1.5 px-2.5 py-1 bg-zinc-800/50 hover:bg-zinc-700/50 border border-zinc-700/40 rounded-lg transition-colors text-zinc-500 hover:text-zinc-300"
title="Audit — view ledger for the selected workspace"
className="flex items-center justify-center w-7 h-7 bg-zinc-800/50 hover:bg-zinc-700/50 border border-zinc-700/40 rounded-lg transition-colors text-zinc-500 hover:text-zinc-300"
>
{/* Scroll / ledger icon */}
<svg
width="12"
height="12"
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
className="shrink-0"
Expand All @@ -259,35 +264,34 @@ export function Toolbar() {
<rect x="3" y="2" width="10" height="12" rx="1.5" stroke="currentColor" strokeWidth="1.4" />
<path d="M6 5.5h4M6 8h4M6 10.5h2.5" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" />
</svg>
<span className="text-[10px] font-medium">Audit</span>
</button>

{/* Search shortcut */}
<button
onClick={() => useCanvasStore.getState().setSearchOpen(true)}
className="flex items-center gap-1.5 px-2.5 py-1 bg-zinc-800/50 hover:bg-zinc-700/50 border border-zinc-700/40 rounded-lg transition-colors"
aria-label="Search workspaces"
title="Search (⌘K)"
className="flex items-center justify-center w-7 h-7 bg-zinc-800/50 hover:bg-zinc-700/50 border border-zinc-700/40 rounded-lg transition-colors text-zinc-500 hover:text-zinc-300"
>
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" className="text-zinc-500" aria-hidden="true">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<circle cx="7" cy="7" r="5" stroke="currentColor" strokeWidth="1.5" />
<path d="M11 11l3 3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
<span className="text-[10px] text-zinc-500">Search</span>
<kbd className="text-[8px] text-zinc-600 bg-zinc-900/60 px-1 py-0.5 rounded border border-zinc-700/30">⌘K</kbd>
</button>

{/* Quick help */}
<div ref={helpRef} className="relative">
<button
onClick={() => setHelpOpen((open) => !open)}
className="flex items-center gap-1.5 px-2.5 py-1 bg-zinc-800/50 hover:bg-zinc-700/50 border border-zinc-700/40 rounded-lg transition-colors"
className="flex items-center justify-center w-7 h-7 bg-zinc-800/50 hover:bg-zinc-700/50 border border-zinc-700/40 rounded-lg transition-colors text-zinc-500 hover:text-zinc-300"
aria-expanded={helpOpen}
aria-label="Open quick help"
title="Help — shortcuts & quick start"
>
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" className="text-zinc-500" aria-hidden="true">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M8 12v.5M6.5 6.3A1.9 1.9 0 1 1 9 8.1c-.7.4-1 .8-1 1.7" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="1.2" />
</svg>
<span className="text-[10px] text-zinc-500">Help</span>
</button>

{helpOpen && (
Expand Down
7 changes: 5 additions & 2 deletions canvas/src/components/WorkspaceNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -202,9 +202,12 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
);
})()}

{/* Role */}
{/* Role — clamp to 2 lines. Without this, a verbose role
* description (common on org-template imports) lets the card
* grow arbitrarily tall, which wrecks the grid-slot layout
* because siblings all plan for the same CHILD_DEFAULT_HEIGHT. */}
{data.role && (
<div className="text-[10px] text-zinc-400 mb-1.5 leading-tight">{data.role}</div>
<div className="text-[10px] text-zinc-400 mb-1.5 leading-tight line-clamp-2">{data.role}</div>
)}

{/* Skills */}
Expand Down
20 changes: 20 additions & 0 deletions canvas/src/components/__tests__/TemplatePalette.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,26 @@ describe("importOrgTemplate", () => {
mockFetch.mockRejectedValueOnce(new Error("offline"));
await expect(importOrgTemplate("x")).rejects.toThrow("offline");
});

it("treats 2xx with `error` field as a failure (StatusMultiStatus partial)", async () => {
// Server returns 207 — `api.post` treats the 2xx as success and
// returns the body. Without the post-check, a partial failure
// (0 workspaces created) would surface as a green "Imported"
// toast and the user would see no canvas change.
mockFetch.mockResolvedValueOnce({
ok: true,
status: 207,
json: async () => ({
org: "Data Team",
workspaces: [],
count: 0,
error: 'pq: column "collapsed" of relation "workspaces" does not exist',
}),
});
await expect(importOrgTemplate("data-team")).rejects.toThrow(
/collapsed.*relation.*workspaces.*created 0 workspaces/,
);
});
});

describe("module exports", () => {
Expand Down
Loading
Loading