Harden repository setup recovery
This commit is contained in:
parent
b128d5f1d5
commit
2285b9d07a
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
{recoveryError ? (
|
|
||||||
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-destructive">
|
|
||||||
Setup failed: {recoveryError}
|
|
||||||
</div>
|
</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">
|
<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}>
|
||||||
{isCreating ? "Creating..." : "Create and start"}
|
{isInitializing ? "Setting up..." : 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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
|
|
@ -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,36 +801,35 @@ 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") {
|
||||||
setError(err instanceof Error ? err.message : "Could not add project");
|
|
||||||
} finally {
|
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const initializeAndCreate = async (selection: CreateProjectAgentSelection) => {
|
|
||||||
if (!selectedPath) return;
|
|
||||||
setError(null);
|
|
||||||
setRecoveryError(null);
|
|
||||||
setIsInitializing(true);
|
setIsInitializing(true);
|
||||||
try {
|
try {
|
||||||
await onInitializeProject(selectedPath);
|
await onInitializeProject(selectedPath);
|
||||||
setRecoveryCode(null);
|
} catch (setupErr) {
|
||||||
setIsCreating(true);
|
setError(setupErr instanceof Error ? `Setup failed: ${setupErr.message}` : "Setup failed");
|
||||||
await onCreateProject({ path: selectedPath, ...selection });
|
return;
|
||||||
setSelectedPath(null);
|
|
||||||
} catch (err) {
|
|
||||||
setRecoveryError(err instanceof Error ? err.message : "Could not initialize repository");
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsInitializing(false);
|
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);
|
setIsCreating(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue