707 lines
31 KiB
Markdown
707 lines
31 KiB
Markdown
# Legacy-Migration Popup + `app-state.json` Marker Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** On app launch, offer to import legacy AO projects via a dashboard popup gated on a `migration` marker in `~/.ao/app-state.json`; run the import through the app-owned daemon; never touch legacy files.
|
|
|
|
**Architecture:** Two layers. **(A) Backend:** a projects-only import daemon API (`GET`/`POST /api/v1/import`). **(B) App:** the desktop app (sole writer of `app-state.json`) stores the migration decision in the marker; a renderer popup reads the marker (over IPC) + the daemon's availability (over HTTP) and offers Proceed / Skip / Don't Migrate.
|
|
|
|
**Tech Stack:** Go (chi, code-first OpenAPI via `cmd/genspec`), Electron (main/preload IPC), React + @tanstack/react-query + openapi-fetch, @radix-ui/react-dialog, vitest.
|
|
|
|
**Design source:** `docs/superpowers/specs/2026-06-26-legacy-migration-popup-design.md`. Backend reuses `docs/plans/2026-06-26-import-offer.md`.
|
|
|
|
## Global Constraints
|
|
|
|
- Grounded on `upstream/main` @ `514946fd8`. Module path `github.com/aoagents/agent-orchestrator/backend`.
|
|
- **No em dashes** in any prose, comment, or UI copy.
|
|
- `openapi.yaml` and `frontend/src/api/schema.ts` are **generated**; never hand-edit. Run `npm run api:spec && npm run api:ts`.
|
|
- App is the **sole writer** of `~/.ao/app-state.json`; daemon is the **sole writer** of the DB. Marker writes are atomic (temp + rename).
|
|
- Import is **projects + per-project settings only** (no orchestrator sessions, no transcripts) and **idempotent**; it **never deletes or modifies** legacy files.
|
|
- v1 popup copy must **not** promise a Settings location (Settings deferred to AgentWrapper/agent-orchestrator#2205).
|
|
- Branch `ao/agent-orchestrator-3/legacy-migration-popup`; PR target `main` on `AgentWrapper/agent-orchestrator`. Commit after every task (AO worktrees can be force-removed).
|
|
|
|
## File Structure
|
|
|
|
| File | Status | Responsibility |
|
|
| ---------------------------------------------------------- | ------------------------ | ----------------------------------------------------------------------------------- |
|
|
| (backend import API) | per import-offer plan | projects-only `legacyimport`, `service/importer`, controller, DTOs, OpenAPI, wiring |
|
|
| `backend/internal/service/importer/importer.go` | create (modified Status) | `Status` = `HasLegacyData` only; `Run` |
|
|
| `backend/internal/cli/start.go` | modify | drop the `§6.4` TODO comment (`:78`) |
|
|
| `frontend/src/main/app-state.ts` | modify | `MigrationState`, schema v2, preserve, `updateMigration`, `readMigrationState` |
|
|
| `frontend/src/main/app-state.test.ts` | modify | marker v2 + migration tests |
|
|
| `frontend/src/main.ts` | modify | `appState:getMigration` / `appState:setMigration` IPC handlers |
|
|
| `frontend/src/preload.ts` | modify | `ao.appState` bridge methods |
|
|
| `frontend/src/renderer/lib/bridge.ts` | modify | `appState` preview fallback |
|
|
| `frontend/src/renderer/hooks/useMigrationOffer.ts` | create | gate: IPC marker + daemon GET |
|
|
| `frontend/src/renderer/components/MigrationPopup.tsx` | create | the three-action dialog |
|
|
| `frontend/src/renderer/components/MigrationPopup.test.tsx` | create | popup tests |
|
|
| `frontend/src/renderer/routes/_shell.index.tsx` | modify | mount `<MigrationPopup/>` on the board |
|
|
|
|
---
|
|
|
|
# Part A: Backend import API (projects-only)
|
|
|
|
Execute the committed plan `docs/plans/2026-06-26-import-offer.md`, **Tasks 1, 3, 4, 5 only**, with the Task-2 change below. **Skip** that plan's Task 2 as written, Task 6 (start.go is already headless from #2201), and Tasks 7-11 (its frontend is replaced by Part B here).
|
|
|
|
- **Task 1** (engine → projects-only): as written.
|
|
- **Task 2 REPLACEMENT** (service): see Task A2 below (simpler `Status`).
|
|
- **Task 3** (controller + DTOs): as written.
|
|
- **Task 4** (OpenAPI regen): as written. After regen, `schema.ts` must contain `/api/v1/import`, `ImportStatusResponse {available, legacyRoot}`, `ImportRunResponse {report}`.
|
|
- **Task 5** (wire api.go + daemon.go): as written, i.e. `Import: importsvc.New(importsvc.Deps{Store: store})`.
|
|
|
|
### Task A2: Import service with availability-only status
|
|
|
|
**Files:** Create `backend/internal/service/importer/importer.go`, `…/importer_test.go`
|
|
|
|
**Interfaces:**
|
|
|
|
- Produces: `importer.Status{Available bool; LegacyRoot string}`, `importer.Service` (`Status(ctx)`, `Run(ctx)`), `importer.Deps{Store, Root}`, `importer.New(Deps) *Manager`. `Store` is exactly `legacyimport.Store` (the projects-only `GetProject`/`UpsertProject`) — **no `ListProjects`** (the design drops the empty-DB heuristic; the app marker governs prompting).
|
|
|
|
- [ ] **Step 1: Write `importer.go`:**
|
|
|
|
```go
|
|
// Package importer is the controller-facing service for the legacy-AO import.
|
|
// It wraps the internal/legacyimport engine with a detection probe (is a legacy
|
|
// install present?) and a trigger that runs the import through the live daemon's
|
|
// store, so the daemon stays the sole writer. Whether to PROMPT for the import
|
|
// is the desktop app's job (the app-state.json migration marker), so this probe
|
|
// reports only physical availability, not "already imported".
|
|
package importer
|
|
|
|
import (
|
|
"context"
|
|
|
|
"github.com/aoagents/agent-orchestrator/backend/internal/legacyimport"
|
|
)
|
|
|
|
// Store is the storage slice the import runs through; *sqlite.Store satisfies it.
|
|
type Store interface {
|
|
legacyimport.Store
|
|
}
|
|
|
|
// Status reports whether a legacy AO install is physically present to import.
|
|
type Status struct {
|
|
Available bool `json:"available"`
|
|
LegacyRoot string `json:"legacyRoot"`
|
|
}
|
|
|
|
// Service is the controller-facing import contract.
|
|
type Service interface {
|
|
Status(ctx context.Context) (Status, error)
|
|
Run(ctx context.Context) (legacyimport.Report, error)
|
|
}
|
|
|
|
// Deps bundles the import service's dependencies.
|
|
type Deps struct {
|
|
// Store is the rewrite's durable store (the daemon's shared *sqlite.Store).
|
|
Store Store
|
|
// Root overrides the legacy AO root to read. Empty -> the default.
|
|
Root string
|
|
}
|
|
|
|
// Manager implements Service over the daemon's store.
|
|
type Manager struct {
|
|
store Store
|
|
root string
|
|
}
|
|
|
|
var _ Service = (*Manager)(nil)
|
|
|
|
// New constructs the import service. An empty Root falls back to the default.
|
|
func New(deps Deps) *Manager {
|
|
root := deps.Root
|
|
if root == "" {
|
|
root = legacyimport.DefaultLegacyRootDir()
|
|
}
|
|
return &Manager{store: deps.Store, root: root}
|
|
}
|
|
|
|
// Status reports availability only: legacy data present at the root. It never
|
|
// errors on a missing legacy store; that is simply "not available".
|
|
func (m *Manager) Status(_ context.Context) (Status, error) {
|
|
return Status{Available: legacyimport.HasLegacyData(m.root), LegacyRoot: m.root}, nil
|
|
}
|
|
|
|
// Run executes the import through the daemon's store. Idempotent: the engine
|
|
// skips rows that already exist. Legacy files are never modified.
|
|
func (m *Manager) Run(ctx context.Context) (legacyimport.Report, error) {
|
|
return legacyimport.Run(ctx, m.store, legacyimport.Options{Root: m.root})
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Write `importer_test.go`:**
|
|
|
|
```go
|
|
package importer
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
|
)
|
|
|
|
type fakeStore struct{ projects map[string]domain.ProjectRecord }
|
|
|
|
func newFakeStore() *fakeStore { return &fakeStore{projects: map[string]domain.ProjectRecord{}} }
|
|
func (f *fakeStore) GetProject(_ context.Context, id string) (domain.ProjectRecord, bool, error) {
|
|
r, ok := f.projects[id]
|
|
return r, ok, nil
|
|
}
|
|
func (f *fakeStore) UpsertProject(_ context.Context, r domain.ProjectRecord) error {
|
|
f.projects[r.ID] = r
|
|
return nil
|
|
}
|
|
|
|
func writeLegacyRoot(t *testing.T) string {
|
|
t.Helper()
|
|
root := filepath.Join(t.TempDir(), ".agent-orchestrator")
|
|
if err := os.MkdirAll(filepath.Join(root, "projects"), 0o750); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg := "projects:\n alpha:\n path: /repos/alpha\n name: Alpha\n"
|
|
if err := os.WriteFile(filepath.Join(root, "config.yaml"), []byte(cfg), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return root
|
|
}
|
|
|
|
func TestStatus_NoLegacyData(t *testing.T) {
|
|
svc := New(Deps{Store: newFakeStore(), Root: filepath.Join(t.TempDir(), "nope")})
|
|
st, err := svc.Status(context.Background())
|
|
if err != nil || st.Available {
|
|
t.Fatalf("want unavailable; got %+v err=%v", st, err)
|
|
}
|
|
}
|
|
|
|
func TestStatus_LegacyPresentStaysAvailableAfterImport(t *testing.T) {
|
|
root := writeLegacyRoot(t)
|
|
svc := New(Deps{Store: newFakeStore(), Root: root})
|
|
st, err := svc.Status(context.Background())
|
|
if err != nil || !st.Available || st.LegacyRoot != root {
|
|
t.Fatalf("want available at %q; got %+v err=%v", root, st, err)
|
|
}
|
|
if _, err := svc.Run(context.Background()); err != nil {
|
|
t.Fatalf("run: %v", err)
|
|
}
|
|
// Availability is physical (legacy data still on disk), so it stays true; the
|
|
// app marker is what stops the prompt after a completed import.
|
|
st, _ = svc.Status(context.Background())
|
|
if !st.Available {
|
|
t.Fatal("availability must remain true after import (marker governs prompting)")
|
|
}
|
|
}
|
|
|
|
func TestRun_ImportsProjects(t *testing.T) {
|
|
root := writeLegacyRoot(t)
|
|
svc := New(Deps{Store: newFakeStore(), Root: root})
|
|
rep, err := svc.Run(context.Background())
|
|
if err != nil || rep.ProjectsImported != 1 {
|
|
t.Fatalf("projectsImported=%d err=%v", rep.ProjectsImported, err)
|
|
}
|
|
}
|
|
|
|
func TestNew_DefaultsRoot(t *testing.T) {
|
|
if New(Deps{Store: newFakeStore()}).root == "" {
|
|
t.Fatal("empty Root should fall back to the default legacy root")
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3:** `cd backend && go test ./internal/service/importer/...` → expect PASS.
|
|
- [ ] **Step 4:** Commit: `feat(importer): availability probe + projects-only run`
|
|
|
|
### Task A6: Clear the resolved TODO in start.go
|
|
|
|
**Files:** Modify `backend/internal/cli/start.go`
|
|
|
|
- [ ] **Step 1:** Delete the now-resolved comment at `start.go:78` (`// TODO(spec §6.4): legacy first-boot import now belongs to the desktop app; …`). The behavior it describes is implemented by this feature.
|
|
- [ ] **Step 2:** `cd backend && go build ./...` → expect PASS.
|
|
- [ ] **Step 3:** Commit: `chore(cli): drop resolved §6.4 first-boot-import TODO`
|
|
|
|
---
|
|
|
|
# Part B: App-side marker + popup
|
|
|
|
### Task B1: Migration marker in `app-state.json` (schema v2)
|
|
|
|
**Files:** Modify `frontend/src/main/app-state.ts`, `frontend/src/main/app-state.test.ts`
|
|
|
|
**Interfaces:**
|
|
|
|
- Produces: `MigrationStatus`, `MigrationState`, `updateMigration({stateDir, migration, now})`, `readMigrationState(stateDir) => Promise<MigrationState>`. `AppStateMarker` gains `migration?: MigrationState`. `SCHEMA_VERSION === 2`. **B2/B3/B4 depend on these names.**
|
|
|
|
- [ ] **Step 1 (TDD): add tests to `app-state.test.ts`** (follows the file's existing temp-dir style):
|
|
|
|
```ts
|
|
import { readFile } from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { mkdtemp } from "node:fs/promises";
|
|
import { describe, expect, it } from "vitest";
|
|
import { APP_STATE_FILE_NAME, readMigrationState, updateMigration, writeAppStateMarker } from "./app-state";
|
|
|
|
const fixedNow = () => new Date("2026-06-26T10:00:00.000Z");
|
|
async function tmp() {
|
|
return mkdtemp(path.join(os.tmpdir(), "ao-appstate-"));
|
|
}
|
|
|
|
describe("migration marker", () => {
|
|
it("readMigrationState defaults to pending when the file is absent", async () => {
|
|
expect(await readMigrationState(await tmp())).toEqual({ status: "pending" });
|
|
});
|
|
|
|
it("updateMigration persists status without an existing marker", async () => {
|
|
const dir = await tmp();
|
|
await updateMigration({ stateDir: dir, migration: { status: "declined" }, now: fixedNow });
|
|
expect((await readMigrationState(dir)).status).toBe("declined");
|
|
});
|
|
|
|
it("a launch write preserves an existing migration block", async () => {
|
|
const dir = await tmp();
|
|
await updateMigration({ stateDir: dir, migration: { status: "completed" }, now: fixedNow });
|
|
await writeAppStateMarker({ stateDir: dir, appPath: "/A.app", version: "1.2.3", now: fixedNow });
|
|
const raw = JSON.parse(await readFile(path.join(dir, APP_STATE_FILE_NAME), "utf8"));
|
|
expect(raw.schemaVersion).toBe(2);
|
|
expect(raw.appPath).toBe("/A.app");
|
|
expect(raw.migration.status).toBe("completed");
|
|
});
|
|
|
|
it("updateMigration does not clobber launch fields", async () => {
|
|
const dir = await tmp();
|
|
await writeAppStateMarker({ stateDir: dir, appPath: "/A.app", version: "1.2.3", now: fixedNow });
|
|
await updateMigration({ stateDir: dir, migration: { status: "failed", error: "x" }, now: fixedNow });
|
|
const raw = JSON.parse(await readFile(path.join(dir, APP_STATE_FILE_NAME), "utf8"));
|
|
expect(raw.appPath).toBe("/A.app");
|
|
expect(raw.migration).toEqual({ status: "failed", error: "x" });
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run → FAIL** (`updateMigration`/`readMigrationState` not exported): `npm --prefix frontend run test -- app-state`.
|
|
- [ ] **Step 3: Edit `app-state.ts`.** Set `const SCHEMA_VERSION = 2;`. Add types and the marker field:
|
|
|
|
```ts
|
|
export type MigrationStatus = "pending" | "completed" | "declined" | "failed";
|
|
|
|
export interface MigrationState {
|
|
status: MigrationStatus;
|
|
lastAttemptAt?: string;
|
|
completedAt?: string;
|
|
report?: { projectsImported: number; projectsSkipped: number };
|
|
error?: string;
|
|
}
|
|
```
|
|
|
|
Add `migration?: MigrationState;` to `AppStateMarker`. Extract the existing temp+rename into a helper and reuse it:
|
|
|
|
```ts
|
|
async function atomicWriteMarker(stateDir: string, marker: AppStateMarker): Promise<void> {
|
|
await mkdir(stateDir, { recursive: true, mode: 0o750 });
|
|
const file = path.join(stateDir, APP_STATE_FILE_NAME);
|
|
const data = `${JSON.stringify(marker, null, 2)}\n`;
|
|
const tmp = path.join(stateDir, `.app-state-${process.pid}-${Date.now()}.json`);
|
|
await writeFile(tmp, data, { mode: 0o600 });
|
|
await rename(tmp, file);
|
|
}
|
|
```
|
|
|
|
In `writeAppStateMarker`, build the `marker` object as today but add `migration: existing?.migration` (preserve), and replace the inline temp+rename with `await atomicWriteMarker(opts.stateDir, marker);`. Then add:
|
|
|
|
```ts
|
|
export interface UpdateMigrationOptions {
|
|
stateDir: string;
|
|
migration: MigrationState;
|
|
now: () => Date;
|
|
}
|
|
|
|
// updateMigration sets ONLY the migration block, preserving every launch-written
|
|
// field already on disk. Used by the app's IPC setter. Atomic like the launch write.
|
|
export async function updateMigration(opts: UpdateMigrationOptions): Promise<void> {
|
|
const file = path.join(opts.stateDir, APP_STATE_FILE_NAME);
|
|
const existing = await readExisting(file);
|
|
const nowIso = opts.now().toISOString();
|
|
const marker: AppStateMarker = existing
|
|
? { ...existing, migration: opts.migration }
|
|
: {
|
|
schemaVersion: SCHEMA_VERSION,
|
|
appPath: "",
|
|
version: "",
|
|
installedAt: nowIso,
|
|
lastReconciledAt: nowIso,
|
|
installSource: "unknown",
|
|
migration: opts.migration,
|
|
};
|
|
await atomicWriteMarker(opts.stateDir, marker);
|
|
}
|
|
|
|
// readMigrationState returns the marker's migration block, defaulting to pending
|
|
// when the file is absent or unparseable (self-healing, like the rest of the reader).
|
|
export async function readMigrationState(stateDir: string): Promise<MigrationState> {
|
|
const existing = await readExisting(path.join(stateDir, APP_STATE_FILE_NAME));
|
|
return existing?.migration ?? { status: "pending" };
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run → PASS**: `npm --prefix frontend run test -- app-state`.
|
|
- [ ] **Step 5:** Commit: `feat(app-state): migration marker (schema v2) + updateMigration`
|
|
|
|
### Task B2: IPC handlers + preload/bridge
|
|
|
|
**Files:** Modify `frontend/src/main.ts`, `frontend/src/preload.ts`, `frontend/src/renderer/lib/bridge.ts`
|
|
|
|
**Interfaces:** Produces `window.ao.appState.getMigration()` / `setMigration(m)` (typed via `AoBridge`). **B3/B4 consume these.**
|
|
|
|
- [ ] **Step 1: `main.ts`** — extend the marker import and add two handlers next to the other `ipcMain.handle` calls (`:773-796`):
|
|
|
|
```ts
|
|
import { readMigrationState, updateMigration, writeAppStateMarker, type MigrationState } from "./main/app-state";
|
|
```
|
|
|
|
```ts
|
|
ipcMain.handle("appState:getMigration", async (): Promise<MigrationState> => {
|
|
const runFile = runFilePath();
|
|
if (!runFile) return { status: "pending" };
|
|
return readMigrationState(path.dirname(runFile));
|
|
});
|
|
ipcMain.handle("appState:setMigration", async (_event, migration: MigrationState) => {
|
|
const runFile = runFilePath();
|
|
if (!runFile) return;
|
|
await updateMigration({ stateDir: path.dirname(runFile), migration, now: () => new Date() });
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: `preload.ts`** — import the type and add to `api`:
|
|
|
|
```ts
|
|
import type { MigrationState } from "./main/app-state";
|
|
```
|
|
|
|
```ts
|
|
appState: {
|
|
getMigration: () => ipcRenderer.invoke("appState:getMigration") as Promise<MigrationState>,
|
|
setMigration: (migration: MigrationState) =>
|
|
ipcRenderer.invoke("appState:setMigration", migration) as Promise<void>,
|
|
},
|
|
```
|
|
|
|
- [ ] **Step 3: `bridge.ts`** — add the preview fallback so `AoBridge` stays satisfied:
|
|
|
|
```ts
|
|
appState: {
|
|
getMigration: async () => ({ status: "pending" }),
|
|
setMigration: async () => undefined,
|
|
},
|
|
```
|
|
|
|
- [ ] **Step 4:** `npm --prefix frontend run typecheck` → expect PASS.
|
|
- [ ] **Step 5:** Commit: `feat(ipc): expose app-state migration getter/setter to the renderer`
|
|
|
|
### Task B3: `useMigrationOffer` gate hook
|
|
|
|
**Files:** Create `frontend/src/renderer/hooks/useMigrationOffer.ts`
|
|
|
|
**Interfaces:** Consumes `apiClient` (`renderer/lib/api-client`, needs the Part A `schema.ts` paths), `aoBridge` (`renderer/lib/bridge`), `MigrationState` (`main/app-state`). Produces `useMigrationOffer()`, `migrationOfferQueryKey`, `MigrationOffer`.
|
|
|
|
- [ ] **Step 1: Create the file:**
|
|
|
|
```ts
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { apiClient } from "../lib/api-client";
|
|
import { aoBridge } from "../lib/bridge";
|
|
import type { MigrationState } from "../../main/app-state";
|
|
|
|
export const migrationOfferQueryKey = ["migration-offer"] as const;
|
|
const usePreviewData = import.meta.env.VITE_NO_ELECTRON === "1";
|
|
|
|
export interface MigrationOffer {
|
|
show: boolean;
|
|
legacyRoot: string;
|
|
migration: MigrationState;
|
|
}
|
|
|
|
// fetchMigrationOffer combines the app marker (decision) with the daemon's
|
|
// availability (is there legacy data). A terminal marker (completed/declined)
|
|
// short-circuits before any daemon call. A 501/unreachable daemon resolves to
|
|
// "no offer", never an error.
|
|
async function fetchMigrationOffer(): Promise<MigrationOffer> {
|
|
const migration = await aoBridge.appState.getMigration();
|
|
if (migration.status === "completed" || migration.status === "declined") {
|
|
return { show: false, legacyRoot: "", migration };
|
|
}
|
|
const { data, error } = await apiClient.GET("/api/v1/import");
|
|
const legacyRoot = data?.legacyRoot ?? "";
|
|
if (error || !data?.available) return { show: false, legacyRoot, migration };
|
|
return { show: true, legacyRoot, migration };
|
|
}
|
|
|
|
export function useMigrationOffer() {
|
|
return useQuery({
|
|
queryKey: migrationOfferQueryKey,
|
|
queryFn: fetchMigrationOffer,
|
|
enabled: !usePreviewData,
|
|
retry: 1,
|
|
throwOnError: false,
|
|
});
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2:** `npm --prefix frontend run typecheck` → expect PASS.
|
|
- [ ] **Step 3:** Commit: `feat(renderer): useMigrationOffer gate (marker + availability)`
|
|
|
|
### Task B4: `MigrationPopup` component
|
|
|
|
**Files:** Create `frontend/src/renderer/components/MigrationPopup.tsx`, `…/MigrationPopup.test.tsx`
|
|
|
|
**Interfaces:** Consumes `useMigrationOffer`/`migrationOfferQueryKey`, `workspaceQueryKey`, `apiClient`/`apiErrorMessage`, `aoBridge`. Self-contained (no props).
|
|
|
|
- [ ] **Step 1 (TDD): Create `MigrationPopup.test.tsx`:**
|
|
|
|
```tsx
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import { render, screen, waitFor } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { MigrationPopup } from "./MigrationPopup";
|
|
|
|
const { getMock, postMock, getMigration, setMigration } = vi.hoisted(() => ({
|
|
getMock: vi.fn(),
|
|
postMock: vi.fn(),
|
|
getMigration: vi.fn(),
|
|
setMigration: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../lib/api-client", () => ({
|
|
apiClient: { GET: getMock, POST: postMock },
|
|
apiErrorMessage: (e: unknown, fb = "Request failed") =>
|
|
e instanceof Error ? e.message : ((e as { message?: string })?.message ?? fb),
|
|
}));
|
|
vi.mock("../lib/bridge", () => ({ aoBridge: { appState: { getMigration, setMigration } } }));
|
|
|
|
function renderPopup() {
|
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
render(
|
|
<QueryClientProvider client={qc}>
|
|
<MigrationPopup />
|
|
</QueryClientProvider>,
|
|
);
|
|
return qc;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
getMock.mockReset();
|
|
postMock.mockReset();
|
|
getMigration.mockReset();
|
|
setMigration.mockReset();
|
|
getMigration.mockResolvedValue({ status: "pending" });
|
|
getMock.mockResolvedValue({ data: { available: true, legacyRoot: "/home/u/.agent-orchestrator" }, error: undefined });
|
|
postMock.mockResolvedValue({ data: { report: { projectsImported: 2, projectsSkipped: 1 } }, error: undefined });
|
|
setMigration.mockResolvedValue(undefined);
|
|
});
|
|
|
|
describe("MigrationPopup", () => {
|
|
it("shows when a legacy install is available and the marker is pending", async () => {
|
|
renderPopup();
|
|
expect(await screen.findByText(/Import projects from your earlier AO/i)).toBeInTheDocument();
|
|
expect(screen.getByText("/home/u/.agent-orchestrator")).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders nothing when the marker is declined", async () => {
|
|
getMigration.mockResolvedValue({ status: "declined" });
|
|
renderPopup();
|
|
await waitFor(() => expect(getMigration).toHaveBeenCalled());
|
|
expect(screen.queryByText(/Import projects from your earlier AO/i)).not.toBeInTheDocument();
|
|
expect(getMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("Proceed imports, marks completed, and retires", async () => {
|
|
renderPopup();
|
|
await screen.findByText(/Import projects from your earlier AO/i);
|
|
await userEvent.click(screen.getByRole("button", { name: "Proceed" }));
|
|
await waitFor(() => expect(postMock).toHaveBeenCalledWith("/api/v1/import"));
|
|
expect(setMigration).toHaveBeenCalledWith(expect.objectContaining({ status: "completed" }));
|
|
await waitFor(() => expect(screen.queryByText(/Import projects from your earlier AO/i)).not.toBeInTheDocument());
|
|
});
|
|
|
|
it("Don't Migrate records declined", async () => {
|
|
renderPopup();
|
|
await screen.findByText(/Import projects from your earlier AO/i);
|
|
await userEvent.click(screen.getByRole("button", { name: "Don't Migrate" }));
|
|
expect(setMigration).toHaveBeenCalledWith(expect.objectContaining({ status: "declined" }));
|
|
});
|
|
|
|
it("Skip dismisses without writing the marker", async () => {
|
|
renderPopup();
|
|
await screen.findByText(/Import projects from your earlier AO/i);
|
|
await userEvent.click(screen.getByRole("button", { name: "Skip" }));
|
|
expect(setMigration).not.toHaveBeenCalled();
|
|
expect(screen.queryByText(/Import projects from your earlier AO/i)).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("a failed import shows the lossless reassurance and marks failed", async () => {
|
|
postMock.mockResolvedValue({ data: undefined, error: { message: "disk full" } });
|
|
renderPopup();
|
|
await screen.findByText(/Import projects from your earlier AO/i);
|
|
await userEvent.click(screen.getByRole("button", { name: "Proceed" }));
|
|
expect(await screen.findByText(/nothing is ever deleted/i)).toBeInTheDocument();
|
|
expect(setMigration).toHaveBeenCalledWith(expect.objectContaining({ status: "failed", error: "disk full" }));
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run → FAIL** (component missing): `npm --prefix frontend run test -- MigrationPopup`.
|
|
- [ ] **Step 3: Create `MigrationPopup.tsx`:**
|
|
|
|
```tsx
|
|
import * as Dialog from "@radix-ui/react-dialog";
|
|
import { Loader2 } from "lucide-react";
|
|
import { useState } from "react";
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
import { Button } from "./ui/button";
|
|
import { apiClient, apiErrorMessage } from "../lib/api-client";
|
|
import { aoBridge } from "../lib/bridge";
|
|
import { migrationOfferQueryKey, useMigrationOffer } from "../hooks/useMigrationOffer";
|
|
import { workspaceQueryKey } from "../hooks/useWorkspaceQuery";
|
|
|
|
// MigrationPopup is the first-run legacy-AO import offer. It shows only when the
|
|
// app marker is non-terminal (pending/failed) AND the daemon reports legacy data
|
|
// available. Proceed runs the idempotent import through the daemon; Skip dismisses
|
|
// for this launch (re-prompts next launch); Don't Migrate declines permanently
|
|
// (re-runnable later once the Settings entry point lands, issue #2205).
|
|
export function MigrationPopup() {
|
|
const offer = useMigrationOffer();
|
|
const queryClient = useQueryClient();
|
|
const [skipped, setSkipped] = useState(false);
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | undefined>();
|
|
|
|
const open = (offer.data?.show ?? false) && !skipped;
|
|
if (!open) return null;
|
|
|
|
const legacyRoot = offer.data?.legacyRoot || "your earlier AO";
|
|
const nowIso = () => new Date().toISOString();
|
|
|
|
const proceed = async () => {
|
|
setBusy(true);
|
|
setError(undefined);
|
|
const { data, error: apiErr } = await apiClient.POST("/api/v1/import");
|
|
if (apiErr) {
|
|
const msg = apiErrorMessage(apiErr);
|
|
setError(msg);
|
|
await aoBridge.appState.setMigration({ status: "failed", lastAttemptAt: nowIso(), error: msg });
|
|
setBusy(false);
|
|
return;
|
|
}
|
|
const report = data?.report;
|
|
await aoBridge.appState.setMigration({
|
|
status: "completed",
|
|
lastAttemptAt: nowIso(),
|
|
completedAt: nowIso(),
|
|
report: report
|
|
? { projectsImported: report.projectsImported, projectsSkipped: report.projectsSkipped }
|
|
: undefined,
|
|
});
|
|
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
|
|
await queryClient.invalidateQueries({ queryKey: migrationOfferQueryKey });
|
|
setBusy(false);
|
|
};
|
|
|
|
const dontMigrate = async () => {
|
|
await aoBridge.appState.setMigration({ status: "declined", lastAttemptAt: nowIso() });
|
|
await queryClient.invalidateQueries({ queryKey: migrationOfferQueryKey });
|
|
};
|
|
|
|
return (
|
|
<Dialog.Root
|
|
open
|
|
onOpenChange={(next) => {
|
|
if (!next) setSkipped(true);
|
|
}}
|
|
>
|
|
<Dialog.Portal>
|
|
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/50" />
|
|
<Dialog.Content className="fixed left-1/2 top-1/2 z-50 w-[440px] -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-surface p-5 shadow-lg">
|
|
<Dialog.Title className="text-sm font-medium text-foreground">
|
|
Import projects from your earlier AO?
|
|
</Dialog.Title>
|
|
<Dialog.Description className="mt-2 text-[13px] leading-[1.5] text-muted-foreground">
|
|
We found an existing install at <span className="font-mono text-[11px] text-foreground">{legacyRoot}</span>.
|
|
Importing brings in your projects. Your old files are never modified, and you can do this later.
|
|
</Dialog.Description>
|
|
{error && (
|
|
<div className="mt-3 text-[12px] text-destructive">
|
|
Migration failed: {error}. Your legacy projects are untouched (nothing is ever deleted). You can retry.
|
|
</div>
|
|
)}
|
|
<p className="mt-3 text-[11px] text-muted-foreground">You can run this again later.</p>
|
|
<div className="mt-4 flex items-center justify-between gap-2">
|
|
<Button variant="ghost" className="text-destructive" onClick={dontMigrate} disabled={busy} type="button">
|
|
Don't Migrate
|
|
</Button>
|
|
<div className="flex gap-2">
|
|
<Button variant="ghost" onClick={() => setSkipped(true)} disabled={busy} type="button">
|
|
Skip
|
|
</Button>
|
|
<Button variant="primary" onClick={proceed} disabled={busy} type="button">
|
|
{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
{error ? "Retry" : "Proceed"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Dialog.Content>
|
|
</Dialog.Portal>
|
|
</Dialog.Root>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run → PASS**: `npm --prefix frontend run test -- MigrationPopup`.
|
|
- [ ] **Step 5:** Commit: `feat(renderer): MigrationPopup (Proceed / Skip / Don't Migrate)`
|
|
|
|
### Task B5: Mount on the dashboard
|
|
|
|
**Files:** Modify `frontend/src/renderer/routes/_shell.index.tsx`
|
|
|
|
- [ ] **Step 1:** Render the popup alongside the board:
|
|
|
|
```tsx
|
|
import { createFileRoute } from "@tanstack/react-router";
|
|
import { MigrationPopup } from "../components/MigrationPopup";
|
|
import { SessionsBoard } from "../components/SessionsBoard";
|
|
|
|
export const Route = createFileRoute("/_shell/")({
|
|
component: () => (
|
|
<>
|
|
<MigrationPopup />
|
|
<SessionsBoard />
|
|
</>
|
|
),
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2:** `npm --prefix frontend run typecheck && npm --prefix frontend run test -- MigrationPopup` → expect PASS.
|
|
- [ ] **Step 3:** Commit: `feat(renderer): surface MigrationPopup on the dashboard`
|
|
|
|
### Task B6: Full verification
|
|
|
|
- [ ] `cd backend && go build ./... && go test -race ./...` → green.
|
|
- [ ] `golangci-lint run` on touched packages → clean.
|
|
- [ ] `npm --prefix frontend run typecheck && npm --prefix frontend run test` → green.
|
|
- [ ] `git status` shows no uncommitted drift after `npm run api:spec && npm run api:ts`.
|
|
- [ ] **Full frontend production build** (rollup tree-shaking can hide missing emits).
|
|
- [ ] Manual: seed `~/.agent-orchestrator` with a legacy `config.yaml`, empty rewrite DB, launch the app → popup appears. Proceed → projects appear, `~/.ao/app-state.json` shows `migration.status: "completed"`, relaunch → no popup. Separately test Skip (re-prompts next launch) and Don't Migrate (`declined`, no re-prompt).
|
|
|
|
---
|
|
|
|
## Self-Review
|
|
|
|
**Spec coverage:** ✅ import daemon API projects-only (Part A); availability-only Status per design §5.2 (A2); marker schema v2 + preserve + updateMigration (B1); IPC getter/setter + bridge (B2); gate combining marker + availability (B3); popup with Proceed/Skip/Don't Migrate + lossless failure copy (B4); mount on board (B5); resolved-TODO cleanup (A6). Settings redo path correctly **excluded** (deferred to #2205); v1 copy avoids any Settings promise.
|
|
|
|
**Placeholder scan:** none — every new file has complete source; Part A references a committed, complete plan for its unchanged tasks and gives full code for the one changed task.
|
|
|
|
**Type consistency:** `MigrationState`/`MigrationStatus` defined in B1 are imported unchanged by B2 (preload/main/bridge), B3 (hook), B4 (popup). `updateMigration({stateDir, migration, now})` signature matches its B2 call. `migrationOfferQueryKey`/`workspaceQueryKey` consistent B3↔B4. `Status{Available, LegacyRoot}` (A2) ↔ `ImportStatusResponse` (Part A Task 3) ↔ `schema.ts` GET shape used in B3. `report.{projectsImported,projectsSkipped}` (engine, Part A Task 1) ↔ B4 usage.
|
|
|
|
**Cross-task coupling:** B3 needs Part A's regenerated `schema.ts` (Task 4) for `apiClient.GET/POST("/api/v1/import")` to typecheck, so **Part A precedes Part B**. B2's bridge fallback must be added or B3/B4 typecheck fails under `AoBridge`.
|