Harden repository setup recovery

This commit is contained in:
maaz 2026-07-05 05:28:54 +05:30
parent b128d5f1d5
commit 2285b9d07a
13 changed files with 213 additions and 103 deletions

View File

@ -11,7 +11,7 @@ on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
important: 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 type: boolean
default: false default: false

View File

@ -2,6 +2,7 @@ package project
import ( import (
"context" "context"
"errors"
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
@ -252,6 +253,13 @@ func (m *Service) Add(ctx context.Context, in AddInput) (Project, error) {
return m.projectFromRow(row), nil 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. // 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) { func (m *Service) InitializeRepository(ctx context.Context, in InitializeRepositoryInput) (InitializeRepositoryResult, error) {
path, err := normalizePath(in.Path) path, err := normalizePath(in.Path)
@ -265,19 +273,63 @@ func (m *Service) InitializeRepository(ctx context.Context, in InitializeReposit
m.addMu.Lock() m.addMu.Lock()
defer m.addMu.Unlock() 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 { 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()}) 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 { 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{}, apierr.Invalid("INITIAL_COMMIT_FAILED", "Could not create the initial commit.", map[string]any{"error": err.Error()})
} }
return InitializeRepositoryResult{Path: path}, nil 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) { func (m *Service) activeProjectCount(ctx context.Context) (int, error) {
projects, err := m.store.ListProjects(ctx) projects, err := m.store.ListProjects(ctx)
if err != nil { if err != nil {
@ -483,6 +535,42 @@ func repoHasCommit(ctx context.Context, path string) bool {
_, err := gitOutput(ctx, path, "rev-parse", "--verify", "HEAD") _, err := gitOutput(ctx, path, "rev-parse", "--verify", "HEAD")
return err == nil 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 { func isGitRepo(path string) bool {
cmd := aoprocess.Command("git", "-C", path, "rev-parse", "--show-toplevel") cmd := aoprocess.Command("git", "-C", path, "rev-parse", "--show-toplevel")
out, err := cmd.Output() out, err := cmd.Output()

View File

@ -513,6 +513,13 @@ func TestManager_InitializeRepositoryRecovery(t *testing.T) {
if _, err := exec.Command("git", "-C", dir, "rev-parse", "--verify", "HEAD").CombinedOutput(); err != nil { if _, err := exec.Command("git", "-C", dir, "rev-parse", "--verify", "HEAD").CombinedOutput(); err != nil {
t.Fatalf("expected initial commit: %v", err) 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 { if _, err := m.Add(ctx, project.AddInput{Path: dir, ProjectID: ptr("plain")}); err != nil {
t.Fatalf("Add after init: %v", err) 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 { if out, err := exec.Command("git", "init", "-b", "main", dir).CombinedOutput(); err != nil {
t.Fatalf("git init: %v (%s)", err, out) 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 { if _, err := m.InitializeRepository(ctx, project.InitializeRepositoryInput{Path: dir}); err != nil {
t.Fatalf("InitializeRepository unborn: %v", err) t.Fatalf("InitializeRepository unborn: %v", err)
} }
if _, err := exec.Command("git", "-C", dir, "rev-parse", "--verify", "HEAD").CombinedOutput(); err != nil { if _, err := exec.Command("git", "-C", dir, "rev-parse", "--verify", "HEAD").CombinedOutput(); err != nil {
t.Fatalf("expected initial commit: %v", err) 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) { t.Run("already committed repo", func(t *testing.T) {
_, err := m.InitializeRepository(ctx, project.InitializeRepositoryInput{Path: gitRepo(t)}) _, err := m.InitializeRepository(ctx, project.InitializeRepositoryInput{Path: gitRepo(t)})
wantCode(t, err, "PROJECT_ALREADY_INITIALIZED") 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) { func TestManager_AddValidationAndConflicts(t *testing.T) {
ctx := context.Background() ctx := context.Background()

View File

@ -20,36 +20,28 @@ export type CreateProjectAgentSelection = {
trackerIntake?: TrackerIntakeConfig; trackerIntake?: TrackerIntakeConfig;
}; };
type RecoveryCode = "NOT_A_GIT_REPO" | "PROJECT_UNBORN";
const EMPTY_INTAKE: IntakeForm = { enabled: false, repo: "", assignee: "" }; const EMPTY_INTAKE: IntakeForm = { enabled: false, repo: "", assignee: "" };
type CreateProjectAgentSheetProps = { type CreateProjectAgentSheetProps = {
error?: string | null; error?: string | null;
isCreating: boolean; isCreating: boolean;
isInitializing?: boolean; isInitializing?: boolean;
onInitialize?: (selection: CreateProjectAgentSelection) => Promise<void>;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
onSubmit: (selection: CreateProjectAgentSelection) => Promise<void>; onSubmit: (selection: CreateProjectAgentSelection) => Promise<void>;
open: boolean; open: boolean;
path: string | null; 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({ export function CreateProjectAgentSheet({
error, error,
isCreating, isCreating,
isInitializing = false, isInitializing = false,
onInitialize,
onOpenChange, onOpenChange,
onSubmit, onSubmit,
open, open,
path, path,
recoveryCode,
recoveryError,
}: CreateProjectAgentSheetProps) { }: CreateProjectAgentSheetProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const agentsQuery = useQuery({ const agentsQuery = useQuery({
@ -73,11 +65,9 @@ export function CreateProjectAgentSheet({
const [workerAgent, setWorkerAgent] = useState(""); const [workerAgent, setWorkerAgent] = useState("");
const [orchestratorAgent, setOrchestratorAgent] = useState(""); const [orchestratorAgent, setOrchestratorAgent] = useState("");
const isBusy = isCreating || isInitializing; const isBusy = isCreating || isInitializing;
const hasRecovery = Boolean(recoveryCode);
const [intake, setIntake] = useState<IntakeForm>(EMPTY_INTAKE); const [intake, setIntake] = useState<IntakeForm>(EMPTY_INTAKE);
const intakeIncomplete = intakeNeedsRule(intake); const intakeIncomplete = intakeNeedsRule(intake);
const canSubmit = workerAgent !== "" && orchestratorAgent !== "" && !intakeIncomplete && !isBusy && !isLoadingAgents; const canSubmit = workerAgent !== "" && orchestratorAgent !== "" && !intakeIncomplete && !isBusy && !isLoadingAgents;
const canInitialize = Boolean(canSubmit && recoveryCode && onInitialize);
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
@ -115,12 +105,6 @@ export function CreateProjectAgentSheet({
aria-busy={isBusy || undefined} aria-busy={isBusy || undefined}
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault(); event.preventDefault();
if (hasRecovery) {
if (canInitialize) {
void onInitialize?.({ workerAgent, orchestratorAgent, trackerIntake: buildIntake(intake) });
}
return;
}
if (!canSubmit) return; if (!canSubmit) return;
void onSubmit({ workerAgent, orchestratorAgent, trackerIntake: buildIntake(intake) }); void onSubmit({ workerAgent, orchestratorAgent, trackerIntake: buildIntake(intake) });
}} }}
@ -189,16 +173,11 @@ export function CreateProjectAgentSheet({
<IntakeFields form={intake} onChange={(patch) => setIntake((f) => ({ ...f, ...patch }))} compact /> <IntakeFields form={intake} onChange={(patch) => setIntake((f) => ({ ...f, ...patch }))} compact />
</div> </div>
{hasRecovery ? ( <div className="rounded-md border border-border bg-surface/70 px-3 py-2 text-[12px] leading-5 text-muted-foreground">
<div className="space-y-3 rounded-md border border-border bg-surface/70 px-3 py-3 text-[12px] leading-5"> {SETUP_NOTE}
<p className="font-medium text-foreground">{RECOVERY_SETUP_MESSAGE}</p> </div>
{recoveryError ? (
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-destructive"> {error ? (
Setup failed: {recoveryError}
</div>
) : null}
</div>
) : error ? (
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-[12px] leading-5 text-destructive"> <div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-[12px] leading-5 text-destructive">
{error} {error}
</div> </div>
@ -208,20 +187,9 @@ export function CreateProjectAgentSheet({
<Button type="button" variant="ghost" disabled={isBusy} onClick={() => onOpenChange(false)}> <Button type="button" variant="ghost" disabled={isBusy} onClick={() => onOpenChange(false)}>
Cancel Cancel
</Button> </Button>
{!hasRecovery ? ( <Button type="submit" variant="primary" disabled={!canSubmit}>
<Button type="submit" variant="primary" disabled={!canSubmit}> {isInitializing ? "Setting up..." : isCreating ? "Creating..." : "Create and start"}
{isCreating ? "Creating..." : "Create and start"} </Button>
</Button>
) : (
<Button
type="submit"
variant="primary"
disabled={!canInitialize}
aria-busy={isInitializing || undefined}
>
Yes
</Button>
)}
</div> </div>
</form> </form>
</Dialog.Content> </Dialog.Content>

View File

@ -41,13 +41,18 @@ export function OrchestratorReplacementDialog({
<AlertTriangle className="size-4" aria-hidden="true" /> <AlertTriangle className="size-4" aria-hidden="true" />
</div> </div>
<div className="min-w-0 flex-1"> <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"> <Dialog.Description className="mt-2 text-[13px] leading-5 text-muted-foreground">
{error ?? "The project orchestrator could not be replaced."} {error ?? "The project orchestrator could not be replaced."}
</Dialog.Description> </Dialog.Description>
</div> </div>
<Dialog.Close asChild> <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" /> <X className="size-4" aria-hidden="true" />
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</button> </button>

View File

@ -95,7 +95,11 @@ beforeEach(() => {
putMock.mockReset(); putMock.mockReset();
postMock.mockReset(); postMock.mockReset();
putMock.mockResolvedValue({ data: { project: {} }, error: undefined }); 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", () => { describe("ProjectSettingsForm", () => {

View File

@ -169,7 +169,13 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) {
type="button" type="button"
> >
<OrchestratorIcon className="h-3.5 w-3.5" aria-hidden="true" /> <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> </button>
</> </>
) : undefined; ) : undefined;

View File

@ -250,16 +250,10 @@ describe("Sidebar", () => {
await user.click(screen.getByRole("button", { name: "Create and start" })); await user.click(screen.getByRole("button", { name: "Create and start" }));
expect( await waitFor(() => expect(onInitializeProject).toHaveBeenCalledWith("/repo/new-project"));
await screen.findByText("Let Agent Orchestrator set up this repository so agents can start?"), expect(screen.queryByRole("button", { name: "Yes" })).not.toBeInTheDocument();
).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Yes" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Create and start" })).not.toBeInTheDocument();
expect(screen.queryByLabelText("Manual Git setup")).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)); await waitFor(() => expect(onCreateProject).toHaveBeenCalledTimes(2));
expect(onCreateProject).toHaveBeenLastCalledWith( expect(onCreateProject).toHaveBeenLastCalledWith(
expect.objectContaining({ expect.objectContaining({
@ -273,19 +267,17 @@ describe("Sidebar", () => {
it("shows repository initialization recovery for git repos with no commits", async () => { it("shows repository initialization recovery for git repos with no commits", async () => {
const onCreateProject = vi const onCreateProject = vi
.fn() .fn()
.mockRejectedValueOnce( .mockRejectedValueOnce(codedError("This repo has no commits yet.", "PROJECT_UNBORN"))
codedError("This repo has no commits yet.", "PROJECT_UNBORN"), .mockResolvedValueOnce(undefined) as unknown as CreateProjectHandler;
) as unknown as CreateProjectHandler; const onInitializeProject = vi.fn().mockResolvedValue(undefined) as InitializeProjectHandler;
renderSidebar({ onCreateProject }); renderSidebar({ onCreateProject, onInitializeProject });
const user = await openCreateProjectDialog("/repo/unborn"); const user = await openCreateProjectDialog("/repo/unborn");
await user.click(screen.getByRole("button", { name: "Create and start" })); await user.click(screen.getByRole("button", { name: "Create and start" }));
expect( await waitFor(() => expect(onInitializeProject).toHaveBeenCalledWith("/repo/unborn"));
await screen.findByText("Let Agent Orchestrator set up this repository so agents can start?"), await waitFor(() => expect(onCreateProject).toHaveBeenCalledTimes(2));
).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Yes" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Yes" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Create and start" })).not.toBeInTheDocument();
expect(screen.queryByLabelText("Manual Git setup")).not.toBeInTheDocument(); expect(screen.queryByLabelText("Manual Git setup")).not.toBeInTheDocument();
}); });
@ -300,9 +292,8 @@ describe("Sidebar", () => {
const user = await openCreateProjectDialog(); const user = await openCreateProjectDialog();
await user.click(screen.getByRole("button", { name: "Create and start" })); 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 () => { it("shows needs-auth agents as unavailable while keeping authorized agents selectable", async () => {

View File

@ -784,13 +784,9 @@ function CreateProjectFlow({
const [isChoosingPath, setIsChoosingPath] = useState(false); const [isChoosingPath, setIsChoosingPath] = useState(false);
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
const [isInitializing, setIsInitializing] = 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 () => { const choosePath = async () => {
setError(null); setError(null);
setRecoveryCode(null);
setRecoveryError(null);
setIsChoosingPath(true); setIsChoosingPath(true);
try { try {
const path = await aoBridge.app.chooseDirectory(); const path = await aoBridge.app.chooseDirectory();
@ -805,39 +801,38 @@ function CreateProjectFlow({
const createProject = async (selection: CreateProjectAgentSelection) => { const createProject = async (selection: CreateProjectAgentSelection) => {
if (!selectedPath) return; if (!selectedPath) return;
setError(null); setError(null);
setRecoveryCode(null);
setRecoveryError(null);
setIsCreating(true); setIsCreating(true);
try { try {
await onCreateProject({ path: selectedPath, ...selection }); await onCreateProject({ path: selectedPath, ...selection });
setSelectedPath(null); setSelectedPath(null);
} catch (err) { } catch (err) {
const code = err instanceof Error && "code" in err ? (err.code as string | undefined) : undefined; 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); if (code === "NOT_A_GIT_REPO" || code === "PROJECT_UNBORN") {
setIsCreating(false);
setIsInitializing(true);
try {
await onInitializeProject(selectedPath);
} 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"); setError(err instanceof Error ? err.message : "Could not add project");
} finally { } finally {
setIsCreating(false); 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");
} finally {
setIsInitializing(false);
setIsCreating(false);
}
};
const label = isChoosingPath const label = isChoosingPath
? "Opening..." ? "Opening..."
: isInitializing : isInitializing
@ -857,15 +852,10 @@ function CreateProjectFlow({
error={error} error={error}
isCreating={isCreating} isCreating={isCreating}
isInitializing={isInitializing} isInitializing={isInitializing}
onInitialize={initializeAndCreate}
recoveryCode={recoveryCode}
recoveryError={recoveryError}
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) { if (!open) {
setSelectedPath(null); setSelectedPath(null);
setError(null); setError(null);
setRecoveryCode(null);
setRecoveryError(null);
} }
}} }}
onSubmit={createProject} onSubmit={createProject}

View File

@ -101,7 +101,12 @@ describe("useWorkspaceQuery", () => {
await waitFor(() => expect(result.current.isSuccess).toBe(true)); await waitFor(() => expect(result.current.isSuccess).toBe(true));
const [workspace] = result.current.data ?? []; 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).toHaveLength(2);
expect(workspace.sessions[0]).toMatchObject({ expect(workspace.sessions[0]).toMatchObject({
id: "sess-1", id: "sess-1",

View File

@ -44,7 +44,10 @@ export async function restartProjectOrchestrator({
}); });
} catch (error) { } catch (error) {
await refreshWorkspaceState(queryClient); 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); onError?.(error);
} finally { } finally {
setProjectRestarting(projectId, false); setProjectRestarting(projectId, false);

View File

@ -239,7 +239,8 @@ describe("orchestratorHealth", () => {
}), }),
).toEqual({ ).toEqual({
state: "duplicates", 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( expect(

View File

@ -386,7 +386,8 @@ export function orchestratorHealth(workspace: WorkspaceSummary, restarting = fal
if (active.length > 1) { if (active.length > 1) {
return { return {
state: "duplicates", 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); const orchestrator = newestActiveOrchestrator(workspace.sessions);