feat: implement repository clone mode with SSH key support
- Add clone_mode and branch fields to tool_instances - Add ssh_key_id to git_repositories for per-repo SSH key assignment - Implement host-side git cloning with branch selection (default: main) - Mount SSH keys into containers for git operations in clone mode - Add dirty state check on clone-mode instance deletion with confirmation - Update SessionsPage with mount/clone selector, branch input, SSH key display - Add SSH key selector to repository creation form - Add dirty delete confirmation modal with changed files list - Update API schemas and endpoints for new fields - Sync delta specs to main specs (git-repo, tool-instances, repo-clone-mode) - Archive completed OpenSpec change: repo-clone-mode-with-ssh - Document git requirement for custom tool types Quality gates: Frontend typecheck and build passed OpenSpec: repo-clone-mode-with-ssh archived with all tasks complete
This commit is contained in:
@@ -16,6 +16,7 @@ import {
|
||||
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||
import { createInstance } from "../api/sessions";
|
||||
import { getUserConfig, updateUserConfig } from "../api/settings";
|
||||
import { listSSHKeys, type SSHKey } from "../api/ssh_keys";
|
||||
import { Icon } from "../components/icon";
|
||||
|
||||
type SessionsStatus = "loading" | "ready" | "error";
|
||||
@@ -38,6 +39,13 @@ export const SessionsPage = () => {
|
||||
const [createStatus, setCreateStatus] = useState<CreateStatus>("idle");
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
|
||||
const [cloneMode, setCloneMode] = useState<"mount" | "clone">("mount");
|
||||
const [branch, setBranch] = useState("main");
|
||||
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
|
||||
|
||||
const [dirtyDeleteSession, setDirtyDeleteSession] = useState<Session | null>(null);
|
||||
const [dirtyDeleteFiles, setDirtyDeleteFiles] = useState<string[]>([]);
|
||||
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
const [stopConfirmId, setStopConfirmId] = useState<string | null>(null);
|
||||
const [tunnelHealth, setTunnelHealth] = useState<Record<string, {
|
||||
@@ -96,6 +104,18 @@ export const SessionsPage = () => {
|
||||
void loadToolTypes();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const loadSshKeys = async () => {
|
||||
try {
|
||||
const data = await listSSHKeys();
|
||||
setSshKeys(data);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
void loadSshKeys();
|
||||
}, []);
|
||||
|
||||
// Poll health every 30 seconds for active instances
|
||||
useEffect(() => {
|
||||
const checkHealth = async () => {
|
||||
@@ -177,13 +197,23 @@ export const SessionsPage = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (cloneMode === "clone") {
|
||||
const repo = repositories.find((r) => r.id === selectedRepo);
|
||||
if (!repo?.ssh_key_id) {
|
||||
setCreateError("Repository must have an SSH key assigned for clone mode");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setCreateStatus("creating");
|
||||
try {
|
||||
const instance = await createInstance(
|
||||
selectedProject,
|
||||
selectedRepo,
|
||||
selectedToolType,
|
||||
displayName || undefined
|
||||
displayName || undefined,
|
||||
cloneMode,
|
||||
cloneMode === "clone" ? branch : undefined
|
||||
);
|
||||
|
||||
// Auto-start the instance
|
||||
@@ -195,10 +225,16 @@ export const SessionsPage = () => {
|
||||
setSelectedRepo("");
|
||||
setSelectedToolType("");
|
||||
setDisplayName("");
|
||||
setCloneMode("mount");
|
||||
setBranch("main");
|
||||
await loadSessions();
|
||||
} catch {
|
||||
} catch (error) {
|
||||
setCreateStatus("error");
|
||||
setCreateError("Failed to create session");
|
||||
const axiosError = error as { response?: { data?: { detail?: string } } };
|
||||
const message = axiosError.response?.data?.detail;
|
||||
setCreateError(
|
||||
typeof message === "string" ? message : "Failed to create session"
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -212,13 +248,25 @@ export const SessionsPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (sessionId: string, projectId: string, repoId: string) => {
|
||||
const handleDelete = async (sessionId: string, projectId: string, repoId: string, force = false) => {
|
||||
try {
|
||||
await deleteInstance(projectId, repoId, sessionId);
|
||||
await deleteInstance(projectId, repoId, sessionId, force);
|
||||
setDeleteConfirmId(null);
|
||||
setDirtyDeleteSession(null);
|
||||
setDirtyDeleteFiles([]);
|
||||
// Remove from local state immediately
|
||||
setSessions((prev) => prev.filter((s) => s.id !== sessionId));
|
||||
} catch {
|
||||
} catch (error) {
|
||||
const axiosError = error as { response?: { status?: number; data?: { detail?: { changed_files?: string[] } } } };
|
||||
if (axiosError.response?.status === 409) {
|
||||
const detail = axiosError.response.data?.detail;
|
||||
if (detail?.changed_files) {
|
||||
setDirtyDeleteSession(sessions.find((s) => s.id === sessionId) ?? null);
|
||||
setDirtyDeleteFiles(detail.changed_files);
|
||||
setDeleteConfirmId(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setDeleteConfirmId(null);
|
||||
}
|
||||
};
|
||||
@@ -608,6 +656,70 @@ export const SessionsPage = () => {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label className="form-field">
|
||||
Repository Access
|
||||
<div className="radio-group">
|
||||
<label className="radio-label">
|
||||
<input
|
||||
type="radio"
|
||||
name="cloneMode"
|
||||
value="mount"
|
||||
checked={cloneMode === "mount"}
|
||||
onChange={(e) => setCloneMode(e.target.value as "mount" | "clone")}
|
||||
/>
|
||||
Mount (live sync)
|
||||
</label>
|
||||
<label className="radio-label">
|
||||
<input
|
||||
type="radio"
|
||||
name="cloneMode"
|
||||
value="clone"
|
||||
checked={cloneMode === "clone"}
|
||||
onChange={(e) => setCloneMode(e.target.value as "mount" | "clone")}
|
||||
/>
|
||||
Clone fresh copy
|
||||
</label>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{cloneMode === "clone" && (
|
||||
<>
|
||||
<label className="form-field">
|
||||
Branch
|
||||
<input
|
||||
type="text"
|
||||
value={branch}
|
||||
onChange={(e) => setBranch(e.target.value)}
|
||||
placeholder="main"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{selectedRepo && (
|
||||
<div className="form-field ssh-key-info">
|
||||
{(() => {
|
||||
const repo = repositories.find((r) => r.id === selectedRepo);
|
||||
if (!repo) return null;
|
||||
if (repo.ssh_key_id) {
|
||||
const key = sshKeys.find((k) => k.id === repo.ssh_key_id);
|
||||
return (
|
||||
<span className="success-text">
|
||||
SSH key: {key?.name || "Assigned"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="warning-text">
|
||||
No SSH key assigned to this repository. Clone mode requires an SSH key.
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label className="form-field">
|
||||
Display Name (optional)
|
||||
<input
|
||||
@@ -641,6 +753,51 @@ export const SessionsPage = () => {
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Dirty Delete Confirmation Modal */}
|
||||
{dirtyDeleteSession && (
|
||||
<div className="modal-overlay" onClick={() => setDirtyDeleteSession(null)}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>Uncommitted Changes</h3>
|
||||
<p>
|
||||
The repository <strong>{dirtyDeleteSession.repository_name}</strong> has
|
||||
uncommitted changes. Deleting this session will permanently lose these
|
||||
changes.
|
||||
</p>
|
||||
<div className="changed-files-list">
|
||||
<h4>Changed files:</h4>
|
||||
<ul>
|
||||
{dirtyDeleteFiles.map((file, idx) => (
|
||||
<li key={idx}>{file}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => setDirtyDeleteSession(null)}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="danger-button"
|
||||
onClick={() =>
|
||||
void handleDelete(
|
||||
dirtyDeleteSession.id,
|
||||
dirtyDeleteSession.project_id,
|
||||
dirtyDeleteSession.repository_id,
|
||||
true
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
Force Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -9,8 +9,6 @@ type SettingsStatus = "loading" | "ready" | "error";
|
||||
const TABS = [
|
||||
{ label: "General", path: "general" },
|
||||
{ label: "SSH Keys", path: "ssh-keys" },
|
||||
{ label: "Tool Types", path: "tool-types" },
|
||||
{ label: "Tool Configs", path: "tool-configs" },
|
||||
] as const;
|
||||
|
||||
const THEME_OPTIONS = [
|
||||
@@ -106,7 +104,7 @@ export const SettingsPage = () => {
|
||||
<p className="eyebrow">Configuration</p>
|
||||
<h1>Settings</h1>
|
||||
</div>
|
||||
<p className="muted">General preferences, SSH keys, tool types, and tool configs live here.</p>
|
||||
<p className="muted">General preferences and SSH keys.</p>
|
||||
</header>
|
||||
|
||||
<nav className="settings-tabs" aria-label="Settings sections">
|
||||
|
||||
Reference in New Issue
Block a user