fix(canvas): forward-port dynamic runtime dropdown from staging (PR #1526)
PR #1526 shipped the /templates registry + canvas dynamic Runtime / Model / Required-Env fields on 2026-04-22 — but merged into the staging branch, not main. The staging→main promotion PR #1496 has been open unmerged for a while with 1172 commits divergence, so prod (which builds from main) still carries the old hardcoded dropdown. Symptom seen on hongmingwang.moleculesai.app today: - New Hermes Agent workspace (template declares runtime: hermes) loads Config tab → Runtime dropdown shows "LangGraph (default)" because there's no <option value="hermes"> in the hardcoded list; it falls back to empty-value silently. - Model field is a plain TextInput with static placeholder "e.g. anthropic:claude-sonnet-4-6" — should be a combobox populated from the selected runtime's models[]. - Required Env Vars is a TagList with static placeholder "e.g. CLAUDE_CODE_OAUTH_TOKEN" — should auto-populate from the selected model's required_env. - Net effect: "Save & Deploy" sends empty model + empty env to the provisioner → workspace instant-fails. This PR cherry-picks the exact three files from PR #1526 (#359dc61 on staging) forward to main, without pulling the other 1171 commits: - canvas/src/components/tabs/ConfigTab.tsx - RuntimeOption interface + FALLBACK_RUNTIME_OPTIONS (hermes, gemini-cli included) - useEffect fetches /templates and populates runtimeOptions dynamically - dropdown renders from runtimeOptions (no hardcoded list) - Model becomes a combobox with datalist of available models per selected runtime - Required Env Vars auto-populates from the selected model's required_env on model change - workspace-server/internal/handlers/templates.go - /templates endpoint returns [{id, name, runtime, models}] with per-template models registry (id, name, required_env) - workspace-server/internal/handlers/templates_test.go - Tests for runtime+models parsing and legacy top-level model fallback The canvas Runtime dropdown now resolves "hermes" correctly; Model dropdown shows the models[] from the hermes template; Env auto-populates with HERMES_API_KEY (or whichever model selected). Verified locally: - workspace-server builds clean - Template handler tests pass: TestTemplatesList_RuntimeAndModelsRegistry, TestTemplatesList_LegacyTopLevelModel, TestTemplatesList_NonexistentDir Follow-up: the staging→main promotion gap (#1496) is the underlying process issue. Either merge that PR or adopt a policy of landing fixes directly on main (as several PRs have today). Files here were chosen minimally to avoid pulling unrelated staging changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0db8445538
commit
f6e6a64ba9
@ -85,6 +85,36 @@ function AgentCardSection({ workspaceId }: { workspaceId: string }) {
|
||||
|
||||
// --- Main ConfigTab ---
|
||||
|
||||
interface ModelSpec {
|
||||
id: string;
|
||||
name?: string;
|
||||
required_env?: string[];
|
||||
}
|
||||
|
||||
function arraysEqual(a: readonly string[], b: readonly string[]): boolean {
|
||||
return a.length === b.length && a.every((v, i) => v === b[i]);
|
||||
}
|
||||
|
||||
interface RuntimeOption {
|
||||
value: string;
|
||||
label: string;
|
||||
models: ModelSpec[];
|
||||
}
|
||||
|
||||
// Fallback used when /templates can't be fetched (offline, older backend).
|
||||
// Keep in sync with manifest.json workspace_templates as a defensive default.
|
||||
// Model + env suggestions only flow when the backend is reachable.
|
||||
const FALLBACK_RUNTIME_OPTIONS: RuntimeOption[] = [
|
||||
{ value: "", label: "LangGraph (default)", models: [] },
|
||||
{ value: "claude-code", label: "Claude Code", models: [] },
|
||||
{ value: "crewai", label: "CrewAI", models: [] },
|
||||
{ value: "autogen", label: "AutoGen", models: [] },
|
||||
{ value: "deepagents", label: "DeepAgents", models: [] },
|
||||
{ value: "openclaw", label: "OpenClaw", models: [] },
|
||||
{ value: "hermes", label: "Hermes", models: [] },
|
||||
{ value: "gemini-cli", label: "Gemini CLI", models: [] },
|
||||
];
|
||||
|
||||
export function ConfigTab({ workspaceId }: Props) {
|
||||
const [config, setConfig] = useState<ConfigData>({ ...DEFAULT_CONFIG });
|
||||
const [originalYaml, setOriginalYaml] = useState("");
|
||||
@ -94,6 +124,7 @@ export function ConfigTab({ workspaceId }: Props) {
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [rawMode, setRawMode] = useState(false);
|
||||
const [rawDraft, setRawDraft] = useState("");
|
||||
const [runtimeOptions, setRuntimeOptions] = useState<RuntimeOption[]>(FALLBACK_RUNTIME_OPTIONS);
|
||||
const successTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
@ -120,6 +151,36 @@ export function ConfigTab({ workspaceId }: Props) {
|
||||
loadConfig();
|
||||
}, [loadConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api.get<Array<{ id: string; name?: string; runtime?: string; models?: ModelSpec[] }>>("/templates")
|
||||
.then((rows) => {
|
||||
if (cancelled || !Array.isArray(rows)) return;
|
||||
const byRuntime = new Map<string, RuntimeOption>();
|
||||
byRuntime.set("", { value: "", label: "LangGraph (default)", models: [] });
|
||||
for (const r of rows) {
|
||||
const v = (r.runtime || "").trim();
|
||||
if (!v || v === "langgraph") continue;
|
||||
// Last template wins if two templates share a runtime — rare, and the
|
||||
// one with the richer models list is probably newer.
|
||||
const existing = byRuntime.get(v);
|
||||
const models = Array.isArray(r.models) ? r.models : [];
|
||||
if (!existing || models.length > existing.models.length) {
|
||||
byRuntime.set(v, { value: v, label: r.name || v, models });
|
||||
}
|
||||
}
|
||||
if (byRuntime.size > 1) setRuntimeOptions(Array.from(byRuntime.values()));
|
||||
})
|
||||
.catch(() => { /* keep fallback */ });
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
// Models + env hints for the currently-selected runtime.
|
||||
const selectedRuntime = runtimeOptions.find((o) => o.value === (config.runtime || "")) ?? null;
|
||||
const availableModels: ModelSpec[] = selectedRuntime?.models ?? [];
|
||||
const currentModelId = config.runtime_config?.model || config.model || "";
|
||||
const currentModelSpec = availableModels.find((m) => m.id === currentModelId) ?? null;
|
||||
|
||||
const update = <K extends keyof ConfigData>(key: K, value: ConfigData[K]) => {
|
||||
setConfig((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
@ -259,23 +320,99 @@ export function ConfigTab({ workspaceId }: Props) {
|
||||
onChange={(e) => update("runtime", e.target.value)}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
<option value="">LangGraph (default)</option>
|
||||
<option value="claude-code">Claude Code</option>
|
||||
<option value="crewai">CrewAI</option>
|
||||
<option value="autogen">AutoGen</option>
|
||||
<option value="deepagents">DeepAgents</option>
|
||||
<option value="openclaw">OpenClaw</option>
|
||||
{runtimeOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<TextInput label="Model" value={config.runtime_config?.model || config.model || ""} onChange={(v) => {
|
||||
if (config.runtime) {
|
||||
update("runtime_config", { ...config.runtime_config, model: v });
|
||||
} else {
|
||||
update("model", v);
|
||||
}
|
||||
}} placeholder="e.g. anthropic:claude-sonnet-4-6" mono />
|
||||
<div>
|
||||
<label className="text-[10px] text-zinc-500 block mb-1">
|
||||
Model
|
||||
{availableModels.length > 0 && (
|
||||
<span className="ml-1 text-zinc-600">({availableModels.length} suggested)</span>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
list={availableModels.length > 0 ? `${runtimeId}-models` : undefined}
|
||||
value={currentModelId}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setConfig((prev) => {
|
||||
// If the new value exactly matches a known modelSpec id,
|
||||
// swap required_env to that spec's list — but only when
|
||||
// the current required_env is empty or was itself
|
||||
// template-driven (i.e. matches the previous modelSpec's
|
||||
// required_env). User-typed envs always win.
|
||||
const nextSpec = availableModels.find((m) => m.id === v) ?? null;
|
||||
const prevModelId = prev.runtime_config?.model || prev.model || "";
|
||||
const prevSpec = availableModels.find((m) => m.id === prevModelId) ?? null;
|
||||
const prevRequired = prev.runtime_config?.required_env ?? [];
|
||||
const wasTemplateDriven =
|
||||
prevRequired.length === 0 ||
|
||||
(prevSpec?.required_env?.length
|
||||
? prevRequired.length === prevSpec.required_env.length &&
|
||||
prevRequired.every((e, i) => e === prevSpec.required_env![i])
|
||||
: false);
|
||||
const nextRequired =
|
||||
nextSpec?.required_env?.length && wasTemplateDriven
|
||||
? nextSpec.required_env
|
||||
: prevRequired;
|
||||
if (prev.runtime) {
|
||||
return {
|
||||
...prev,
|
||||
runtime_config: {
|
||||
...prev.runtime_config,
|
||||
model: v,
|
||||
...(nextSpec?.required_env?.length && wasTemplateDriven
|
||||
? { required_env: nextRequired }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
return { ...prev, model: v };
|
||||
});
|
||||
}}
|
||||
placeholder="e.g. anthropic:claude-sonnet-4-6"
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-xs text-zinc-200 font-mono focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
{availableModels.length > 0 && (
|
||||
<datalist id={`${runtimeId}-models`}>
|
||||
{availableModels.map((m, i) => (
|
||||
<option key={`${m.id}-${i}`} value={m.id}>{m.name || m.id}</option>
|
||||
))}
|
||||
</datalist>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<TagList label="Required Env Vars" values={config.runtime_config?.required_env || []} onChange={(v) => updateNested("runtime_config" as keyof ConfigData, "required_env", v)} placeholder="e.g. CLAUDE_CODE_OAUTH_TOKEN" />
|
||||
<TagList
|
||||
label={
|
||||
currentModelSpec?.required_env?.length &&
|
||||
arraysEqual(config.runtime_config?.required_env ?? [], currentModelSpec.required_env)
|
||||
? "Required Env Vars (from template)"
|
||||
: "Required Env Vars"
|
||||
}
|
||||
values={config.runtime_config?.required_env ?? []}
|
||||
onChange={(v) => updateNested("runtime_config" as keyof ConfigData, "required_env", v)}
|
||||
placeholder="e.g. CLAUDE_CODE_OAUTH_TOKEN"
|
||||
/>
|
||||
{currentModelSpec?.required_env?.length &&
|
||||
!arraysEqual(config.runtime_config?.required_env ?? [], currentModelSpec.required_env) && (
|
||||
<div className="text-[10px] text-zinc-500 mt-1 flex items-center gap-2">
|
||||
<span>
|
||||
Template suggests{" "}
|
||||
<code className="text-zinc-400">{currentModelSpec.required_env.join(", ")}</code>{" "}
|
||||
for <code className="text-zinc-400">{currentModelSpec.name || currentModelSpec.id}</code>.
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateNested("runtime_config" as keyof ConfigData, "required_env", currentModelSpec.required_env)}
|
||||
className="text-blue-400 hover:text-blue-300 underline"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Claude Settings — shown for claude-code runtime or claude/anthropic model names */}
|
||||
|
||||
@ -36,14 +36,25 @@ func NewTemplatesHandler(configsDir string, dockerCli *client.Client) *Templates
|
||||
return &TemplatesHandler{configsDir: configsDir, docker: dockerCli}
|
||||
}
|
||||
|
||||
// modelSpec describes a single supported model on a template: its id (sent
|
||||
// to the runtime), a human-readable label, and the env vars that must be
|
||||
// present for that model to work (e.g. API keys).
|
||||
type modelSpec struct {
|
||||
ID string `json:"id" yaml:"id"`
|
||||
Name string `json:"name,omitempty" yaml:"name"`
|
||||
RequiredEnv []string `json:"required_env,omitempty" yaml:"required_env"`
|
||||
}
|
||||
|
||||
type templateSummary struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Tier int `json:"tier"`
|
||||
Model string `json:"model"`
|
||||
Skills []string `json:"skills"`
|
||||
SkillCount int `json:"skill_count"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Tier int `json:"tier"`
|
||||
Runtime string `json:"runtime"`
|
||||
Model string `json:"model"`
|
||||
Models []modelSpec `json:"models,omitempty"`
|
||||
Skills []string `json:"skills"`
|
||||
SkillCount int `json:"skill_count"`
|
||||
}
|
||||
|
||||
// resolveTemplateDir finds the template directory for a workspace on the host.
|
||||
@ -82,22 +93,35 @@ func (h *TemplatesHandler) List(c *gin.Context) {
|
||||
}
|
||||
|
||||
var raw struct {
|
||||
Name string `yaml:"name"`
|
||||
Description string `yaml:"description"`
|
||||
Tier int `yaml:"tier"`
|
||||
Model string `yaml:"model"`
|
||||
Skills []string `yaml:"skills"`
|
||||
Name string `yaml:"name"`
|
||||
Description string `yaml:"description"`
|
||||
Tier int `yaml:"tier"`
|
||||
Runtime string `yaml:"runtime"`
|
||||
Model string `yaml:"model"`
|
||||
Skills []string `yaml:"skills"`
|
||||
RuntimeConfig struct {
|
||||
Model string `yaml:"model"`
|
||||
Models []modelSpec `yaml:"models"`
|
||||
} `yaml:"runtime_config"`
|
||||
}
|
||||
if err := yaml.Unmarshal(data, &raw); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Model comes from either top-level (legacy) or runtime_config.model (current).
|
||||
model := raw.Model
|
||||
if model == "" {
|
||||
model = raw.RuntimeConfig.Model
|
||||
}
|
||||
|
||||
templates = append(templates, templateSummary{
|
||||
ID: entry.Name(),
|
||||
Name: raw.Name,
|
||||
Description: raw.Description,
|
||||
Tier: raw.Tier,
|
||||
Model: raw.Model,
|
||||
Runtime: raw.Runtime,
|
||||
Model: model,
|
||||
Models: raw.RuntimeConfig.Models,
|
||||
Skills: raw.Skills,
|
||||
SkillCount: len(raw.Skills),
|
||||
})
|
||||
|
||||
@ -129,6 +129,115 @@ skills:
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemplatesList_RuntimeAndModelsRegistry(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
tmplDir := filepath.Join(tmpDir, "hermes")
|
||||
if err := os.MkdirAll(tmplDir, 0755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
configYaml := `name: Hermes Agent
|
||||
description: test
|
||||
tier: 2
|
||||
runtime: hermes
|
||||
runtime_config:
|
||||
model: nous-hermes-3-70b
|
||||
models:
|
||||
- id: nous-hermes-3-70b
|
||||
name: Nous Hermes 3 70B
|
||||
required_env: [HERMES_API_KEY]
|
||||
- id: minimax/minimax-m2.7
|
||||
name: MiniMax M2.7 (via OpenRouter)
|
||||
required_env: [OPENROUTER_API_KEY]
|
||||
skills: []
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(tmplDir, "config.yaml"), []byte(configYaml), 0644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
handler := NewTemplatesHandler(tmpDir, nil)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/templates", nil)
|
||||
handler.List(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var resp []templateSummary
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if len(resp) != 1 {
|
||||
t.Fatalf("expected 1 template, got %d", len(resp))
|
||||
}
|
||||
got := resp[0]
|
||||
if got.Runtime != "hermes" {
|
||||
t.Errorf("Runtime: want hermes, got %q", got.Runtime)
|
||||
}
|
||||
if got.Model != "nous-hermes-3-70b" {
|
||||
t.Errorf("Model: want nous-hermes-3-70b (from runtime_config.model), got %q", got.Model)
|
||||
}
|
||||
if len(got.Models) != 2 {
|
||||
t.Fatalf("Models: want 2, got %d", len(got.Models))
|
||||
}
|
||||
if got.Models[0].ID != "nous-hermes-3-70b" || got.Models[0].Name != "Nous Hermes 3 70B" {
|
||||
t.Errorf("Models[0] id/name mismatch: %+v", got.Models[0])
|
||||
}
|
||||
if len(got.Models[0].RequiredEnv) != 1 || got.Models[0].RequiredEnv[0] != "HERMES_API_KEY" {
|
||||
t.Errorf("Models[0] required_env: want [HERMES_API_KEY], got %+v", got.Models[0].RequiredEnv)
|
||||
}
|
||||
if got.Models[1].ID != "minimax/minimax-m2.7" {
|
||||
t.Errorf("Models[1].ID: got %q", got.Models[1].ID)
|
||||
}
|
||||
if len(got.Models[1].RequiredEnv) != 1 || got.Models[1].RequiredEnv[0] != "OPENROUTER_API_KEY" {
|
||||
t.Errorf("Models[1] required_env: want [OPENROUTER_API_KEY], got %+v", got.Models[1].RequiredEnv)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemplatesList_LegacyTopLevelModel(t *testing.T) {
|
||||
// Older templates (pre-runtime_config) declared `model:` at the top level.
|
||||
// The /templates endpoint should keep surfacing those for backward compat.
|
||||
setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
tmplDir := filepath.Join(tmpDir, "legacy")
|
||||
if err := os.MkdirAll(tmplDir, 0755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
configYaml := `name: Legacy Agent
|
||||
tier: 1
|
||||
model: anthropic:claude-sonnet-4-6
|
||||
skills: []
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(tmplDir, "config.yaml"), []byte(configYaml), 0644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
handler := NewTemplatesHandler(tmpDir, nil)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/templates", nil)
|
||||
handler.List(c)
|
||||
|
||||
var resp []templateSummary
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if len(resp) != 1 || resp[0].Model != "anthropic:claude-sonnet-4-6" {
|
||||
t.Errorf("legacy top-level model not surfaced: %+v", resp)
|
||||
}
|
||||
if resp[0].Runtime != "" {
|
||||
t.Errorf("Runtime should be empty for legacy template, got %q", resp[0].Runtime)
|
||||
}
|
||||
if len(resp[0].Models) != 0 {
|
||||
t.Errorf("Models should be empty for legacy template, got %+v", resp[0].Models)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemplatesList_NonexistentDir(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user