Harden repository setup recovery
This commit is contained in:
parent
b128d5f1d5
commit
2285b9d07a
|
|
@ -11,7 +11,7 @@ on:
|
|||
workflow_dispatch:
|
||||
inputs:
|
||||
important:
|
||||
description: 'Mark this nightly as an important update (escalates the in-app restart prompt). Retro-flag an already-published nightly by re-running only the publish-feed job with this input set.'
|
||||
description: "Mark this nightly as an important update (escalates the in-app restart prompt). Retro-flag an already-published nightly by re-running only the publish-feed job with this input set."
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package project
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
|
|
@ -252,6 +253,13 @@ func (m *Service) Add(ctx context.Context, in AddInput) (Project, error) {
|
|||
return m.projectFromRow(row), nil
|
||||
}
|
||||
|
||||
type repositorySetupTarget int
|
||||
|
||||
const (
|
||||
repositorySetupPlainFolder repositorySetupTarget = iota
|
||||
repositorySetupUnbornRepo
|
||||
)
|
||||
|
||||
// InitializeRepository prepares a selected folder for project registration by ensuring it has an initial Git commit.
|
||||
func (m *Service) InitializeRepository(ctx context.Context, in InitializeRepositoryInput) (InitializeRepositoryResult, error) {
|
||||
path, err := normalizePath(in.Path)
|
||||
|
|
@ -265,19 +273,63 @@ func (m *Service) InitializeRepository(ctx context.Context, in InitializeReposit
|
|||
m.addMu.Lock()
|
||||
defer m.addMu.Unlock()
|
||||
|
||||
if !isGitRepo(path) {
|
||||
target, err := classifyRepositorySetupTarget(ctx, path)
|
||||
if err != nil {
|
||||
return InitializeRepositoryResult{}, err
|
||||
}
|
||||
|
||||
if target == repositorySetupPlainFolder {
|
||||
if _, err := gitOutput(ctx, path, "init", "-b", domain.DefaultBranchName); err != nil {
|
||||
return InitializeRepositoryResult{}, apierr.Invalid("GIT_INIT_FAILED", "Could not initialize a Git repository in this folder.", map[string]any{"error": err.Error()})
|
||||
}
|
||||
} else if repoHasCommit(ctx, path) {
|
||||
return InitializeRepositoryResult{}, apierr.Conflict("PROJECT_ALREADY_INITIALIZED", "This repository already has commits.", map[string]any{"path": path})
|
||||
}
|
||||
|
||||
if _, err := gitOutput(ctx, path, "add", "-A"); err != nil {
|
||||
return InitializeRepositoryResult{}, apierr.Invalid("GIT_ADD_FAILED", "Could not stage files for the initial commit.", map[string]any{"error": err.Error()})
|
||||
}
|
||||
if _, err := gitOutput(ctx, path, "-c", "user.name=Agent Orchestrator", "-c", "user.email=ao@example.com", "commit", "--allow-empty", "-m", "initial commit"); err != nil {
|
||||
return InitializeRepositoryResult{}, apierr.Invalid("INITIAL_COMMIT_FAILED", "Could not create the initial commit.", map[string]any{"error": err.Error()})
|
||||
}
|
||||
return InitializeRepositoryResult{Path: path}, nil
|
||||
}
|
||||
|
||||
func classifyRepositorySetupTarget(ctx context.Context, path string) (repositorySetupTarget, error) {
|
||||
if isBareGitRepository(ctx, path) {
|
||||
return repositorySetupPlainFolder, apierr.Invalid("PROJECT_BARE_REPOSITORY", "Selected folder must be a non-bare Git repository or a plain folder.", map[string]any{
|
||||
"path": path,
|
||||
"suggestedFix": "Use a normal checkout, or select a plain folder for AO to initialize.",
|
||||
})
|
||||
}
|
||||
|
||||
if isGitRepo(path) {
|
||||
if repoHasCommit(ctx, path) {
|
||||
return repositorySetupUnbornRepo, apierr.Conflict("PROJECT_ALREADY_INITIALIZED", "This repository already has commits.", map[string]any{"path": path})
|
||||
}
|
||||
return repositorySetupUnbornRepo, nil
|
||||
}
|
||||
|
||||
if top, err := gitOutput(ctx, path, "rev-parse", "--show-toplevel"); err == nil {
|
||||
root := normalizeGitReportedPath(path, strings.TrimSpace(top))
|
||||
selected := comparablePath(path)
|
||||
if !samePath(root, selected) {
|
||||
return repositorySetupPlainFolder, apierr.Invalid("PROJECT_PATH_NOT_REPO_ROOT", "Selected folder is inside a Git repository. Select the repository root instead.", map[string]any{
|
||||
"path": path,
|
||||
"repoRoot": root,
|
||||
"suggestedFix": "Select the repository root folder, then try again.",
|
||||
})
|
||||
}
|
||||
return repositorySetupPlainFolder, apierr.Invalid("UNSUPPORTED_GIT_REPO", "Selected folder contains an unsupported Git repository layout.", map[string]any{"path": path})
|
||||
}
|
||||
|
||||
if hasGitMetadata(path) {
|
||||
return repositorySetupPlainFolder, apierr.Invalid("UNSUPPORTED_GIT_REPO", "Selected folder contains Git metadata that AO could not inspect.", map[string]any{
|
||||
"path": path,
|
||||
"suggestedFix": "Repair the Git repository or select a plain folder.",
|
||||
})
|
||||
}
|
||||
|
||||
return repositorySetupPlainFolder, nil
|
||||
}
|
||||
func (m *Service) activeProjectCount(ctx context.Context) (int, error) {
|
||||
projects, err := m.store.ListProjects(ctx)
|
||||
if err != nil {
|
||||
|
|
@ -483,6 +535,42 @@ func repoHasCommit(ctx context.Context, path string) bool {
|
|||
_, err := gitOutput(ctx, path, "rev-parse", "--verify", "HEAD")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func isBareGitRepository(ctx context.Context, path string) bool {
|
||||
out, err := gitOutput(ctx, path, "rev-parse", "--is-bare-repository")
|
||||
return err == nil && strings.TrimSpace(out) == "true"
|
||||
}
|
||||
|
||||
func hasGitMetadata(path string) bool {
|
||||
_, err := os.Lstat(filepath.Join(path, ".git"))
|
||||
return err == nil || !errors.Is(err, os.ErrNotExist)
|
||||
}
|
||||
|
||||
func normalizeGitReportedPath(base, reported string) string {
|
||||
if reported == "" {
|
||||
return comparablePath(reported)
|
||||
}
|
||||
if !filepath.IsAbs(reported) {
|
||||
reported = filepath.Join(base, reported)
|
||||
}
|
||||
return comparablePath(reported)
|
||||
}
|
||||
|
||||
func comparablePath(path string) string {
|
||||
clean := filepath.Clean(path)
|
||||
if resolved, err := filepath.EvalSymlinks(clean); err == nil {
|
||||
clean = resolved
|
||||
}
|
||||
return filepath.Clean(clean)
|
||||
}
|
||||
|
||||
func samePath(a, b string) bool {
|
||||
if strings.EqualFold(a, b) {
|
||||
return true
|
||||
}
|
||||
return a == b
|
||||
}
|
||||
|
||||
func isGitRepo(path string) bool {
|
||||
cmd := aoprocess.Command("git", "-C", path, "rev-parse", "--show-toplevel")
|
||||
out, err := cmd.Output()
|
||||
|
|
|
|||
|
|
@ -513,6 +513,13 @@ func TestManager_InitializeRepositoryRecovery(t *testing.T) {
|
|||
if _, err := exec.Command("git", "-C", dir, "rev-parse", "--verify", "HEAD").CombinedOutput(); err != nil {
|
||||
t.Fatalf("expected initial commit: %v", err)
|
||||
}
|
||||
out, err := exec.Command("git", "-C", dir, "show", "HEAD:notes.txt").CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("expected existing file in initial commit: %v (%s)", err, out)
|
||||
}
|
||||
if got := string(out); got != "keep me\n" {
|
||||
t.Fatalf("HEAD:notes.txt = %q, want %q", got, "keep me\n")
|
||||
}
|
||||
if _, err := m.Add(ctx, project.AddInput{Path: dir, ProjectID: ptr("plain")}); err != nil {
|
||||
t.Fatalf("Add after init: %v", err)
|
||||
}
|
||||
|
|
@ -523,18 +530,59 @@ func TestManager_InitializeRepositoryRecovery(t *testing.T) {
|
|||
if out, err := exec.Command("git", "init", "-b", "main", dir).CombinedOutput(); err != nil {
|
||||
t.Fatalf("git init: %v (%s)", err, out)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("unborn file\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := m.InitializeRepository(ctx, project.InitializeRepositoryInput{Path: dir}); err != nil {
|
||||
t.Fatalf("InitializeRepository unborn: %v", err)
|
||||
}
|
||||
if _, err := exec.Command("git", "-C", dir, "rev-parse", "--verify", "HEAD").CombinedOutput(); err != nil {
|
||||
t.Fatalf("expected initial commit: %v", err)
|
||||
}
|
||||
out, err := exec.Command("git", "-C", dir, "show", "HEAD:notes.txt").CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("expected unborn repo file in initial commit: %v (%s)", err, out)
|
||||
}
|
||||
if got := string(out); got != "unborn file\n" {
|
||||
t.Fatalf("HEAD:notes.txt = %q, want %q", got, "unborn file\n")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("already committed repo", func(t *testing.T) {
|
||||
_, err := m.InitializeRepository(ctx, project.InitializeRepositoryInput{Path: gitRepo(t)})
|
||||
wantCode(t, err, "PROJECT_ALREADY_INITIALIZED")
|
||||
})
|
||||
|
||||
t.Run("repo subdirectory is rejected", func(t *testing.T) {
|
||||
repo := gitRepo(t)
|
||||
subdir := filepath.Join(repo, "nested")
|
||||
if err := os.Mkdir(subdir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := m.InitializeRepository(ctx, project.InitializeRepositoryInput{Path: subdir})
|
||||
wantCode(t, err, "PROJECT_PATH_NOT_REPO_ROOT")
|
||||
if _, statErr := os.Stat(filepath.Join(subdir, ".git")); !errors.Is(statErr, os.ErrNotExist) {
|
||||
t.Fatalf("unexpected nested .git after rejected init: %v", statErr)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bare repo is rejected", func(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "bare.git")
|
||||
if out, err := exec.Command("git", "init", "--bare", dir).CombinedOutput(); err != nil {
|
||||
t.Fatalf("git init --bare: %v (%s)", err, out)
|
||||
}
|
||||
_, err := m.InitializeRepository(ctx, project.InitializeRepositoryInput{Path: dir})
|
||||
wantCode(t, err, "PROJECT_BARE_REPOSITORY")
|
||||
})
|
||||
|
||||
t.Run("unsupported git metadata is rejected", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, ".git"), []byte("gitdir: missing\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := m.InitializeRepository(ctx, project.InitializeRepositoryInput{Path: dir})
|
||||
wantCode(t, err, "UNSUPPORTED_GIT_REPO")
|
||||
})
|
||||
}
|
||||
func TestManager_AddValidationAndConflicts(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
|
|
|||
|
|
@ -20,36 +20,28 @@ export type CreateProjectAgentSelection = {
|
|||
trackerIntake?: TrackerIntakeConfig;
|
||||
};
|
||||
|
||||
type RecoveryCode = "NOT_A_GIT_REPO" | "PROJECT_UNBORN";
|
||||
|
||||
const EMPTY_INTAKE: IntakeForm = { enabled: false, repo: "", assignee: "" };
|
||||
|
||||
type CreateProjectAgentSheetProps = {
|
||||
error?: string | null;
|
||||
isCreating: boolean;
|
||||
isInitializing?: boolean;
|
||||
onInitialize?: (selection: CreateProjectAgentSelection) => Promise<void>;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (selection: CreateProjectAgentSelection) => Promise<void>;
|
||||
open: boolean;
|
||||
path: string | null;
|
||||
recoveryCode?: RecoveryCode | null;
|
||||
recoveryError?: string | null;
|
||||
};
|
||||
|
||||
const RECOVERY_SETUP_MESSAGE = "Let Agent Orchestrator set up this repository so agents can start?";
|
||||
const SETUP_NOTE = "If this folder needs Git setup, AO will initialize it and create the first commit before starting.";
|
||||
|
||||
export function CreateProjectAgentSheet({
|
||||
error,
|
||||
isCreating,
|
||||
isInitializing = false,
|
||||
onInitialize,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
open,
|
||||
path,
|
||||
recoveryCode,
|
||||
recoveryError,
|
||||
}: CreateProjectAgentSheetProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const agentsQuery = useQuery({
|
||||
|
|
@ -73,11 +65,9 @@ export function CreateProjectAgentSheet({
|
|||
const [workerAgent, setWorkerAgent] = useState("");
|
||||
const [orchestratorAgent, setOrchestratorAgent] = useState("");
|
||||
const isBusy = isCreating || isInitializing;
|
||||
const hasRecovery = Boolean(recoveryCode);
|
||||
const [intake, setIntake] = useState<IntakeForm>(EMPTY_INTAKE);
|
||||
const intakeIncomplete = intakeNeedsRule(intake);
|
||||
const canSubmit = workerAgent !== "" && orchestratorAgent !== "" && !intakeIncomplete && !isBusy && !isLoadingAgents;
|
||||
const canInitialize = Boolean(canSubmit && recoveryCode && onInitialize);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
|
|
@ -115,12 +105,6 @@ export function CreateProjectAgentSheet({
|
|||
aria-busy={isBusy || undefined}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (hasRecovery) {
|
||||
if (canInitialize) {
|
||||
void onInitialize?.({ workerAgent, orchestratorAgent, trackerIntake: buildIntake(intake) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!canSubmit) return;
|
||||
void onSubmit({ workerAgent, orchestratorAgent, trackerIntake: buildIntake(intake) });
|
||||
}}
|
||||
|
|
@ -189,16 +173,11 @@ export function CreateProjectAgentSheet({
|
|||
<IntakeFields form={intake} onChange={(patch) => setIntake((f) => ({ ...f, ...patch }))} compact />
|
||||
</div>
|
||||
|
||||
{hasRecovery ? (
|
||||
<div className="space-y-3 rounded-md border border-border bg-surface/70 px-3 py-3 text-[12px] leading-5">
|
||||
<p className="font-medium text-foreground">{RECOVERY_SETUP_MESSAGE}</p>
|
||||
{recoveryError ? (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-destructive">
|
||||
Setup failed: {recoveryError}
|
||||
<div className="rounded-md border border-border bg-surface/70 px-3 py-2 text-[12px] leading-5 text-muted-foreground">
|
||||
{SETUP_NOTE}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : error ? (
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-[12px] leading-5 text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
|
|
@ -208,20 +187,9 @@ export function CreateProjectAgentSheet({
|
|||
<Button type="button" variant="ghost" disabled={isBusy} onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
{!hasRecovery ? (
|
||||
<Button type="submit" variant="primary" disabled={!canSubmit}>
|
||||
{isCreating ? "Creating..." : "Create and start"}
|
||||
{isInitializing ? "Setting up..." : isCreating ? "Creating..." : "Create and start"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={!canInitialize}
|
||||
aria-busy={isInitializing || undefined}
|
||||
>
|
||||
Yes
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
|
|
|
|||
|
|
@ -41,13 +41,18 @@ export function OrchestratorReplacementDialog({
|
|||
<AlertTriangle className="size-4" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Dialog.Title className="text-sm font-medium text-foreground">Orchestrator replacement failed</Dialog.Title>
|
||||
<Dialog.Title className="text-sm font-medium text-foreground">
|
||||
Orchestrator replacement failed
|
||||
</Dialog.Title>
|
||||
<Dialog.Description className="mt-2 text-[13px] leading-5 text-muted-foreground">
|
||||
{error ?? "The project orchestrator could not be replaced."}
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
<Dialog.Close asChild>
|
||||
<button className="rounded-md p-1 text-passive hover:bg-interactive-hover hover:text-foreground" type="button">
|
||||
<button
|
||||
className="rounded-md p-1 text-passive hover:bg-interactive-hover hover:text-foreground"
|
||||
type="button"
|
||||
>
|
||||
<X className="size-4" aria-hidden="true" />
|
||||
<span className="sr-only">Close</span>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -95,7 +95,11 @@ beforeEach(() => {
|
|||
putMock.mockReset();
|
||||
postMock.mockReset();
|
||||
putMock.mockResolvedValue({ data: { project: {} }, error: undefined });
|
||||
postMock.mockResolvedValue({ data: { orchestrator: { id: "proj-1-orch-2" } }, error: undefined, response: { status: 200 } });
|
||||
postMock.mockResolvedValue({
|
||||
data: { orchestrator: { id: "proj-1-orch-2" } },
|
||||
error: undefined,
|
||||
response: { status: 200 },
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProjectSettingsForm", () => {
|
||||
|
|
|
|||
|
|
@ -169,7 +169,13 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) {
|
|||
type="button"
|
||||
>
|
||||
<OrchestratorIcon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{isProjectRestarting ? "Restarting..." : isSpawning ? "Spawning..." : orchestrator ? "Orchestrator" : "Spawn Orchestrator"}
|
||||
{isProjectRestarting
|
||||
? "Restarting..."
|
||||
: isSpawning
|
||||
? "Spawning..."
|
||||
: orchestrator
|
||||
? "Orchestrator"
|
||||
: "Spawn Orchestrator"}
|
||||
</button>
|
||||
</>
|
||||
) : undefined;
|
||||
|
|
|
|||
|
|
@ -250,16 +250,10 @@ describe("Sidebar", () => {
|
|||
|
||||
await user.click(screen.getByRole("button", { name: "Create and start" }));
|
||||
|
||||
expect(
|
||||
await screen.findByText("Let Agent Orchestrator set up this repository so agents can start?"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Yes" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Create and start" })).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(onInitializeProject).toHaveBeenCalledWith("/repo/new-project"));
|
||||
expect(screen.queryByRole("button", { name: "Yes" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Manual Git setup")).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Yes" }));
|
||||
|
||||
await waitFor(() => expect(onInitializeProject).toHaveBeenCalledWith("/repo/new-project"));
|
||||
await waitFor(() => expect(onCreateProject).toHaveBeenCalledTimes(2));
|
||||
expect(onCreateProject).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
|
|
@ -273,19 +267,17 @@ describe("Sidebar", () => {
|
|||
it("shows repository initialization recovery for git repos with no commits", async () => {
|
||||
const onCreateProject = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(
|
||||
codedError("This repo has no commits yet.", "PROJECT_UNBORN"),
|
||||
) as unknown as CreateProjectHandler;
|
||||
renderSidebar({ onCreateProject });
|
||||
.mockRejectedValueOnce(codedError("This repo has no commits yet.", "PROJECT_UNBORN"))
|
||||
.mockResolvedValueOnce(undefined) as unknown as CreateProjectHandler;
|
||||
const onInitializeProject = vi.fn().mockResolvedValue(undefined) as InitializeProjectHandler;
|
||||
renderSidebar({ onCreateProject, onInitializeProject });
|
||||
const user = await openCreateProjectDialog("/repo/unborn");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Create and start" }));
|
||||
|
||||
expect(
|
||||
await screen.findByText("Let Agent Orchestrator set up this repository so agents can start?"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Yes" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Create and start" })).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(onInitializeProject).toHaveBeenCalledWith("/repo/unborn"));
|
||||
await waitFor(() => expect(onCreateProject).toHaveBeenCalledTimes(2));
|
||||
expect(screen.queryByRole("button", { name: "Yes" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Manual Git setup")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
|
@ -300,9 +292,8 @@ describe("Sidebar", () => {
|
|||
const user = await openCreateProjectDialog();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Create and start" }));
|
||||
await user.click(await screen.findByRole("button", { name: "Yes" }));
|
||||
|
||||
expect(await screen.findByText("Setup failed: git init failed")).toBeInTheDocument();
|
||||
expect((await screen.findAllByText("Setup failed: git init failed")).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("shows needs-auth agents as unavailable while keeping authorized agents selectable", async () => {
|
||||
|
|
|
|||
|
|
@ -784,13 +784,9 @@ function CreateProjectFlow({
|
|||
const [isChoosingPath, setIsChoosingPath] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [isInitializing, setIsInitializing] = useState(false);
|
||||
const [recoveryCode, setRecoveryCode] = useState<"NOT_A_GIT_REPO" | "PROJECT_UNBORN" | null>(null);
|
||||
const [recoveryError, setRecoveryError] = useState<string | null>(null);
|
||||
|
||||
const choosePath = async () => {
|
||||
setError(null);
|
||||
setRecoveryCode(null);
|
||||
setRecoveryError(null);
|
||||
setIsChoosingPath(true);
|
||||
try {
|
||||
const path = await aoBridge.app.chooseDirectory();
|
||||
|
|
@ -805,36 +801,35 @@ function CreateProjectFlow({
|
|||
const createProject = async (selection: CreateProjectAgentSelection) => {
|
||||
if (!selectedPath) return;
|
||||
setError(null);
|
||||
setRecoveryCode(null);
|
||||
setRecoveryError(null);
|
||||
setIsCreating(true);
|
||||
try {
|
||||
await onCreateProject({ path: selectedPath, ...selection });
|
||||
setSelectedPath(null);
|
||||
} catch (err) {
|
||||
const code = err instanceof Error && "code" in err ? (err.code as string | undefined) : undefined;
|
||||
setRecoveryCode(code === "NOT_A_GIT_REPO" || code === "PROJECT_UNBORN" ? code : null);
|
||||
setError(err instanceof Error ? err.message : "Could not add project");
|
||||
} finally {
|
||||
if (code === "NOT_A_GIT_REPO" || code === "PROJECT_UNBORN") {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const initializeAndCreate = async (selection: CreateProjectAgentSelection) => {
|
||||
if (!selectedPath) return;
|
||||
setError(null);
|
||||
setRecoveryError(null);
|
||||
setIsInitializing(true);
|
||||
try {
|
||||
await onInitializeProject(selectedPath);
|
||||
setRecoveryCode(null);
|
||||
setIsCreating(true);
|
||||
await onCreateProject({ path: selectedPath, ...selection });
|
||||
setSelectedPath(null);
|
||||
} catch (err) {
|
||||
setRecoveryError(err instanceof Error ? err.message : "Could not initialize repository");
|
||||
} catch (setupErr) {
|
||||
setError(setupErr instanceof Error ? `Setup failed: ${setupErr.message}` : "Setup failed");
|
||||
return;
|
||||
} finally {
|
||||
setIsInitializing(false);
|
||||
}
|
||||
|
||||
setIsCreating(true);
|
||||
try {
|
||||
await onCreateProject({ path: selectedPath, ...selection });
|
||||
setSelectedPath(null);
|
||||
} catch (retryErr) {
|
||||
setError(retryErr instanceof Error ? retryErr.message : "Could not add project");
|
||||
}
|
||||
return;
|
||||
}
|
||||
setError(err instanceof Error ? err.message : "Could not add project");
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
|
@ -857,15 +852,10 @@ function CreateProjectFlow({
|
|||
error={error}
|
||||
isCreating={isCreating}
|
||||
isInitializing={isInitializing}
|
||||
onInitialize={initializeAndCreate}
|
||||
recoveryCode={recoveryCode}
|
||||
recoveryError={recoveryError}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setSelectedPath(null);
|
||||
setError(null);
|
||||
setRecoveryCode(null);
|
||||
setRecoveryError(null);
|
||||
}
|
||||
}}
|
||||
onSubmit={createProject}
|
||||
|
|
|
|||
|
|
@ -101,7 +101,12 @@ describe("useWorkspaceQuery", () => {
|
|||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
const [workspace] = result.current.data ?? [];
|
||||
expect(workspace).toMatchObject({ id: "proj-1", name: "my-app", path: "/home/me/my-app", orchestratorAgent: "codex" });
|
||||
expect(workspace).toMatchObject({
|
||||
id: "proj-1",
|
||||
name: "my-app",
|
||||
path: "/home/me/my-app",
|
||||
orchestratorAgent: "codex",
|
||||
});
|
||||
expect(workspace.sessions).toHaveLength(2);
|
||||
expect(workspace.sessions[0]).toMatchObject({
|
||||
id: "sess-1",
|
||||
|
|
|
|||
|
|
@ -44,7 +44,10 @@ export async function restartProjectOrchestrator({
|
|||
});
|
||||
} catch (error) {
|
||||
await refreshWorkspaceState(queryClient);
|
||||
setOrchestratorReplacementError(projectId, error instanceof Error ? error.message : "Could not replace orchestrator");
|
||||
setOrchestratorReplacementError(
|
||||
projectId,
|
||||
error instanceof Error ? error.message : "Could not replace orchestrator",
|
||||
);
|
||||
onError?.(error);
|
||||
} finally {
|
||||
setProjectRestarting(projectId, false);
|
||||
|
|
|
|||
|
|
@ -239,7 +239,8 @@ describe("orchestratorHealth", () => {
|
|||
}),
|
||||
).toEqual({
|
||||
state: "duplicates",
|
||||
message: "Multiple orchestrators are active. The newest one is used; stale ones will be cleaned up on daemon reconcile.",
|
||||
message:
|
||||
"Multiple orchestrators are active. The newest one is used; stale ones will be cleaned up on daemon reconcile.",
|
||||
});
|
||||
|
||||
expect(
|
||||
|
|
|
|||
|
|
@ -386,7 +386,8 @@ export function orchestratorHealth(workspace: WorkspaceSummary, restarting = fal
|
|||
if (active.length > 1) {
|
||||
return {
|
||||
state: "duplicates",
|
||||
message: "Multiple orchestrators are active. The newest one is used; stale ones will be cleaned up on daemon reconcile.",
|
||||
message:
|
||||
"Multiple orchestrators are active. The newest one is used; stale ones will be cleaned up on daemon reconcile.",
|
||||
};
|
||||
}
|
||||
const orchestrator = newestActiveOrchestrator(workspace.sessions);
|
||||
|
|
|
|||
Loading…
Reference in New Issue