Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c178225c8b | |||
| b5e961ebe9 | |||
| 5d379c5f8b | |||
| 671540ded9 | |||
| ad26fd9f35 | |||
| 415aecc0dd |
@@ -283,16 +283,27 @@ def clone_git_repo(
|
||||
url_hash = hashlib.md5(f"{remote_url}:{branch_segment}".encode()).hexdigest()[:12]
|
||||
repo_name = remote_url.split("/")[-1].replace(".git", "") or "repo"
|
||||
clone_dir = os.path.join(clone_parent, "git-mounts", f"{repo_name}-{url_hash}")
|
||||
repo_path = clone_repository(
|
||||
remote_url,
|
||||
None, # No SSH key for now - can be added later
|
||||
clone_dir,
|
||||
branch or "main",
|
||||
project_name=project_name,
|
||||
)
|
||||
clone_name = _slugify_directory_name(project_name) if project_name else "repo-clone"
|
||||
repo_path = os.path.join(clone_dir, clone_name)
|
||||
|
||||
if not os.path.exists(repo_path):
|
||||
if os.path.isdir(os.path.join(repo_path, ".git")):
|
||||
# Reuse the deterministic per-repository cache on repeated starts.
|
||||
try:
|
||||
pull_repository_updates(repo_path, remote_url)
|
||||
logger.debug("Pulled updates for git mount %s", remote_url)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to pull updates for %s: %s", remote_url, exc)
|
||||
else:
|
||||
try:
|
||||
# A failed clone can leave its destination behind. Remove only the
|
||||
# computed clone path so the next start can retry cleanly.
|
||||
if os.path.lexists(repo_path):
|
||||
logger.warning("Removing incomplete git mount clone at %s", repo_path)
|
||||
if os.path.isdir(repo_path) and not os.path.islink(repo_path):
|
||||
shutil.rmtree(repo_path)
|
||||
else:
|
||||
os.unlink(repo_path)
|
||||
|
||||
os.makedirs(clone_dir, exist_ok=True)
|
||||
repo_path = clone_repository(
|
||||
remote_url,
|
||||
@@ -305,13 +316,6 @@ def clone_git_repo(
|
||||
except Exception as exc:
|
||||
logger.warning("Clone failed for git mount %s: %s", remote_url, exc)
|
||||
raise
|
||||
else:
|
||||
# Repo exists - pull latest updates
|
||||
try:
|
||||
pull_repository_updates(repo_path, remote_url)
|
||||
logger.debug("Pulled updates for git mount %s", remote_url)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to pull updates for %s: %s", remote_url, exc)
|
||||
|
||||
# Handle branch checkout if specified
|
||||
if branch and repo_path:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Unit tests for the tool instance service."""
|
||||
|
||||
import hashlib
|
||||
import uuid
|
||||
from unittest.mock import MagicMock, AsyncMock
|
||||
|
||||
@@ -8,6 +9,7 @@ import pytest
|
||||
from src.services.tool.instance_service import (
|
||||
_get_repository_mount_name,
|
||||
_stack_profile_mounts_with_git_mounts,
|
||||
clone_git_repo,
|
||||
modify_compose_file,
|
||||
prepare_manifest_instance,
|
||||
)
|
||||
@@ -67,6 +69,61 @@ class TestGetRepositoryMountName:
|
||||
assert _get_repository_mount_name(project, repo) == "project-v2-0"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestCloneGitRepo:
|
||||
"""Regression tests for reusable config-profile git mount clones."""
|
||||
|
||||
def test_reuses_existing_clone(self, monkeypatch, tmp_path) -> None:
|
||||
from src.services.tool import instance_service
|
||||
|
||||
remote_url = "https://gitlab.com/example/dotfiles"
|
||||
branch = None
|
||||
clone_parent = str(tmp_path)
|
||||
url_hash = hashlib.md5(f"{remote_url}:default".encode()).hexdigest()[:12]
|
||||
repo_path = (
|
||||
tmp_path
|
||||
/ "git-mounts"
|
||||
/ f"dotfiles-{url_hash}"
|
||||
/ "repo-clone"
|
||||
)
|
||||
(repo_path / ".git").mkdir(parents=True)
|
||||
|
||||
clone = MagicMock(side_effect=AssertionError("existing clone must be reused"))
|
||||
pull = MagicMock()
|
||||
monkeypatch.setattr(instance_service, "clone_repository", clone)
|
||||
monkeypatch.setattr(instance_service, "pull_repository_updates", pull)
|
||||
|
||||
result = clone_git_repo(remote_url, branch, clone_parent)
|
||||
|
||||
assert result == str(repo_path)
|
||||
clone.assert_not_called()
|
||||
pull.assert_called_once_with(str(repo_path), remote_url)
|
||||
|
||||
def test_replaces_incomplete_clone_before_retry(self, monkeypatch, tmp_path) -> None:
|
||||
from src.services.tool import instance_service
|
||||
|
||||
remote_url = "https://gitlab.com/example/dotfiles"
|
||||
url_hash = hashlib.md5(f"{remote_url}:main".encode()).hexdigest()[:12]
|
||||
clone_dir = tmp_path / "git-mounts" / f"dotfiles-{url_hash}"
|
||||
repo_path = clone_dir / "repo-clone"
|
||||
repo_path.mkdir(parents=True)
|
||||
(repo_path / "partial-file").write_text("incomplete")
|
||||
|
||||
def clone(_url, _key, destination, _branch, project_name=None):
|
||||
assert destination == str(clone_dir)
|
||||
assert project_name is None
|
||||
assert not repo_path.exists()
|
||||
(repo_path / ".git").mkdir(parents=True)
|
||||
return str(repo_path)
|
||||
|
||||
monkeypatch.setattr(instance_service, "clone_repository", clone)
|
||||
|
||||
result = clone_git_repo(remote_url, "main", str(tmp_path))
|
||||
|
||||
assert result == str(repo_path)
|
||||
assert (repo_path / ".git").is_dir()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestStackProfileMountsWithGitMounts:
|
||||
"""Tests for _stack_profile_mounts_with_git_mounts."""
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#275d4b" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<title>Headquarter</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Headquarter</title>
|
||||
<rect width="64" height="64" rx="15" fill="#275d4b"/>
|
||||
<path fill="#fffef9" d="M17 15h8v13h14V15h8v34h-8V36H25v13h-8z"/>
|
||||
<path fill="#9dcdb7" d="M25 28h14v8H25z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 266 B |
@@ -578,19 +578,23 @@ export const InstanceList = ({
|
||||
{showCreate && (
|
||||
<div className="dialog-overlay" role="dialog" aria-modal="true">
|
||||
<div className="dialog">
|
||||
<h2>Launch Tool</h2>
|
||||
<CreateSessionForm
|
||||
projects={[]}
|
||||
repositories={[]}
|
||||
toolTypes={toolTypes}
|
||||
fixedProjectId={projectId}
|
||||
fixedRepoId={repoId}
|
||||
projectName={projectName}
|
||||
repoName={repoName}
|
||||
onSuccess={handleCreateSuccess}
|
||||
onCancel={() => setShowCreate(false)}
|
||||
submitLabel="Launch"
|
||||
/>
|
||||
<div className="dialog-header">
|
||||
<h2>Launch Tool</h2>
|
||||
</div>
|
||||
<div className="dialog-body">
|
||||
<CreateSessionForm
|
||||
projects={[]}
|
||||
repositories={[]}
|
||||
toolTypes={toolTypes}
|
||||
fixedProjectId={projectId}
|
||||
fixedRepoId={repoId}
|
||||
projectName={projectName}
|
||||
repoName={repoName}
|
||||
onSuccess={handleCreateSuccess}
|
||||
onCancel={() => setShowCreate(false)}
|
||||
submitLabel="Launch"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -62,6 +62,7 @@ export function StartToolFAB() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
{workspacesLoading ? (
|
||||
<p className="muted">Loading workspaces...</p>
|
||||
) : workspaces.length === 0 ? (
|
||||
@@ -110,6 +111,7 @@ export function StartToolFAB() {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { WorkspaceToolsPanel } from "./workspace-tools-panel";
|
||||
import type { Workspace } from "../../../types/workspace";
|
||||
|
||||
vi.mock("../../../hooks/use-workspace-instances", () => ({
|
||||
useWorkspaceInstances: () => ({
|
||||
instances: [],
|
||||
loading: false,
|
||||
refresh: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../tool/tool-starter", () => ({
|
||||
ToolStarter: () => <div data-testid="tool-starter" />,
|
||||
}));
|
||||
|
||||
const workspace: Workspace = {
|
||||
id: "workspace-1",
|
||||
name: "Main",
|
||||
repo_id: "repo-1",
|
||||
repo_name: "repository",
|
||||
repo_ssh_key_id: null,
|
||||
project_id: "project-1",
|
||||
project_name: "Project",
|
||||
user_id: "user-1",
|
||||
branch: "main",
|
||||
path: "/workspace",
|
||||
status: "ready",
|
||||
last_sync_at: null,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
instance_count: 0,
|
||||
};
|
||||
|
||||
describe("WorkspaceToolsPanel", () => {
|
||||
it("places the tool launcher inside the shared scrollable dialog body", () => {
|
||||
const { container } = render(<WorkspaceToolsPanel workspace={workspace} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Start Tool" }));
|
||||
|
||||
const dialogBody = container.querySelector(".dialog-body");
|
||||
expect(dialogBody).toContainElement(screen.getByTestId("tool-starter"));
|
||||
});
|
||||
});
|
||||
@@ -64,15 +64,19 @@ export function WorkspaceToolsPanel({ workspace }: WorkspaceToolsPanelProps) {
|
||||
{showModal && (
|
||||
<div className="dialog-overlay" onClick={() => setShowModal(false)}>
|
||||
<div className="dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>Start Tool</h3>
|
||||
<ToolStarter
|
||||
workspace={workspace}
|
||||
onStarted={() => {
|
||||
setShowModal(false);
|
||||
void refresh();
|
||||
}}
|
||||
onCancel={() => setShowModal(false)}
|
||||
/>
|
||||
<div className="dialog-header">
|
||||
<h3>Start Tool</h3>
|
||||
</div>
|
||||
<div className="dialog-body">
|
||||
<ToolStarter
|
||||
workspace={workspace}
|
||||
onStarted={() => {
|
||||
setShowModal(false);
|
||||
void refresh();
|
||||
}}
|
||||
onCancel={() => setShowModal(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ProjectsPage } from "./ProjectsPage";
|
||||
import * as projectsApi from "../api/projects";
|
||||
import { SessionsProvider } from "../state/sessions";
|
||||
import type { ProjectWithRepos } from "../types";
|
||||
|
||||
const mockProjects = [
|
||||
vi.mock("../api/sessions", () => ({
|
||||
getUserSessions: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
const mockProjects: ProjectWithRepos[] = [
|
||||
{
|
||||
id: "proj-1",
|
||||
name: "Alpha Project",
|
||||
description: "First project",
|
||||
owner_id: "user-1",
|
||||
default_ssh_key_id: null,
|
||||
repositories: [],
|
||||
created_at: "2026-07-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "proj-2",
|
||||
@@ -19,6 +28,8 @@ const mockProjects = [
|
||||
description: null,
|
||||
owner_id: "user-1",
|
||||
default_ssh_key_id: null,
|
||||
repositories: [],
|
||||
created_at: "2026-07-01T00:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -31,9 +42,7 @@ describe("ProjectsPage", () => {
|
||||
it("renders loading state initially", () => {
|
||||
vi.spyOn(projectsApi, "listProjects").mockImplementation(() => new Promise(() => {}));
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
<MemoryRouter><SessionsProvider><ProjectsPage /></SessionsProvider></MemoryRouter>
|
||||
);
|
||||
expect(screen.getByText(/loading projects/i)).toBeInTheDocument();
|
||||
});
|
||||
@@ -41,9 +50,7 @@ describe("ProjectsPage", () => {
|
||||
it("renders project list after loading", async () => {
|
||||
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
<MemoryRouter><SessionsProvider><ProjectsPage /></SessionsProvider></MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -56,9 +63,7 @@ describe("ProjectsPage", () => {
|
||||
it("renders empty state when no projects", async () => {
|
||||
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
<MemoryRouter><SessionsProvider><ProjectsPage /></SessionsProvider></MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -69,9 +74,7 @@ describe("ProjectsPage", () => {
|
||||
it("renders error state with retry button", async () => {
|
||||
vi.spyOn(projectsApi, "listProjects").mockRejectedValue(new Error("fail"));
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
<MemoryRouter><SessionsProvider><ProjectsPage /></SessionsProvider></MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -85,9 +88,7 @@ describe("ProjectsPage", () => {
|
||||
const createMock = vi.spyOn(projectsApi, "createProject").mockResolvedValue(mockProjects[0]);
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
<MemoryRouter><SessionsProvider><ProjectsPage /></SessionsProvider></MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -118,9 +119,7 @@ describe("ProjectsPage", () => {
|
||||
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
<MemoryRouter><SessionsProvider><ProjectsPage /></SessionsProvider></MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -138,16 +137,14 @@ describe("ProjectsPage", () => {
|
||||
const updateMock = vi.spyOn(projectsApi, "updateProject").mockResolvedValue(mockProjects[0]);
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
<MemoryRouter><SessionsProvider><ProjectsPage /></SessionsProvider></MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const alphaCard = screen.getByText("Alpha Project").closest(".project-card") as HTMLElement | null;
|
||||
const alphaCard = screen.getByText("Alpha Project").closest(".project-list-item") as HTMLElement | null;
|
||||
if (!alphaCard) throw new Error("Card not found");
|
||||
|
||||
fireEvent.click(within(alphaCard).getByRole("button", { name: /edit/i }));
|
||||
@@ -171,16 +168,14 @@ describe("ProjectsPage", () => {
|
||||
const deleteMock = vi.spyOn(projectsApi, "deleteProject").mockResolvedValue(undefined);
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
<MemoryRouter><SessionsProvider><ProjectsPage /></SessionsProvider></MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const alphaCard = screen.getByText("Alpha Project").closest(".project-card") as HTMLElement | null;
|
||||
const alphaCard = screen.getByText("Alpha Project").closest(".project-list-item") as HTMLElement | null;
|
||||
if (!alphaCard) throw new Error("Card not found");
|
||||
|
||||
fireEvent.click(within(alphaCard).getByRole("button", { name: /delete/i }));
|
||||
|
||||
@@ -453,12 +453,16 @@
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.dialog {
|
||||
.dialog,
|
||||
.modal-content,
|
||||
.commit-dialog {
|
||||
width: 100%;
|
||||
max-width: 32rem;
|
||||
max-height: calc(100vh - var(--space-8));
|
||||
max-height: calc(100dvh - var(--space-8));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
@@ -468,16 +472,6 @@
|
||||
|
||||
.modal-content {
|
||||
/* deprecated alias */
|
||||
width: 100%;
|
||||
max-width: 32rem;
|
||||
max-height: calc(100vh - var(--space-8));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-xl);
|
||||
}
|
||||
|
||||
.dialog-lg {
|
||||
@@ -500,9 +494,13 @@
|
||||
line-height: var(--line-height-tight);
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
.dialog-body,
|
||||
.modal-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
@@ -543,9 +541,11 @@
|
||||
}
|
||||
|
||||
.dialog,
|
||||
.modal-content {
|
||||
.modal-content,
|
||||
.commit-dialog {
|
||||
max-width: 100%;
|
||||
max-height: calc(100vh - var(--space-6));
|
||||
max-height: calc(100dvh - var(--space-6));
|
||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2328,8 +2328,10 @@ a.nav-item,
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
max-height: 70vh;
|
||||
max-height: 70dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
animation: slide-up 0.2s ease-out;
|
||||
}
|
||||
|
||||
@@ -2365,8 +2367,12 @@ a.nav-item,
|
||||
}
|
||||
|
||||
.mobile-bottom-sheet-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 8px 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.mobile-bottom-sheet-item {
|
||||
@@ -3240,7 +3246,10 @@ a:active,
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
max-height: 80vh;
|
||||
max-height: 80dvh;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
animation: slideUp 0.3s ease;
|
||||
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||
}
|
||||
@@ -3602,6 +3611,7 @@ a:active,
|
||||
max-width: none;
|
||||
border-radius: 12px;
|
||||
max-height: 70vh;
|
||||
max-height: 70dvh;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,9 @@ map: openspec/.pi-map.md
|
||||
- openspec/changes/archive
|
||||
index: openspec/changes/archive/.pi-map.index.md
|
||||
map: openspec/changes/archive/.pi-map.md
|
||||
- openspec/changes/fix-config-profile-git-mount-clone-reuse
|
||||
index: openspec/changes/fix-config-profile-git-mount-clone-reuse/.pi-map.index.md
|
||||
map: openspec/changes/fix-config-profile-git-mount-clone-reuse/.pi-map.md
|
||||
- openspec/changes/fix-container-status-false-positive
|
||||
index: openspec/changes/fix-container-status-false-positive/.pi-map.index.md
|
||||
map: openspec/changes/fix-container-status-false-positive/.pi-map.md
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# Add a Headquarter Favicon
|
||||
|
||||
## Summary
|
||||
|
||||
Add a compact, recognizable favicon for Headquarter and register it in the web document head.
|
||||
|
||||
## Design
|
||||
|
||||
Use a geometric cream `H` on the product's evergreen brand field. The mark remains identifiable at small browser-tab sizes, avoids font rendering dependencies, and matches both light and dark application themes.
|
||||
|
||||
## Scope
|
||||
|
||||
- `apps/web/public/favicon.svg`
|
||||
- `apps/web/index.html`
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] The browser uses a dedicated Headquarter favicon.
|
||||
- [ ] The mark remains legible at small sizes and on light or dark browser chrome.
|
||||
- [ ] The HTML declares the icon type and a matching browser theme color.
|
||||
- [ ] Frontend production build passes.
|
||||
@@ -0,0 +1,6 @@
|
||||
# Add a Headquarter Favicon — Tasks
|
||||
|
||||
- [x] Create the favicon asset.
|
||||
- [x] Register the favicon and browser theme color in the web entry document.
|
||||
- [x] Run the frontend production build.
|
||||
- [x] Update project maps for changed source files.
|
||||
@@ -0,0 +1,20 @@
|
||||
# openspec/changes/fix-config-profile-git-mount-clone-reuse (index)
|
||||
dir: openspec/changes/fix-config-profile-git-mount-clone-reuse
|
||||
|
||||
## role
|
||||
Documents and tracks a bug fix for reusing cached Config Profile git mount clones during tool instance startup.
|
||||
## parent
|
||||
index: openspec/changes/.pi-map.index.md
|
||||
map: openspec/changes/.pi-map.md
|
||||
## children
|
||||
-
|
||||
## files
|
||||
- change.md
|
||||
- tasks.md
|
||||
## links
|
||||
index: openspec/changes/fix-config-profile-git-mount-clone-reuse/.pi-map.index.md
|
||||
map: openspec/changes/fix-config-profile-git-mount-clone-reuse/.pi-map.md
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,20 @@
|
||||
# openspec/changes/fix-config-profile-git-mount-clone-reuse
|
||||
dir: openspec/changes/fix-config-profile-git-mount-clone-reuse
|
||||
|
||||
index: openspec/changes/fix-config-profile-git-mount-clone-reuse/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Documents and tracks a bug fix for reusing cached Config Profile git mount clones during tool instance startup.
|
||||
## files
|
||||
- change.md | Describes the cached clone regression, required behavior, implementation scope, and verification plan.
|
||||
- tasks.md | Tracks investigation, regression testing, implementation, project-map maintenance, verification, and commit status.
|
||||
## arch
|
||||
Documentation-only OpenSpec change package with separate change rationale and implementation task checklist.
|
||||
## tags
|
||||
config, profile, git, mount, clone, cache, startup, fix
|
||||
## symbols
|
||||
-
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,27 @@
|
||||
# Fix config profile git mount clone reuse
|
||||
|
||||
## Problem
|
||||
|
||||
Starting a tool instance with a Config Profile git mount can omit the mount when the repository was already cloned into the instance cache. The startup log reports that the destination path already exists and is not an empty directory.
|
||||
|
||||
## Root cause
|
||||
|
||||
`clone_git_repo()` computes a deterministic cache directory but calls `clone_repository()` before checking whether that directory already contains a clone. `git clone` therefore fails on repeated starts or overlapping start requests. `resolve_single_git_mount()` treats auxiliary mount failures as non-blocking, so startup continues without the configured volume.
|
||||
|
||||
## Required behavior
|
||||
|
||||
1. A valid existing git mount clone must be reused and updated instead of cloned again.
|
||||
2. A missing clone must still be created normally.
|
||||
3. An incomplete clone directory must not permanently prevent a later retry.
|
||||
4. A clone/update failure remains non-blocking at the git mount resolver boundary.
|
||||
|
||||
## Scope
|
||||
|
||||
- Correct clone-cache handling in `apps/api/src/services/tool/instance_service.py`.
|
||||
- Add focused regression tests in `apps/api/tests/unit/test_instance_service.py`.
|
||||
- No API, database, frontend, or Docker Compose contract changes.
|
||||
|
||||
## Verification
|
||||
|
||||
- Targeted `pytest` for git mount clone reuse and instance service tests.
|
||||
- Ruff and mypy checks for changed backend files.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Tasks: fix config profile git mount clone reuse
|
||||
|
||||
- [x] Capture the failing runtime trace from an affected tool session.
|
||||
- [x] Add a regression test proving a valid cached clone is reused.
|
||||
- [x] Update `clone_git_repo()` to check the deterministic clone path before cloning.
|
||||
- [x] Recover safely from an incomplete clone directory.
|
||||
- [x] Run targeted backend tests and quality checks.
|
||||
- [x] Update project maps and validate map freshness.
|
||||
- [x] Commit the verified fix.
|
||||
@@ -0,0 +1,28 @@
|
||||
# Constrain and Scroll Edit Dialogs and Popups
|
||||
|
||||
## Summary
|
||||
|
||||
Ensure every edit dialog, form popup, and modal popup remains usable on a viewport that is shorter than its content. Dialog chrome must stay within the visible viewport while the content area scrolls independently.
|
||||
|
||||
## Problem
|
||||
|
||||
The shared dialog system constrains only components that follow its `dialog-header` / `dialog-body` structure. Several tool-launch and commit dialogs place form content directly inside the container or use a bespoke container, so long forms can be clipped. Existing viewport sizing also relies on `vh`, which is unreliable when mobile browser chrome changes height.
|
||||
|
||||
## Scope
|
||||
|
||||
- Shared dialog and modal CSS in `apps/web/src/styles/global.css`.
|
||||
- Mobile notification dropdown sizing in `apps/web/src/styles/utilities.css`.
|
||||
- Tool-launch and commit popup markup that does not currently provide a scrollable content region.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Dialogs and modal popups are bounded by the current visible viewport, including mobile dynamic viewport changes.
|
||||
- [ ] Headers and footer/action bars remain visible while long form content scrolls independently.
|
||||
- [ ] Tool-launch and commit popups use the shared scrollable content pattern.
|
||||
- [ ] Mobile sheets, action sheets, and notification dropdowns remain scrollable without propagating scroll gestures to the page.
|
||||
- [ ] Relevant frontend tests, typecheck, lint, and production build pass.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Redesigning dialog visuals or interaction flows.
|
||||
- Changing page-level scrolling outside overlays.
|
||||
@@ -0,0 +1,8 @@
|
||||
# Constrain and Scroll Edit Dialogs and Popups — Tasks
|
||||
|
||||
- [x] Audit all dialog, modal, sheet, action-sheet, and popup implementations.
|
||||
- [x] Strengthen shared dialog/modal viewport and body scrolling rules.
|
||||
- [x] Update bespoke tool-launch and commit popups to use scrollable content regions.
|
||||
- [x] Add focused coverage for the shared scrollable dialog-body markup.
|
||||
- [x] Run frontend typecheck, lint, tests (88 passed), and production build.
|
||||
- [x] Update project maps for changed source files.
|
||||
Reference in New Issue
Block a user