refactor: remove Tool Configs and Config Folders

These features are fully superseded by Config Profiles which provide:
- Env vars, file mounts, port overrides, start commands, working dirs
- Git mounts, profile composition, cycle detection
- Default selection, project/tool-type scoping

Changes:
- Delete backend models: ToolConfig, ConfigFolder
- Delete backend APIs: tool_configs.py, config_folders.py
- Delete frontend API clients: tool_configs.ts, config_folders.ts
- Remove Tool Config fetching from start_instance, use ConfigProfile only
- Simplify merge_with_config to accept only profile (no tool_configs)
- Remove configs/folders tabs from Tool Workshop page
- Delete associated integration and unit tests
- Add Alembic migration to drop tool_configs and config_folders tables

Quality gates: backend tests 59 passed, frontend typecheck clean
This commit is contained in:
Alex Blank
2026-05-28 20:08:48 +02:00
parent 62c1fb3836
commit 9bd5fc5c68
17 changed files with 124 additions and 2792 deletions
+2 -778
View File
@@ -26,25 +26,6 @@ import {
type ToolDefinitionManifest,
} from "../api/tool_definitions";
import { ManifestEditor } from "../components/manifest-editor";
import {
createToolConfig,
deleteToolConfig,
listToolConfigs,
updateToolConfig,
type CreateToolConfigRequest,
type ToolConfig,
} from "../api/tool_configs";
import {
createConfigFolder,
deleteConfigFolder,
listConfigFolders,
updateConfigFolder,
type ConfigFolder,
type CreateConfigFolderRequest,
type UpdateConfigFolderRequest,
} from "../api/config_folders";
type RightPanelTab = "details" | "configs" | "folders";
type Status = "loading" | "ready" | "error";
type MobileView = "list" | "detail" | "edit";
@@ -54,8 +35,6 @@ export const ToolWorkshopPage = () => {
const [mobileView, setMobileView] = useState<MobileView>("list");
const [status, setStatus] = useState<Status>("loading");
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [configs, setConfigs] = useState<ToolConfig[]>([]);
const [folders, setFolders] = useState<ConfigFolder[]>([]);
const [baseDefinitions, setBaseDefinitions] = useState<
ToolDefinitionManifest[]
>([]);
@@ -72,7 +51,6 @@ export const ToolWorkshopPage = () => {
null,
);
const [isCreating, setIsCreating] = useState(false);
const [rightPanelTab, setRightPanelTab] = useState<RightPanelTab>("details");
// Tool Type form state
const [toolTypeForm, setToolTypeForm] = useState({
@@ -95,55 +73,18 @@ export const ToolWorkshopPage = () => {
const [toolTypeError, setToolTypeError] = useState<string | null>(null);
const [toolTypeDirty, setToolTypeDirty] = useState(false);
// Config form state
const [configForm, setConfigForm] = useState({
key: "",
value: "",
config_type: "env",
file_path: "",
port_override: "",
start_command: "",
working_directory: "",
env_vars_json: "{}",
volumes_json: "[]",
});
const [configError, setConfigError] = useState<string | null>(null);
const [showConfigForm, setShowConfigForm] = useState(false);
const [selectedConfig, setSelectedConfig] = useState<ToolConfig | null>(null);
// Folder form state
const [folderForm, setFolderForm] = useState({
name: "",
description: "",
mount_path: "/home/user",
files_json: "{}",
is_active: true,
});
const [folderError, setFolderError] = useState<string | null>(null);
const [showFolderForm, setShowFolderForm] = useState(false);
const [selectedFolder, setSelectedFolder] = useState<ConfigFolder | null>(
null,
);
const selectedToolType =
(toolTypes || []).find((t) => t.id === selectedToolTypeId) || null;
const toolConfigs = (configs || []).filter(
(c) => c.tool_type_id === selectedToolTypeId,
);
const toolFolders = folders || []; // Config folders are global, not per-tool-type in current API
const loadData = useCallback(async () => {
setStatus("loading");
try {
const [types, cfgs, fldrs, defs] = await Promise.all([
const [types, defs] = await Promise.all([
listToolTypes(),
listToolConfigs(),
listConfigFolders(),
listToolDefinitions(),
]);
setToolTypes(types || []);
setConfigs(cfgs || []);
setFolders(fldrs || []);
setBaseDefinitions((defs || []).filter((d) => d.is_base));
setStatus("ready");
} catch {
@@ -219,9 +160,6 @@ export const ToolWorkshopPage = () => {
} else {
setSelectedToolTypeId(null);
}
setRightPanelTab("details");
setShowConfigForm(false);
setShowFolderForm(false);
};
const handleCreateNew = () => {
@@ -233,9 +171,6 @@ export const ToolWorkshopPage = () => {
setSelectedToolTypeId(null);
setIsCreating(true);
resetToolTypeForm();
setRightPanelTab("details");
setShowConfigForm(false);
setShowFolderForm(false);
};
const handleToolTypeSubmit = async (e: React.FormEvent) => {
@@ -366,213 +301,6 @@ export const ToolWorkshopPage = () => {
}
};
// Config handlers
const resetConfigForm = () => {
setConfigForm({
key: "",
value: "",
config_type: "env",
file_path: "",
port_override: "",
start_command: "",
working_directory: "",
env_vars_json: "{}",
volumes_json: "[]",
});
setConfigError(null);
setSelectedConfig(null);
};
const openCreateConfig = () => {
resetConfigForm();
setShowConfigForm(true);
};
const openEditConfig = (config: ToolConfig) => {
setConfigForm({
key: config.key,
value: config.value,
config_type: config.config_type,
file_path: config.file_path || "",
port_override: config.port_override?.toString() || "",
start_command: config.start_command || "",
working_directory: config.working_directory || "",
env_vars_json: config.environment_variables
? JSON.stringify(config.environment_variables, null, 2)
: "{}",
volumes_json: config.volumes
? JSON.stringify(config.volumes, null, 2)
: "[]",
});
setConfigError(null);
setShowConfigForm(true);
setSelectedConfig(config);
};
const handleConfigSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setConfigError(null);
if (!selectedToolTypeId || !configForm.key.trim()) {
setConfigError("Tool type and key are required");
return;
}
let envVars: Record<string, string> | undefined;
let volumes:
| Array<{ source: string; target: string; type?: string }>
| undefined;
try {
if (
configForm.env_vars_json.trim() &&
configForm.env_vars_json.trim() !== "{}"
) {
envVars = JSON.parse(configForm.env_vars_json);
}
} catch {
setConfigError("Environment variables must be valid JSON");
return;
}
try {
if (
configForm.volumes_json.trim() &&
configForm.volumes_json.trim() !== "[]"
) {
volumes = JSON.parse(configForm.volumes_json);
}
} catch {
setConfigError("Volumes must be valid JSON array");
return;
}
const data: CreateToolConfigRequest = {
tool_type_id: selectedToolTypeId,
key: configForm.key.trim(),
value: configForm.value,
config_type: configForm.config_type,
file_path:
configForm.config_type === "file" ? configForm.file_path : undefined,
port_override: configForm.port_override
? Number(configForm.port_override)
: undefined,
start_command: configForm.start_command.trim() || undefined,
working_directory: configForm.working_directory.trim() || undefined,
environment_variables: envVars,
volumes,
};
try {
if (selectedConfig) {
await updateToolConfig(selectedConfig.id, data);
} else {
await createToolConfig(data);
}
setShowConfigForm(false);
setSelectedConfig(null);
resetConfigForm();
await loadData();
} catch (err) {
setConfigError(extractErrorMessage(err));
}
};
const handleDeleteConfig = async (id: string) => {
if (!window.confirm("Delete this config?")) return;
try {
await deleteToolConfig(id);
await loadData();
} catch {
alert("Failed to delete config");
}
};
// Folder handlers
const resetFolderForm = () => {
setFolderForm({
name: "",
description: "",
mount_path: "/home/user",
files_json: "{}",
is_active: true,
});
setFolderError(null);
setSelectedFolder(null);
};
const openCreateFolder = () => {
resetFolderForm();
setShowFolderForm(true);
};
const openEditFolder = (folder: ConfigFolder) => {
setFolderForm({
name: folder.name,
description: folder.description || "",
mount_path: folder.mount_path,
files_json: JSON.stringify(folder.files, null, 2),
is_active: folder.is_active,
});
setFolderError(null);
setShowFolderForm(true);
setSelectedFolder(folder);
};
const handleFolderSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setFolderError(null);
if (!folderForm.name.trim() || !folderForm.mount_path.trim()) {
setFolderError("Name and mount path are required");
return;
}
let files: Record<string, string> | undefined;
try {
if (
folderForm.files_json.trim() &&
folderForm.files_json.trim() !== "{}"
) {
files = JSON.parse(folderForm.files_json);
}
} catch {
setFolderError("Files must be valid JSON object");
return;
}
const data: CreateConfigFolderRequest | UpdateConfigFolderRequest = {
name: folderForm.name.trim(),
description: folderForm.description.trim() || undefined,
mount_path: folderForm.mount_path.trim(),
files,
is_active: folderForm.is_active,
};
try {
if (selectedFolder) {
await updateConfigFolder(selectedFolder.id, data);
} else {
await createConfigFolder(data as CreateConfigFolderRequest);
}
setShowFolderForm(false);
setSelectedFolder(null);
resetFolderForm();
await loadData();
} catch (err) {
setFolderError(extractErrorMessage(err));
}
};
const handleDeleteFolder = async (id: string) => {
if (!window.confirm("Delete this config folder?")) return;
try {
await deleteConfigFolder(id);
await loadData();
} catch {
alert("Failed to delete folder");
}
};
if (status === "loading") {
return (
@@ -1143,45 +871,7 @@ export const ToolWorkshopPage = () => {
)}
</div>
{/* Right Panel Tabs */}
<div
style={{
display: "flex",
gap: "0",
marginBottom: "1.5rem",
borderBottom: "1px solid var(--border)",
}}
>
{(["details", "configs", "folders"] as RightPanelTab[]).map(
(tab) => (
<button
key={tab}
onClick={() => setRightPanelTab(tab)}
style={{
padding: "0.625rem 1.25rem",
borderBottom:
rightPanelTab === tab
? "2px solid var(--brand)"
: "2px solid transparent",
background: "none",
border: "none",
cursor: "pointer",
fontWeight: rightPanelTab === tab ? 600 : 400,
color:
rightPanelTab === tab ? "var(--brand)" : "var(--muted)",
fontSize: "0.9375rem",
marginBottom: "-1px",
textTransform: "capitalize",
}}
>
{tab}
</button>
),
)}
</div>
{/* Details Tab */}
{rightPanelTab === "details" && (
{(
<form
onSubmit={handleToolTypeSubmit}
className="stack"
@@ -1513,472 +1203,6 @@ export const ToolWorkshopPage = () => {
</form>
)}
{/* Configs Tab */}
{rightPanelTab === "configs" && selectedToolTypeId && (
<div>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "1rem",
}}
>
<h3 style={{ margin: 0 }}>
Configurations for {selectedToolType?.display_name}
</h3>
<button onClick={openCreateConfig}>
<Icon name="add" size="sm" /> Add Config
</button>
</div>
{showConfigForm && (
<div
className="card stack"
style={{ marginBottom: "1rem", padding: "1rem" }}
>
<h4>{selectedConfig ? "Edit" : "Add"} Config</h4>
<form
onSubmit={handleConfigSubmit}
className="stack"
style={{ gap: "0.75rem" }}
>
<div className="form-group">
<label>Key *</label>
<input
type="text"
value={configForm.key}
onChange={(e) =>
setConfigForm({
...configForm,
key: e.target.value,
})
}
placeholder="e.g., OPENAI_API_KEY"
className="form-input"
required
/>
</div>
<div className="form-group">
<label>Config Type</label>
<select
value={configForm.config_type}
onChange={(e) =>
setConfigForm({
...configForm,
config_type: e.target.value,
})
}
className="form-input"
>
<option value="env">Environment Variable</option>
<option value="file">Configuration File</option>
</select>
</div>
{configForm.config_type === "file" && (
<div className="form-group">
<label>File Path</label>
<input
type="text"
value={configForm.file_path}
onChange={(e) =>
setConfigForm({
...configForm,
file_path: e.target.value,
})
}
placeholder="e.g., /app/config.json"
className="form-input"
/>
</div>
)}
<div className="form-group">
<label>Value</label>
<textarea
value={configForm.value}
onChange={(e) =>
setConfigForm({
...configForm,
value: e.target.value,
})
}
placeholder={
configForm.config_type === "env"
? "Enter value..."
: "Enter file contents..."
}
className="form-input"
rows={configForm.config_type === "file" ? 8 : 2}
required
/>
</div>
<div className="row" style={{ gap: "1rem" }}>
<div className="form-group" style={{ flex: 1 }}>
<label>Port Override</label>
<input
type="number"
value={configForm.port_override}
onChange={(e) =>
setConfigForm({
...configForm,
port_override: e.target.value,
})
}
placeholder="e.g., 8080"
className="form-input"
/>
</div>
<div className="form-group" style={{ flex: 1 }}>
<label>Start Command</label>
<input
type="text"
value={configForm.start_command}
onChange={(e) =>
setConfigForm({
...configForm,
start_command: e.target.value,
})
}
placeholder="e.g., npm start"
className="form-input"
/>
</div>
</div>
<div className="form-group">
<label>Working Directory</label>
<input
type="text"
value={configForm.working_directory}
onChange={(e) =>
setConfigForm({
...configForm,
working_directory: e.target.value,
})
}
placeholder="e.g., /workspace"
className="form-input"
/>
</div>
<div className="form-group">
<label>Environment Variables (JSON)</label>
<textarea
value={configForm.env_vars_json}
onChange={(e) =>
setConfigForm({
...configForm,
env_vars_json: e.target.value,
})
}
placeholder='{"KEY": "value"}'
className="form-input"
rows={3}
style={{
fontFamily: "monospace",
fontSize: "0.8125rem",
}}
/>
</div>
<div className="form-group">
<label>Volumes (JSON array)</label>
<textarea
value={configForm.volumes_json}
onChange={(e) =>
setConfigForm({
...configForm,
volumes_json: e.target.value,
})
}
placeholder='[{"source": "/host", "target": "/container"}]'
className="form-input"
rows={3}
style={{
fontFamily: "monospace",
fontSize: "0.8125rem",
}}
/>
</div>
{configError && (
<p className="text-error">{configError}</p>
)}
<div className="dialog-actions">
<button type="submit">
{selectedConfig ? "Update" : "Add"}
</button>
<button
type="button"
onClick={() => {
setShowConfigForm(false);
resetConfigForm();
}}
className="button-secondary"
>
Cancel
</button>
</div>
</form>
</div>
)}
<div className="stack" style={{ gap: "0.5rem" }}>
{toolConfigs.length === 0 ? (
<EmptyState message="No configurations for this tool type yet." />
) : (
toolConfigs.map((config) => (
<div
key={config.id}
className="card"
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "0.75rem 1rem",
}}
>
<div>
<div
className="row"
style={{ gap: "0.5rem", alignItems: "center" }}
>
<code style={{ fontWeight: 600 }}>
{config.key}
</code>
<span
style={{
fontSize: "0.7rem",
textTransform: "uppercase",
padding: "0.125rem 0.5rem",
borderRadius: "9999px",
background:
config.config_type === "env"
? "var(--info, #3b82f6)"
: "var(--warning, #f59e0b)",
color: "white",
fontWeight: 600,
}}
>
{config.config_type}
</span>
</div>
<p
className="muted"
style={{
marginTop: "0.25rem",
fontSize: "0.875rem",
}}
>
{config.config_type === "file" && config.file_path
? `File: ${config.file_path}`
: "Environment variable"}
{config.port_override &&
` · Port: ${config.port_override}`}
</p>
</div>
<div className="row" style={{ gap: "0.5rem" }}>
<button
className="ghost-button small"
onClick={() => openEditConfig(config)}
type="button"
>
<Icon name="edit" size="sm" />
</button>
<button
className="ghost-button small"
onClick={() => handleDeleteConfig(config.id)}
type="button"
>
<Icon name="delete" size="sm" />
</button>
</div>
</div>
))
)}
</div>
</div>
)}
{/* Folders Tab */}
{rightPanelTab === "folders" && (
<div>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "1rem",
}}
>
<h3 style={{ margin: 0 }}>Config Folders</h3>
<button onClick={openCreateFolder}>
<Icon name="add" size="sm" /> Create Folder
</button>
</div>
{showFolderForm && (
<div
className="card stack"
style={{ marginBottom: "1rem", padding: "1rem" }}
>
<h4>{selectedFolder ? "Edit" : "Create"} Config Folder</h4>
<form
onSubmit={handleFolderSubmit}
className="stack"
style={{ gap: "0.75rem" }}
>
<div className="form-group">
<label>Name *</label>
<input
type="text"
value={folderForm.name}
onChange={(e) =>
setFolderForm({
...folderForm,
name: e.target.value,
})
}
placeholder="e.g., my-dotfiles"
className="form-input"
required
/>
</div>
<div className="form-group">
<label>Description</label>
<input
type="text"
value={folderForm.description}
onChange={(e) =>
setFolderForm({
...folderForm,
description: e.target.value,
})
}
placeholder="Optional description"
className="form-input"
/>
</div>
<div className="form-group">
<label>Mount Path *</label>
<input
type="text"
value={folderForm.mount_path}
onChange={(e) =>
setFolderForm({
...folderForm,
mount_path: e.target.value,
})
}
placeholder="e.g., /home/user"
className="form-input"
required
/>
</div>
<div className="form-group">
<label>Files (JSON object)</label>
<textarea
value={folderForm.files_json}
onChange={(e) =>
setFolderForm({
...folderForm,
files_json: e.target.value,
})
}
placeholder='{".zshrc": "export ZSH=...", ".gitconfig": "[user]\\nname = ..."}'
className="form-input"
rows={8}
style={{
fontFamily: "monospace",
fontSize: "0.8125rem",
}}
/>
</div>
<div className="form-group">
<label className="checkbox-label">
<input
type="checkbox"
checked={folderForm.is_active}
onChange={(e) =>
setFolderForm({
...folderForm,
is_active: e.target.checked,
})
}
/>
Active (mount into new instances)
</label>
</div>
{folderError && (
<p className="text-error">{folderError}</p>
)}
<div className="dialog-actions">
<button type="submit">
{selectedFolder ? "Update" : "Create"}
</button>
<button
type="button"
onClick={() => {
setShowFolderForm(false);
resetFolderForm();
}}
className="button-secondary"
>
Cancel
</button>
</div>
</form>
</div>
)}
<div className="card-grid">
{toolFolders.map((folder) => (
<div key={folder.id} className="card">
<div className="card-header">
<h4>{folder.name}</h4>
{folder.is_active && (
<span className="badge">Active</span>
)}
</div>
<p className="text-secondary">
{folder.description || "No description"}
</p>
<div className="tool-type-meta">
<span>Mount: {folder.mount_path}</span>
<span>
Files: {Object.keys(folder.files || {}).length}
</span>
</div>
<div className="card-actions">
<button
onClick={() => openEditFolder(folder)}
className="button-secondary"
>
<Icon name="edit" size="sm" /> Edit
</button>
<button
onClick={() => handleDeleteFolder(folder.id)}
className="button-danger"
>
<Icon name="delete" size="sm" /> Delete
</button>
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>