Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fee8b9d86 | |||
| 6d802abcd1 | |||
| b364c16ea6 | |||
| c2a5b62521 | |||
| aa0e30ee76 | |||
| 4c86f047c7 | |||
| 34179e64a3 | |||
| 0c4970cdb7 | |||
| 9eefa5c474 | |||
| 305a38c5bb | |||
| bddfa4e403 | |||
| f820780036 | |||
| 50e7173c75 | |||
| 03ad9e6feb | |||
| bee46f0a06 | |||
| 7999924edf | |||
| 286a499819 | |||
| 6964b26474 | |||
| 8019231a16 | |||
| 5cdb486269 | |||
| 9b096b0cbe | |||
| 4a610ca3c4 | |||
| 09614f4cb3 | |||
| e0f9a16e99 | |||
| 94bdd8ff35 | |||
| a773973d37 | |||
| b9d41474a7 | |||
| 25c7ee9689 | |||
| 919e632ccb | |||
| 2f1bf09030 | |||
| 7604e113d2 | |||
| 6ba9424196 | |||
| 531d98efea | |||
| 0b17567891 | |||
| 59d699b61c | |||
| 154c67b754 | |||
| a66c37b920 | |||
| 575f44475f |
@@ -239,7 +239,7 @@ jobs:
|
||||
# Strip the package-import prefix so we can match .coverage-allowlist.txt
|
||||
# entries written as paths relative to workspace-server/.
|
||||
# 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/||')
|
||||
rel=$(echo "$file" | sed 's|^git.moleculesai.app/molecule-ai/molecule-core/workspace-server/workspace-server/||; s|^git.moleculesai.app/molecule-ai/molecule-core/workspace-server/||')
|
||||
|
||||
if echo "$ALLOWLIST" | grep -qxF "$rel"; then
|
||||
echo "::warning file=workspace-server/$rel::Critical file at ${pct}% coverage (allowlisted, #1823) — fix before expiry."
|
||||
|
||||
@@ -152,7 +152,7 @@ jobs:
|
||||
# block). See #2578 PR comment for the rationale.
|
||||
E2E_ANTHROPIC_API_KEY: ${{ secrets.MOLECULE_STAGING_ANTHROPIC_API_KEY }}
|
||||
# OpenAI fallback — kept wired so an operator-dispatched run with
|
||||
# E2E_RUNTIME=hermes or =langgraph via workflow_dispatch can still
|
||||
# E2E_RUNTIME=hermes or =codex via workflow_dispatch can still
|
||||
# exercise the OpenAI path.
|
||||
E2E_OPENAI_API_KEY: ${{ secrets.MOLECULE_STAGING_OPENAI_API_KEY }}
|
||||
E2E_RUNTIME: ${{ github.event.inputs.runtime || 'claude-code' }}
|
||||
@@ -161,7 +161,7 @@ jobs:
|
||||
# and defeats the cost saving. Operators can override via the
|
||||
# workflow_dispatch flow (no input wired here yet — runtime
|
||||
# override is enough for ad-hoc).
|
||||
E2E_MODEL_SLUG: ${{ github.event.inputs.runtime == 'hermes' && 'openai/gpt-4o' || github.event.inputs.runtime == 'langgraph' && 'openai:gpt-4o' || 'MiniMax-M2' }}
|
||||
E2E_MODEL_SLUG: ${{ github.event.inputs.runtime == 'hermes' && 'openai/gpt-4o' || github.event.inputs.runtime == 'codex' && 'openai/gpt-4o' || 'MiniMax-M2' }}
|
||||
E2E_RUN_ID: "${{ github.run_id }}-${{ github.run_attempt }}"
|
||||
E2E_KEEP_ORG: ${{ github.event.inputs.keep_org && '1' || '0' }}
|
||||
|
||||
@@ -185,7 +185,7 @@ jobs:
|
||||
- name: Verify LLM key present
|
||||
run: |
|
||||
# Per-runtime key check — claude-code uses MiniMax; hermes /
|
||||
# langgraph (operator-dispatched only) use OpenAI. Hard-fail
|
||||
# codex (operator-dispatched only) use OpenAI. Hard-fail
|
||||
# rather than soft-skip per #2578's lesson — empty key
|
||||
# silently falls through to the wrong SECRETS_JSON branch and
|
||||
# produces a confusing auth error 5 min later instead of the
|
||||
@@ -206,7 +206,7 @@ jobs:
|
||||
required_secret_value=""
|
||||
fi
|
||||
;;
|
||||
langgraph|hermes)
|
||||
codex|hermes)
|
||||
required_secret_name="MOLECULE_STAGING_OPENAI_API_KEY"
|
||||
required_secret_value="${E2E_OPENAI_API_KEY:-}"
|
||||
;;
|
||||
|
||||
@@ -32,6 +32,24 @@ on:
|
||||
# iterating all open PRs when PR_NUMBER is empty.
|
||||
workflow_dispatch:
|
||||
|
||||
# Serialize per PR (or per repo for schedule/manual ticks) to prevent
|
||||
# the fan-out OOM class documented in
|
||||
# `reference_operator_host_python3_oom_storm_2026_05_18`. `edited`
|
||||
# events fan out on every PR-body edit; combined with the hourly cron
|
||||
# and synchronize bursts this workflow can stack runs of the same
|
||||
# workflow_id on the same PR (each ~4GB anon-RSS) and trip the
|
||||
# `--memory=4g --memory-swap=8g` per-container cap.
|
||||
#
|
||||
# NO `cancel-in-progress` (defaults to false). Per
|
||||
# `feedback_janitor_supersede_must_group_by_workflow_id`, cancelling
|
||||
# in-flight runs of any required-check-shaped workflow risks the
|
||||
# dismiss_stale_approvals + empty-commit-rerun dance (Gitea 1.22.6 has
|
||||
# no REST rerun). The gate-check is `continue-on-error: true` +
|
||||
# idempotent (POST/PATCH gate-check comment by context) so sequential
|
||||
# ticks are strictly safe.
|
||||
concurrency:
|
||||
group: gate-check-v3-${{ github.event.pull_request.number || github.event.issue.number || github.ref }}
|
||||
|
||||
permissions:
|
||||
# read: contents — for checkout (base ref, not PR head for security)
|
||||
# read: pull-requests — for reading PR info via API
|
||||
|
||||
@@ -106,7 +106,7 @@ jobs:
|
||||
[[ "$file" == *_test.go ]] && continue
|
||||
[[ "$file" == *"$path"* ]] || continue
|
||||
awk "BEGIN{exit !(\$pct < 10)}" || continue
|
||||
rel=$(echo "$file" | sed 's|^github.com/molecule-ai/molecule-monorepo/platform/workspace-server/||; s|^github.com/molecule-ai/molecule-monorepo/platform/||')
|
||||
rel=$(echo "$file" | sed 's|^git.moleculesai.app/molecule-ai/molecule-core/workspace-server/workspace-server/||; s|^git.moleculesai.app/molecule-ai/molecule-core/workspace-server/||')
|
||||
if echo "$ALLOWLIST" | grep -qxF "$rel"; then
|
||||
continue
|
||||
fi
|
||||
|
||||
@@ -15,9 +15,11 @@ test("FilesTab renders after split", async ({ page, request }) => {
|
||||
// Clean slate
|
||||
const { workspaces } = await request
|
||||
.get("http://localhost:8080/workspaces")
|
||||
.then(async (r) => ({ workspaces: (await r.json()) as Array<{ id: string }> }));
|
||||
.then(async (r) => ({ workspaces: (await r.json()) as Array<{ id: string; name: string }> }));
|
||||
for (const w of workspaces) {
|
||||
await request.delete(`http://localhost:8080/workspaces/${w.id}?confirm=true`);
|
||||
await request.delete(`http://localhost:8080/workspaces/${w.id}?confirm=true`, {
|
||||
headers: { "X-Confirm-Name": w.name },
|
||||
});
|
||||
}
|
||||
|
||||
// Create a workspace
|
||||
@@ -80,5 +82,7 @@ test("FilesTab renders after split", async ({ page, request }) => {
|
||||
await expect(editorEmpty.first()).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Cleanup
|
||||
await request.delete(`http://localhost:8080/workspaces/${wsId}?confirm=true`);
|
||||
await request.delete(`http://localhost:8080/workspaces/${wsId}?confirm=true`, {
|
||||
headers: { "X-Confirm-Name": "FilesTab Smoke" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
});
|
||||
|
||||
const eslintConfig = [
|
||||
{
|
||||
ignores: [
|
||||
".next/**",
|
||||
"coverage/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
],
|
||||
},
|
||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||
{
|
||||
rules: {
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"@typescript-eslint/no-require-imports": "warn",
|
||||
"prefer-const": "warn",
|
||||
"react-hooks/rules-of-hooks": "warn",
|
||||
"react/display-name": "warn",
|
||||
"react/no-unescaped-entities": "warn",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
||||
Generated
+4330
-1
File diff suppressed because it is too large
Load Diff
+4
-2
@@ -6,7 +6,7 @@
|
||||
"dev": "next dev --turbopack -p 3000",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
@@ -31,6 +31,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
"@testing-library/jest-dom": "^6.6.0",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@types/node": "^25.6.0",
|
||||
@@ -38,7 +39,8 @@
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-config-next": "^15.5.15",
|
||||
"jsdom": "^29.1.1",
|
||||
"postcss": "^8.5.13",
|
||||
"tailwindcss": "^4.0.0",
|
||||
|
||||
@@ -232,7 +232,10 @@ function CanvasInner() {
|
||||
}
|
||||
state.beginDelete(subtree);
|
||||
try {
|
||||
await api.del(`/workspaces/${id}?confirm=true`);
|
||||
const workspaceName = state.nodes.find((n) => n.id === id)?.data.name ?? "";
|
||||
await api.del(`/workspaces/${id}?confirm=true`, {
|
||||
headers: { "X-Confirm-Name": workspaceName },
|
||||
});
|
||||
// Mirror the server-side cascade locally — drop the parent AND
|
||||
// every descendant in one atomic update. The per-descendant
|
||||
// WORKSPACE_REMOVED WS events still arrive (and are no-ops
|
||||
|
||||
@@ -128,7 +128,7 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
|
||||
|
||||
<div className="flex-1 overflow-auto bg-black/80 p-4">
|
||||
{loading && (
|
||||
<div className="text-[12px] text-ink-mid" data-testid="console-loading">
|
||||
<div role="status" aria-live="polite" className="text-[12px] text-ink-mid" data-testid="console-loading">
|
||||
Loading console output…
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -33,7 +33,55 @@ interface HermesProvider {
|
||||
models: string[];
|
||||
}
|
||||
|
||||
const DEFAULT_CREATE_MODEL = "anthropic:claude-opus-4-7";
|
||||
type LLMAuthMode = "platform" | "api_key" | "oauth";
|
||||
|
||||
interface NativeLLMProvider {
|
||||
id: string;
|
||||
label: string;
|
||||
envVar?: string;
|
||||
defaultModel: string;
|
||||
models: string[];
|
||||
authModes: LLMAuthMode[];
|
||||
}
|
||||
|
||||
export const NATIVE_LLM_PROVIDERS: NativeLLMProvider[] = [
|
||||
{
|
||||
id: "minimax",
|
||||
label: "MiniMax",
|
||||
envVar: "MINIMAX_API_KEY",
|
||||
defaultModel: "MiniMax-M2.7",
|
||||
models: ["MiniMax-M2.7", "MiniMax-M2.7-highspeed", "MiniMax-M2.5"],
|
||||
authModes: ["platform", "api_key"],
|
||||
},
|
||||
{
|
||||
id: "kimi-coding",
|
||||
label: "Kimi",
|
||||
envVar: "KIMI_API_KEY",
|
||||
defaultModel: "kimi-for-coding",
|
||||
models: ["kimi-for-coding", "kimi-k2.5", "kimi-k2"],
|
||||
authModes: ["platform", "api_key"],
|
||||
},
|
||||
{
|
||||
id: "anthropic",
|
||||
label: "Anthropic",
|
||||
envVar: "ANTHROPIC_API_KEY",
|
||||
defaultModel: "claude-sonnet-4-6",
|
||||
models: ["claude-sonnet-4-6", "claude-opus-4-7", "claude-haiku-4-5"],
|
||||
authModes: ["platform", "api_key"],
|
||||
},
|
||||
{
|
||||
id: "anthropic-oauth",
|
||||
label: "Claude OAuth",
|
||||
envVar: "CLAUDE_CODE_OAUTH_TOKEN",
|
||||
defaultModel: "sonnet",
|
||||
models: ["sonnet", "opus", "haiku"],
|
||||
authModes: ["oauth"],
|
||||
},
|
||||
];
|
||||
const DEFAULT_HEADLESS_INSTANCE_TYPE = "t3.medium";
|
||||
const DEFAULT_HEADLESS_ROOT_GB = 30;
|
||||
const DEFAULT_DISPLAY_INSTANCE_TYPE = "t3.xlarge";
|
||||
const DEFAULT_DISPLAY_ROOT_GB = 80;
|
||||
|
||||
// All providers supported by Hermes runtime via providers.resolve_provider().
|
||||
// `defaultModel` is the slug injected into the workspace provision request
|
||||
@@ -71,8 +119,8 @@ export function CreateWorkspaceButton() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [workspaces, setWorkspaces] = useState<WorkspaceOption[]>([]);
|
||||
const [displayEnabled, setDisplayEnabled] = useState(false);
|
||||
const [displayInstanceType, setDisplayInstanceType] = useState("t3.xlarge");
|
||||
const [displayRootGB, setDisplayRootGB] = useState("80");
|
||||
const [displayInstanceType, setDisplayInstanceType] = useState(DEFAULT_DISPLAY_INSTANCE_TYPE);
|
||||
const [displayRootGB, setDisplayRootGB] = useState(String(DEFAULT_DISPLAY_ROOT_GB));
|
||||
const [displayResolution, setDisplayResolution] = useState("1920x1080");
|
||||
// Templates fetched from /api/templates — drives the dynamic provider
|
||||
// filter below. Same data source ConfigTab uses (PR #2454). When the
|
||||
@@ -101,11 +149,16 @@ export function CreateWorkspaceButton() {
|
||||
// (Anthropic), which 401s if the user's key is for a different
|
||||
// provider. Hence: require model when template=hermes.
|
||||
const [hermesModel, setHermesModel] = useState("");
|
||||
const [llmAuthMode, setLLMAuthMode] = useState<LLMAuthMode>("platform");
|
||||
const [llmProvider, setLLMProvider] = useState("minimax");
|
||||
const [llmModel, setLLMModel] = useState("MiniMax-M2.7");
|
||||
const [llmSecret, setLLMSecret] = useState("");
|
||||
|
||||
// Tier picker: on SaaS every workspace gets its own EC2 VM (Full Access
|
||||
// by construction), so we hide the T1/T2/T3 Docker-sandbox tiers and
|
||||
// lock to T4 — the full-host access tier, which maps to t3.large at the
|
||||
// CP level. On self-hosted we still offer T1/T2/T3 because the Docker-
|
||||
// lock to T4 — the full-host access tier. The EC2 size is controlled by
|
||||
// the compute profile below. On self-hosted we still offer T1/T2/T3
|
||||
// because the Docker-
|
||||
// sandbox distinction is a real choice there; T4 is available too for
|
||||
// operators who want the full-host tier.
|
||||
//
|
||||
@@ -156,6 +209,14 @@ export function CreateWorkspaceButton() {
|
||||
);
|
||||
|
||||
const isHermes = template.trim().toLowerCase() === "hermes";
|
||||
const nativeLLMProviders = useMemo(
|
||||
() => NATIVE_LLM_PROVIDERS.filter((p) => p.authModes.includes(llmAuthMode)),
|
||||
[llmAuthMode],
|
||||
);
|
||||
const selectedNativeProvider = useMemo(
|
||||
() => nativeLLMProviders.find((p) => p.id === llmProvider) ?? nativeLLMProviders[0],
|
||||
[llmProvider, nativeLLMProviders],
|
||||
);
|
||||
|
||||
// Resolve the selected template's spec from the /templates response.
|
||||
// The `template` input is free-text; templates can be matched by id,
|
||||
@@ -203,6 +264,22 @@ export function CreateWorkspaceButton() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [availableProviders, isHermes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isHermes) return;
|
||||
if (nativeLLMProviders.length === 0) return;
|
||||
if (!nativeLLMProviders.some((p) => p.id === llmProvider)) {
|
||||
setLLMProvider(nativeLLMProviders[0].id);
|
||||
setLLMModel(nativeLLMProviders[0].defaultModel);
|
||||
}
|
||||
}, [isHermes, llmProvider, nativeLLMProviders]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isHermes || !selectedNativeProvider) return;
|
||||
if (!selectedNativeProvider.models.includes(llmModel)) {
|
||||
setLLMModel(selectedNativeProvider.defaultModel);
|
||||
}
|
||||
}, [isHermes, llmModel, selectedNativeProvider]);
|
||||
|
||||
// Auto-fill hermesModel with the provider's defaultModel whenever the
|
||||
// provider changes, but only if the user hasn't already typed their own
|
||||
// slug. Prevents the empty-model → "auto" → Anthropic-default 401 trap.
|
||||
@@ -230,13 +307,17 @@ export function CreateWorkspaceButton() {
|
||||
setBudgetLimit("");
|
||||
setError(null);
|
||||
setDisplayEnabled(false);
|
||||
setDisplayInstanceType("t3.xlarge");
|
||||
setDisplayRootGB("80");
|
||||
setDisplayInstanceType(DEFAULT_DISPLAY_INSTANCE_TYPE);
|
||||
setDisplayRootGB(String(DEFAULT_DISPLAY_ROOT_GB));
|
||||
setDisplayResolution("1920x1080");
|
||||
setHermesProvider("anthropic");
|
||||
setExternalRuntime("external");
|
||||
setHermesApiKey("");
|
||||
setHermesModel("");
|
||||
setLLMAuthMode("platform");
|
||||
setLLMProvider("minimax");
|
||||
setLLMModel("MiniMax-M2.7");
|
||||
setLLMSecret("");
|
||||
api
|
||||
.get<WorkspaceOption[]>("/workspaces")
|
||||
.then((ws) => setWorkspaces(ws))
|
||||
@@ -263,12 +344,21 @@ export function CreateWorkspaceButton() {
|
||||
setError("Model is required for Hermes workspaces — provider routing depends on the model slug prefix");
|
||||
return;
|
||||
}
|
||||
if (!isExternal && !isHermes && !llmModel.trim()) {
|
||||
setError("Model is required");
|
||||
return;
|
||||
}
|
||||
if (!isExternal && !isHermes && llmAuthMode !== "platform" && !llmSecret.trim()) {
|
||||
setError(llmAuthMode === "oauth" ? "Claude OAuth token is required" : "API key is required");
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
setError(null);
|
||||
|
||||
const provider = isHermes
|
||||
? HERMES_PROVIDERS.find((p) => p.id === hermesProvider)
|
||||
: undefined;
|
||||
const nativeProvider = !isHermes ? selectedNativeProvider : undefined;
|
||||
|
||||
try {
|
||||
const parsedBudget = budgetLimit.trim()
|
||||
@@ -292,19 +382,33 @@ export function CreateWorkspaceButton() {
|
||||
tier,
|
||||
parent_id: parentId || undefined,
|
||||
budget_limit: parsedBudget,
|
||||
...(!isExternal && !isHermes ? { model: DEFAULT_CREATE_MODEL } : {}),
|
||||
...(displayEnabled
|
||||
...(!isExternal && !isHermes && nativeProvider
|
||||
? {
|
||||
compute: {
|
||||
instance_type: displayInstanceType,
|
||||
volume: { root_gb: Number.isFinite(parsedRootGB) ? parsedRootGB : 80 },
|
||||
display: {
|
||||
mode: "desktop-control",
|
||||
protocol: "novnc",
|
||||
width: Number.isFinite(displayWidth) ? displayWidth : 1920,
|
||||
height: Number.isFinite(displayHeight) ? displayHeight : 1080,
|
||||
},
|
||||
},
|
||||
model: llmModel.trim(),
|
||||
llm_provider: nativeProvider.id,
|
||||
...(llmAuthMode !== "platform" && nativeProvider.envVar
|
||||
? { secrets: { [nativeProvider.envVar]: llmSecret.trim() } }
|
||||
: {}),
|
||||
}
|
||||
: {}),
|
||||
...(!isExternal
|
||||
? {
|
||||
compute: displayEnabled
|
||||
? {
|
||||
instance_type: displayInstanceType,
|
||||
volume: { root_gb: Number.isFinite(parsedRootGB) ? parsedRootGB : DEFAULT_DISPLAY_ROOT_GB },
|
||||
display: {
|
||||
mode: "desktop-control",
|
||||
protocol: "novnc",
|
||||
width: Number.isFinite(displayWidth) ? displayWidth : 1920,
|
||||
height: Number.isFinite(displayHeight) ? displayHeight : 1080,
|
||||
},
|
||||
}
|
||||
: {
|
||||
instance_type: DEFAULT_HEADLESS_INSTANCE_TYPE,
|
||||
volume: { root_gb: DEFAULT_HEADLESS_ROOT_GB },
|
||||
display: { mode: "none" },
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
canvas: { x: Math.random() * 400 + 100, y: Math.random() * 300 + 100 },
|
||||
@@ -438,6 +542,82 @@ export function CreateWorkspaceButton() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isExternal && !isHermes && selectedNativeProvider && (
|
||||
<div className="rounded-lg border border-line/50 bg-surface-card/40 p-3 space-y-3">
|
||||
<div className="text-[11px] font-medium text-ink-mid">
|
||||
LLM
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="llm-auth-mode" className="text-[11px] text-ink-mid block mb-1">
|
||||
Auth Mode
|
||||
</label>
|
||||
<select
|
||||
id="llm-auth-mode"
|
||||
value={llmAuthMode}
|
||||
onChange={(e) => setLLMAuthMode(e.target.value as LLMAuthMode)}
|
||||
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors"
|
||||
>
|
||||
<option value="platform">Platform provided</option>
|
||||
<option value="api_key">API key</option>
|
||||
<option value="oauth">Claude OAuth</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="llm-provider-select" className="text-[11px] text-ink-mid block mb-1">
|
||||
Provider
|
||||
</label>
|
||||
<select
|
||||
id="llm-provider-select"
|
||||
value={selectedNativeProvider.id}
|
||||
onChange={(e) => {
|
||||
const next = nativeLLMProviders.find((p) => p.id === e.target.value);
|
||||
setLLMProvider(e.target.value);
|
||||
if (next) setLLMModel(next.defaultModel);
|
||||
}}
|
||||
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors"
|
||||
>
|
||||
{nativeLLMProviders.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="llm-model-input" className="text-[11px] text-ink-mid block mb-1">
|
||||
Model
|
||||
</label>
|
||||
<input
|
||||
id="llm-model-input"
|
||||
type="text"
|
||||
value={llmModel}
|
||||
onChange={(e) => setLLMModel(e.target.value)}
|
||||
list="llm-model-suggestions"
|
||||
spellCheck={false}
|
||||
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink placeholder-ink-soft focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors font-mono"
|
||||
/>
|
||||
<datalist id="llm-model-suggestions">
|
||||
{selectedNativeProvider.models.map((m) => <option key={m} value={m} />)}
|
||||
</datalist>
|
||||
</div>
|
||||
{llmAuthMode !== "platform" && (
|
||||
<div>
|
||||
<label htmlFor="llm-secret-input" className="text-[11px] text-ink-mid block mb-1">
|
||||
{llmAuthMode === "oauth" ? "OAuth Token" : "API Key"}
|
||||
</label>
|
||||
<input
|
||||
id="llm-secret-input"
|
||||
type="password"
|
||||
value={llmSecret}
|
||||
onChange={(e) => setLLMSecret(e.target.value)}
|
||||
autoComplete="off"
|
||||
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink placeholder-ink-soft focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors font-mono"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div
|
||||
role="radiogroup"
|
||||
@@ -542,10 +722,11 @@ export function CreateWorkspaceButton() {
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="text-[11px] text-ink-mid block mb-1">
|
||||
<label htmlFor="parent-workspace-select" className="text-[11px] text-ink-mid block mb-1">
|
||||
Parent Workspace
|
||||
</label>
|
||||
<select
|
||||
id="parent-workspace-select"
|
||||
value={parentId}
|
||||
onChange={(e) => setParentId(e.target.value)}
|
||||
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors"
|
||||
|
||||
@@ -242,10 +242,13 @@ export function ProvisioningTimeout({
|
||||
const handleCancelConfirm = useCallback(async () => {
|
||||
if (!confirmingCancel) return;
|
||||
const workspaceId = confirmingCancel;
|
||||
const workspaceName = timedOut.find((e) => e.workspaceId === workspaceId)?.workspaceName ?? "";
|
||||
setConfirmingCancel(null);
|
||||
setCancelling((prev) => new Set(prev).add(workspaceId));
|
||||
try {
|
||||
await api.del(`/workspaces/${workspaceId}`);
|
||||
await api.del(`/workspaces/${workspaceId}`, {
|
||||
headers: { "X-Confirm-Name": workspaceName },
|
||||
});
|
||||
setTimedOut((prev) => prev.filter((e) => e.workspaceId !== workspaceId));
|
||||
trackingRef.current.delete(workspaceId);
|
||||
showToast("Deployment cancelled", "info");
|
||||
|
||||
@@ -305,7 +305,9 @@ export function SidePanel() {
|
||||
{panelTab === "chat" && <ChatTab key={selectedNodeId} workspaceId={selectedNodeId} data={node.data} />}
|
||||
{panelTab === "terminal" && <TerminalTab key={selectedNodeId} workspaceId={selectedNodeId} data={node.data} />}
|
||||
{panelTab === "display" && <DisplayTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
||||
{panelTab === "container-config" && <ContainerConfigTab key={selectedNodeId} data={node.data} />}
|
||||
{panelTab === "container-config" && selectedNodeId && (
|
||||
<ContainerConfigTab key={selectedNodeId} workspaceId={selectedNodeId} data={node.data} />
|
||||
)}
|
||||
{panelTab === "config" && <ConfigTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
||||
{panelTab === "schedule" && <ScheduleTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
||||
{panelTab === "channels" && <ChannelsTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
||||
|
||||
@@ -63,7 +63,7 @@ describe("CreateWorkspaceDialog", () => {
|
||||
|
||||
it('first option is "None (root level)" with empty value', async () => {
|
||||
await openDialog();
|
||||
const select = document.querySelector("select") as HTMLSelectElement;
|
||||
const select = screen.getByLabelText("Parent Workspace") as HTMLSelectElement;
|
||||
expect(select).toBeTruthy();
|
||||
const firstOption = select.options[0];
|
||||
expect(firstOption.value).toBe("");
|
||||
@@ -73,12 +73,12 @@ describe("CreateWorkspaceDialog", () => {
|
||||
it("populates select with workspace names from GET /workspaces", async () => {
|
||||
await openDialog();
|
||||
await waitFor(() => {
|
||||
const select = document.querySelector("select") as HTMLSelectElement;
|
||||
const select = screen.getByLabelText("Parent Workspace") as HTMLSelectElement;
|
||||
const optionValues = Array.from(select.options).map((o) => o.value);
|
||||
expect(optionValues).toContain("ws-1");
|
||||
expect(optionValues).toContain("ws-2");
|
||||
});
|
||||
const select = document.querySelector("select") as HTMLSelectElement;
|
||||
const select = screen.getByLabelText("Parent Workspace") as HTMLSelectElement;
|
||||
const optionTexts = Array.from(select.options).map((o) => o.text.trim());
|
||||
expect(optionTexts.some((t) => t.includes("Platform Team"))).toBe(true);
|
||||
expect(optionTexts.some((t) => t.includes("Research Agent"))).toBe(true);
|
||||
@@ -87,7 +87,7 @@ describe("CreateWorkspaceDialog", () => {
|
||||
it("sends parent_id in POST body when a workspace is selected", async () => {
|
||||
await openDialog();
|
||||
await waitFor(() => {
|
||||
const select = document.querySelector("select") as HTMLSelectElement;
|
||||
const select = screen.getByLabelText("Parent Workspace") as HTMLSelectElement;
|
||||
expect(select.options.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
@@ -95,7 +95,7 @@ describe("CreateWorkspaceDialog", () => {
|
||||
target: { value: "My Agent" },
|
||||
});
|
||||
|
||||
const select = document.querySelector("select") as HTMLSelectElement;
|
||||
const select = screen.getByLabelText("Parent Workspace") as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: "ws-1" } });
|
||||
|
||||
const createBtn = screen.getAllByRole("button").find((b) => b.textContent === "Create");
|
||||
@@ -112,7 +112,7 @@ describe("CreateWorkspaceDialog", () => {
|
||||
target: { value: "Root Agent" },
|
||||
});
|
||||
|
||||
const select = document.querySelector("select") as HTMLSelectElement;
|
||||
const select = screen.getByLabelText("Parent Workspace") as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: "" } });
|
||||
|
||||
const createBtn = screen.getAllByRole("button").find((b) => b.textContent === "Create");
|
||||
@@ -123,7 +123,7 @@ describe("CreateWorkspaceDialog", () => {
|
||||
expect(body.parent_id).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits compute config by default", async () => {
|
||||
it("sends the cost-efficient headless compute profile by default", async () => {
|
||||
await openDialog();
|
||||
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
|
||||
target: { value: "Plain Agent" },
|
||||
@@ -132,10 +132,32 @@ describe("CreateWorkspaceDialog", () => {
|
||||
const createBtn = screen.getAllByRole("button").find((b) => b.textContent === "Create");
|
||||
fireEvent.click(createBtn!);
|
||||
|
||||
await waitFor(() => expect(mockPost).toHaveBeenCalled());
|
||||
const body = mockPost.mock.calls[0][1] as Record<string, unknown>;
|
||||
expect(body.compute).toEqual({
|
||||
instance_type: "t3.medium",
|
||||
volume: { root_gb: 30 },
|
||||
display: { mode: "none" },
|
||||
});
|
||||
expect(body.model).toBe("MiniMax-M2.7");
|
||||
expect(body.llm_provider).toBe("minimax");
|
||||
expect(body.secrets).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not send managed compute for external agents", async () => {
|
||||
await openDialog();
|
||||
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
|
||||
target: { value: "External Agent" },
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText(/External agent/));
|
||||
|
||||
const createBtn = screen.getAllByRole("button").find((b) => b.textContent === "Create");
|
||||
fireEvent.click(createBtn!);
|
||||
|
||||
await waitFor(() => expect(mockPost).toHaveBeenCalled());
|
||||
const body = mockPost.mock.calls[0][1] as Record<string, unknown>;
|
||||
expect(body.compute).toBeUndefined();
|
||||
expect(body.model).toBe("anthropic:claude-opus-4-7");
|
||||
expect(body.runtime).toBe("external");
|
||||
});
|
||||
|
||||
it("sends display compute profile when desktop display is enabled", async () => {
|
||||
@@ -150,7 +172,8 @@ describe("CreateWorkspaceDialog", () => {
|
||||
|
||||
await waitFor(() => expect(mockPost).toHaveBeenCalled());
|
||||
const body = mockPost.mock.calls[0][1] as Record<string, unknown>;
|
||||
expect(body.model).toBe("anthropic:claude-opus-4-7");
|
||||
expect(body.model).toBe("MiniMax-M2.7");
|
||||
expect(body.llm_provider).toBe("minimax");
|
||||
expect(body.compute).toEqual({
|
||||
instance_type: "t3.xlarge",
|
||||
volume: { root_gb: 80 },
|
||||
@@ -163,13 +186,57 @@ describe("CreateWorkspaceDialog", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("sends BYOK API key secrets when API key auth mode is selected", async () => {
|
||||
await openDialog();
|
||||
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
|
||||
target: { value: "BYOK Agent" },
|
||||
});
|
||||
fireEvent.change(document.getElementById("llm-auth-mode") as HTMLSelectElement, {
|
||||
target: { value: "api_key" },
|
||||
});
|
||||
fireEvent.change(document.getElementById("llm-secret-input") as HTMLInputElement, {
|
||||
target: { value: "sk-minimax-test" },
|
||||
});
|
||||
|
||||
const createBtn = screen.getAllByRole("button").find((b) => b.textContent === "Create");
|
||||
fireEvent.click(createBtn!);
|
||||
|
||||
await waitFor(() => expect(mockPost).toHaveBeenCalled());
|
||||
const body = mockPost.mock.calls[0][1] as Record<string, unknown>;
|
||||
expect(body.model).toBe("MiniMax-M2.7");
|
||||
expect(body.llm_provider).toBe("minimax");
|
||||
expect(body.secrets).toEqual({ MINIMAX_API_KEY: "sk-minimax-test" });
|
||||
});
|
||||
|
||||
it("sends Claude OAuth token separately from platform-managed mode", async () => {
|
||||
await openDialog();
|
||||
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
|
||||
target: { value: "OAuth Agent" },
|
||||
});
|
||||
fireEvent.change(document.getElementById("llm-auth-mode") as HTMLSelectElement, {
|
||||
target: { value: "oauth" },
|
||||
});
|
||||
fireEvent.change(document.getElementById("llm-secret-input") as HTMLInputElement, {
|
||||
target: { value: "oauth-token" },
|
||||
});
|
||||
|
||||
const createBtn = screen.getAllByRole("button").find((b) => b.textContent === "Create");
|
||||
fireEvent.click(createBtn!);
|
||||
|
||||
await waitFor(() => expect(mockPost).toHaveBeenCalled());
|
||||
const body = mockPost.mock.calls[0][1] as Record<string, unknown>;
|
||||
expect(body.model).toBe("sonnet");
|
||||
expect(body.llm_provider).toBe("anthropic-oauth");
|
||||
expect(body.secrets).toEqual({ CLAUDE_CODE_OAUTH_TOKEN: "oauth-token" });
|
||||
});
|
||||
|
||||
it("renders gracefully when GET /workspaces fails", async () => {
|
||||
mockGet.mockRejectedValueOnce(new Error("Network error"));
|
||||
await openDialog();
|
||||
|
||||
// Dialog still renders; select exists with only the root option
|
||||
await waitFor(() => {
|
||||
const select = document.querySelector("select") as HTMLSelectElement;
|
||||
const select = screen.getByLabelText("Parent Workspace") as HTMLSelectElement;
|
||||
expect(select.options.length).toBe(1);
|
||||
expect(select.options[0].value).toBe("");
|
||||
});
|
||||
|
||||
@@ -272,7 +272,9 @@ describe("OrgCancelButton — API interactions", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /cancel deployment of test org/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /yes/i }));
|
||||
await act(async () => { /* flush */ });
|
||||
expect(mockApiDel).toHaveBeenCalledWith("/workspaces/root-1?confirm=true");
|
||||
expect(mockApiDel).toHaveBeenCalledWith("/workspaces/root-1?confirm=true", {
|
||||
headers: { "X-Confirm-Name": "Test Org" },
|
||||
});
|
||||
});
|
||||
|
||||
it("shows success toast on DELETE success", async () => {
|
||||
|
||||
@@ -57,6 +57,7 @@ export function OrgCancelButton({ rootId, rootName, workspaceCount }: Props) {
|
||||
try {
|
||||
await api.del<{ status: string }>(
|
||||
`/workspaces/${rootId}?confirm=true`,
|
||||
{ headers: { "X-Confirm-Name": rootName } },
|
||||
);
|
||||
showToast(`Cancelled deployment of "${rootName}"`, "success");
|
||||
// Optimistic local removal — workspace-server broadcasts
|
||||
|
||||
@@ -199,7 +199,9 @@ describe("OrgCancelButton — Yes / cascade delete", () => {
|
||||
});
|
||||
|
||||
// 1) API call hit the cascade-delete endpoint with confirm=true
|
||||
expect(mockApiDel).toHaveBeenCalledWith("/workspaces/ws-root?confirm=true");
|
||||
expect(mockApiDel).toHaveBeenCalledWith("/workspaces/ws-root?confirm=true", {
|
||||
headers: { "X-Confirm-Name": "My Org" },
|
||||
});
|
||||
|
||||
// 2) beginDelete locked the WHOLE subtree (root + 2 children) — NOT the unrelated node
|
||||
expect(mockState.beginDelete).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -1,46 +1,210 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { runtimeDisplayName } from "@/lib/runtime-names";
|
||||
import type { WorkspaceNodeData } from "@/store/canvas";
|
||||
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
|
||||
import type { WorkspaceCompute } from "@/store/socket";
|
||||
|
||||
const INSTANCE_TYPES = ["t3.medium", "t3.large", "t3.xlarge", "t3.2xlarge", "m6i.large", "m6i.xlarge", "c6i.xlarge"];
|
||||
const RUNTIME_OPTIONS = ["claude-code", "codex", "hermes", "openclaw", "kimi", "kimi-cli", "external"];
|
||||
const RESOLUTIONS = ["1280x720", "1440x900", "1920x1080", "2560x1440"];
|
||||
const DEFAULT_HEADLESS_INSTANCE_TYPE = "t3.medium";
|
||||
const DEFAULT_HEADLESS_ROOT_GB = 30;
|
||||
|
||||
type Props = {
|
||||
workspaceId: string;
|
||||
data: Pick<
|
||||
WorkspaceNodeData,
|
||||
"runtime" | "status" | "needsRestart" | "activeTasks" | "deliveryMode"
|
||||
| "workspaceAccess" | "maxConcurrentTasks"
|
||||
| "workspaceAccess" | "maxConcurrentTasks" | "compute" | "applyTemplateOnRestart"
|
||||
>;
|
||||
};
|
||||
|
||||
export function ContainerConfigTab({ data }: Props) {
|
||||
const runtime = data.runtime || "unknown";
|
||||
type FormState = {
|
||||
runtime: string;
|
||||
instanceType: string;
|
||||
rootGB: string;
|
||||
displayEnabled: boolean;
|
||||
displayMode: string;
|
||||
displayProtocol: string;
|
||||
resolution: string;
|
||||
};
|
||||
|
||||
export function ContainerConfigTab({ workspaceId, data }: Props) {
|
||||
const runtime = data.runtime;
|
||||
const instanceType = data.compute?.instance_type;
|
||||
const rootGB = data.compute?.volume?.root_gb;
|
||||
const displayMode = data.compute?.display?.mode;
|
||||
const displayProtocol = data.compute?.display?.protocol;
|
||||
const displayWidth = data.compute?.display?.width;
|
||||
const displayHeight = data.compute?.display?.height;
|
||||
const initial = useMemo(
|
||||
() => formFromData({ runtime, instanceType, rootGB, displayMode, displayProtocol, displayWidth, displayHeight }),
|
||||
[runtime, instanceType, rootGB, displayMode, displayProtocol, displayWidth, displayHeight],
|
||||
);
|
||||
const [form, setForm] = useState<FormState>(initial);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setForm(initial);
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
}, [initial]);
|
||||
|
||||
const workspaceAccess = formatAccess(data.workspaceAccess);
|
||||
const maxConcurrentTasks = data.maxConcurrentTasks ? String(data.maxConcurrentTasks) : "platform-managed";
|
||||
const mountedPath = "/workspace";
|
||||
const privilegeStatus = "standard";
|
||||
const deliveryMode = data.deliveryMode || "push";
|
||||
const dirty = JSON.stringify(form) !== JSON.stringify(initial);
|
||||
const restartLabel = dirty ? "Save & Restart" : "Restart to apply";
|
||||
const resolutionOptions = RESOLUTIONS.includes(form.resolution)
|
||||
? RESOLUTIONS
|
||||
: [form.resolution, ...RESOLUTIONS];
|
||||
|
||||
const save = async (restart: boolean) => {
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
let applyTemplateOnRestart = data.applyTemplateOnRestart ?? false;
|
||||
if (dirty) {
|
||||
const rootGB = parseInt(form.rootGB, 10);
|
||||
if (!Number.isFinite(rootGB)) {
|
||||
setError("Root volume must be a number");
|
||||
return;
|
||||
}
|
||||
|
||||
const [width, height] = form.resolution.split("x").map((v) => parseInt(v, 10));
|
||||
const compute: WorkspaceCompute = {
|
||||
instance_type: form.instanceType,
|
||||
volume: { root_gb: rootGB },
|
||||
display: form.displayEnabled
|
||||
? { mode: form.displayMode, protocol: form.displayProtocol, width, height }
|
||||
: { mode: "none" },
|
||||
};
|
||||
|
||||
const resp = await api.patch<{ needs_restart?: boolean }>(`/workspaces/${workspaceId}`, {
|
||||
runtime: form.runtime,
|
||||
compute,
|
||||
});
|
||||
useCanvasStore.getState().updateNodeData(workspaceId, {
|
||||
runtime: form.runtime,
|
||||
compute,
|
||||
needsRestart: resp.needs_restart ?? true,
|
||||
applyTemplateOnRestart: form.runtime !== initial.runtime,
|
||||
});
|
||||
applyTemplateOnRestart = form.runtime !== initial.runtime;
|
||||
}
|
||||
|
||||
if (restart) {
|
||||
await useCanvasStore.getState().restartWorkspace(workspaceId, {
|
||||
applyTemplate: applyTemplateOnRestart,
|
||||
});
|
||||
}
|
||||
setSuccess(true);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to save");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
<section className="rounded-lg border border-line/50 bg-surface-card/40 p-4">
|
||||
<div className="mb-3">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold text-ink">Container Config</h3>
|
||||
{data.needsRestart && <span className="text-[11px] text-warm">Restart required</span>}
|
||||
</div>
|
||||
|
||||
<dl className="grid grid-cols-1 gap-2 text-[11px]">
|
||||
<ConfigRow label="Runtime image" value={runtimeDisplayName(runtime)} detail={runtime} />
|
||||
<ConfigRow label="Workspace access" value={workspaceAccess} />
|
||||
<ConfigRow label="Max concurrent tasks" value={maxConcurrentTasks} />
|
||||
<ConfigRow label="Mounted workspace path" value={mountedPath} />
|
||||
<ConfigRow label="Container privileges" value={privilegeStatus} />
|
||||
<ConfigRow label="Delivery mode" value={deliveryMode} />
|
||||
</dl>
|
||||
</section>
|
||||
<div className="grid grid-cols-1 gap-3 text-[11px]">
|
||||
<SelectField
|
||||
id="runtime-image-profile"
|
||||
label="Runtime image"
|
||||
value={form.runtime}
|
||||
options={RUNTIME_OPTIONS}
|
||||
optionLabel={runtimeDisplayName}
|
||||
onChange={(runtime) => setForm((s) => ({ ...s, runtime }))}
|
||||
/>
|
||||
<SelectField
|
||||
id="instance-type"
|
||||
label="Instance type"
|
||||
value={form.instanceType}
|
||||
options={INSTANCE_TYPES}
|
||||
onChange={(instanceType) => setForm((s) => ({ ...s, instanceType }))}
|
||||
/>
|
||||
<label className="grid gap-1" htmlFor="root-volume-gb">
|
||||
<span className="text-ink-mid">Root volume</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id="root-volume-gb"
|
||||
aria-label="Root volume"
|
||||
type="number"
|
||||
min={30}
|
||||
max={500}
|
||||
value={form.rootGB}
|
||||
onChange={(e) => setForm((s) => ({ ...s, rootGB: e.target.value }))}
|
||||
className="min-w-0 flex-1 rounded-md border border-line/60 bg-surface-sunken px-3 py-2 font-mono text-ink outline-none focus:border-accent"
|
||||
/>
|
||||
<span className="text-ink-mid">GB</span>
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-center justify-between gap-3 rounded-md bg-surface-sunken/40 px-3 py-2">
|
||||
<span className="text-ink-mid">Display</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label="Enable display"
|
||||
checked={form.displayEnabled}
|
||||
onChange={(e) => setForm((s) => ({
|
||||
...s,
|
||||
displayEnabled: e.target.checked,
|
||||
displayMode: e.target.checked && s.displayMode === "none" ? "desktop-control" : s.displayMode,
|
||||
displayProtocol: e.target.checked && !s.displayProtocol ? "novnc" : s.displayProtocol,
|
||||
}))}
|
||||
className="h-4 w-4 accent-accent"
|
||||
/>
|
||||
</label>
|
||||
{form.displayEnabled && (
|
||||
<SelectField
|
||||
id="display-resolution"
|
||||
label="Resolution"
|
||||
value={form.resolution}
|
||||
options={resolutionOptions}
|
||||
onChange={(resolution) => setForm((s) => ({ ...s, resolution }))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<section className="rounded-lg border border-line/50 bg-surface-card/40 p-4">
|
||||
<h3 className="mb-3 text-sm font-semibold text-ink">Session Controls</h3>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<ReadOnlyAction label={data.needsRestart ? "Restart required" : "Restart"} />
|
||||
<ReadOnlyAction label="Reset session" />
|
||||
<div className="mt-4 flex items-center justify-end gap-2">
|
||||
{error && <span className="mr-auto text-[11px] text-bad">{error}</span>}
|
||||
{success && <span className="mr-auto text-[11px] text-good">Saved</span>}
|
||||
<button
|
||||
type="button"
|
||||
disabled={!dirty || saving}
|
||||
onClick={() => setForm(initial)}
|
||||
className="rounded-md border border-line/60 px-3 py-2 text-[11px] text-ink-mid disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!dirty || saving}
|
||||
onClick={() => save(false)}
|
||||
className="rounded-md bg-accent px-3 py-2 text-[11px] font-medium text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={(!dirty && !data.needsRestart) || saving}
|
||||
onClick={() => save(true)}
|
||||
className="rounded-md bg-ink px-3 py-2 text-[11px] font-medium text-surface disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Restarting..." : restartLabel}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -49,13 +213,73 @@ export function ContainerConfigTab({ data }: Props) {
|
||||
<dl className="grid grid-cols-1 gap-2 text-[11px]">
|
||||
<ConfigRow label="Container status" value={data.status} />
|
||||
<ConfigRow label="Active tasks" value={String(data.activeTasks ?? 0)} />
|
||||
<ConfigRow label="Mounted path access" value="available" />
|
||||
<ConfigRow label="Workspace access" value={workspaceAccess} />
|
||||
<ConfigRow label="Max concurrent tasks" value={maxConcurrentTasks} />
|
||||
<ConfigRow label="Mounted workspace path" value="/workspace" />
|
||||
<ConfigRow label="Delivery mode" value={deliveryMode} />
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formFromData(data: {
|
||||
runtime?: string;
|
||||
instanceType?: string;
|
||||
rootGB?: number;
|
||||
displayMode?: string;
|
||||
displayProtocol?: string;
|
||||
displayWidth?: number;
|
||||
displayHeight?: number;
|
||||
}): FormState {
|
||||
const width = data.displayWidth ?? 1920;
|
||||
const height = data.displayHeight ?? 1080;
|
||||
const resolution = `${width}x${height}`;
|
||||
return {
|
||||
runtime: data.runtime || "claude-code",
|
||||
instanceType: data.instanceType || DEFAULT_HEADLESS_INSTANCE_TYPE,
|
||||
rootGB: String(data.rootGB || DEFAULT_HEADLESS_ROOT_GB),
|
||||
displayEnabled: !!data.displayMode && data.displayMode !== "none",
|
||||
displayMode: data.displayMode && data.displayMode !== "none" ? data.displayMode : "desktop-control",
|
||||
displayProtocol: data.displayProtocol || "novnc",
|
||||
resolution,
|
||||
};
|
||||
}
|
||||
|
||||
function SelectField({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
optionLabel = (v: string) => v,
|
||||
onChange,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
options: string[];
|
||||
optionLabel?: (value: string) => string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="grid gap-1" htmlFor={id}>
|
||||
<span className="text-ink-mid">{label}</span>
|
||||
<select
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="rounded-md border border-line/60 bg-surface-sunken px-3 py-2 font-mono text-ink outline-none focus:border-accent"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{optionLabel(option)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function formatAccess(value: string | null | undefined): string {
|
||||
if (!value) return "none";
|
||||
return value.replace(/_/g, "-");
|
||||
@@ -64,33 +288,16 @@ function formatAccess(value: string | null | undefined): string {
|
||||
function ConfigRow({
|
||||
label,
|
||||
value,
|
||||
detail,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
detail?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-3 rounded-md bg-surface-sunken/40 px-3 py-2">
|
||||
<dt className="text-ink-mid">{label}</dt>
|
||||
<dd className="min-w-0 text-right">
|
||||
<div className="font-mono text-ink break-words">{value}</div>
|
||||
{detail && detail !== value && (
|
||||
<div className="mt-0.5 font-mono text-[10px] text-ink-mid break-words">{detail}</div>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReadOnlyAction({ label }: { label: string }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className="rounded-md border border-line/50 bg-surface-sunken/40 px-3 py-2 text-[11px] text-ink-mid disabled:cursor-not-allowed disabled:opacity-70"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -93,7 +93,9 @@ export function DetailsTab({ workspaceId, data }: Props) {
|
||||
const handleDelete = async () => {
|
||||
setDeleteError(null);
|
||||
try {
|
||||
await api.del(`/workspaces/${workspaceId}?confirm=true`);
|
||||
await api.del(`/workspaces/${workspaceId}?confirm=true`, {
|
||||
headers: { "X-Confirm-Name": name },
|
||||
});
|
||||
// Mirror the server-side cascade — drop the row + every
|
||||
// descendant locally so the canvas reflects the deletion
|
||||
// immediately, even when the WS is dead and the per-descendant
|
||||
|
||||
@@ -265,6 +265,11 @@ function DisplayControlBar({
|
||||
onAcquire: () => void;
|
||||
onRelease: () => void;
|
||||
}) {
|
||||
const userControl = control?.controller === "user";
|
||||
const adminControl = userControl && control?.controlled_by === "admin-token";
|
||||
const canAcquireUserControl = control?.controller === "none" || (userControl && !hasSession);
|
||||
const canReleaseUserControl = adminControl || (userControl && hasSession);
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
{control && (
|
||||
@@ -282,8 +287,7 @@ function DisplayControlBar({
|
||||
{controlError && <p className="mt-0.5 text-[10px] text-red-200">{controlError}</p>}
|
||||
</div>
|
||||
)}
|
||||
{(control?.controller === "none" ||
|
||||
(control?.controller === "user" && control.controlled_by === "admin-token" && !hasSession)) && (
|
||||
{canAcquireUserControl && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAcquire}
|
||||
@@ -293,7 +297,7 @@ function DisplayControlBar({
|
||||
Take control
|
||||
</button>
|
||||
)}
|
||||
{control?.controller === "user" && control.controlled_by === "admin-token" && (
|
||||
{canReleaseUserControl && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRelease}
|
||||
|
||||
@@ -67,7 +67,7 @@ export function FileEditor({
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl opacity-20 mb-2">📄</div>
|
||||
<div aria-hidden="true" className="text-2xl opacity-20 mb-2">📄</div>
|
||||
<p className="text-[10px] text-ink-mid">Select a file to edit</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -79,16 +79,16 @@ export function FileEditor({
|
||||
{/* File header */}
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-b border-line/40 bg-surface-sunken/20">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className="text-[10px] opacity-50">{getIcon(selectedFile, false)}</span>
|
||||
<span aria-hidden="true" className="text-[10px] opacity-50">{getIcon(selectedFile, false)}</span>
|
||||
<span className="text-[10px] font-mono text-ink-mid truncate">{selectedFile}</span>
|
||||
{isDirty && <span className="text-[9px] text-warm">modified</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{success && <span className="text-[9px] text-good">{success}</span>}
|
||||
{success && <span role="status" aria-live="polite" className="text-[9px] text-good">{success}</span>}
|
||||
<button
|
||||
onClick={onDownload}
|
||||
aria-label="Download file"
|
||||
className="text-[10px] text-ink-mid hover:text-ink-mid"
|
||||
className="text-[10px] text-ink-mid hover:text-ink-mid focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
@@ -96,7 +96,7 @@ export function FileEditor({
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={!isDirty || saving}
|
||||
className="text-[10px] text-accent hover:text-accent disabled:opacity-30"
|
||||
className="text-[10px] text-accent hover:text-accent disabled:opacity-30 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</button>
|
||||
@@ -166,6 +166,7 @@ export function FileEditor({
|
||||
}
|
||||
}}
|
||||
spellCheck={false}
|
||||
aria-label="File content editor"
|
||||
className="flex-1 w-full bg-surface p-3 text-[11px] font-mono text-ink leading-relaxed resize-none focus:outline-none"
|
||||
style={{ tabSize: 2 }}
|
||||
/>
|
||||
|
||||
@@ -29,8 +29,8 @@ afterEach(() => {
|
||||
|
||||
const defaultProps = {
|
||||
selectedFile: "/configs/agent.yaml",
|
||||
fileContent: "name: test\nruntime: langgraph",
|
||||
editContent: "name: test\nruntime: langgraph",
|
||||
fileContent: "name: test\nruntime: claude-code",
|
||||
editContent: "name: test\nruntime: claude-code",
|
||||
setEditContent: vi.fn(),
|
||||
loadingFile: false,
|
||||
saving: false,
|
||||
@@ -197,12 +197,12 @@ describe("FileEditor — textarea", () => {
|
||||
render(
|
||||
<FileEditor
|
||||
{...defaultProps}
|
||||
editContent="runtime: langgraph"
|
||||
editContent="runtime: claude-code"
|
||||
/>,
|
||||
);
|
||||
const ta = document.querySelector("textarea");
|
||||
expect(ta).toBeTruthy();
|
||||
expect(ta?.value).toBe("runtime: langgraph");
|
||||
expect(ta?.value).toBe("runtime: claude-code");
|
||||
});
|
||||
|
||||
it("textarea is readOnly when root is not /configs", () => {
|
||||
@@ -210,7 +210,7 @@ describe("FileEditor — textarea", () => {
|
||||
<FileEditor
|
||||
{...defaultProps}
|
||||
root="/workspace"
|
||||
editContent="runtime: langgraph"
|
||||
editContent="runtime: claude-code"
|
||||
/>,
|
||||
);
|
||||
const ta = document.querySelector("textarea");
|
||||
@@ -222,7 +222,7 @@ describe("FileEditor — textarea", () => {
|
||||
<FileEditor
|
||||
{...defaultProps}
|
||||
root="/configs"
|
||||
editContent="runtime: langgraph"
|
||||
editContent="runtime: claude-code"
|
||||
/>,
|
||||
);
|
||||
const ta = document.querySelector("textarea");
|
||||
|
||||
@@ -78,11 +78,11 @@ describe("walkEntry — file entry", () => {
|
||||
});
|
||||
|
||||
it("populates the File object with correct content", async () => {
|
||||
const { entry, file } = makeFile("config.yaml", "runtime: langgraph");
|
||||
const { entry, file } = makeFile("config.yaml", "runtime: claude-code");
|
||||
const out: CollectedEntry[] = [];
|
||||
await walkEntry(entry as never, "", out);
|
||||
expect(out[0]!.file).toBe(file);
|
||||
expect(await out[0]!.file.text()).toBe("runtime: langgraph");
|
||||
expect(await out[0]!.file.text()).toBe("runtime: claude-code");
|
||||
});
|
||||
|
||||
it("appends to existing entries array (non-destructive)", async () => {
|
||||
|
||||
@@ -32,7 +32,7 @@ interface PluginInfo {
|
||||
author: string;
|
||||
tags: string[];
|
||||
skills: string[];
|
||||
// Declared supported runtimes (e.g. ["claude_code", "deepagents"]).
|
||||
// Declared supported runtimes (e.g. ["claude_code", "hermes"]).
|
||||
// Empty / absent = "unspecified, try it".
|
||||
runtimes?: string[];
|
||||
// Only present on /workspaces/:id/plugins responses — true if the
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
// Regression tests for ConfigTab hermes-workspace UX (#1894 + #1900).
|
||||
//
|
||||
// All four bugs this suite pins hit the same workspace on 2026-04-23:
|
||||
// a hermes-runtime workspace whose Config tab showed "LangGraph
|
||||
// a hermes-runtime workspace whose Config tab showed "Claude Code
|
||||
// (default)" in the runtime dropdown, an empty Model field, and a
|
||||
// scary red "No config.yaml found" banner. Clicking Save would
|
||||
// silently PATCH runtime back to LangGraph, breaking the workspace.
|
||||
// silently PATCH runtime back to Claude Code, breaking the workspace.
|
||||
//
|
||||
// Each test pins one invariant. If any fails, the bug is back.
|
||||
|
||||
@@ -91,7 +91,7 @@ describe("ConfigTab — hermes workspace", () => {
|
||||
it("loads runtime from workspace metadata when config.yaml is missing (#1894 bug 1)", async () => {
|
||||
// This is the hermes case: no platform config.yaml, so the form must
|
||||
// fall back to GET /workspaces/:id's runtime field. Before the fix, the
|
||||
// runtime dropdown showed "LangGraph (default)" because the fallback
|
||||
// runtime dropdown showed "Claude Code (default)" because the fallback
|
||||
// didn't exist.
|
||||
wireApi({
|
||||
workspaceRuntime: "hermes",
|
||||
@@ -150,9 +150,9 @@ describe("ConfigTab — hermes workspace", () => {
|
||||
expect(screen.queryByText(/Hermes manages its own config/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("DOES show 'No config.yaml found' error for langgraph workspace (default runtime)", async () => {
|
||||
it("DOES show 'No config.yaml found' error for claude-code workspace (default runtime)", async () => {
|
||||
// Regression guard the other way — the gray info banner is hermes-
|
||||
// specific. A langgraph workspace with no config.yaml SHOULD still
|
||||
// specific. A claude-code workspace with no config.yaml SHOULD still
|
||||
// see the red error so the user knows to provide a template config.
|
||||
wireApi({
|
||||
workspaceRuntime: "",
|
||||
@@ -302,21 +302,21 @@ describe("ConfigTab — config.yaml on disk", () => {
|
||||
// MCP server list, etc.) but runtime/model/tier come from the
|
||||
// workspace row so the node badge matches the form.
|
||||
//
|
||||
// Scenario: DB says "hermes", config.yaml says "crewai". The form
|
||||
// Scenario: DB says "hermes", config.yaml says "openclaw". The form
|
||||
// must show hermes (DB wins).
|
||||
//
|
||||
// We pick hermes (not langgraph) on the DB side because "langgraph"
|
||||
// is collapsed to the empty-string "LangGraph (default)" option in
|
||||
// the runtime dropdown — so a "langgraph" DB value would render as
|
||||
// We pick hermes (not claude-code) on the DB side because "claude-code"
|
||||
// is collapsed to the empty-string "Claude Code (default)" option in
|
||||
// the runtime dropdown — so a "claude-code" DB value would render as
|
||||
// the empty-valued option and obscure whether the DB-wins logic
|
||||
// actually fired. Hermes has its own non-empty option value and
|
||||
// gives the assertion a clean signal.
|
||||
wireApi({
|
||||
workspaceRuntime: "hermes", // DB — authoritative
|
||||
configYamlContent: 'runtime: crewai\nmodel: "claude-opus"\n',
|
||||
configYamlContent: 'runtime: openclaw\nmodel: "claude-opus"\n',
|
||||
templates: [
|
||||
{ id: "t-hermes", name: "Hermes", runtime: "hermes", models: [] },
|
||||
{ id: "t-crewai", name: "CrewAI", runtime: "crewai", models: [] },
|
||||
{ id: "t-openclaw", name: "OpenClaw", runtime: "openclaw", models: [] },
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -1,21 +1,66 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const apiPatch = vi.fn();
|
||||
const updateNodeData = vi.fn();
|
||||
const restartWorkspace = vi.fn();
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
patch: (path: string, body: unknown) => apiPatch(path, body),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/runtime-names", () => ({
|
||||
runtimeDisplayName: (runtime: string) => runtime,
|
||||
}));
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
useCanvasStore: Object.assign(
|
||||
(selector: (s: unknown) => unknown) => selector({ restartWorkspace, updateNodeData }),
|
||||
{ getState: () => ({ restartWorkspace, updateNodeData }) },
|
||||
),
|
||||
}));
|
||||
|
||||
import { ContainerConfigTab } from "../ContainerConfigTab";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
apiPatch.mockReset();
|
||||
restartWorkspace.mockReset();
|
||||
updateNodeData.mockReset();
|
||||
});
|
||||
|
||||
describe("ContainerConfigTab", () => {
|
||||
it("renders read-only runtime and container settings separate from compute shape", () => {
|
||||
it("defaults missing compute to the cost-efficient headless profile", () => {
|
||||
render(
|
||||
<ContainerConfigTab
|
||||
workspaceId="ws-compute"
|
||||
data={{
|
||||
runtime: "claude-code",
|
||||
status: "online",
|
||||
needsRestart: false,
|
||||
activeTasks: 0,
|
||||
maxConcurrentTasks: null,
|
||||
workspaceAccess: "none",
|
||||
deliveryMode: "push",
|
||||
compute: undefined,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText("Instance type")).toHaveProperty("value", "t3.medium");
|
||||
expect(screen.getByLabelText("Root volume")).toHaveProperty("value", "30");
|
||||
});
|
||||
|
||||
it("renders persisted compute and status settings", () => {
|
||||
render(
|
||||
<ContainerConfigTab
|
||||
workspaceId="ws-compute"
|
||||
data={{
|
||||
runtime: "claude-code",
|
||||
status: "online",
|
||||
@@ -24,19 +69,249 @@ describe("ContainerConfigTab", () => {
|
||||
maxConcurrentTasks: 3,
|
||||
workspaceAccess: "read_write",
|
||||
deliveryMode: "poll",
|
||||
compute: {
|
||||
instance_type: "t3.xlarge",
|
||||
volume: { root_gb: 80 },
|
||||
display: { mode: "desktop-control", protocol: "novnc", width: 1920, height: 1080 },
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Runtime image")).toBeTruthy();
|
||||
expect(screen.getByText("claude-code")).toBeTruthy();
|
||||
expect(screen.getByLabelText("Runtime image")).toHaveProperty("value", "claude-code");
|
||||
expect(screen.getByLabelText("Instance type")).toHaveProperty("value", "t3.xlarge");
|
||||
expect(screen.getByLabelText("Root volume")).toHaveProperty("value", "80");
|
||||
expect(screen.getByLabelText("Enable display")).toHaveProperty("checked", true);
|
||||
expect(screen.getByLabelText("Resolution")).toHaveProperty("value", "1920x1080");
|
||||
expect(screen.getByText("Workspace access")).toBeTruthy();
|
||||
expect(screen.getByText("read-write")).toBeTruthy();
|
||||
expect(screen.getByText("Max concurrent tasks")).toBeTruthy();
|
||||
expect(screen.getByText("3")).toBeTruthy();
|
||||
expect(screen.getByText("/workspace")).toBeTruthy();
|
||||
expect(screen.getByText("Container privileges")).toBeTruthy();
|
||||
expect(screen.queryByText("Instance type")).toBeNull();
|
||||
expect(screen.queryByText("Root volume")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not reset dirty form edits on unrelated status rerender", () => {
|
||||
const { rerender } = render(
|
||||
<ContainerConfigTab
|
||||
workspaceId="ws-compute"
|
||||
data={{
|
||||
runtime: "claude-code",
|
||||
status: "online",
|
||||
needsRestart: false,
|
||||
activeTasks: 0,
|
||||
maxConcurrentTasks: null,
|
||||
workspaceAccess: "none",
|
||||
deliveryMode: "push",
|
||||
compute: {
|
||||
instance_type: "t3.large",
|
||||
volume: { root_gb: 50 },
|
||||
display: { mode: "none" },
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Root volume"), { target: { value: "120" } });
|
||||
|
||||
rerender(
|
||||
<ContainerConfigTab
|
||||
workspaceId="ws-compute"
|
||||
data={{
|
||||
runtime: "claude-code",
|
||||
status: "online",
|
||||
needsRestart: false,
|
||||
activeTasks: 1,
|
||||
maxConcurrentTasks: null,
|
||||
workspaceAccess: "none",
|
||||
deliveryMode: "push",
|
||||
compute: {
|
||||
instance_type: "t3.large",
|
||||
volume: { root_gb: 50 },
|
||||
display: { mode: "none" },
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText("Root volume")).toHaveProperty("value", "120");
|
||||
});
|
||||
|
||||
it("saves runtime and compute changes through workspace PATCH", async () => {
|
||||
apiPatch.mockResolvedValueOnce({ needs_restart: true });
|
||||
|
||||
render(
|
||||
<ContainerConfigTab
|
||||
workspaceId="ws-compute"
|
||||
data={{
|
||||
runtime: "claude-code",
|
||||
status: "online",
|
||||
needsRestart: false,
|
||||
activeTasks: 0,
|
||||
maxConcurrentTasks: null,
|
||||
workspaceAccess: "none",
|
||||
deliveryMode: "push",
|
||||
compute: {
|
||||
instance_type: "t3.large",
|
||||
volume: { root_gb: 50 },
|
||||
display: { mode: "none" },
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Runtime image"), { target: { value: "hermes" } });
|
||||
fireEvent.change(screen.getByLabelText("Instance type"), { target: { value: "m6i.xlarge" } });
|
||||
fireEvent.change(screen.getByLabelText("Root volume"), { target: { value: "100" } });
|
||||
fireEvent.click(screen.getByLabelText("Enable display"));
|
||||
fireEvent.change(screen.getByLabelText("Resolution"), { target: { value: "2560x1440" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => expect(apiPatch).toHaveBeenCalledTimes(1));
|
||||
expect(apiPatch).toHaveBeenCalledWith("/workspaces/ws-compute", {
|
||||
runtime: "hermes",
|
||||
compute: {
|
||||
instance_type: "m6i.xlarge",
|
||||
volume: { root_gb: 100 },
|
||||
display: { mode: "desktop-control", protocol: "novnc", width: 2560, height: 1440 },
|
||||
},
|
||||
});
|
||||
expect(updateNodeData).toHaveBeenCalledWith("ws-compute", {
|
||||
runtime: "hermes",
|
||||
compute: {
|
||||
instance_type: "m6i.xlarge",
|
||||
volume: { root_gb: 100 },
|
||||
display: { mode: "desktop-control", protocol: "novnc", width: 2560, height: 1440 },
|
||||
},
|
||||
needsRestart: true,
|
||||
applyTemplateOnRestart: true,
|
||||
});
|
||||
expect(restartWorkspace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves existing custom display mode and resolution when saving unrelated compute", async () => {
|
||||
apiPatch.mockResolvedValueOnce({ needs_restart: true });
|
||||
|
||||
render(
|
||||
<ContainerConfigTab
|
||||
workspaceId="ws-compute"
|
||||
data={{
|
||||
runtime: "claude-code",
|
||||
status: "online",
|
||||
needsRestart: false,
|
||||
activeTasks: 0,
|
||||
maxConcurrentTasks: null,
|
||||
workspaceAccess: "none",
|
||||
deliveryMode: "push",
|
||||
compute: {
|
||||
instance_type: "t3.large",
|
||||
volume: { root_gb: 50 },
|
||||
display: { mode: "gpu-desktop-control", protocol: "dcv", width: 1600, height: 1000 },
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText("Resolution")).toHaveProperty("value", "1600x1000");
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Instance type"), { target: { value: "t3.xlarge" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => expect(apiPatch).toHaveBeenCalledTimes(1));
|
||||
expect(apiPatch).toHaveBeenCalledWith("/workspaces/ws-compute", {
|
||||
runtime: "claude-code",
|
||||
compute: {
|
||||
instance_type: "t3.xlarge",
|
||||
volume: { root_gb: 50 },
|
||||
display: { mode: "gpu-desktop-control", protocol: "dcv", width: 1600, height: 1000 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("can save changed compute and restart the workspace to apply it", async () => {
|
||||
apiPatch.mockResolvedValueOnce({ needs_restart: true });
|
||||
restartWorkspace.mockResolvedValueOnce(undefined);
|
||||
|
||||
render(
|
||||
<ContainerConfigTab
|
||||
workspaceId="ws-compute"
|
||||
data={{
|
||||
runtime: "claude-code",
|
||||
status: "online",
|
||||
needsRestart: false,
|
||||
activeTasks: 0,
|
||||
maxConcurrentTasks: null,
|
||||
workspaceAccess: "none",
|
||||
deliveryMode: "push",
|
||||
compute: {
|
||||
instance_type: "t3.large",
|
||||
volume: { root_gb: 50 },
|
||||
display: { mode: "none" },
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Instance type"), { target: { value: "t3.xlarge" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save & Restart" }));
|
||||
|
||||
await waitFor(() => expect(apiPatch).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(restartWorkspace).toHaveBeenCalledWith("ws-compute", { applyTemplate: false }));
|
||||
});
|
||||
|
||||
it("requests template re-apply when saving a runtime change and restarting", async () => {
|
||||
apiPatch.mockResolvedValueOnce({ needs_restart: true });
|
||||
restartWorkspace.mockResolvedValueOnce(undefined);
|
||||
|
||||
render(
|
||||
<ContainerConfigTab
|
||||
workspaceId="ws-compute"
|
||||
data={{
|
||||
runtime: "claude-code",
|
||||
status: "online",
|
||||
needsRestart: false,
|
||||
activeTasks: 0,
|
||||
maxConcurrentTasks: null,
|
||||
workspaceAccess: "none",
|
||||
deliveryMode: "push",
|
||||
compute: {
|
||||
instance_type: "t3.large",
|
||||
volume: { root_gb: 50 },
|
||||
display: { mode: "none" },
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Runtime image"), { target: { value: "hermes" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save & Restart" }));
|
||||
|
||||
await waitFor(() => expect(restartWorkspace).toHaveBeenCalledWith("ws-compute", { applyTemplate: true }));
|
||||
});
|
||||
|
||||
it("can restart without re-saving when changes are already pending", async () => {
|
||||
restartWorkspace.mockResolvedValueOnce(undefined);
|
||||
|
||||
render(
|
||||
<ContainerConfigTab
|
||||
workspaceId="ws-compute"
|
||||
data={{
|
||||
runtime: "claude-code",
|
||||
status: "online",
|
||||
needsRestart: true,
|
||||
activeTasks: 0,
|
||||
maxConcurrentTasks: null,
|
||||
workspaceAccess: "none",
|
||||
deliveryMode: "push",
|
||||
applyTemplateOnRestart: true,
|
||||
compute: {
|
||||
instance_type: "t3.large",
|
||||
volume: { root_gb: 50 },
|
||||
display: { mode: "none" },
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Restart to apply" }));
|
||||
|
||||
await waitFor(() => expect(restartWorkspace).toHaveBeenCalledWith("ws-compute", { applyTemplate: true }));
|
||||
expect(apiPatch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -290,7 +290,9 @@ describe("DetailsTab — delete workflow", () => {
|
||||
) as HTMLButtonElement;
|
||||
fireEvent(confirmBtn, new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
expect(mockApi.del).toHaveBeenCalledWith("/workspaces/ws-1?confirm=true");
|
||||
expect(mockApi.del).toHaveBeenCalledWith("/workspaces/ws-1?confirm=true", {
|
||||
headers: { "X-Confirm-Name": "Test Workspace" },
|
||||
});
|
||||
expect(mockRemoveSubtree).toHaveBeenCalledWith("ws-1");
|
||||
expect(mockSelectNode).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
@@ -143,46 +143,30 @@ afterEach(() => {
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Drive the always-show-picker flow to completion: deploy() opens the
|
||||
* modal, then we click "keys added" to fire the actual POST. Centralised
|
||||
* here because as of the always-prompt change, every happy-path test
|
||||
* must click through the modal before asserting on POST.
|
||||
*/
|
||||
async function deployThroughPicker<T>(
|
||||
result: { current: ReturnType<typeof useTemplateDeploy> },
|
||||
rerender: () => void,
|
||||
template: Template,
|
||||
): Promise<void> {
|
||||
await act(async () => {
|
||||
await result.current.deploy(template);
|
||||
});
|
||||
rerender();
|
||||
render(<>{result.current.modal}</>);
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId("modal-keys-added"));
|
||||
// Let the fire-and-forget executeDeploy resolve.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe("useTemplateDeploy — happy path", () => {
|
||||
it("preflight ok → modal opens → keys-added → POST /workspaces → onDeployed fires", async () => {
|
||||
it("preflight ok with no key requirements → POST /workspaces directly → onDeployed fires", async () => {
|
||||
const onDeployed = vi.fn();
|
||||
const { result, rerender } = renderHook(() =>
|
||||
const { result } = renderHook(() =>
|
||||
useTemplateDeploy({ onDeployed }),
|
||||
);
|
||||
|
||||
await deployThroughPicker(result, rerender, makeTemplate());
|
||||
await act(async () => {
|
||||
await result.current.deploy(makeTemplate({
|
||||
id: "seo-agent",
|
||||
name: "SEO Agent",
|
||||
model: "MiniMax-M2.7",
|
||||
}));
|
||||
});
|
||||
|
||||
expect(mockCheckDeploySecrets).toHaveBeenCalledTimes(1);
|
||||
expect(mockApiPost).toHaveBeenCalledWith(
|
||||
"/workspaces",
|
||||
expect.objectContaining({
|
||||
name: "Claude Code",
|
||||
template: "claude-code-default",
|
||||
name: "SEO Agent",
|
||||
template: "seo-agent",
|
||||
tier: 1,
|
||||
model: "MiniMax-M2.7",
|
||||
llm_provider: "minimax",
|
||||
}),
|
||||
);
|
||||
expect(onDeployed).toHaveBeenCalledWith("ws-new");
|
||||
@@ -192,11 +176,13 @@ describe("useTemplateDeploy — happy path", () => {
|
||||
|
||||
it("uses caller-supplied canvasCoords when provided", async () => {
|
||||
const canvasCoords = vi.fn(() => ({ x: 42, y: 99 }));
|
||||
const { result, rerender } = renderHook(() =>
|
||||
const { result } = renderHook(() =>
|
||||
useTemplateDeploy({ canvasCoords }),
|
||||
);
|
||||
|
||||
await deployThroughPicker(result, rerender, makeTemplate());
|
||||
await act(async () => {
|
||||
await result.current.deploy(makeTemplate());
|
||||
});
|
||||
|
||||
expect(canvasCoords).toHaveBeenCalledTimes(1);
|
||||
expect(mockApiPost).toHaveBeenCalledWith(
|
||||
@@ -206,9 +192,11 @@ describe("useTemplateDeploy — happy path", () => {
|
||||
});
|
||||
|
||||
it("falls back to random coords inside [100,500] × [100,400] when canvasCoords omitted", async () => {
|
||||
const { result, rerender } = renderHook(() => useTemplateDeploy());
|
||||
const { result } = renderHook(() => useTemplateDeploy());
|
||||
|
||||
await deployThroughPicker(result, rerender, makeTemplate());
|
||||
await act(async () => {
|
||||
await result.current.deploy(makeTemplate());
|
||||
});
|
||||
|
||||
const body = (mockApiPost as Mock).mock.calls[0]?.[1] as {
|
||||
canvas: { x: number; y: number };
|
||||
@@ -458,16 +446,9 @@ describe("useTemplateDeploy — multi-provider always-ask flow", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("single-provider template ALSO opens picker when preflight.ok (always-prompt rule)", async () => {
|
||||
// Default preflight mock: ok=true, providers=[]. claude-code is
|
||||
// single-provider, but the always-prompt rule means the user must
|
||||
// still click through the picker to confirm provider+model — even
|
||||
// when keys are saved and the runtime has only one provider option.
|
||||
// Reason: the user needs an explicit chance to override the
|
||||
// template's default model (e.g. opus vs sonnet vs haiku) before
|
||||
// an EC2 boots and burns billing on the wrong tier.
|
||||
it("template with no provider requirements deploys directly on platform-managed defaults", async () => {
|
||||
const onDeployed = vi.fn();
|
||||
const { result, rerender } = renderHook(() =>
|
||||
const { result } = renderHook(() =>
|
||||
useTemplateDeploy({ onDeployed }),
|
||||
);
|
||||
|
||||
@@ -475,13 +456,18 @@ describe("useTemplateDeploy — multi-provider always-ask flow", () => {
|
||||
await result.current.deploy(makeTemplate());
|
||||
});
|
||||
|
||||
rerender();
|
||||
render(<>{result.current.modal}</>);
|
||||
|
||||
expect(screen.getByTestId("missing-keys-modal")).toBeTruthy();
|
||||
// POST does NOT fire until the user confirms in the picker.
|
||||
expect(mockApiPost).not.toHaveBeenCalled();
|
||||
expect(onDeployed).not.toHaveBeenCalled();
|
||||
expect(screen.queryByTestId("missing-keys-modal")).toBeNull();
|
||||
expect(mockApiPost).toHaveBeenCalledWith(
|
||||
"/workspaces",
|
||||
expect.objectContaining({
|
||||
template: "claude-code-default",
|
||||
model: "claude-sonnet-4-5",
|
||||
llm_provider: "anthropic",
|
||||
}),
|
||||
);
|
||||
expect(onDeployed).toHaveBeenCalledWith("ws-new");
|
||||
expect(result.current.deploying).toBeNull();
|
||||
});
|
||||
|
||||
@@ -519,11 +505,13 @@ describe("useTemplateDeploy — POST failure", () => {
|
||||
it("POST rejection sets error and clears deploying", async () => {
|
||||
mockApiPost.mockRejectedValueOnce(new Error("server 500"));
|
||||
const onDeployed = vi.fn();
|
||||
const { result, rerender } = renderHook(() =>
|
||||
const { result } = renderHook(() =>
|
||||
useTemplateDeploy({ onDeployed }),
|
||||
);
|
||||
|
||||
await deployThroughPicker(result, rerender, makeTemplate());
|
||||
await act(async () => {
|
||||
await result.current.deploy(makeTemplate());
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe("server 500");
|
||||
expect(result.current.deploying).toBeNull();
|
||||
@@ -532,9 +520,11 @@ describe("useTemplateDeploy — POST failure", () => {
|
||||
|
||||
it("non-Error rejection still surfaces a message (defensive)", async () => {
|
||||
mockApiPost.mockRejectedValueOnce("plain string");
|
||||
const { result, rerender } = renderHook(() => useTemplateDeploy());
|
||||
const { result } = renderHook(() => useTemplateDeploy());
|
||||
|
||||
await deployThroughPicker(result, rerender, makeTemplate());
|
||||
await act(async () => {
|
||||
await result.current.deploy(makeTemplate());
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe("Deploy failed");
|
||||
expect(result.current.deploying).toBeNull();
|
||||
|
||||
@@ -55,6 +55,22 @@ interface MissingKeysInfo {
|
||||
preflight: PreflightResult;
|
||||
}
|
||||
|
||||
function nativeProviderForClaudeCodeModel(model: string): string | undefined {
|
||||
const trimmed = model.trim();
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (!trimmed) return undefined;
|
||||
if (lower.startsWith("minimax")) return "minimax";
|
||||
if (lower.startsWith("kimi")) return "kimi-coding";
|
||||
if (lower.startsWith("claude")) return "anthropic";
|
||||
if (/^(sonnet|opus|haiku)$/.test(lower)) return "anthropic-oauth";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isNativeClaudeCodeRuntime(template: Template): boolean {
|
||||
const runtime = template.runtime ?? resolveRuntime(template.id);
|
||||
return runtime === "claude-code";
|
||||
}
|
||||
|
||||
export interface UseTemplateDeployResult {
|
||||
/** Template id currently being deployed (incl. the preflight
|
||||
* network call), or null when idle. Callers pass this to disable
|
||||
@@ -97,6 +113,10 @@ export function useTemplateDeploy(
|
||||
setDeploying(template.id);
|
||||
setError(null);
|
||||
try {
|
||||
const selectedModel = model?.trim() || template.model?.trim();
|
||||
const nativeProvider = isNativeClaudeCodeRuntime(template) && selectedModel
|
||||
? nativeProviderForClaudeCodeModel(selectedModel)
|
||||
: undefined;
|
||||
const coords = canvasCoords
|
||||
? canvasCoords()
|
||||
: {
|
||||
@@ -108,7 +128,8 @@ export function useTemplateDeploy(
|
||||
template: template.id,
|
||||
tier: isSaaSTenant() ? 4 : template.tier,
|
||||
canvas: coords,
|
||||
...(model ? { model } : {}),
|
||||
...(selectedModel ? { model: selectedModel } : {}),
|
||||
...(nativeProvider ? { llm_provider: nativeProvider } : {}),
|
||||
});
|
||||
onDeployed?.(ws.id);
|
||||
} catch (e) {
|
||||
@@ -144,8 +165,13 @@ export function useTemplateDeploy(
|
||||
setDeploying(null);
|
||||
return;
|
||||
}
|
||||
// Always open the picker — every deploy goes through an
|
||||
// explicit confirm-provider/model step. Reasons:
|
||||
if (preflight.ok && preflight.providers.length === 0) {
|
||||
await executeDeploy(template);
|
||||
return;
|
||||
}
|
||||
// Open the picker whenever a template declares provider/key choices.
|
||||
// Templates with no provider requirements deploy directly on the
|
||||
// platform-managed default above. Reasons to keep the picker here:
|
||||
// 1. Multi-provider templates (e.g. hermes) need a per-
|
||||
// workspace pick or the adapter falls back to its
|
||||
// compiled-in default and 500s with "No LLM provider
|
||||
@@ -164,7 +190,7 @@ export function useTemplateDeploy(
|
||||
setMissingKeysInfo({ template, preflight });
|
||||
setDeploying(null);
|
||||
},
|
||||
[],
|
||||
[executeDeploy],
|
||||
);
|
||||
|
||||
// No useCallback here — consumers call this on every render anyway
|
||||
|
||||
@@ -32,8 +32,8 @@ const hermesModels: ModelSpec[] = [
|
||||
|
||||
const HERMES: TemplateLike = { runtime: "hermes", models: hermesModels };
|
||||
|
||||
const LANGGRAPH: TemplateLike = {
|
||||
runtime: "langgraph",
|
||||
const CLAUDE_CODE: TemplateLike = {
|
||||
runtime: "claude-code",
|
||||
required_env: ["OPENAI_API_KEY"],
|
||||
};
|
||||
|
||||
@@ -69,7 +69,7 @@ describe("providersFromTemplate", () => {
|
||||
});
|
||||
|
||||
it("falls back to top-level required_env when no models[] are declared", () => {
|
||||
const providers = providersFromTemplate(LANGGRAPH);
|
||||
const providers = providersFromTemplate(CLAUDE_CODE);
|
||||
expect(providers).toHaveLength(1);
|
||||
expect(providers[0].envVars).toEqual(["OPENAI_API_KEY"]);
|
||||
});
|
||||
@@ -151,10 +151,10 @@ describe("checkDeploySecrets", () => {
|
||||
]),
|
||||
} as Response);
|
||||
|
||||
const result = await checkDeploySecrets(LANGGRAPH);
|
||||
const result = await checkDeploySecrets(CLAUDE_CODE);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.missingKeys).toEqual([]);
|
||||
expect(result.runtime).toBe("langgraph");
|
||||
expect(result.runtime).toBe("claude-code");
|
||||
});
|
||||
|
||||
it("returns ok=true on a multi-provider template when ANY provider is configured", async () => {
|
||||
@@ -195,7 +195,7 @@ describe("checkDeploySecrets", () => {
|
||||
]),
|
||||
} as Response);
|
||||
|
||||
const result = await checkDeploySecrets(LANGGRAPH);
|
||||
const result = await checkDeploySecrets(CLAUDE_CODE);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.missingKeys).toEqual(["OPENAI_API_KEY"]);
|
||||
});
|
||||
@@ -216,7 +216,7 @@ describe("checkDeploySecrets", () => {
|
||||
]),
|
||||
} as Response);
|
||||
|
||||
await checkDeploySecrets(LANGGRAPH, "ws-123");
|
||||
await checkDeploySecrets(CLAUDE_CODE, "ws-123");
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/workspaces/ws-123/secrets"),
|
||||
expect.any(Object),
|
||||
@@ -229,7 +229,7 @@ describe("checkDeploySecrets", () => {
|
||||
json: () => Promise.resolve([]),
|
||||
} as Response);
|
||||
|
||||
await checkDeploySecrets(LANGGRAPH);
|
||||
await checkDeploySecrets(CLAUDE_CODE);
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/settings/secrets"),
|
||||
expect.any(Object),
|
||||
@@ -241,7 +241,7 @@ describe("checkDeploySecrets", () => {
|
||||
new Error("Network error"),
|
||||
);
|
||||
|
||||
const result = await checkDeploySecrets(LANGGRAPH);
|
||||
const result = await checkDeploySecrets(CLAUDE_CODE);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.missingKeys).toEqual(["OPENAI_API_KEY"]);
|
||||
// Empty Set on fetch failure — useTemplateDeploy relies on this
|
||||
|
||||
@@ -28,8 +28,8 @@ describe("isExternalLikeRuntime", () => {
|
||||
"docker",
|
||||
"local",
|
||||
"agent",
|
||||
"crewai",
|
||||
"langgraph",
|
||||
"legacy-runtime",
|
||||
"codex",
|
||||
"openclaw",
|
||||
"custom-runtime",
|
||||
])("%q returns false", (runtime) => {
|
||||
|
||||
@@ -68,8 +68,7 @@ describe("provisionTimeoutForRuntime", () => {
|
||||
});
|
||||
|
||||
it("returns 120_000 for any unknown runtime", () => {
|
||||
expect(provisionTimeoutForRuntime("langgraph")).toBe(120_000);
|
||||
expect(provisionTimeoutForRuntime("crewai")).toBe(120_000);
|
||||
expect(provisionTimeoutForRuntime("legacy-runtime")).toBe(120_000);
|
||||
expect(provisionTimeoutForRuntime("some-new-runtime")).toBe(120_000);
|
||||
});
|
||||
|
||||
@@ -77,7 +76,7 @@ describe("provisionTimeoutForRuntime", () => {
|
||||
const cases: Array<[string | undefined, { provisionTimeoutMs?: number } | undefined]> = [
|
||||
[undefined, undefined],
|
||||
["claude-code", undefined],
|
||||
["langgraph", { provisionTimeoutMs: 500_000 }],
|
||||
["claude-code", { provisionTimeoutMs: 500_000 }],
|
||||
[undefined, { provisionTimeoutMs: 45_000 }],
|
||||
];
|
||||
for (const [runtime, overrides] of cases) {
|
||||
|
||||
@@ -23,6 +23,7 @@ const DEFAULT_TIMEOUT_MS = 35_000;
|
||||
|
||||
export interface RequestOptions {
|
||||
timeoutMs?: number;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,6 +77,7 @@ async function request<T>(
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...platformAuthHeaders(),
|
||||
...(options?.headers ?? {}),
|
||||
};
|
||||
// Re-read slug locally for the 401 handler below — `headers` already
|
||||
// has it, but the 401 branch needs the bare value to gate the
|
||||
|
||||
@@ -44,7 +44,7 @@ export const plans: Plan[] = [
|
||||
price: "$0",
|
||||
features: [
|
||||
"3 workspaces",
|
||||
"Claude Code, LangGraph, OpenClaw runtimes",
|
||||
"Claude Code, Codex, Hermes, OpenClaw runtimes",
|
||||
"Shared Redis + bounded storage",
|
||||
"Community support",
|
||||
],
|
||||
|
||||
@@ -528,6 +528,7 @@ export function buildNodesAndEdges(
|
||||
// A2A delivery mode (task #227). Absent on older ws-server builds
|
||||
// — leave undefined so the chat UI's "?? 'push'" fallback applies.
|
||||
deliveryMode: ws.delivery_mode,
|
||||
compute: ws.compute,
|
||||
},
|
||||
};
|
||||
if (hasParent) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from "@xyflow/react";
|
||||
import { api } from "@/lib/api";
|
||||
import { showToast } from "@/components/Toaster";
|
||||
import type { WorkspaceData, WSMessage } from "./socket";
|
||||
import type { WorkspaceCompute, WorkspaceData, WSMessage } from "./socket";
|
||||
import { handleCanvasEvent } from "./canvas-events";
|
||||
import { markDeleted, wasRecentlyDeleted } from "./deleteTombstones";
|
||||
import {
|
||||
@@ -130,6 +130,14 @@ export interface WorkspaceNodeData extends Record<string, unknown> {
|
||||
* builds — that fallthrough is treated as "push" to match
|
||||
* ws-server's `lookupDeliveryMode` default. */
|
||||
deliveryMode?: string;
|
||||
/** Desired EC2/container shape persisted in workspaces.compute. Applied
|
||||
* at next restart/reprovision, and used to determine Display tab
|
||||
* availability. */
|
||||
compute?: WorkspaceCompute;
|
||||
/** Runtime image changed through Container Config; next restart must
|
||||
* re-apply the runtime-default template instead of reusing the old
|
||||
* config volume. UI-only, cleared after restart. */
|
||||
applyTemplateOnRestart?: boolean;
|
||||
}
|
||||
|
||||
export type PanelTab = "details" | "skills" | "chat" | "terminal" | "display" | "container-config" | "config" | "schedule" | "channels" | "files" | "memory" | "traces" | "events" | "activity" | "audit";
|
||||
@@ -168,7 +176,7 @@ interface CanvasState {
|
||||
setPanelTab: (tab: PanelTab) => void;
|
||||
getSelectedNode: () => Node<WorkspaceNodeData> | null;
|
||||
updateNodeData: (id: string, data: Partial<WorkspaceNodeData>) => void;
|
||||
restartWorkspace: (id: string) => Promise<void>;
|
||||
restartWorkspace: (id: string, options?: { applyTemplate?: boolean }) => Promise<void>;
|
||||
removeNode: (id: string) => void;
|
||||
/** Remove a node AND every descendant in one atomic update. Mirrors
|
||||
* the server-side cascade — `DELETE /workspaces/:id?confirm=true`
|
||||
@@ -329,8 +337,11 @@ export const useCanvasStore = create<CanvasState>((set, get) => ({
|
||||
},
|
||||
batchDelete: async () => {
|
||||
const ids = Array.from(get().selectedNodeIds);
|
||||
const names = new Map(get().nodes.map((node) => [node.id, node.data.name]));
|
||||
const results = await Promise.allSettled(
|
||||
ids.map((id) => api.del(`/workspaces/${id}`))
|
||||
ids.map((id) => api.del(`/workspaces/${id}`, {
|
||||
headers: { "X-Confirm-Name": names.get(id) ?? "" },
|
||||
}))
|
||||
);
|
||||
const failed: string[] = [];
|
||||
results.forEach((r, i) => {
|
||||
@@ -821,9 +832,10 @@ export const useCanvasStore = create<CanvasState>((set, get) => ({
|
||||
});
|
||||
},
|
||||
|
||||
restartWorkspace: async (id) => {
|
||||
await api.post(`/workspaces/${id}/restart`);
|
||||
get().updateNodeData(id, { needsRestart: false });
|
||||
restartWorkspace: async (id, options) => {
|
||||
const body = options?.applyTemplate ? { apply_template: true } : undefined;
|
||||
await api.post(`/workspaces/${id}/restart`, body);
|
||||
get().updateNodeData(id, { needsRestart: false, applyTemplateOnRestart: false });
|
||||
},
|
||||
|
||||
removeNode: (id) => {
|
||||
|
||||
@@ -354,6 +354,20 @@ export interface WorkspaceData {
|
||||
* collapsing the spinner the moment the synchronous queued-200 returns
|
||||
* (task #227 — external/MCP workspaces had no progress UX). */
|
||||
delivery_mode?: string;
|
||||
compute?: WorkspaceCompute;
|
||||
}
|
||||
|
||||
export interface WorkspaceCompute {
|
||||
instance_type?: string;
|
||||
volume?: {
|
||||
root_gb?: number;
|
||||
};
|
||||
display?: {
|
||||
mode?: string;
|
||||
protocol?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
}
|
||||
|
||||
let socket: ReconnectingSocket | null = null;
|
||||
|
||||
@@ -26,7 +26,7 @@ Full contract: `docs/runbooks/admin-auth.md`.
|
||||
|--------|------|---------|
|
||||
| GET | /health | inline |
|
||||
| GET | /metrics | metrics.Handler() — Prometheus text format; no auth, scrape-safe |
|
||||
| POST/GET/PATCH/DELETE | /workspaces[/:id] | workspace.go — `GET /workspaces`, `POST /workspaces`, and `DELETE /workspaces/:id` require `AdminAuth`. `PATCH /workspaces/:id` enforces field-level authz: cosmetic fields (name, role, x, y, canvas) pass through; sensitive fields (tier, parent_id, runtime, workspace_dir) require a valid bearer token when any live token exists. |
|
||||
| POST/GET/PATCH/DELETE | /workspaces[/:id] | workspace.go — `GET /workspaces`, `POST /workspaces`, and `DELETE /workspaces/:id` require `AdminAuth`. `DELETE /workspaces/:id` also requires `X-Confirm-Name: <workspace name>`; cascading deletes still require `?confirm=true`. `PATCH /workspaces/:id` enforces field-level authz: cosmetic fields (name, role, x, y, canvas) pass through; sensitive fields (tier, parent_id, runtime, workspace_dir) require a valid bearer token when any live token exists. |
|
||||
| GET/PATCH | /workspaces/:id/config | workspace.go |
|
||||
| GET/POST | /workspaces/:id/memory | workspace.go |
|
||||
| DELETE | /workspaces/:id/memory/:key | workspace.go |
|
||||
|
||||
@@ -6,6 +6,8 @@ Molecule AI's memory model is built around one principle:
|
||||
|
||||
That is the purpose of **HMA: Hierarchical Memory Architecture**.
|
||||
|
||||
The organizational boundary is enforced **physically**, not at the application layer: each org runs as its own tenant on its own EC2, with its own memory plugin sidecar and its own Postgres. Memory writes are loopback-only — never cross-tenant. See [`workspace-placement.md`](workspace-placement.md) for the architecture contract that makes HMA tenant-isolated by construction.
|
||||
|
||||
## The Three Scopes
|
||||
|
||||
| Scope | Meaning | Intended use |
|
||||
|
||||
@@ -84,6 +84,8 @@ Six runtime adapters ship production-ready on `main`: LangGraph, DeepAgents, Cla
|
||||
|
||||
## 3. System Architecture
|
||||
|
||||
> **Workspace placement contract:** every Molecule org runs as a fully isolated tenant on its own EC2, with workspace-server, memory plugin, Postgres, and Redis all co-located. The platform (controlplane on Railway) handles provisioning, billing, and DNS only — it never holds tenant data. See [`workspace-placement.md`](workspace-placement.md) for the formal RFC.
|
||||
|
||||
### System Boundary Diagram
|
||||
|
||||
```
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
# Workspace placement — org-per-EC2 architecture
|
||||
|
||||
Status: Accepted (implicit since 2026-05; formalized 2026-05-24)
|
||||
Owners: hongming (CTO), cui (CEO)
|
||||
Tracking: #1793
|
||||
|
||||
This RFC formalizes the architecture decision that has been implicit in the system since the post-suspension rebuild: **each Molecule AI org is one isolated tenant on its own EC2 instance**, with every functional surface (workspace-server, memory plugin, Postgres, Redis, canvas) co-located on that instance. The platform's role is provisioning, billing, and the cross-tenant control plane — never the data path.
|
||||
|
||||
The implementation already follows this pattern in every direction we look (provisioner, memory v2 cutover, tenant entrypoint, controlplane user-data, even the OSS deploy story). Writing it down so it stays that way.
|
||||
|
||||
## TL;DR
|
||||
|
||||
```
|
||||
┌──────────────────────────────────┐
|
||||
│ Platform (controlplane) │
|
||||
│ Railway-hosted │
|
||||
│ api.moleculesai.app │
|
||||
│ │
|
||||
│ - org provisioning │
|
||||
│ - billing + Stripe integration │
|
||||
│ - DNS + tunnel orchestration │
|
||||
│ - auth / org-token issuance │
|
||||
│ - fleet redeploy orchestration │
|
||||
│ │
|
||||
│ NEVER holds tenant data │
|
||||
└──────────────────────────────────┘
|
||||
│ │
|
||||
provision │ │ provision
|
||||
+ billing │ │ + billing
|
||||
▼ ▼
|
||||
┌─────────────────────────┐ ┌─────────────────────────┐
|
||||
│ Tenant: agents-team │ │ Tenant: <other-org> │
|
||||
│ Own EC2 (us-east-2) │ │ Own EC2 (us-east-2) │
|
||||
│ agents-team.molecule.. │ │ <slug>.moleculesai.app │
|
||||
│ │ │ │
|
||||
│ ┌───────────────────┐ │ │ ┌───────────────────┐ │
|
||||
│ │ molecule-tenant │ │ │ │ molecule-tenant │ │
|
||||
│ │ (workspace-server │ │ │ │ (workspace-server │ │
|
||||
│ │ + canvas + go) │ │ │ │ + canvas + go) │ │
|
||||
│ └───────────────────┘ │ │ └───────────────────┘ │
|
||||
│ ┌───────────────────┐ │ │ ┌───────────────────┐ │
|
||||
│ │ memory-plugin │ │ │ │ memory-plugin │ │
|
||||
│ │ (loopback :9100) │ │ │ │ (loopback :9100) │ │
|
||||
│ └───────────────────┘ │ │ └───────────────────┘ │
|
||||
│ ┌───────────────────┐ │ │ ┌───────────────────┐ │
|
||||
│ │ postgres pgvector │ │ │ │ postgres pgvector │ │
|
||||
│ │ (172.17.0.1:5432) │ │ │ │ (172.17.0.1:5432) │ │
|
||||
│ └───────────────────┘ │ │ └───────────────────┘ │
|
||||
│ ┌───────────────────┐ │ │ ┌───────────────────┐ │
|
||||
│ │ redis │ │ │ │ redis │ │
|
||||
│ └───────────────────┘ │ │ └───────────────────┘ │
|
||||
│ ┌───────────────────┐ │ │ ┌───────────────────┐ │
|
||||
│ │ workspace runtime │ │ │ │ workspace runtime │ │
|
||||
│ │ containers (ws-*) │ │ │ │ containers (ws-*) │ │
|
||||
│ └───────────────────┘ │ │ └───────────────────┘ │
|
||||
└─────────────────────────┘ └─────────────────────────┘
|
||||
```
|
||||
|
||||
Every tenant is a self-contained molecule-core instance. The platform is a thin coordinator above them.
|
||||
|
||||
## What crosses the platform/tenant boundary
|
||||
|
||||
What the platform sends down to the tenant:
|
||||
|
||||
- Initial EC2 provisioning (user-data script via SSM) — see `molecule-controlplane/internal/provisioner/ec2.go`
|
||||
- Per-tenant secrets (DB password, `SECRETS_ENCRYPTION_KEY`, `MOLECULE_CP_SHARED_SECRET`) injected as env at boot
|
||||
- Image redeploys via `POST /cp/admin/tenants/:slug/redeploy` → SSM → `docker pull && docker stop && docker run`
|
||||
- DNS records (Cloudflare) and tunnel registration (cloudflared)
|
||||
- Billing-state changes (subscription status, plan upgrades)
|
||||
|
||||
What the tenant sends up to the platform:
|
||||
|
||||
- Boot-stage telemetry (`report_stage` calls during EC2 user-data execution)
|
||||
- LLM usage events (for billing aggregation; documented in `controlplane/migrations/037_llm_usage_billing.up.sql`)
|
||||
- Workspace lifecycle events for cross-tenant analytics — read-only, no remote control implied
|
||||
|
||||
What does NOT cross the boundary:
|
||||
|
||||
- Memory contents (HMA scopes, agent_memories before A3, memory_plugin records after)
|
||||
- Workspace state, files, canvas layouts
|
||||
- Workspace runtime container state
|
||||
- Per-org user authentication state (tenant issues its own session tokens via `wsauth`)
|
||||
|
||||
If a feature design wants to put any of those on the platform side, that's a violation of this RFC and needs explicit justification.
|
||||
|
||||
## SSOT rationale
|
||||
|
||||
The single-source-of-truth boundary is **the tenant EC2**.
|
||||
|
||||
This decision was the implicit basis for the memory v1→v2 migration that ran 2026-05-24 (issues #1747 → #1791 → #1792). The v2 memory plugin runs as a sidecar on each tenant EC2, sharing the tenant's Postgres under a dedicated `memory_plugin` schema. There is no platform-side memory aggregation, no central index, no cross-tenant memory federation. Memory writes are loopback-only (workspace-server → memory-plugin on `127.0.0.1:9100`).
|
||||
|
||||
Why this is correct:
|
||||
|
||||
1. **Organizational isolation is the product.** A tenant's memory, workspaces, secrets, and conversation history must not be readable by another org, ever. The simplest enforcement is physical: different EC2, different DB, different network. Application-level multi-tenancy adds a class of cross-tenant data leak bugs that can't happen here.
|
||||
|
||||
2. **The platform must remain horizontally scalable independent of tenant data volume.** If memory aggregation lived on the platform, billing/provisioning/auth would scale with the volume of memory across all tenants. With per-tenant storage, the platform's scaling envelope depends only on the number of orgs.
|
||||
|
||||
3. **OSS-deployability requires it.** molecule-core is open-source; anyone can deploy it. If functional state lived on a centralized platform, OSS deployers would either have to run their own platform (high barrier) or call ours (privacy concern + scale concern). Per-tenant SSOT means the OSS molecule-core instance is functionally complete — it just talks to a platform for billing.
|
||||
|
||||
## OSS-deployment shape
|
||||
|
||||
A workspace inside any tenant reaches its parent tenant by injecting two env vars at container start:
|
||||
|
||||
- `MOLECULE_ORG_ID` — the UUID of the org this workspace belongs to
|
||||
- `MOLECULE_PLATFORM_URL` — the tenant's HTTPS URL (e.g. `https://agents-team.moleculesai.app`)
|
||||
|
||||
These are baked into the workspace runtime's docker run by the workspace-server when it provisions a workspace. The workspace's agent runtime uses them to:
|
||||
|
||||
- Register itself in the tenant's `workspaces` table
|
||||
- Send heartbeats (Redis TTL key on the tenant)
|
||||
- Subscribe to A2A messages via the tenant's WebSocket hub
|
||||
- Commit memories via the tenant's MCP bridge or HTTP `/memories` endpoints
|
||||
|
||||
An OSS deployer running their own molecule-core instance gets the same shape: their workspaces inject the deployer's tenant URL and org ID. The agent runtime is **agnostic** to whether it's talking to our hosted platform or a self-hosted one.
|
||||
|
||||
The only thing tying a tenant to **our** platform is the billing/auth path:
|
||||
|
||||
- `MOLECULE_CP_URL` env on the tenant container points at `api.moleculesai.app`
|
||||
- `MOLECULE_CP_SHARED_SECRET` env authenticates the tenant→platform direction
|
||||
- LLM usage events POST to `cp_url/cp/llm-usage-events` for billing aggregation
|
||||
|
||||
An OSS deployer can leave `MOLECULE_CP_URL` unset (or point at their own platform). The workspace-server's `wiring.go` and `cp_provisioner.go` already handle the absent-CP case gracefully — the tenant is fully functional without it.
|
||||
|
||||
## Scaling envelope
|
||||
|
||||
Per-tenant resource shape (current):
|
||||
|
||||
| Layer | Sizing |
|
||||
|---|---|
|
||||
| EC2 | t3.medium (2 vCPU, 4 GiB) for default-tier orgs |
|
||||
| Postgres | Single container, pgvector pre-installed, ~1-10 GiB per org expected |
|
||||
| Memory plugin | Loopback only, ~50 MB resident, scales with memory record count |
|
||||
| Workspace runtime containers (ws-\*) | One per workspace; sized by template tier |
|
||||
|
||||
The platform's scaling envelope:
|
||||
|
||||
| Layer | Sizing |
|
||||
|---|---|
|
||||
| controlplane | Single Railway service, scales horizontally |
|
||||
| Postgres | One Railway-hosted Postgres for billing + org registry + auth tokens |
|
||||
| DNS | Cloudflare zone with one CNAME per tenant |
|
||||
| Tunnels | One Cloudflare tunnel per tenant |
|
||||
|
||||
Order-of-magnitude:
|
||||
|
||||
- 100 orgs: trivial (100 EC2s, controlplane unchanged)
|
||||
- 10K orgs: needs an EC2 placement strategy (region pinning, dedicated-tier hosts), but the platform is still a single service
|
||||
- 1M orgs: this design starts to strain — Cloudflare tunnel-per-tenant becomes expensive, EC2-per-tenant becomes resource-wasteful, and we'd want a denser tenant-on-shared-infra mode
|
||||
|
||||
The current architecture is sized for the 100–10K range. The 1M-org variant is explicitly out of scope for this RFC.
|
||||
|
||||
## Decision points for new feature design
|
||||
|
||||
When proposing a new feature, the design must answer "where does the data live?" Pick one:
|
||||
|
||||
1. **On the tenant.** Default choice for anything functional. Tenant DB, tenant memory plugin, tenant filesystem. The feature ships in `molecule-core` and is deployed via the tenant image.
|
||||
|
||||
2. **On the platform.** ONLY for billing, cross-org analytics (anonymized), org registry, auth tokens, DNS/tunnel state. The feature ships in `molecule-controlplane`.
|
||||
|
||||
3. **Both, with one as SSOT.** Rare. The tenant is the SSOT; the platform may cache for cross-tenant queries but must be willing to re-read from the tenant on miss. Document the cache invalidation contract.
|
||||
|
||||
When in doubt, default to #1. If you find yourself wanting to put HMA memory, workspace state, or session history on the platform, stop — you're re-introducing the SSOT violation the v1→v2 memory migration was designed to remove.
|
||||
|
||||
## Migration path for non-conforming code
|
||||
|
||||
The implementation already conforms. There is no migration backlog as of 2026-05-24:
|
||||
|
||||
- Memory: v1→v2 migration complete (#1747 → #1791 → #1792). v2 plugin per-tenant is SSOT.
|
||||
- Workspace state: always per-tenant (the `workspaces` table lives in the tenant Postgres).
|
||||
- Activity logs: per-tenant `activity_logs` table.
|
||||
- Files: per-tenant (Docker volumes attached to ws-\* containers).
|
||||
- Secrets: per-tenant (`workspace_secrets` + `global_secrets` tables in tenant DB).
|
||||
- LLM usage events: tenant emits, platform aggregates for billing — correct shape.
|
||||
|
||||
If a future PR proposes platform-side aggregation of something functional, link this RFC in the review.
|
||||
|
||||
## What this RFC does NOT cover
|
||||
|
||||
Out of scope for this document; tracked separately if needed:
|
||||
|
||||
- **Multi-region tenant placement** — current design is single-region (us-east-2). Multi-region needs its own RFC because it changes the EC2 placement contract.
|
||||
- **BYO-compute / customer-managed VPC** — adjacent design; the org-per-EC2 boundary holds but the EC2 ownership shifts to the customer.
|
||||
- **Workspace runtime selection** — separately documented in `docs/architecture/workspace-tiers.md`.
|
||||
- **Tenant image upgrade strategy** — separately documented in `docs/architecture/tenant-image-upgrades.md`.
|
||||
- **OSS billing alternatives** — how OSS deployers handle billing without our controlplane is a separate go-to-market decision.
|
||||
|
||||
## References
|
||||
|
||||
- `docs/architecture/memory.md` — HMA scopes + v2 plugin
|
||||
- `docs/architecture/saas-prod-migration-2026-04-19.md` — provisioning pipeline reference
|
||||
- `docs/architecture/molecule-technical-doc.md` §3 (System Architecture) — top-level picture
|
||||
- `molecule-controlplane/internal/provisioner/ec2.go` — the canonical user-data + docker run for tenants
|
||||
- `workspace-server/entrypoint-tenant.sh` — the canonical tenant boot script
|
||||
- Memory system migration: #1747 (kill v1 fallback), #1791 (Phase A2 backfill), #1792 (Phase A3 drop table)
|
||||
@@ -19,8 +19,8 @@ import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
mclient "github.com/Molecule-AI/molecule-monorepo/platform/internal/memory/client"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/memory/contract"
|
||||
mclient "git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/memory/client"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/memory/contract"
|
||||
)
|
||||
|
||||
func TestMyPlugin_FullRoundTrip(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
# local-e2e — session-continuity canary harness
|
||||
|
||||
Self-contained Docker-Compose harness that gates RFC#600-class template
|
||||
changes (session continuity, file-only messages, multimodal prompts,
|
||||
cross-session memory) **before** they reach customer canary.
|
||||
|
||||
Per CTO standing directive "fully tested + separate CI": this is a
|
||||
dedicated, *fast* (target <3 min), *small-surface* harness that uses a
|
||||
Python tenant-CP simulator (not the full `workspace-server` Go service)
|
||||
to exercise the runtime image end-to-end against canonical canary turns.
|
||||
|
||||
See [`feedback_no_single_source_of_truth`] — the harness IS the canonical
|
||||
session-continuity validator. Per-runtime unit tests still cover their
|
||||
own guard logic; the harness covers the live conversational behaviour
|
||||
that those unit tests cannot prove.
|
||||
|
||||
See [`feedback_image_promote_is_not_user_live`] — every assertion reads
|
||||
state back from the *running container*, never from a publish-pipeline
|
||||
ack.
|
||||
|
||||
## What it tests (the 4 canaries)
|
||||
|
||||
| # | Scenario | Asserts |
|
||||
|---|----------|---------|
|
||||
| 1 | 2-turn name canary | turn 2 reply contains "Hongming" → SessionStore continuity |
|
||||
| 2 | File-only message (no caption) | NOT "(empty prompt — nothing to do)" + reply references filename or asks for clarification |
|
||||
| 3 | File + caption ("summarize this") | reply addresses attachment + caption |
|
||||
| 4 | Cross-session memory recall | new session pulls "blue" via memory tool |
|
||||
|
||||
Each scenario re-uses the same A2A wire-shape that the production
|
||||
`workspace-server` POSTs to runtime `:8000` (canvas-thread-id semantics
|
||||
via `context_id`).
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
local-e2e/
|
||||
docker-compose.yml # runtime under test + cp_sim
|
||||
cp_sim/ # ≈300 LoC Python A2A poster + file uploader
|
||||
cp_sim.py
|
||||
Dockerfile
|
||||
requirements.txt
|
||||
canary/
|
||||
conftest.py
|
||||
test_session_continuity.py # 4 canary scenarios
|
||||
test_layer_diagnostics.py # SessionStore state probe + key derivation
|
||||
scripts/
|
||||
run-canary.sh # one-shot orchestration entrypoint
|
||||
```
|
||||
|
||||
The CP simulator emits the **exact** JSON-RPC `message/send` envelope
|
||||
that `workspace-server` produces (verified against
|
||||
`tests/e2e/test_chat_attachments_e2e.sh`). No Go service is in the loop —
|
||||
this keeps the harness lean per the CTO directive.
|
||||
|
||||
## Run locally
|
||||
|
||||
```bash
|
||||
# from molecule-core repo root:
|
||||
export TEMPLATE_IMAGE=ghcr.io/molecule-ai/workspace-template-hermes:latest
|
||||
./local-e2e/scripts/run-canary.sh
|
||||
```
|
||||
|
||||
Exit code 0 = all 4 canaries pass. Non-zero = at least one canary failed
|
||||
and the harness dumped SessionStore state + last 200 log lines from the
|
||||
runtime container into `./local-e2e/artifacts/`.
|
||||
|
||||
## How it integrates into CI
|
||||
|
||||
Each template repo's `.gitea/workflows/session-continuity-e2e.yml` calls
|
||||
`run-canary.sh` with its own freshly-built `TEMPLATE_IMAGE`. The
|
||||
template repo's Gitea branch-protection lists
|
||||
`session-continuity-e2e (pull_request)` as a required context.
|
||||
|
||||
Rollout order (deliberate — per `feedback_image_promote_is_not_user_live`
|
||||
we bake before we cascade):
|
||||
|
||||
1. `molecule-ai-workspace-template-hermes` — highest-traffic + most
|
||||
recent RFC#600-class fixes — REQUIRED gate
|
||||
2. Bake for 5 business days
|
||||
3. Cascade to claude-code, langgraph, autogen, openclaw, smolagents,
|
||||
google-adk (one PR per template — see `scripts/onboard-template.sh`)
|
||||
|
||||
## Future extensions (out of scope for the initial PR)
|
||||
|
||||
- Multi-session memory consistency (3+ sessions deep)
|
||||
- Tool-use canary (workspace seeded with skills/, agent must invoke)
|
||||
- Streaming-cancellation canary (mid-stream client disconnect)
|
||||
- Cross-runtime A2A peer call (currently covered by `e2e-peer-visibility`)
|
||||
|
||||
## Why a thin Python simulator and not the real `workspace-server`?
|
||||
|
||||
`workspace-server` is a 60+ MB Go binary that requires Postgres, Redis,
|
||||
admin-token wiring, registry plumbing, and a 30+ second cold-boot. None
|
||||
of that touches session-continuity behaviour, which is fully owned by
|
||||
the runtime container's `a2a_executor.py`. Per CTO directive "separate
|
||||
CI as possible" + the <3 min target, we excise the platform-tenant Go
|
||||
service from the loop and emit identical wire-shape envelopes from a
|
||||
single Python file.
|
||||
|
||||
If the simulator diverges from `workspace-server` wire shape, the gate
|
||||
goes red — fix the simulator to match production. The wire shape is
|
||||
asserted in `tests/e2e/test_chat_attachments_e2e.sh` and the runtime's
|
||||
`workspace/a2a_executor.py:_core_execute`.
|
||||
@@ -0,0 +1,19 @@
|
||||
# Python tenant-CP simulator + canary test driver.
|
||||
# Single image — pytest + httpx + the canary tests baked in.
|
||||
FROM python:3.11-slim@sha256:e78299e55776ca065dcb769f80161f48465ad352014240eb5fe4712e22505e9b
|
||||
|
||||
WORKDIR /harness
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Test files are bind-mounted by docker-compose at run time so a `pytest -x`
|
||||
# rerun loop doesn't require a rebuild. The COPY here is for the
|
||||
# self-contained image used by Gitea Actions (where bind mounts are awkward).
|
||||
COPY cp_sim.py /harness/cp_sim.py
|
||||
COPY canary /harness/canary
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Default: run the 4 canaries with verbose output + JUnit XML for CI.
|
||||
CMD ["pytest", "-v", "--tb=short", "--junitxml=/harness/artifacts/junit.xml", "canary/"]
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Shared pytest fixtures for the canary suite."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
# cp_sim.py lives one dir up — make it importable without packaging.
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import pytest # noqa: E402
|
||||
|
||||
from cp_sim import CPSim, CPSimConfig # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sim() -> CPSim:
|
||||
"""Fresh CPSim per test — cheap, isolates connection state."""
|
||||
return CPSim(
|
||||
cfg=CPSimConfig(
|
||||
runtime_url=os.environ.get("RUNTIME_URL", "http://localhost:18000"),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def context_id() -> str:
|
||||
"""A unique canvas-thread-id per test — guarantees SessionStore isolation
|
||||
between scenarios so a failing canary doesn't poison the next one."""
|
||||
return f"canary-ctx-{uuid.uuid4().hex[:12]}"
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Layer-isolation diagnostics — runs alongside the 4 canaries.
|
||||
|
||||
These probes are not strict pass/fail gates by themselves; they exist so
|
||||
when a canary fails, the artifacts include enough state to tell whether
|
||||
the regression is in the wire-shape layer, the SessionStore layer, or
|
||||
the memory layer. Each test always passes (returns early) when the
|
||||
underlying surface is unavailable on the runtime under test — different
|
||||
templates expose different debug endpoints.
|
||||
|
||||
Cross-refs:
|
||||
- feedback_verify_actual_endstate_not_ack_follow_sop — we read state
|
||||
back, not the side-effect ack.
|
||||
- feedback_image_promote_is_not_user_live — the verification is at
|
||||
the running-container layer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
|
||||
from cp_sim import CPSim
|
||||
|
||||
|
||||
def test_diag_agent_card_advertises_a2a(sim: CPSim) -> None:
|
||||
"""The runtime's /agent-card must advertise A2A capabilities.
|
||||
|
||||
If this fails, the canaries' transport assumption (POST /a2a) is
|
||||
already broken — diagnose the runtime image, not the canary.
|
||||
"""
|
||||
url = f"{sim.cfg.runtime_url}/agent-card"
|
||||
r = httpx.get(url, timeout=10.0)
|
||||
assert r.status_code == 200, (
|
||||
f"/agent-card returned {r.status_code}: {r.text[:300]!r}"
|
||||
)
|
||||
body = r.json()
|
||||
# AgentCard spec: capabilities object must exist, even if empty.
|
||||
assert isinstance(body, dict), f"/agent-card body not an object: {body!r}"
|
||||
# We don't require any specific capability flag — different templates
|
||||
# advertise different sets. The point of this diag is "is the card
|
||||
# there at all", which signals the runtime booted past entrypoint.
|
||||
|
||||
|
||||
def test_diag_context_id_required_for_continuity(sim: CPSim) -> None:
|
||||
"""Same context_id in two turns must not crash the runtime.
|
||||
|
||||
Pure smoke probe — proves the executor accepts a continuation
|
||||
message without 5xx-ing. The substantive assertion is canary 1; this
|
||||
one just guarantees the path is reachable.
|
||||
"""
|
||||
ctx = f"diag-{uuid.uuid4().hex[:8]}"
|
||||
r1 = sim.send_text("ping", context_id=ctx)
|
||||
r2 = sim.send_text("ping again", context_id=ctx, task_id=r1.get("result", {}).get("id"))
|
||||
# Both replies must parse — non-empty envelope, no JSON-RPC error.
|
||||
for label, env in (("turn1", r1), ("turn2", r2)):
|
||||
assert "error" not in env, f"{label} returned JSON-RPC error: {env['error']}"
|
||||
|
||||
|
||||
def test_diag_memory_root_writable_in_canary_mode(sim: CPSim) -> None:
|
||||
"""When MOLECULE_CANARY_MODE=1, the memory root must accept writes.
|
||||
|
||||
Probes via the recall_memory MCP tool — if /mcp is not exposed,
|
||||
returns early (skip-style; we still pass because some templates
|
||||
proxy MCP elsewhere).
|
||||
"""
|
||||
# We can't write directly here — only confirm the read path doesn't
|
||||
# 500 on a missing key. A real write happens in canary 4.
|
||||
key = f"canary-probe-{uuid.uuid4().hex[:8]}"
|
||||
try:
|
||||
val = sim.probe_memory(key)
|
||||
except Exception as e:
|
||||
# /mcp may not be exposed on this template — canary 4 will
|
||||
# surface the real defect if memory is actually broken.
|
||||
if os.environ.get("CANARY_STRICT_MCP") == "1":
|
||||
raise
|
||||
return
|
||||
# Unknown key → None is fine. The point is the call didn't crash.
|
||||
assert val is None or isinstance(val, str)
|
||||
@@ -0,0 +1,204 @@
|
||||
"""The 4 canonical session-continuity canaries (task #342, RFC#600 class).
|
||||
|
||||
These tests speak A2A directly to the runtime under test. They are the
|
||||
authoritative gate that the runtime preserves conversation continuity,
|
||||
handles file-only messages without dropping to the empty-prompt error,
|
||||
addresses multimodal prompts, and persists memory across sessions.
|
||||
|
||||
Wire-shape source of truth: see ../cp_sim.py docstring.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import uuid
|
||||
|
||||
from cp_sim import CPSim
|
||||
|
||||
|
||||
# ---------- canary 1: 2-turn name continuity -------------------------------
|
||||
|
||||
|
||||
def test_canary_1_two_turn_name_continuity(sim: CPSim, context_id: str) -> None:
|
||||
"""SessionStore continuity — turn 2 must recall the name from turn 1.
|
||||
|
||||
Empirically tests:
|
||||
- ``a2a_executor._core_execute`` injects prior-turn history via
|
||||
``_extract_history(context)`` (workspace/a2a_executor.py:313).
|
||||
- The runtime's session store is keyed on ``context_id`` (canvas
|
||||
thread id) NOT ``task_id`` — task_id is per-turn, context_id is
|
||||
per-conversation. Regressions to that key derivation were the
|
||||
root cause of the 2026-05 multi-turn-amnesia incidents
|
||||
(#a60623344 diagnosis).
|
||||
"""
|
||||
# Turn 1 — establish the fact.
|
||||
r1 = sim.send_text(
|
||||
"Hi, my name is Hongming.",
|
||||
context_id=context_id,
|
||||
)
|
||||
reply1 = sim.extract_text_parts(r1)
|
||||
assert reply1, f"Turn 1 produced empty reply. envelope={r1!r}"
|
||||
|
||||
# Turn 2 — ask back. Same context_id → same SessionStore key.
|
||||
r2 = sim.send_text(
|
||||
"What's my name?",
|
||||
context_id=context_id,
|
||||
)
|
||||
reply2 = sim.extract_text_parts(r2)
|
||||
assert reply2, f"Turn 2 produced empty reply. envelope={r2!r}"
|
||||
|
||||
# Substring match, case-insensitive — agents may reply
|
||||
# "Your name is Hongming." or "It's Hongming!" or similar.
|
||||
assert re.search(r"\bhongming\b", reply2, flags=re.IGNORECASE), (
|
||||
f"Turn 2 reply does not contain 'Hongming' — SessionStore "
|
||||
f"continuity regression suspected. context_id={context_id} "
|
||||
f"turn1_reply={reply1[:200]!r} turn2_reply={reply2[:400]!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------- canary 2: file-only message (no caption) -----------------------
|
||||
|
||||
|
||||
_DROPPED_TURN_MARKERS = (
|
||||
"(empty prompt — nothing to do)",
|
||||
"empty prompt",
|
||||
"message contained no text content",
|
||||
"no text content",
|
||||
)
|
||||
|
||||
|
||||
def test_canary_2_file_only_message(sim: CPSim, context_id: str) -> None:
|
||||
"""File-attached A2A message with NO text part must not be dropped.
|
||||
|
||||
Root cause this guards against: a long-standing executor bug where
|
||||
``extract_message_text`` returned "" for file-only messages and the
|
||||
executor short-circuited with the "Error: message contained no text
|
||||
content." reply, even though the attached file was the entire point
|
||||
of the turn.
|
||||
|
||||
Hard assertions:
|
||||
- Reply is non-empty AND not the dropped-turn marker.
|
||||
- Reply references the file by name OR asks an actionable
|
||||
clarifying question (NOT a flat error).
|
||||
"""
|
||||
file_name = f"canary-{uuid.uuid4().hex[:8]}.txt"
|
||||
file_body = b"Project status: nominal. Lighthouse score 98."
|
||||
|
||||
r = sim.send_with_file(
|
||||
context_id=context_id,
|
||||
text=None, # ← THE CANARY: no caption.
|
||||
file_name=file_name,
|
||||
file_bytes=file_body,
|
||||
mime_type="text/plain",
|
||||
)
|
||||
reply = sim.extract_text_parts(r)
|
||||
assert reply, f"File-only message produced empty reply. envelope={r!r}"
|
||||
|
||||
low = reply.lower()
|
||||
for marker in _DROPPED_TURN_MARKERS:
|
||||
assert marker.lower() not in low, (
|
||||
f"File-only message was dropped — reply contains "
|
||||
f"{marker!r}. Full reply: {reply[:500]!r}"
|
||||
)
|
||||
|
||||
# Soft assertion: reply must engage with the file (reference its
|
||||
# name) OR ask an actionable clarification. We require ONE of those —
|
||||
# a generic "Hello! How can I help?" reply is also a drop.
|
||||
name_referenced = file_name.lower() in low or "file" in low or "attach" in low
|
||||
asks_clarification = (
|
||||
"what" in low or "would you like" in low or "?" in reply
|
||||
)
|
||||
assert name_referenced or asks_clarification, (
|
||||
f"File-only reply neither references the file nor asks a "
|
||||
f"clarifying question. Reply: {reply[:500]!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------- canary 3: file + prompt (multimodal) ---------------------------
|
||||
|
||||
|
||||
def test_canary_3_file_with_prompt(sim: CPSim, context_id: str) -> None:
|
||||
"""File-attached A2A message WITH a caption — multimodal happy path.
|
||||
|
||||
Lower bar than canary 2: assert the agent acknowledges the file was
|
||||
received and tries to address the caption. We deliberately don't
|
||||
require a perfect summary because canary mode replies are canned —
|
||||
the goal is to prove the executor's multimodal code path doesn't
|
||||
drop EITHER the file OR the caption.
|
||||
"""
|
||||
file_name = f"canary-doc-{uuid.uuid4().hex[:8]}.txt"
|
||||
file_body = (
|
||||
b"Quarterly review. Revenue up 14%. Churn down 3%. "
|
||||
b"Team headcount steady. Action: ship RFC#600 by end of week."
|
||||
)
|
||||
r = sim.send_with_file(
|
||||
context_id=context_id,
|
||||
text="summarize this",
|
||||
file_name=file_name,
|
||||
file_bytes=file_body,
|
||||
mime_type="text/plain",
|
||||
)
|
||||
reply = sim.extract_text_parts(r)
|
||||
assert reply, f"File+prompt produced empty reply. envelope={r!r}"
|
||||
|
||||
low = reply.lower()
|
||||
for marker in _DROPPED_TURN_MARKERS:
|
||||
assert marker.lower() not in low, (
|
||||
f"File+prompt was dropped — reply contains {marker!r}. "
|
||||
f"Full reply: {reply[:500]!r}"
|
||||
)
|
||||
|
||||
# At minimum: the reply must mention file/attach/summary semantics,
|
||||
# demonstrating the executor accepted both parts.
|
||||
engaged = any(
|
||||
kw in low for kw in ("file", "attach", "summary", "summarize", "content", file_name.lower())
|
||||
)
|
||||
assert engaged, (
|
||||
f"Multimodal reply doesn't engage with attached file or caption. "
|
||||
f"Reply: {reply[:500]!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------- canary 4: cross-session memory recall --------------------------
|
||||
|
||||
|
||||
def test_canary_4_cross_session_memory_recall(sim: CPSim) -> None:
|
||||
"""Memory persists across distinct context_ids → memory layer (NOT
|
||||
SessionStore) is the storage.
|
||||
|
||||
Two distinct context_ids in this test — SessionStore CANNOT bridge
|
||||
them. The bridge is the runtime's persistent memory (MOLECULE_MEMORY_ROOT
|
||||
in canary mode). If the recall returns "blue" in session 2, the
|
||||
memory layer is wired correctly.
|
||||
|
||||
Note: we ask the agent to commit the memory explicitly in session 1
|
||||
so that the canary doesn't depend on memory auto-extraction
|
||||
heuristics (which vary by runtime). The commit goes through the
|
||||
same MCP tool the canvas would invoke.
|
||||
"""
|
||||
ctx_a = f"canary-ctx-{uuid.uuid4().hex[:12]}"
|
||||
ctx_b = f"canary-ctx-{uuid.uuid4().hex[:12]}"
|
||||
|
||||
# Session 1 — commit a fact via the memory tool. Use the explicit
|
||||
# "remember" verb so canary-mode agents (which short-circuit to a
|
||||
# deterministic tool-call) reliably invoke `commit_memory`.
|
||||
r1 = sim.send_text(
|
||||
"Please use the memory tool to remember: my favorite color is blue.",
|
||||
context_id=ctx_a,
|
||||
)
|
||||
reply1 = sim.extract_text_parts(r1)
|
||||
assert reply1, f"Session 1 produced empty reply. envelope={r1!r}"
|
||||
|
||||
# Session 2 — different context_id. Same workspace, same memory.
|
||||
r2 = sim.send_text(
|
||||
"Use the memory tool to recall my favorite color, then tell me what it is.",
|
||||
context_id=ctx_b,
|
||||
)
|
||||
reply2 = sim.extract_text_parts(r2)
|
||||
assert reply2, f"Session 2 produced empty reply. envelope={r2!r}"
|
||||
|
||||
assert re.search(r"\bblue\b", reply2, flags=re.IGNORECASE), (
|
||||
f"Session 2 reply does not contain 'blue' — cross-session memory "
|
||||
f"recall regression suspected. ctx_a={ctx_a} ctx_b={ctx_b} "
|
||||
f"session1_reply={reply1[:200]!r} session2_reply={reply2[:400]!r}"
|
||||
)
|
||||
@@ -0,0 +1,214 @@
|
||||
"""Tenant control-plane simulator.
|
||||
|
||||
Emits the byte-identical JSON-RPC `message/send` wire shape that the
|
||||
production `workspace-server` POSTs to the runtime's :8000 — see
|
||||
``workspace-server/internal/handlers/a2a.go`` and the canonical sample
|
||||
in ``tests/e2e/test_chat_attachments_e2e.sh``.
|
||||
|
||||
This file is purposefully small (~250 LoC). It is NOT a re-implementation
|
||||
of `workspace-server`; it is just the minimum surface required to drive
|
||||
the 4 session-continuity canaries.
|
||||
|
||||
If the runtime asserts on a header / envelope field that the production
|
||||
platform sets but this simulator omits, FIX THE SIMULATOR — never weaken
|
||||
the runtime to accept divergent wire shapes. The simulator is the
|
||||
canonical contract emitter for canary purposes
|
||||
(``feedback_no_single_source_of_truth``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
@dataclass
|
||||
class CPSimConfig:
|
||||
runtime_url: str
|
||||
"""Base URL of the runtime under test (e.g. http://runtime:8000)."""
|
||||
request_timeout_s: float = 60.0
|
||||
"""Per-A2A-call timeout. Generous — canary mode replies are fast,
|
||||
but a real Provider-backed runtime under cold cache can take 30+s."""
|
||||
|
||||
|
||||
class CPSim:
|
||||
"""Thin client matching workspace-server's wire shape."""
|
||||
|
||||
def __init__(self, cfg: CPSimConfig | None = None) -> None:
|
||||
self.cfg = cfg or CPSimConfig(
|
||||
runtime_url=os.environ.get("RUNTIME_URL", "http://localhost:18000"),
|
||||
)
|
||||
self._client = httpx.Client(timeout=self.cfg.request_timeout_s)
|
||||
|
||||
# ------------------------------------------------------------------ A2A
|
||||
|
||||
def send_text(
|
||||
self,
|
||||
text: str,
|
||||
*,
|
||||
context_id: str,
|
||||
task_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""POST a text-only A2A message. Returns the JSON-RPC envelope."""
|
||||
msg_id = f"canary-{uuid.uuid4().hex[:12]}"
|
||||
payload = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg_id,
|
||||
"method": "message/send",
|
||||
"params": {
|
||||
"message": {
|
||||
"role": "user",
|
||||
"messageId": msg_id,
|
||||
"kind": "message",
|
||||
"contextId": context_id,
|
||||
"taskId": task_id,
|
||||
"parts": [{"kind": "text", "text": text}],
|
||||
},
|
||||
"configuration": {
|
||||
"acceptedOutputModes": ["text/plain"],
|
||||
"blocking": True,
|
||||
},
|
||||
},
|
||||
}
|
||||
return self._post(payload)
|
||||
|
||||
def send_with_file(
|
||||
self,
|
||||
*,
|
||||
context_id: str,
|
||||
text: str | None,
|
||||
file_name: str,
|
||||
file_bytes: bytes,
|
||||
mime_type: str = "text/plain",
|
||||
task_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""POST an A2A message with an inline file part.
|
||||
|
||||
Uses the inline `bytes` form of A2A file parts (RFC#600 — the
|
||||
no-URI variant added precisely so canary tests don't need a
|
||||
`/chat/uploads` round-trip). Each runtime's executor calls
|
||||
``extract_attached_files`` which handles both forms — verified
|
||||
in ``workspace/executor_helpers.py:903``.
|
||||
"""
|
||||
msg_id = f"canary-{uuid.uuid4().hex[:12]}"
|
||||
parts: list[dict[str, Any]] = []
|
||||
if text:
|
||||
parts.append({"kind": "text", "text": text})
|
||||
parts.append(
|
||||
{
|
||||
"kind": "file",
|
||||
"file": {
|
||||
"name": file_name,
|
||||
"mimeType": mime_type,
|
||||
"bytes": base64.b64encode(file_bytes).decode("ascii"),
|
||||
},
|
||||
}
|
||||
)
|
||||
payload = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg_id,
|
||||
"method": "message/send",
|
||||
"params": {
|
||||
"message": {
|
||||
"role": "user",
|
||||
"messageId": msg_id,
|
||||
"kind": "message",
|
||||
"contextId": context_id,
|
||||
"taskId": task_id,
|
||||
"parts": parts,
|
||||
},
|
||||
"configuration": {
|
||||
"acceptedOutputModes": ["text/plain"],
|
||||
"blocking": True,
|
||||
},
|
||||
},
|
||||
}
|
||||
return self._post(payload)
|
||||
|
||||
# ------------------------------------------------------------ helpers
|
||||
|
||||
def _post(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
url = f"{self.cfg.runtime_url}/a2a"
|
||||
try:
|
||||
r = self._client.post(url, json=payload)
|
||||
except httpx.HTTPError as e:
|
||||
raise CPSimError(f"A2A POST failed: {e}") from e
|
||||
if r.status_code != 200:
|
||||
raise CPSimError(
|
||||
f"A2A non-200: status={r.status_code} body={r.text[:500]}"
|
||||
)
|
||||
try:
|
||||
return r.json()
|
||||
except json.JSONDecodeError as e:
|
||||
raise CPSimError(f"A2A body not JSON: {r.text[:500]}") from e
|
||||
|
||||
@staticmethod
|
||||
def extract_text_parts(envelope: dict[str, Any]) -> str:
|
||||
"""Return concatenated text from all text parts of a reply.
|
||||
|
||||
Handles both top-level `result.parts` (the canonical shape) and
|
||||
`result.artifacts[*].parts` (which some runtimes emit when the
|
||||
reply was streamed as artifact chunks). Matches the extractor in
|
||||
``tests/e2e/test_chat_attachments_e2e.sh``.
|
||||
"""
|
||||
result = envelope.get("result") or {}
|
||||
chunks: list[str] = []
|
||||
for p in result.get("parts", []) or []:
|
||||
if p.get("kind") == "text":
|
||||
chunks.append(p.get("text", ""))
|
||||
for art in result.get("artifacts", []) or []:
|
||||
for p in art.get("parts", []) or []:
|
||||
if p.get("kind") == "text":
|
||||
chunks.append(p.get("text", ""))
|
||||
# Some runtimes return a status.message instead of/in addition to parts.
|
||||
status = result.get("status") or {}
|
||||
status_msg = status.get("message") or {}
|
||||
for p in status_msg.get("parts", []) or []:
|
||||
if p.get("kind") == "text":
|
||||
chunks.append(p.get("text", ""))
|
||||
return "\n".join(chunks).strip()
|
||||
|
||||
# ----------------------------------------------------- memory probe
|
||||
|
||||
def probe_memory(self, key: str) -> str | None:
|
||||
"""Read a memory value via the runtime's MCP memory tool.
|
||||
|
||||
Uses the same MCP transport the canvas uses
|
||||
(``POST /workspaces/:id/mcp``-shaped JSON-RPC over /mcp). Returns
|
||||
the recalled string or None if the key is missing.
|
||||
"""
|
||||
payload = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": f"canary-mem-{uuid.uuid4().hex[:8]}",
|
||||
"method": "tools/call",
|
||||
"params": {"name": "recall_memory", "arguments": {"key": key}},
|
||||
}
|
||||
try:
|
||||
r = self._client.post(f"{self.cfg.runtime_url}/mcp", json=payload)
|
||||
except httpx.HTTPError as e:
|
||||
raise CPSimError(f"MCP POST failed: {e}") from e
|
||||
if r.status_code != 200:
|
||||
return None
|
||||
body = r.json()
|
||||
result = body.get("result") or {}
|
||||
# MCP responses wrap the tool output in result.content[*].text per
|
||||
# the JSON-RPC tools/call contract.
|
||||
for c in result.get("content", []) or []:
|
||||
if c.get("type") == "text":
|
||||
return c.get("text")
|
||||
return None
|
||||
|
||||
|
||||
class CPSimError(RuntimeError):
|
||||
"""Raised on transport / envelope failures (NOT canary assertion failures).
|
||||
|
||||
Distinct from AssertionError so pytest reports them as ERROR not
|
||||
FAILED — a transport-layer fault should be debugged differently from
|
||||
a real session-continuity regression.
|
||||
"""
|
||||
@@ -0,0 +1,5 @@
|
||||
# Pinned (not floating) so the harness is reproducible across CI runs.
|
||||
# These versions match what tests/e2e/_lib.sh and tests/e2e/conftest.py use.
|
||||
httpx==0.27.2
|
||||
pytest==8.3.3
|
||||
pytest-asyncio==0.24.0
|
||||
@@ -0,0 +1,58 @@
|
||||
# local-e2e/docker-compose.yml — minimal harness stack.
|
||||
#
|
||||
# Two services:
|
||||
# runtime — the template image under test (TEMPLATE_IMAGE env var).
|
||||
# Exposes :8000 for A2A traffic. The simulator POSTs to it.
|
||||
# cp_sim — thin Python tenant-CP simulator. Drives the canary turns.
|
||||
#
|
||||
# Deliberately NO postgres, NO redis, NO platform Go service. SessionStore
|
||||
# continuity is a runtime-internal concern (a2a_executor + executor_helpers);
|
||||
# we test it without dragging the platform-tenant Go binary into the loop.
|
||||
# See README.md "Why a thin Python simulator" for rationale.
|
||||
|
||||
services:
|
||||
runtime:
|
||||
image: ${TEMPLATE_IMAGE:?TEMPLATE_IMAGE env required, e.g. ghcr.io/molecule-ai/workspace-template-hermes:latest}
|
||||
# The runtime entrypoint (workspace/entrypoint.sh) refuses to start when
|
||||
# any operator-scope env var is present. We deliberately set no creds —
|
||||
# the canary doesn't invoke a real LLM provider (see TEST_NO_PROVIDER below).
|
||||
environment:
|
||||
# Disable provider calls during canary — the runtime returns canned
|
||||
# echo-style replies so the harness can assert continuity / file-handling
|
||||
# behaviour without burning provider quota. The template image must
|
||||
# honour MOLECULE_CANARY_MODE=1 (added in molecule-ai-workspace-runtime
|
||||
# PR #46 — see molecule_runtime/a2a_executor.py canary short-circuit).
|
||||
MOLECULE_CANARY_MODE: "1"
|
||||
# Anonymous workspace identity so RBAC paths exercise the same code
|
||||
# they would in tenant production.
|
||||
WORKSPACE_ID: "canary-${CANARY_RUN_ID:-local}"
|
||||
# Memory tool requires a writable scope; point at /tmp inside the
|
||||
# container so cross-session canary (#4) works without bind mounts.
|
||||
MOLECULE_MEMORY_ROOT: "/tmp/canary-memory"
|
||||
# The provisioner's forbidden-env guard exits non-zero when any
|
||||
# operator-scope literal is present; the canary intentionally sets
|
||||
# zero of them. Leave guard ON (do NOT set MOLECULE_TENANT_GUARD_DISABLE)
|
||||
# so we exercise the prod entrypoint code path verbatim.
|
||||
ports:
|
||||
- "${RUNTIME_PORT:-18000}:8000"
|
||||
healthcheck:
|
||||
# /agent-card is the universal A2A discovery endpoint — every template
|
||||
# exposes it. /health varies per template.
|
||||
test: ["CMD-SHELL", "wget -qO /dev/null --tries=1 http://localhost:8000/agent-card || exit 1"]
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
start_period: 30s
|
||||
|
||||
cp_sim:
|
||||
build:
|
||||
context: ./cp_sim
|
||||
depends_on:
|
||||
runtime:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
RUNTIME_URL: "http://runtime:8000"
|
||||
CANARY_RUN_ID: "${CANARY_RUN_ID:-local}"
|
||||
# cp_sim doesn't expose a port — it's a one-shot driver invoked by
|
||||
# run-canary.sh via `docker compose run cp_sim pytest ...`.
|
||||
profiles: ["driver"]
|
||||
Executable
+68
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env bash
|
||||
# onboard-template.sh — gitops helper to wire local-e2e into a new template.
|
||||
#
|
||||
# Drops .gitea/workflows/session-continuity-e2e.yml into the target template
|
||||
# repo (a thin shim that clones molecule-core's local-e2e harness, then runs
|
||||
# run-canary.sh against the locally-built template image). Opens a PR.
|
||||
#
|
||||
# Usage:
|
||||
# ./local-e2e/scripts/onboard-template.sh molecule-ai-workspace-template-claude-code
|
||||
#
|
||||
# Per task #342 sequencing: do NOT run this for every template at once.
|
||||
# Bake the gate on hermes for ≥5 business days first; expand only after
|
||||
# the canary is empirically stable.
|
||||
#
|
||||
# Cross-refs:
|
||||
# feedback_no_single_source_of_truth — the workflow content is identical
|
||||
# across templates; this helper guarantees it.
|
||||
# feedback_image_promote_is_not_user_live — we wire the gate at the
|
||||
# CI layer; flipping it to REQUIRED in branch_protection is a
|
||||
# separate step (see README.md).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO="${1:?usage: onboard-template.sh <template-repo-name>}"
|
||||
HARNESS_ROOT="$( cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd )"
|
||||
|
||||
# Sanity: ensure the template-side workflow file exists in this repo.
|
||||
TEMPLATE_WORKFLOW="$HARNESS_ROOT/templates/session-continuity-e2e.yml"
|
||||
[ -f "$TEMPLATE_WORKFLOW" ] || {
|
||||
echo "ERROR: $TEMPLATE_WORKFLOW not found in this harness checkout"
|
||||
exit 1
|
||||
}
|
||||
|
||||
WORK_DIR=$(mktemp -d -t e2e-onboard-XXXXXX)
|
||||
trap 'rm -rf "$WORK_DIR"' EXIT
|
||||
|
||||
cd "$WORK_DIR"
|
||||
|
||||
# Use mol_clone — preserves the persona credential model.
|
||||
# shellcheck disable=SC1090
|
||||
source "$HOME/.molecule-ai/ops.sh"
|
||||
mol_clone "$REPO"
|
||||
cd "$REPO"
|
||||
|
||||
git checkout -b "task342/session-continuity-e2e-gate"
|
||||
|
||||
mkdir -p .gitea/workflows
|
||||
cp "$TEMPLATE_WORKFLOW" .gitea/workflows/session-continuity-e2e.yml
|
||||
|
||||
git add .gitea/workflows/session-continuity-e2e.yml
|
||||
git commit -m "ci: add local-e2e session-continuity canary gate (task #342)
|
||||
|
||||
Wires this template into the cross-template session-continuity harness
|
||||
in molecule-ai/molecule-core/local-e2e/. The gate boots THIS repo's
|
||||
locally-built image, drives 4 canonical canaries (2-turn name continuity,
|
||||
file-only message, file+prompt, cross-session memory recall), and fails
|
||||
PRs that regress any of them.
|
||||
|
||||
Per CTO directive: required-context flip in branch_protection is a
|
||||
SEPARATE step after 5 business days of bake."
|
||||
|
||||
# Push branch; do not auto-open PR — leave that to the operator so the
|
||||
# review-relay routing follows the same rules as a normal change.
|
||||
git push -u origin "task342/session-continuity-e2e-gate"
|
||||
|
||||
echo
|
||||
echo "DONE. Branch pushed to $REPO. Open PR manually:"
|
||||
echo " https://git.moleculesai.app/molecule-ai/$REPO/compare/main...task342/session-continuity-e2e-gate"
|
||||
Executable
+105
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env bash
|
||||
# run-canary.sh — one-shot orchestration for the local-e2e session-continuity
|
||||
# canary harness. Used by both interactive local runs and the per-template
|
||||
# .gitea/workflows/session-continuity-e2e.yml.
|
||||
#
|
||||
# Usage:
|
||||
# TEMPLATE_IMAGE=ghcr.io/molecule-ai/workspace-template-hermes:latest \
|
||||
# ./local-e2e/scripts/run-canary.sh
|
||||
#
|
||||
# Optional env:
|
||||
# CANARY_RUN_ID — disambiguator for parallel CI runs (default: random)
|
||||
# RUNTIME_PORT — host port for runtime :8000 (default: 18000)
|
||||
# KEEP_RUNNING — set =1 to leave containers up for post-mortem
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 — all 4 canaries passed
|
||||
# 1 — at least one canary failed (artifacts/ has the dump)
|
||||
# 2 — harness infrastructure failure (image pull / compose / etc.)
|
||||
#
|
||||
# Cross-refs:
|
||||
# feedback_image_promote_is_not_user_live — we verify at the running
|
||||
# container layer, NOT at the pipeline-green layer.
|
||||
# feedback_verify_actual_endstate_not_ack_follow_sop — every assert
|
||||
# reads state back; no side-effect-ack claims success.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
: "${TEMPLATE_IMAGE:?TEMPLATE_IMAGE env required (the runtime image under test)}"
|
||||
|
||||
# ----------------------------------------------------------------- paths
|
||||
HARNESS_ROOT="$( cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd )"
|
||||
ARTIFACTS_DIR="$HARNESS_ROOT/artifacts"
|
||||
mkdir -p "$ARTIFACTS_DIR"
|
||||
|
||||
export CANARY_RUN_ID="${CANARY_RUN_ID:-$(uuidgen 2>/dev/null | tr A-Z a-z | tr -d - | cut -c1-12 || date +%s)}"
|
||||
export RUNTIME_PORT="${RUNTIME_PORT:-18000}"
|
||||
export TEMPLATE_IMAGE
|
||||
COMPOSE_PROJECT="canary-${CANARY_RUN_ID}"
|
||||
COMPOSE_FILE="$HARNESS_ROOT/docker-compose.yml"
|
||||
|
||||
log() { printf "\n=== [%s] %s ===\n" "$(date +%H:%M:%S)" "$*"; }
|
||||
|
||||
# ----------------------------------------------------------- cleanup hook
|
||||
cleanup() {
|
||||
local rc=$?
|
||||
if [ "${KEEP_RUNNING:-0}" = "1" ]; then
|
||||
log "KEEP_RUNNING=1 — leaving containers up (project=$COMPOSE_PROJECT)"
|
||||
return $rc
|
||||
fi
|
||||
log "Tearing down compose project $COMPOSE_PROJECT"
|
||||
# On non-zero exit, capture logs FIRST. Per feedback_image_promote_is_
|
||||
# not_user_live: dump state from the actually-running container, not
|
||||
# an inferred pipeline state.
|
||||
if [ $rc -ne 0 ]; then
|
||||
log "Canary FAILED — dumping artifacts to $ARTIFACTS_DIR"
|
||||
docker compose -p "$COMPOSE_PROJECT" -f "$COMPOSE_FILE" logs \
|
||||
--no-color --tail=200 runtime \
|
||||
> "$ARTIFACTS_DIR/runtime.log" 2>&1 || true
|
||||
# SessionStore state probe — runtime exposes /admin/session-store
|
||||
# in canary mode; if not present this 404s and the file is empty.
|
||||
docker compose -p "$COMPOSE_PROJECT" -f "$COMPOSE_FILE" exec -T runtime \
|
||||
sh -c 'ls -la /tmp/canary-memory 2>/dev/null; find /tmp -name "session*.json" -exec cat {} \; 2>/dev/null' \
|
||||
> "$ARTIFACTS_DIR/session-store.txt" 2>&1 || true
|
||||
fi
|
||||
docker compose -p "$COMPOSE_PROJECT" -f "$COMPOSE_FILE" down --volumes --remove-orphans >/dev/null 2>&1 || true
|
||||
return $rc
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# ------------------------------------------------------ stack bring-up
|
||||
log "Building cp_sim image"
|
||||
docker compose -p "$COMPOSE_PROJECT" -f "$COMPOSE_FILE" build cp_sim
|
||||
|
||||
log "Pulling runtime image: $TEMPLATE_IMAGE"
|
||||
docker compose -p "$COMPOSE_PROJECT" -f "$COMPOSE_FILE" pull runtime 2>&1 \
|
||||
| tail -5 || true
|
||||
|
||||
log "Starting runtime (host port $RUNTIME_PORT)"
|
||||
docker compose -p "$COMPOSE_PROJECT" -f "$COMPOSE_FILE" up -d runtime
|
||||
|
||||
# Wait for healthcheck — docker-compose `--wait` is the canonical mechanism
|
||||
# (introduced in v2.1.1 in 2021, available on every supported runner pool).
|
||||
log "Waiting for runtime healthcheck"
|
||||
if ! docker compose -p "$COMPOSE_PROJECT" -f "$COMPOSE_FILE" up -d --wait runtime; then
|
||||
log "Runtime never went healthy — dumping logs"
|
||||
docker compose -p "$COMPOSE_PROJECT" -f "$COMPOSE_FILE" logs --no-color --tail=200 runtime \
|
||||
> "$ARTIFACTS_DIR/runtime-boot-failure.log" 2>&1 || true
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# -------------------------------------------------------------- run tests
|
||||
log "Running canary suite"
|
||||
# Run cp_sim under the same compose project so DNS (runtime hostname)
|
||||
# resolves on the molecule-core-net bridge. --rm cleans the driver container
|
||||
# after pytest exits; volume bind mounts pytest's junit-xml back to host.
|
||||
if docker compose -p "$COMPOSE_PROJECT" -f "$COMPOSE_FILE" --profile driver run \
|
||||
--rm \
|
||||
-v "$ARTIFACTS_DIR:/harness/artifacts" \
|
||||
cp_sim; then
|
||||
log "All canaries PASSED"
|
||||
exit 0
|
||||
else
|
||||
log "At least one canary FAILED — see $ARTIFACTS_DIR/junit.xml"
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,85 @@
|
||||
name: session-continuity-e2e
|
||||
|
||||
# Per-template wrapper for the molecule-core/local-e2e canary harness.
|
||||
# DO NOT EDIT THIS FILE IN A TEMPLATE REPO — the canonical copy lives at
|
||||
# molecule-ai/molecule-core:local-e2e/templates/session-continuity-e2e.yml
|
||||
# (feedback_no_single_source_of_truth). The onboard-template.sh script
|
||||
# copies it verbatim into each template; future fixes propagate via that
|
||||
# helper, not by editing the template-side copy.
|
||||
#
|
||||
# What this workflow does:
|
||||
# 1. Build THIS template's runtime image locally on the docker-host runner.
|
||||
# 2. Clone molecule-core (canonical harness source).
|
||||
# 3. Invoke local-e2e/scripts/run-canary.sh with TEMPLATE_IMAGE set to
|
||||
# the just-built local image.
|
||||
# 4. Upload artifacts/ on failure for post-mortem.
|
||||
#
|
||||
# Required-context flip:
|
||||
# This workflow posts a status under the literal context name
|
||||
# "session-continuity-e2e (pull_request)" — Gitea's standard
|
||||
# <workflow-name> (<event>) format. To make it REQUIRED, add that
|
||||
# exact string to the template repo's branch_protection
|
||||
# status_check_contexts list. See README.md for the bake-period rule.
|
||||
#
|
||||
# Gitea 1.22.6 / act_runner notes (cross-refs to known footguns):
|
||||
# - No cross-repo `uses:` (feedback_gitea_cross_repo_uses_blocked) —
|
||||
# we clone molecule-core via plain git instead.
|
||||
# - Per-SHA concurrency (feedback_concurrency_group_per_sha).
|
||||
# - Workflow-level GITHUB_SERVER_URL pinned to the Gitea host
|
||||
# (feedback_act_runner_github_server_url).
|
||||
# - Runs on docker-host pool — NOT the heavy CI pool — per CTO
|
||||
# directive "separate CI as possible" and the <3 min target.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: session-continuity-e2e-${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.head.sha || github.sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
GITHUB_SERVER_URL: https://git.moleculesai.app
|
||||
|
||||
jobs:
|
||||
session-continuity-e2e:
|
||||
runs-on: docker-host
|
||||
timeout-minutes: 8
|
||||
steps:
|
||||
- name: Checkout template
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
path: template
|
||||
|
||||
- name: Build template image
|
||||
id: build
|
||||
working-directory: template
|
||||
run: |
|
||||
IMAGE_TAG="local-e2e-${GITHUB_SHA::12}"
|
||||
docker build -t "molecule-ai/template-under-test:${IMAGE_TAG}" .
|
||||
echo "image=molecule-ai/template-under-test:${IMAGE_TAG}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Clone harness from molecule-core
|
||||
run: |
|
||||
# Anonymous clone — molecule-core is internal-readable. NEVER bake
|
||||
# an auth token into the URL (feedback_credentials_in_git_url).
|
||||
git clone --depth 1 "${GITHUB_SERVER_URL}/molecule-ai/molecule-core.git" harness
|
||||
|
||||
- name: Run canary
|
||||
env:
|
||||
TEMPLATE_IMAGE: ${{ steps.build.outputs.image }}
|
||||
CANARY_RUN_ID: ${{ github.run_id }}-${{ github.run_attempt }}
|
||||
run: |
|
||||
cd harness
|
||||
./local-e2e/scripts/run-canary.sh
|
||||
|
||||
- name: Upload artifacts on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: session-continuity-canary-${{ github.run_id }}
|
||||
path: harness/local-e2e/artifacts/
|
||||
if-no-files-found: warn
|
||||
retention-days: 7
|
||||
@@ -38,4 +38,3 @@
|
||||
{"name": "ux-ab-lab", "repo": "molecule-ai/molecule-ai-org-template-ux-ab-lab", "ref": "main"}
|
||||
]
|
||||
}
|
||||
// Triggered by Integration Tester at 2026-05-10T08:52Z
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ There are three related scripts; pick the right one:
|
||||
|
||||
| Script | Purpose | Targets |
|
||||
|---|---|---|
|
||||
| `measure-coordinator-task-bounds.sh` | **Canonical** v1 harness for the RFC #2251 / Issue 4 reproduction. Provisions a PM coordinator + Researcher child via `claude-code-default` + `langgraph` templates, sends a synthesis-heavy A2A kickoff, observes elapsed time + activity trace. | OSS-shape platform — localhost or any `/workspaces`-shaped endpoint. Has tenant/admin-token guards for non-localhost runs. |
|
||||
| `measure-coordinator-task-bounds.sh` | **Canonical** v1 harness for the RFC #2251 / Issue 4 reproduction. Provisions a PM coordinator + Researcher child via `claude-code-default` + `claude-code` templates, sends a synthesis-heavy A2A kickoff, observes elapsed time + activity trace. | OSS-shape platform — localhost or any `/workspaces`-shaped endpoint. Has tenant/admin-token guards for non-localhost runs. |
|
||||
| `measure-coordinator-task-bounds-runner.sh` | Generalised runner for the same measurement contract but with **arbitrary template + secret + model combinations** (Hermes/MiniMax, etc.). Useful for cross-runtime variants without modifying the canonical harness. | Same as above (local or SaaS via `MODE=saas`). |
|
||||
| `measure-coordinator-task-bounds.sh` (in [molecule-controlplane](https://git.moleculesai.app/molecule-ai/molecule-controlplane)) | **Production-shape** variant that bootstraps a real staging tenant via `POST /cp/admin/orgs`, then runs the same measurement against `<slug>.staging.moleculesai.app`. | Staging controlplane only — refuses to run against production. |
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ Cold-start times on workspace-template images:
|
||||
|---|---|
|
||||
| claude-code | ~30-60s |
|
||||
| openclaw | ~1-2 min |
|
||||
| langgraph | ~1 min |
|
||||
| claude-code | ~1 min |
|
||||
| hermes | **~7 min** (large image) |
|
||||
|
||||
If the demo will use `hermes`, provision the demo workspace at least
|
||||
|
||||
@@ -86,13 +86,9 @@ esac
|
||||
# RuntimeImages — keep this list in sync if a runtime is added.
|
||||
TEMPLATES=(
|
||||
"claude-code"
|
||||
"codex"
|
||||
"hermes"
|
||||
"openclaw"
|
||||
"langgraph"
|
||||
"deepagents"
|
||||
"crewai"
|
||||
"autogen"
|
||||
"gemini-cli"
|
||||
)
|
||||
|
||||
# Pre-flight: required tooling.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# Standalone runner for Issue 4 reproduction (RFC #2251) — exists alongside
|
||||
# `measure-coordinator-task-bounds.sh` to support arbitrary template + secret
|
||||
# combinations without modifying the canonical harness. The canonical harness
|
||||
# stays focused on its v1 contract (claude-code-default + langgraph + OpenRouter);
|
||||
# stays focused on its v1 contract (claude-code-default + claude-code + OpenRouter);
|
||||
# this runner wraps the same workspace-server API calls but takes everything as
|
||||
# env-var inputs so a Hermes/MiniMax run can share the measurement code path.
|
||||
#
|
||||
|
||||
@@ -196,7 +196,7 @@ Auth: $([ -n "$ADMIN_TOKEN" ] && echo "Bearer ***${ADMIN_TOKEN: -4}" ||
|
||||
|
||||
Would provision:
|
||||
PM (coordinator, tier=2, template=claude-code-default)
|
||||
Researcher (child, tier=2, template=langgraph)
|
||||
Researcher (child, tier=2, template=claude-code-default)
|
||||
|
||||
Would send synthesis-heavy task: $SYNTHESIS_DEPTH delegations + 600w
|
||||
synthesis. Coordinator A2A timeout: ${A2A_TIMEOUT}s.
|
||||
@@ -220,7 +220,7 @@ emit "pm_provisioned" "{\"workspace_id\":\"$PM_ID\"}"
|
||||
|
||||
emit "provisioning_child" null
|
||||
R=$(api -X POST "$PLATFORM/workspaces" -H 'Content-Type: application/json' \
|
||||
-d '{"name":"Researcher","role":"Returns short research findings","tier":2,"template":"langgraph"}')
|
||||
-d '{"name":"Researcher","role":"Returns short research findings","tier":2,"template":"claude-code-default"}')
|
||||
CHILD_ID=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))")
|
||||
[ -n "$CHILD_ID" ] || { echo "ERROR: child create failed: $R" >&2; exit 1; }
|
||||
emit "child_provisioned" "{\"workspace_id\":\"$CHILD_ID\"}"
|
||||
|
||||
@@ -47,23 +47,23 @@ echo " Cross-Agent Chat: Agents Talk to Each Other"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
|
||||
# --- Create 3 agents: PM (LangGraph), Developer (CrewAI), Researcher (AutoGen) ---
|
||||
# --- Create 3 agents: PM (Claude Code), Developer (OpenClaw), Researcher (Codex) ---
|
||||
echo "--- Creating 3 agents ---"
|
||||
|
||||
R=$(curl -s -X POST "$PLATFORM/workspaces" -H 'Content-Type: application/json' \
|
||||
-d '{"name":"PM","role":"Project Manager","tier":2,"template":"langgraph"}')
|
||||
-d '{"name":"PM","role":"Project Manager","tier":2,"template":"claude-code-default"}')
|
||||
PM=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
||||
echo "PM (LangGraph): $PM"
|
||||
echo "PM (Claude Code): $PM"
|
||||
|
||||
R=$(curl -s -X POST "$PLATFORM/workspaces" -H 'Content-Type: application/json' \
|
||||
-d '{"name":"Developer","role":"Code implementation","tier":2,"template":"crewai"}')
|
||||
-d '{"name":"Developer","role":"Code implementation","tier":2,"template":"openclaw"}')
|
||||
DEV=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
||||
echo "Developer (CrewAI): $DEV"
|
||||
echo "Developer (OpenClaw): $DEV"
|
||||
|
||||
R=$(curl -s -X POST "$PLATFORM/workspaces" -H 'Content-Type: application/json' \
|
||||
-d '{"name":"Researcher","role":"Research and analysis","tier":2,"template":"autogen"}')
|
||||
-d '{"name":"Researcher","role":"Research and analysis","tier":2,"template":"codex"}')
|
||||
RES=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
||||
echo "Researcher (AutoGen): $RES"
|
||||
echo "Researcher (Codex): $RES"
|
||||
|
||||
# --- Set hierarchy: PM -> Developer, Researcher ---
|
||||
echo ""
|
||||
@@ -136,7 +136,7 @@ check "Researcher responds directly" "agent" "$RESP"
|
||||
echo ""
|
||||
echo "--- Test 2: PM delegates to Researcher (cross-runtime A2A) ---"
|
||||
echo " Asking PM to research something (should delegate to Researcher)..."
|
||||
RESP=$(a2a_send "$PM" "Please ask the Researcher to briefly explain what LangGraph is.")
|
||||
RESP=$(a2a_send "$PM" "Please ask the Researcher to briefly explain what Claude Code is.")
|
||||
echo " PM says: $RESP"
|
||||
# The response should contain info from the Researcher
|
||||
check "PM got Researcher's response" "graph\|agent\|lang\|workflow" "$RESP"
|
||||
|
||||
@@ -49,11 +49,11 @@ R=$(curl -s -X POST "$PLATFORM/workspaces" -H 'Content-Type: application/json' \
|
||||
PM_ID=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
||||
check "Create PM (claude-code)" "provisioning" "$R"
|
||||
|
||||
# Research Agent — LangGraph + Gemini Flash
|
||||
# Research Agent — Claude Code + Gemini Flash
|
||||
R=$(curl -s -X POST "$PLATFORM/workspaces" -H 'Content-Type: application/json' \
|
||||
-d '{"name":"Researcher","role":"Deep research and analysis","tier":2,"template":"langgraph"}')
|
||||
-d '{"name":"Researcher","role":"Deep research and analysis","tier":2,"template":"claude-code-default"}')
|
||||
RES_ID=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
||||
check "Create Researcher (langgraph)" "provisioning" "$R"
|
||||
check "Create Researcher (claude-code)" "provisioning" "$R"
|
||||
|
||||
# Dev Agent — OpenClaw + Gemini Flash
|
||||
R=$(curl -s -X POST "$PLATFORM/workspaces" -H 'Content-Type: application/json' \
|
||||
@@ -61,11 +61,11 @@ R=$(curl -s -X POST "$PLATFORM/workspaces" -H 'Content-Type: application/json' \
|
||||
DEV_ID=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
||||
check "Create Developer (openclaw)" "provisioning" "$R"
|
||||
|
||||
# Analyst — DeepAgents + Gemini Flash
|
||||
# Analyst — Hermes + Gemini Flash
|
||||
R=$(curl -s -X POST "$PLATFORM/workspaces" -H 'Content-Type: application/json' \
|
||||
-d '{"name":"Analyst","role":"Data analysis and reporting","tier":2,"template":"deepagents"}')
|
||||
-d '{"name":"Analyst","role":"Data analysis and reporting","tier":2,"template":"hermes"}')
|
||||
ANA_ID=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
||||
check "Create Analyst (deepagents)" "provisioning" "$R"
|
||||
check "Create Analyst (hermes)" "provisioning" "$R"
|
||||
|
||||
echo ""
|
||||
echo " PM: $PM_ID"
|
||||
|
||||
+24
-5
@@ -45,12 +45,31 @@ e2e_mint_workspace_token() {
|
||||
printf '%s' "$json" | python3 -c "import json,sys; print(json.load(sys.stdin)['auth_token'])"
|
||||
}
|
||||
|
||||
e2e_cleanup_all_workspaces() {
|
||||
for _wid in $(curl -s "$BASE/workspaces" | python3 -c "import json,sys
|
||||
e2e_delete_workspace() {
|
||||
local wid="$1"
|
||||
local name="${2:-}"
|
||||
shift 2 || true
|
||||
local curl_args=("$@")
|
||||
if [ -z "$wid" ]; then
|
||||
return 0
|
||||
fi
|
||||
if [ -z "$name" ]; then
|
||||
name=$(curl -s "$BASE/workspaces/$wid" "${curl_args[@]}" | python3 -c "import json,sys
|
||||
try:
|
||||
[print(w['id']) for w in json.load(sys.stdin)]
|
||||
print(json.load(sys.stdin).get('name',''))
|
||||
except Exception:
|
||||
pass" 2>/dev/null); do
|
||||
curl -s -X DELETE "$BASE/workspaces/$_wid?confirm=true" > /dev/null || true
|
||||
pass" 2>/dev/null || true)
|
||||
fi
|
||||
curl -s -X DELETE "$BASE/workspaces/$wid?confirm=true" \
|
||||
-H "X-Confirm-Name: $name" "${curl_args[@]}" > /dev/null || true
|
||||
}
|
||||
|
||||
e2e_cleanup_all_workspaces() {
|
||||
curl -s "$BASE/workspaces" | python3 -c "import json,sys
|
||||
try:
|
||||
[print(f\"{w.get('id','')}\\t{w.get('name','')}\") for w in json.load(sys.stdin)]
|
||||
except Exception:
|
||||
pass" 2>/dev/null | while IFS=$'\t' read -r _wid _name; do
|
||||
e2e_delete_workspace "$_wid" "$_name"
|
||||
done
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ R=$(curl -s --max-time 10 -X POST "$BASE/workspaces/$OFFLINE_ID/a2a" \
|
||||
-d '{"method":"message/send","params":{"message":{"role":"user","parts":[{"type":"text","text":"test"}]}}}')
|
||||
check "Offline workspace returns error" '"error"' "$R"
|
||||
# Clean up
|
||||
curl -s -X DELETE "$BASE/workspaces/$OFFLINE_ID" >/dev/null
|
||||
e2e_delete_workspace "$OFFLINE_ID" "Offline Test"
|
||||
echo ""
|
||||
|
||||
# ========================================
|
||||
|
||||
@@ -235,7 +235,7 @@ R=$(curl -s "$BASE/workspaces/$TEMP_ID/activity")
|
||||
check "Activity in correct workspace" 'Temp workspace log' "$R"
|
||||
|
||||
# Cleanup
|
||||
curl -s -X DELETE "$BASE/workspaces/$TEMP_ID" > /dev/null
|
||||
e2e_delete_workspace "$TEMP_ID" "Activity Test Workspace"
|
||||
|
||||
# ---------- Edge Cases ----------
|
||||
echo ""
|
||||
|
||||
@@ -289,7 +289,9 @@ R=$(curl -s "$BASE/workspaces" -H "Authorization: Bearer $ECHO_TOKEN")
|
||||
check "current_task in list response" '"current_task"' "$R"
|
||||
|
||||
# Test 21: Delete
|
||||
R=$(acurl -X DELETE "$BASE/workspaces/$ECHO_ID" -H "Authorization: Bearer $ECHO_TOKEN")
|
||||
R=$(acurl -X DELETE "$BASE/workspaces/$ECHO_ID?confirm=true" \
|
||||
-H "Authorization: Bearer $ECHO_TOKEN" \
|
||||
-H "X-Confirm-Name: Echo Agent v2")
|
||||
check "DELETE /workspaces/:id" '"status":"removed"' "$R"
|
||||
|
||||
R=$(curl -s "$BASE/workspaces" -H "Authorization: Bearer $SUM_TOKEN")
|
||||
@@ -310,7 +312,9 @@ ORIG_TIER=$(echo "$BUNDLE" | python3 -c "import sys,json; print(json.load(sys.st
|
||||
|
||||
# Delete the workspace — use SUM_TOKEN (per-workspace) for WorkspaceAuth
|
||||
# and ADMIN_TOKEN for the AdminAuth layer.
|
||||
R=$(curl -s -X DELETE "$BASE/workspaces/$SUM_ID" -H "Authorization: Bearer $SUM_TOKEN")
|
||||
R=$(curl -s -X DELETE "$BASE/workspaces/$SUM_ID?confirm=true" \
|
||||
-H "Authorization: Bearer $SUM_TOKEN" \
|
||||
-H "X-Confirm-Name: Summarizer Agent")
|
||||
check "Delete before re-import" '"status":"removed"' "$R"
|
||||
|
||||
# After deleting both workspaces, all per-workspace tokens are revoked.
|
||||
@@ -381,7 +385,7 @@ REBUNDLE=$(curl -s "$BASE/bundles/export/$NEW_ID" -H "Authorization: Bearer $NEW
|
||||
check "Re-exported bundle has agent_card" '"agent_card"' "$REBUNDLE"
|
||||
|
||||
# Clean up — use the token just issued to the re-imported workspace
|
||||
curl -s -X DELETE "$BASE/workspaces/$NEW_ID" -H "Authorization: Bearer $NEW_TOKEN" > /dev/null
|
||||
e2e_delete_workspace "$NEW_ID" "$ORIG_NAME" -H "Authorization: Bearer $NEW_TOKEN"
|
||||
|
||||
echo ""
|
||||
echo "=== Results: $PASS passed, $FAIL failed ==="
|
||||
|
||||
@@ -39,6 +39,7 @@ cleanup() {
|
||||
set +e
|
||||
if [ -n "$PARENT" ]; then
|
||||
curl -sS -X DELETE "$BASE/workspaces/$PARENT?confirm=true&purge=true" \
|
||||
-H "X-Confirm-Name: e2e-chat-upload" \
|
||||
${PARENT_TOK:+-H "Authorization: Bearer $PARENT_TOK"} >/dev/null 2>&1
|
||||
fi
|
||||
exit $rc
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
set -euo pipefail
|
||||
|
||||
PLATFORM="http://localhost:8080"
|
||||
export BASE="$PLATFORM"
|
||||
source "$(dirname "$0")/_lib.sh"
|
||||
PASS=0
|
||||
FAIL=0
|
||||
ERRORS=""
|
||||
@@ -38,9 +40,7 @@ else
|
||||
fi
|
||||
|
||||
# --- Clean existing workspaces ---
|
||||
for id in $(curl -s $PLATFORM/workspaces | python3 -c "import sys,json; [print(w['id']) for w in json.load(sys.stdin)]" 2>/dev/null); do
|
||||
curl -s -X DELETE "$PLATFORM/workspaces/$id" > /dev/null
|
||||
done
|
||||
e2e_cleanup_all_workspaces
|
||||
# shellcheck disable=SC2046 # Intentional word-split over container IDs
|
||||
docker stop $(docker ps -q --filter "name=ws-") 2>/dev/null || true
|
||||
# shellcheck disable=SC2046
|
||||
|
||||
@@ -228,10 +228,12 @@ else
|
||||
fi
|
||||
|
||||
# Clean up runtime test workspaces
|
||||
for rt_id in $RT_CC_ID $RT_CX_ID $RT_HM_ID; do
|
||||
curl -s -X DELETE "$BASE/workspaces/$rt_id?confirm=true" > /dev/null 2>&1
|
||||
sleep 0.3
|
||||
done
|
||||
e2e_delete_workspace "$RT_CC_ID" "RT Claude"
|
||||
sleep 0.3
|
||||
e2e_delete_workspace "$RT_CX_ID" "RT Codex"
|
||||
sleep 0.3
|
||||
e2e_delete_workspace "$RT_HM_ID" "RT Hermes"
|
||||
sleep 0.3
|
||||
|
||||
# ============================================================
|
||||
# Section 3: Registry & Heartbeat
|
||||
@@ -550,16 +552,21 @@ check "Import bundle" '"status"' "$R"
|
||||
echo ""
|
||||
echo "--- Section 14: Cleanup & Delete ---"
|
||||
|
||||
# Delete with children — should require confirmation
|
||||
# Delete without name confirmation should be rejected before cascade.
|
||||
R=$(curl -s -X DELETE "$BASE/workspaces/$PM_ID")
|
||||
check "Delete PM requires confirmation" '"confirmation_required"' "$R"
|
||||
check "Delete PM requires name confirmation" '"destructive_action_requires_confirmation"' "$R"
|
||||
|
||||
# Delete with name confirmation but without cascade confirmation should
|
||||
# still require explicit child confirmation.
|
||||
R=$(curl -s -X DELETE "$BASE/workspaces/$PM_ID" -H "X-Confirm-Name: Test PM")
|
||||
check "Delete PM requires cascade confirmation" '"confirmation_required"' "$R"
|
||||
|
||||
# Delete with confirmation
|
||||
R=$(curl -s -X DELETE "$BASE/workspaces/$PM_ID?confirm=true")
|
||||
R=$(curl -s -X DELETE "$BASE/workspaces/$PM_ID?confirm=true" -H "X-Confirm-Name: Test PM")
|
||||
check "Delete PM cascades" '"cascade_deleted"' "$R"
|
||||
|
||||
# Delete outsider
|
||||
curl -s -X DELETE "$BASE/workspaces/$OUTSIDER_ID?confirm=true" > /dev/null
|
||||
e2e_delete_workspace "$OUTSIDER_ID" "Test Outsider"
|
||||
|
||||
# Clean up remaining workspaces (bundle imports, runtime test workspaces, etc.)
|
||||
sleep 2
|
||||
@@ -568,7 +575,7 @@ import json, sys, subprocess, time
|
||||
ws = json.load(sys.stdin)
|
||||
for w in ws:
|
||||
time.sleep(0.5) # avoid rate limit
|
||||
subprocess.run(['curl', '-s', '-X', 'DELETE', '$BASE/workspaces/' + w['id'] + '?confirm=true'], capture_output=True)
|
||||
subprocess.run(['curl', '-s', '-X', 'DELETE', '$BASE/workspaces/' + w['id'] + '?confirm=true', '-H', 'X-Confirm-Name: ' + w.get('name','')], capture_output=True)
|
||||
" 2>/dev/null
|
||||
|
||||
# Poll for clean state up to 30s — DB cascade + container stop is async on busy systems
|
||||
|
||||
@@ -134,7 +134,7 @@ fi
|
||||
# ----------------------------------------------------------------------
|
||||
# Cleanup
|
||||
# ----------------------------------------------------------------------
|
||||
curl -s -X DELETE "$BASE/workspaces/$WS_ID?confirm=true" > /dev/null || true
|
||||
e2e_delete_workspace "$WS_ID" "Dev-Mode-Test"
|
||||
|
||||
echo ""
|
||||
echo "=== Results: $PASS passed, $FAIL failed ==="
|
||||
|
||||
@@ -32,7 +32,7 @@ cleanup() {
|
||||
# Workspace teardown — best-effort, ignore errors so an unrelated CP
|
||||
# outage doesn't shadow a real test failure.
|
||||
if [ -n "$WSID" ]; then
|
||||
curl -s -X DELETE "$BASE/workspaces/$WSID?confirm=true" > /dev/null || true
|
||||
e2e_delete_workspace "$WSID" "Notify E2E"
|
||||
fi
|
||||
# /tmp scratch — pre-fix only ran on success path (the unconditional
|
||||
# rm at the bottom of the script). Trap-based path lets the file leak
|
||||
@@ -89,7 +89,7 @@ except Exception:
|
||||
')
|
||||
for _wid in $PRIOR; do
|
||||
echo "Sweeping leftover Notify E2E workspace: $_wid"
|
||||
curl -s -X DELETE "$BASE/workspaces/$_wid?confirm=true" > /dev/null || true
|
||||
e2e_delete_workspace "$_wid" "Notify E2E"
|
||||
done
|
||||
|
||||
# model is required at the Create boundary (CTO 2026-05-22 SSOT — see
|
||||
|
||||
@@ -113,7 +113,7 @@ teardown() {
|
||||
log "[teardown] deleting ${#CREATED_WSIDS[@]} workspace(s) this run created (scoped)"
|
||||
for wid in ${CREATED_WSIDS[@]+"${CREATED_WSIDS[@]}"}; do
|
||||
[ -n "$wid" ] || continue
|
||||
curl -s -X DELETE "$BASE/workspaces/$wid?confirm=true" ${ADMIN_AUTH[@]+"${ADMIN_AUTH[@]}"} >/dev/null 2>&1 || true
|
||||
e2e_delete_workspace "$wid" "" ${ADMIN_AUTH[@]+"${ADMIN_AUTH[@]}"}
|
||||
done
|
||||
exit $rc
|
||||
}
|
||||
@@ -131,7 +131,7 @@ except Exception:
|
||||
' 2>/dev/null)
|
||||
for _wid in $PRIOR; do
|
||||
log "Pre-sweeping prior PV-Local workspace: $_wid"
|
||||
curl -s -X DELETE "$BASE/workspaces/$_wid?confirm=true" ${ADMIN_AUTH[@]+"${ADMIN_AUTH[@]}"} >/dev/null 2>&1 || true
|
||||
e2e_delete_workspace "$_wid" "" ${ADMIN_AUTH[@]+"${ADMIN_AUTH[@]}"}
|
||||
done
|
||||
|
||||
# ─── Local-stack preflight ─────────────────────────────────────────────
|
||||
|
||||
@@ -48,8 +48,8 @@ TMPDIR_E2E=$(mktemp -d -t poll-chat-upload-e2e-XXXXXX)
|
||||
|
||||
cleanup() {
|
||||
local rc=$?
|
||||
curl -s -X DELETE "$BASE/workspaces/$WS_A?confirm=true" >/dev/null 2>&1 || true
|
||||
curl -s -X DELETE "$BASE/workspaces/$WS_B?confirm=true" >/dev/null 2>&1 || true
|
||||
e2e_delete_workspace "$WS_A" "poll-chat-upload-test-a"
|
||||
e2e_delete_workspace "$WS_B" "poll-chat-upload-test-b"
|
||||
rm -rf "$TMPDIR_E2E"
|
||||
exit $rc
|
||||
}
|
||||
|
||||
@@ -43,8 +43,8 @@ INVALID_PROBE_ID="$(gen_uuid)"
|
||||
cleanup() {
|
||||
local rc=$?
|
||||
# Best-effort delete; non-fatal if the row was never created.
|
||||
curl -s -X DELETE "$BASE/workspaces/$POLL_WS_ID" >/dev/null || true
|
||||
curl -s -X DELETE "$BASE/workspaces/$CALLER_WS_ID" >/dev/null || true
|
||||
e2e_delete_workspace "$POLL_WS_ID" "poll-mode-test"
|
||||
e2e_delete_workspace "$CALLER_WS_ID" "poll-cross-test"
|
||||
exit $rc
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
@@ -53,7 +53,7 @@ cleanup() {
|
||||
# ${VAR[@]+"…"} form expands to nothing when the array is unset/empty
|
||||
# so the loop body is skipped cleanly. Hits the skip-no-keys path.
|
||||
for wid in ${CREATED_WSIDS[@]+"${CREATED_WSIDS[@]}"}; do
|
||||
[ -n "$wid" ] && curl -s -X DELETE "$BASE/workspaces/$wid?confirm=true" > /dev/null || true
|
||||
[ -n "$wid" ] && e2e_delete_workspace "$wid" ""
|
||||
done
|
||||
}
|
||||
trap cleanup EXIT
|
||||
@@ -74,7 +74,7 @@ except Exception:
|
||||
')
|
||||
for _wid in $PRIOR; do
|
||||
echo "Sweeping prior workspace: $_wid"
|
||||
curl -s -X DELETE "$BASE/workspaces/$_wid?confirm=true" > /dev/null || true
|
||||
e2e_delete_workspace "$_wid" ""
|
||||
done
|
||||
|
||||
# Block until $1 reaches one of $2 (space-separated states), or $3 sec elapse.
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
# MOLECULE_ADMIN_TOKEN CP admin bearer — Railway CP_ADMIN_API_TOKEN
|
||||
#
|
||||
# Optional env:
|
||||
# E2E_RUNTIME hermes (default) | claude-code | langgraph
|
||||
# E2E_RUNTIME hermes (default) | claude-code | codex | openclaw
|
||||
# E2E_PROVISION_TIMEOUT_SECS default 900 (15 min cold EC2 budget)
|
||||
# E2E_WORKSPACE_ONLINE_TIMEOUT_SECS default 3600 (60 min — hermes
|
||||
# cold-boot worst-case + slack). Raised from
|
||||
@@ -458,9 +458,9 @@ wait_workspaces_online_routable() {
|
||||
# who already have an Anthropic API key for their own Claude
|
||||
# Code session. Pricier per-token than MiniMax but billing is
|
||||
# still independent of MOLECULE_STAGING_OPENAI_API_KEY. Pinned to the
|
||||
# claude-code runtime — hermes/langgraph use OpenAI-shaped envs.
|
||||
# claude-code runtime — hermes/codex/openclaw use OpenAI-shaped envs.
|
||||
#
|
||||
# E2E_OPENAI_API_KEY → langgraph + hermes paths. Kept as fallback
|
||||
# E2E_OPENAI_API_KEY → hermes/codex/openclaw paths. Kept as fallback
|
||||
# for operator dispatches that explicitly want to exercise the
|
||||
# OpenAI path. The HERMES_* fields pin hermes-agent's bridge to
|
||||
# api.openai.com (template-hermes' derive-provider.sh otherwise
|
||||
@@ -486,7 +486,7 @@ elif [ -n "${E2E_ANTHROPIC_API_KEY:-}" ]; then
|
||||
# account just for E2E. Pricier per-token than MiniMax but billing
|
||||
# is still independent of MOLECULE_STAGING_OPENAI_API_KEY, so an OpenAI
|
||||
# quota collapse doesn't wedge this path. Pinned to the claude-code
|
||||
# runtime: hermes/langgraph use OpenAI-shaped envs and won't honour
|
||||
# runtime: hermes/codex/openclaw use OpenAI-shaped envs and won't honour
|
||||
# ANTHROPIC_API_KEY without further wiring. pick_model_slug maps this
|
||||
# branch to claude-sonnet-4-6 so the claude-code provider registry
|
||||
# selects anthropic-api instead of the OAuth-only sonnet alias.
|
||||
|
||||
@@ -364,7 +364,13 @@ for wid in "${WS_A_ID:-}" "${WS_B_ID:-}"; do
|
||||
DELETE_AUTH=("${WS_B_AUTH[@]}")
|
||||
fi
|
||||
fi
|
||||
curl -s -X DELETE "$BASE/workspaces/$wid?confirm=true" "${DELETE_AUTH[@]}" > /dev/null || true
|
||||
if [ "$wid" = "${WS_A_ID:-}" ]; then
|
||||
e2e_delete_workspace "$wid" "$WS_A_NAME" "${DELETE_AUTH[@]}"
|
||||
elif [ "$wid" = "${WS_B_ID:-}" ]; then
|
||||
e2e_delete_workspace "$wid" "$WS_B_NAME" "${DELETE_AUTH[@]}"
|
||||
else
|
||||
e2e_delete_workspace "$wid" "" "${DELETE_AUTH[@]}"
|
||||
fi
|
||||
echo "deleted $wid"
|
||||
done
|
||||
|
||||
|
||||
@@ -31,7 +31,11 @@ RECEIVER_TOKEN=""
|
||||
cleanup() {
|
||||
for wid in "$SENDER_ID" "$RECEIVER_ID"; do
|
||||
if [ -n "$wid" ]; then
|
||||
curl -s -X DELETE "$BASE/workspaces/$wid?confirm=true" > /dev/null || true
|
||||
if [ "$wid" = "$SENDER_ID" ]; then
|
||||
e2e_delete_workspace "$wid" "Abilities Sender"
|
||||
else
|
||||
e2e_delete_workspace "$wid" "Abilities Receiver"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
}
|
||||
@@ -88,7 +92,7 @@ except Exception:
|
||||
")
|
||||
for _wid in $PRIOR; do
|
||||
echo "Sweeping leftover '$NAME' workspace: $_wid"
|
||||
curl -s -X DELETE "$BASE/workspaces/$_wid?confirm=true" > /dev/null || true
|
||||
e2e_delete_workspace "$_wid" "$NAME"
|
||||
done
|
||||
done
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ ARG GIT_SHA=dev
|
||||
# Mirrors the pattern already in molecule-controlplane/Dockerfile.
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build \
|
||||
-trimpath \
|
||||
-ldflags "-s -w -X github.com/Molecule-AI/molecule-monorepo/platform/internal/buildinfo.GitSHA=${GIT_SHA}" \
|
||||
-ldflags "-s -w -X git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/buildinfo.GitSHA=${GIT_SHA}" \
|
||||
-o /platform ./cmd/server
|
||||
# Bundle the built-in memory-plugin-postgres binary so an operator can
|
||||
# activate Memory v2 by setting MEMORY_V2_CUTOVER=true + (default)
|
||||
@@ -43,7 +43,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build \
|
||||
# Stays inert until the operator flips the cutover env var.
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build \
|
||||
-trimpath \
|
||||
-ldflags "-s -w -X github.com/Molecule-AI/molecule-monorepo/platform/internal/buildinfo.GitSHA=${GIT_SHA}" \
|
||||
-ldflags "-s -w -X git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/buildinfo.GitSHA=${GIT_SHA}" \
|
||||
-o /memory-plugin ./cmd/memory-plugin-postgres
|
||||
|
||||
FROM alpine:3.20@sha256:c64c687cbea9300178b30c95835354e34c4e4febc4badfe27102879de0483b5e
|
||||
|
||||
@@ -63,7 +63,7 @@ ARG GIT_SHA=dev
|
||||
# Mirrors the pattern already in molecule-controlplane/Dockerfile.
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build \
|
||||
-trimpath \
|
||||
-ldflags "-s -w -X github.com/Molecule-AI/molecule-monorepo/platform/internal/buildinfo.GitSHA=${GIT_SHA}" \
|
||||
-ldflags "-s -w -X git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/buildinfo.GitSHA=${GIT_SHA}" \
|
||||
-o /platform ./cmd/server
|
||||
# Memory v2 sidecar binary (Memory v2 #2728). Bundled so an operator
|
||||
# can activate cutover by flipping MEMORY_V2_CUTOVER=true without
|
||||
@@ -71,23 +71,16 @@ RUN CGO_ENABLED=0 GOOS=linux go build \
|
||||
# launch logic.
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build \
|
||||
-trimpath \
|
||||
-ldflags "-s -w -X github.com/Molecule-AI/molecule-monorepo/platform/internal/buildinfo.GitSHA=${GIT_SHA}" \
|
||||
-ldflags "-s -w -X git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/buildinfo.GitSHA=${GIT_SHA}" \
|
||||
-o /memory-plugin ./cmd/memory-plugin-postgres
|
||||
|
||||
# Memory v1→v2 backfill CLI (issue #1791 Phase A2). Bundled so an
|
||||
# operator can migrate the historical agent_memories rows into the v2
|
||||
# plugin via:
|
||||
#
|
||||
# docker exec molecule-tenant /memory-backfill -dry-run
|
||||
# docker exec molecule-tenant /memory-backfill -apply
|
||||
#
|
||||
# Idempotent (UUID upsert in the plugin); safe to re-run. See the
|
||||
# tool's main.go for full usage. Stays inert until invoked — does not
|
||||
# run automatically on boot.
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build \
|
||||
-trimpath \
|
||||
-ldflags "-s -w -X github.com/Molecule-AI/molecule-monorepo/platform/internal/buildinfo.GitSHA=${GIT_SHA}" \
|
||||
-o /memory-backfill ./cmd/memory-backfill
|
||||
# Phase A2 memory-backfill CLI was bundled here briefly (#1796) to
|
||||
# migrate agent_memories rows into the v2 plugin. After Phase A3 (#1809)
|
||||
# dropped the source table, the binary is permanently inert — running
|
||||
# it now hits `pq: relation "agent_memories" does not exist`. Removed
|
||||
# the build to drop ~7MB from the image and remove the foot-gun.
|
||||
# Source still lives in cmd/memory-backfill/ for history; safe to
|
||||
# delete entirely in a future cleanup PR.
|
||||
|
||||
# ── Stage 2: Canvas Next.js standalone ────────────────────────────────
|
||||
FROM node:20-alpine@sha256:afdf98210b07b586eb71fa22ba2e432e058e4cd1304d31ed60888755b8c865fb AS canvas-builder
|
||||
@@ -124,7 +117,6 @@ RUN deluser --remove-home node 2>/dev/null || true; \
|
||||
# Go platform binary + Memory v2 sidecar + v1→v2 backfill CLI
|
||||
COPY --from=go-builder /platform /platform
|
||||
COPY --from=go-builder /memory-plugin /memory-plugin
|
||||
COPY --from=go-builder /memory-backfill /memory-backfill
|
||||
COPY workspace-server/migrations /migrations
|
||||
|
||||
# Templates + plugins (pre-cloned by scripts/clone-manifest.sh in the
|
||||
@@ -151,7 +143,7 @@ COPY workspace-server/entrypoint-tenant.sh /entrypoint.sh
|
||||
# !external (e.g. molecule-dev → dev-lead). Caught on staging-cplead-2
|
||||
# 2026-05-10 — see internal incident debrief.
|
||||
RUN chmod +x /entrypoint.sh && \
|
||||
chown -R canvas:canvas /canvas /platform /memory-plugin /memory-backfill /migrations /org-templates
|
||||
chown -R canvas:canvas /canvas /platform /memory-plugin /migrations /org-templates
|
||||
|
||||
EXPOSE 8080
|
||||
# entrypoint.sh starts as root to fix volume perms, then drops to
|
||||
|
||||
@@ -32,9 +32,9 @@ import (
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
mclient "github.com/Molecule-AI/molecule-monorepo/platform/internal/memory/client"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/memory/contract"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/memory/namespace"
|
||||
mclient "git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/memory/client"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/memory/contract"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/memory/namespace"
|
||||
)
|
||||
|
||||
const defaultLimit = 1000000 // effectively unlimited; cap keeps SQL pageable
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/memory/contract"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/memory/namespace"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/memory/contract"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/memory/namespace"
|
||||
)
|
||||
|
||||
// stubBackfillPlugin records calls for assertions.
|
||||
|
||||
@@ -20,8 +20,8 @@ import (
|
||||
"math/rand"
|
||||
"os"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/memory/contract"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/textutil"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/memory/contract"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/textutil"
|
||||
)
|
||||
|
||||
// verifyConfig is the typed dependency bundle for verifyParity.
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/memory/contract"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/memory/contract"
|
||||
)
|
||||
|
||||
// stubVerifyPlugin records search calls and returns canned results.
|
||||
|
||||
@@ -45,8 +45,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
mclient "github.com/Molecule-AI/molecule-monorepo/platform/internal/memory/client"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/memory/contract"
|
||||
mclient "git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/memory/client"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/memory/contract"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -25,7 +25,7 @@ import (
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/memory/pgplugin"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/memory/pgplugin"
|
||||
)
|
||||
|
||||
// migrationsFS bundles the .up.sql files into the binary at build time
|
||||
|
||||
@@ -30,7 +30,7 @@ func TestRefreshEnvFromCP_AppliesCPResponse(t *testing.T) {
|
||||
t.Errorf("org id header: got %q", got)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"MOLECULE_CP_SHARED_SECRET":"new-secret","MOLECULE_CP_URL":"https://api.moleculesai.app","DISPLAY_SESSION_SIGNING_SECRET":"display-secret"}`)
|
||||
fmt.Fprint(w, `{"MOLECULE_CP_SHARED_SECRET":"new-secret","MOLECULE_CP_URL":"https://api.moleculesai.app","DISPLAY_SESSION_SIGNING_SECRET":"display-secret","MOLECULE_LLM_BASE_URL":"https://api.moleculesai.app/api/v1/internal/llm/openai/v1","MOLECULE_LLM_USAGE_TOKEN":"tenant-admin-token","MOLECULE_LLM_DEFAULT_MODEL":"moonshot/kimi-k2.6"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
@@ -48,6 +48,15 @@ func TestRefreshEnvFromCP_AppliesCPResponse(t *testing.T) {
|
||||
if got := os.Getenv("DISPLAY_SESSION_SIGNING_SECRET"); got != "display-secret" {
|
||||
t.Errorf("DISPLAY_SESSION_SIGNING_SECRET: want display-secret, got %q", got)
|
||||
}
|
||||
if got := os.Getenv("MOLECULE_LLM_BASE_URL"); got != "https://api.moleculesai.app/api/v1/internal/llm/openai/v1" {
|
||||
t.Errorf("MOLECULE_LLM_BASE_URL: got %q", got)
|
||||
}
|
||||
if got := os.Getenv("MOLECULE_LLM_USAGE_TOKEN"); got != "tenant-admin-token" {
|
||||
t.Errorf("MOLECULE_LLM_USAGE_TOKEN: got %q", got)
|
||||
}
|
||||
if got := os.Getenv("MOLECULE_LLM_DEFAULT_MODEL"); got != "moonshot/kimi-k2.6" {
|
||||
t.Errorf("MOLECULE_LLM_DEFAULT_MODEL: got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefreshEnvFromCP_CPUnreachableDoesNotFailBoot: network errors must
|
||||
|
||||
@@ -35,29 +35,29 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/channels"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/crypto"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/handlers"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/imagewatch"
|
||||
memwiring "github.com/Molecule-AI/molecule-monorepo/platform/internal/memory/wiring"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/middleware"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/pendinguploads"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/plugins"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provisioner"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/registry"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/router"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/scheduler"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/supervised"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/ws"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/channels"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/crypto"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/db"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/events"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/handlers"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/imagewatch"
|
||||
memwiring "git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/memory/wiring"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/middleware"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/pendinguploads"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/plugins"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/provisioner"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/registry"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/router"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/scheduler"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/supervised"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/ws"
|
||||
|
||||
// External plugins — each registers EnvMutator(s) that run at workspace
|
||||
// provision time. Loaded via soft-dep gates in main() so self-hosters
|
||||
// without per-agent identity configured keep working.
|
||||
ghidentity "go.moleculesai.app/plugin/gh-identity/pluginloader"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/pkg/provisionhook"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/pkg/provisionhook"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
@@ -23,7 +23,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provisioner"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/provisioner"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
@@ -32,19 +32,18 @@ CANVAS_PID=$!
|
||||
# Defaults (when sidecar IS spawned): MEMORY_PLUGIN_DATABASE_URL
|
||||
# falls back to the tenant's DATABASE_URL.
|
||||
#
|
||||
# MEMORY_V2_CUTOVER is deprecated as of #1747 — the workspace-server
|
||||
# binary no longer reads it (v2 is unconditional now; the legacy SQL
|
||||
# fallback in mcp_tools.go is gone). The entrypoint still accepts it
|
||||
# as a synonym for "operator wants the sidecar" so old CP user-data
|
||||
# templates keep working through the rollout. When CP user-data drops
|
||||
# the var, this branch can go.
|
||||
# Phase A3 (#1792): MEMORY_V2_CUTOVER acceptance removed. The variable
|
||||
# was deprecated by #1747 (binary stopped reading it) and only kept
|
||||
# alive here as a synonym to bridge old CP user-data templates. With
|
||||
# A3 dropping the entire v1 surface, the synonym is gone too. CP
|
||||
# user-data sets MEMORY_PLUGIN_URL directly; if a stale template
|
||||
# without that var ships, the sidecar simply doesn't start and the
|
||||
# tenant boots without memory — loud but recoverable, same posture as
|
||||
# any other required env missing.
|
||||
MEMORY_PLUGIN_PID=""
|
||||
memory_plugin_wanted=""
|
||||
if [ -n "$MEMORY_PLUGIN_URL" ]; then
|
||||
memory_plugin_wanted=1
|
||||
elif [ "$MEMORY_V2_CUTOVER" = "true" ]; then
|
||||
memory_plugin_wanted=1
|
||||
echo "memory-plugin: ⚠️ MEMORY_V2_CUTOVER is deprecated (#1747) — set MEMORY_PLUGIN_URL instead. Spawning sidecar on the implied default this boot." >&2
|
||||
fi
|
||||
if [ -z "$MEMORY_PLUGIN_DISABLE" ] && [ -n "$memory_plugin_wanted" ] && [ -n "$DATABASE_URL" ]; then
|
||||
# Schema isolation (issue #1733): when defaulting from the tenant
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
module github.com/Molecule-AI/molecule-monorepo/platform
|
||||
module git.moleculesai.app/molecule-ai/molecule-core/workspace-server
|
||||
|
||||
go 1.25.0
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/artifacts"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/artifacts"
|
||||
)
|
||||
|
||||
// cfEnvelope wraps a result value in the Cloudflare v4 response envelope.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//
|
||||
// Set at link time:
|
||||
//
|
||||
// go build -ldflags "-X github.com/Molecule-AI/molecule-monorepo/platform/internal/buildinfo.GitSHA=<sha>"
|
||||
// go build -ldflags "-X git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/buildinfo.GitSHA=<sha>"
|
||||
//
|
||||
// CI passes ${{ github.sha }} via Dockerfile.tenant ARG GIT_SHA; local
|
||||
// dev builds default to "dev" so unset never reads as success.
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/buildinfo"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/buildinfo"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provisioner"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/db"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/provisioner"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/docker/docker/pkg/stdcopy"
|
||||
|
||||
@@ -5,10 +5,10 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provisioner"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/db"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/events"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/models"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/provisioner"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/db"
|
||||
)
|
||||
|
||||
// ==================== Adapter Interface Tests ====================
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/db"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/events"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -29,7 +29,7 @@ import (
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/crypto"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/crypto"
|
||||
)
|
||||
|
||||
// sensitiveFields is the set of channel_config keys that get encrypted at
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/crypto"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/crypto"
|
||||
)
|
||||
|
||||
// withTestEncryptionKey installs a deterministic 32-byte key for the
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user